Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f4d1c0baa4 | |||
| ada18dd70d | |||
| 91025683bb | |||
| b26be30a25 | |||
| 7dc921dff3 | |||
| 7eabf6066f | |||
| 75e3bb3985 | |||
| 819aa793ae |
@@ -22,7 +22,9 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
# 22 matches the TREK server runtime and has node:sqlite, which the
|
||||
# dev-server tests exercise.
|
||||
node-version: 22
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
- run: npm ci
|
||||
- run: npm publish
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
# Contributor Covenant 3.0 Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We pledge to make our community welcoming, safe, and equitable for all.
|
||||
|
||||
We are committed to fostering an environment that respects and promotes the dignity, rights, and contributions of all
|
||||
individuals, regardless of characteristics including race, ethnicity, caste, color, age, physical characteristics,
|
||||
neurodiversity, disability, sex or gender, gender identity or expression, sexual orientation, language, philosophy or
|
||||
religion, national or social origin, socio-economic position, level of education, or other status. The same privileges
|
||||
of participation are extended to everyone who participates in good faith and in accordance with this Covenant.
|
||||
|
||||
## Encouraged Behaviors
|
||||
|
||||
While acknowledging differences in social norms, we all strive to meet our community's expectations for positive
|
||||
behavior. We also understand that our words and actions may be interpreted differently than we intend based on culture,
|
||||
background, or native language.
|
||||
|
||||
With these considerations in mind, we agree to behave mindfully toward each other and act in ways that center our shared
|
||||
values, including:
|
||||
|
||||
1. Respecting the **purpose of our community**, our activities, and our ways of gathering.
|
||||
2. Engaging **kindly and honestly** with others.
|
||||
3. Respecting **different viewpoints** and experiences.
|
||||
4. **Taking responsibility** for our actions and contributions.
|
||||
5. Gracefully giving and accepting **constructive feedback**.
|
||||
6. Committing to **repairing harm** when it occurs.
|
||||
7. Behaving in other ways that promote and sustain the **well-being of our community**.
|
||||
|
||||
## Restricted Behaviors
|
||||
|
||||
We agree to restrict the following behaviors in our community. Instances, threats, and promotion of these behaviors are
|
||||
violations of this Code of Conduct.
|
||||
|
||||
1. **Harassment.** Violating explicitly expressed boundaries or engaging in unnecessary personal attention after any
|
||||
clear request to stop.
|
||||
2. **Character attacks.** Making insulting, demeaning, or pejorative comments directed at a community member or group of
|
||||
people.
|
||||
3. **Stereotyping or discrimination.** Characterizing anyone’s personality or behavior on the basis of immutable
|
||||
identities or traits.
|
||||
4. **Sexualization.** Behaving in a way that would generally be considered inappropriately intimate in the context or
|
||||
purpose of the community.
|
||||
5. **Violating confidentiality**. Sharing or acting on someone's personal or private information without their
|
||||
permission.
|
||||
6. **Endangerment.** Causing, encouraging, or threatening violence or other harm toward any person or group.
|
||||
7. Behaving in other ways that **threaten the well-being** of our community.
|
||||
|
||||
### Other Restrictions
|
||||
|
||||
1. **Misleading identity.** Impersonating someone else for any reason, or pretending to be someone else to evade
|
||||
enforcement actions.
|
||||
2. **Failing to credit sources.** Not properly crediting the sources of content you contribute.
|
||||
3. **Promotional materials**. Sharing marketing or other commercial content in a way that is outside the norms of the
|
||||
community.
|
||||
4. **Irresponsible communication.** Failing to responsibly present content which includes, links or describes any other
|
||||
restricted behaviors.
|
||||
|
||||
## Reporting an Issue
|
||||
|
||||
Tensions can occur between community members even when they are trying their best to collaborate. Not every conflict
|
||||
represents a code of conduct violation, and this Code of Conduct reinforces encouraged behaviors and norms that can help
|
||||
avoid conflicts and minimize harm.
|
||||
|
||||
When an incident does occur, it is important to report it promptly. To report a possible violation, **send an email to
|
||||
report@liketrek.com**.
|
||||
|
||||
Community Moderators take reports of violations seriously and will make every effort to respond in a timely manner. They
|
||||
will investigate all reports of code of conduct violations, reviewing messages, logs, and recordings, or interviewing
|
||||
witnesses and other participants. Community Moderators will keep investigation and enforcement actions as transparent as
|
||||
possible while prioritizing safety and confidentiality. In order to honor these values, enforcement actions are carried
|
||||
out in private with the involved parties, but communicating to the whole community may be part of a mutually agreed upon
|
||||
resolution.
|
||||
|
||||
## Addressing and Repairing Harm
|
||||
|
||||
****
|
||||
|
||||
If an investigation by the Community Moderators finds that this Code of Conduct has been violated, the following
|
||||
enforcement ladder may be used to determine how best to repair harm, based on the incident's impact on the individuals
|
||||
involved and the community as a whole. Depending on the severity of a violation, lower rungs on the ladder may be
|
||||
skipped.
|
||||
|
||||
1) Warning
|
||||
1) Event: A violation involving a single incident or series of incidents.
|
||||
2) Consequence: A private, written warning from the Community Moderators.
|
||||
3) Repair: Examples of repair include a private written apology, acknowledgement of responsibility, and seeking
|
||||
clarification on expectations.
|
||||
2) Temporarily Limited Activities
|
||||
1) Event: A repeated incidence of a violation that previously resulted in a warning, or the first incidence of a
|
||||
more serious violation.
|
||||
2) Consequence: A private, written warning with a time-limited cooldown period designed to underscore the
|
||||
seriousness of the situation and give the community members involved time to process the incident. The cooldown
|
||||
period may be limited to particular communication channels or interactions with particular community members.
|
||||
3) Repair: Examples of repair may include making an apology, using the cooldown period to reflect on actions and
|
||||
impact, and being thoughtful about re-entering community spaces after the period is over.
|
||||
3) Temporary Suspension
|
||||
1) Event: A pattern of repeated violation which the Community Moderators have tried to address with warnings, or a
|
||||
single serious violation.
|
||||
2) Consequence: A private written warning with conditions for return from suspension. In general, temporary
|
||||
suspensions give the person being suspended time to reflect upon their behavior and possible corrective actions.
|
||||
3) Repair: Examples of repair include respecting the spirit of the suspension, meeting the specified conditions for
|
||||
return, and being thoughtful about how to reintegrate with the community when the suspension is lifted.
|
||||
4) Permanent Ban
|
||||
1) Event: A pattern of repeated code of conduct violations that other steps on the ladder have failed to resolve, or
|
||||
a violation so serious that the Community Moderators determine there is no way to keep the community safe with
|
||||
this person as a member.
|
||||
2) Consequence: Access to all community spaces, tools, and communication channels is removed. In general, permanent
|
||||
bans should be rarely used, should have strong reasoning behind them, and should only be resorted to if working
|
||||
through other remedies has failed to change the behavior.
|
||||
3) Repair: There is no possible repair in cases of this severity.
|
||||
|
||||
This enforcement ladder is intended as a guideline. It does not limit the ability of Community Managers to use their
|
||||
discretion and judgment, in keeping with the best interests of our community.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing
|
||||
the community in public or other spaces. Examples of representing our community include using an official email address,
|
||||
posting via an official social media account, or acting as an appointed representative at an online or offline event.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the Contributor Covenant, version 3.0, permanently available
|
||||
at [https://www.contributor-covenant.org/version/3/0/](https://www.contributor-covenant.org/version/3/0/).
|
||||
|
||||
Contributor Covenant is stewarded by the Organization for Ethical Source and licensed under CC BY-SA 4.0. To view a copy
|
||||
of this license,
|
||||
visit [https://creativecommons.org/licenses/by-sa/4.0/](https://creativecommons.org/licenses/by-sa/4.0/)
|
||||
|
||||
For answers to common questions about Contributor Covenant, see the FAQ
|
||||
at [https://www.contributor-covenant.org/faq](https://www.contributor-covenant.org/faq). Translations are provided
|
||||
at [https://www.contributor-covenant.org/translations](https://www.contributor-covenant.org/translations). Additional
|
||||
enforcement and community guideline resources can be found
|
||||
at [https://www.contributor-covenant.org/resources](https://www.contributor-covenant.org/resources). The enforcement
|
||||
ladder was inspired by the work of [Mozilla’s code of conduct team](https://github.com/mozilla/inclusion).
|
||||
|
||||
@@ -49,12 +49,12 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
|
||||
<div align="center">
|
||||
<a href="docs/screenshots/dashboard.png"><img src="docs/screenshots/dashboard.png" alt="Dashboard" width="49%" /></a>
|
||||
<a href="docs/screenshots/trip-planner.png"><img src="docs/screenshots/trip-planner.png" alt="Trip planner with 3D map" width="49%" /></a>
|
||||
<a href="docs/screenshots/trip-planner.png"><img src="docs/screenshots/trip-planner.png" alt="Trip planner · day plan & route" width="49%" /></a>
|
||||
<a href="docs/screenshots/journey.png"><img src="docs/screenshots/journey.png" alt="Journey journal" width="49%" /></a>
|
||||
<a href="docs/screenshots/budget.png"><img src="docs/screenshots/budget.png" alt="Costs · expense splitting" width="49%" /></a>
|
||||
<a href="docs/screenshots/atlas.png"><img src="docs/screenshots/atlas.png" alt="Atlas · visited countries" width="49%" /></a>
|
||||
<a href="docs/screenshots/vacay.png"><img src="docs/screenshots/vacay.png" alt="Vacay planner" width="49%" /></a>
|
||||
<a href="docs/screenshots/trip-iceland.png"><img src="docs/screenshots/trip-iceland.png" alt="Trip planner · day plan and route" width="49%" /></a>
|
||||
<a href="docs/screenshots/collections.png"><img src="docs/screenshots/collections.png" alt="Collections · saved place lists" width="49%" /></a>
|
||||
<a href="docs/screenshots/admin.png"><img src="docs/screenshots/admin.png" alt="Admin panel" width="49%" /></a>
|
||||
</div>
|
||||
|
||||
@@ -428,6 +428,7 @@ Caddy handles TLS and WebSockets automatically.
|
||||
| `ADMIN_PASSWORD` | Password for the first admin on initial boot. Pairs with `ADMIN_EMAIL`. | random |
|
||||
| **Other** | | |
|
||||
| `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` |
|
||||
| `UNSPLASH_ACCESS_KEY` | Optional Unsplash Access Key for trip-cover and place-image search. Without one, TREK uses Unsplash's unauthenticated endpoint, which some datacenter/VPS IPs are blocked from. Get a free key at [unsplash.com/developers](https://unsplash.com/developers). Overrides any per-admin key set in Admin > Settings (where it can also be configured instead). | — |
|
||||
| `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `300` |
|
||||
| `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user | `20` |
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: trek
|
||||
version: 3.1.3
|
||||
version: 3.2.1
|
||||
description: Minimal Helm chart for TREK app
|
||||
appVersion: "3.1.3"
|
||||
appVersion: "3.2.1"
|
||||
|
||||
@@ -63,6 +63,12 @@ spec:
|
||||
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
|
||||
key: OIDC_CLIENT_SECRET
|
||||
optional: true
|
||||
- name: UNSPLASH_ACCESS_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
|
||||
key: UNSPLASH_ACCESS_KEY
|
||||
optional: true
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /app/data
|
||||
|
||||
@@ -17,6 +17,9 @@ data:
|
||||
{{- if .Values.secretEnv.OIDC_CLIENT_SECRET }}
|
||||
OIDC_CLIENT_SECRET: {{ .Values.secretEnv.OIDC_CLIENT_SECRET | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.UNSPLASH_ACCESS_KEY }}
|
||||
UNSPLASH_ACCESS_KEY: {{ .Values.secretEnv.UNSPLASH_ACCESS_KEY | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- if and (not .Values.existingSecret) (.Values.generateEncryptionKey) }}
|
||||
@@ -44,4 +47,7 @@ stringData:
|
||||
{{- if .Values.secretEnv.OIDC_CLIENT_SECRET }}
|
||||
OIDC_CLIENT_SECRET: {{ .Values.secretEnv.OIDC_CLIENT_SECRET }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.UNSPLASH_ACCESS_KEY }}
|
||||
UNSPLASH_ACCESS_KEY: {{ .Values.secretEnv.UNSPLASH_ACCESS_KEY }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
@@ -92,6 +92,12 @@ secretEnv:
|
||||
ADMIN_PASSWORD: ""
|
||||
# OIDC client secret — set together with env.OIDC_ISSUER and env.OIDC_CLIENT_ID.
|
||||
OIDC_CLIENT_SECRET: ""
|
||||
# Optional Unsplash Access Key for trip-cover and place-image search.
|
||||
# Without one, TREK uses Unsplash's unauthenticated endpoint, which some
|
||||
# datacenter/VPS IPs (including many Kubernetes clusters) are blocked from.
|
||||
# Get a free key at https://unsplash.com/developers. Can also be set per-admin
|
||||
# in Admin > Settings; this value overrides that. Leave empty to disable.
|
||||
UNSPLASH_ACCESS_KEY: ""
|
||||
|
||||
# If true, a random ENCRYPTION_KEY is generated at install and preserved across upgrades
|
||||
generateEncryptionKey: false
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trek/client",
|
||||
"version": "3.1.3",
|
||||
"version": "3.2.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -461,14 +461,16 @@ export const adminApi = {
|
||||
addons: () => apiClient.get('/admin/addons').then(r => r.data),
|
||||
updateAddon: (id: number | string, data: Record<string, unknown>) => apiClient.put(`/admin/addons/${id}`, data).then(r => r.data),
|
||||
plugins: () => apiClient.get('/admin/plugins').then(r => r.data),
|
||||
pluginBrowse: () => apiClient.get('/admin/plugins/registry').then(r => r.data),
|
||||
pluginBrowse: (refresh?: boolean) => apiClient.get('/admin/plugins/registry', { params: refresh ? { refresh: 1 } : undefined }).then(r => r.data),
|
||||
pluginDetail: (id: string) => apiClient.get(`/admin/plugins/registry/${encodeURIComponent(id)}`).then(r => r.data),
|
||||
pluginInstall: (id: string, version?: string) => apiClient.post('/admin/plugins/install', { id, version }).then(r => r.data),
|
||||
pluginActivate: (id: string) => apiClient.post(`/admin/plugins/${id}/activate`).then(r => r.data),
|
||||
pluginInstall: (id: string, opts?: { version?: string; constraint?: string; withDependencies?: boolean }) =>
|
||||
apiClient.post('/admin/plugins/install', { id, ...opts }).then(r => r.data),
|
||||
pluginActivate: (id: string, consent?: boolean) => apiClient.post(`/admin/plugins/${id}/activate`, consent ? { consent: true } : {}).then(r => r.data),
|
||||
pluginDeactivate: (id: string) => apiClient.post(`/admin/plugins/${id}/deactivate`).then(r => r.data),
|
||||
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) },
|
||||
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.
|
||||
@@ -556,9 +558,34 @@ export const addonsApi = {
|
||||
export const pluginsApi = {
|
||||
// Active plugins the client renders (page nav entries, dashboard widgets).
|
||||
active: () => apiClient.get('/plugins').then(r => r.data),
|
||||
// Call one of a plugin's own declared routes through the host proxy.
|
||||
invoke: (id: string, sub: string, init?: { method?: string; body?: unknown }) =>
|
||||
apiClient.request({ url: `/plugins/${id}${sub}`, method: init?.method || 'GET', data: init?.body }).then(r => r.data),
|
||||
// Extra place info contributed by placeDetailProvider plugins (#1429). Fail-safe:
|
||||
// the server skips any slow/failing provider, so this only ever adds rows.
|
||||
placeDetails: (placeId: number) =>
|
||||
apiClient.get(`/place-details/${placeId}`).then(r => r.data as { providers: Array<{ pluginId: string; items: Array<{ label: string; value?: string; url?: string }> }> }),
|
||||
// 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 }> }),
|
||||
// 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
|
||||
// the URL parser — which normalizes `../`, encoded traversal and backslashes the
|
||||
// same way the browser would before sending — and reject anything that escapes
|
||||
// the prefix or points off-origin. Without this a plugin could send
|
||||
// sub='/../../auth/me' and drive arbitrary authenticated /api routes as the user.
|
||||
invoke: (id: string, sub: string, init?: { method?: string; body?: unknown }) => {
|
||||
const prefix = `/api/plugins/${id}/`
|
||||
let resolved: URL
|
||||
try {
|
||||
resolved = new URL(String(sub).replace(/^\/+/, ''), window.location.origin + prefix)
|
||||
} catch {
|
||||
return Promise.reject(new Error('invalid plugin route'))
|
||||
}
|
||||
if (resolved.origin !== window.location.origin || !resolved.pathname.startsWith(prefix)) {
|
||||
return Promise.reject(new Error('plugin route escapes its namespace'))
|
||||
}
|
||||
const url = resolved.pathname.slice('/api'.length) + resolved.search
|
||||
return apiClient.request({ url, method: init?.method || 'GET', data: init?.body }).then(r => r.data)
|
||||
},
|
||||
}
|
||||
|
||||
export const airtrailApi = {
|
||||
@@ -675,8 +702,8 @@ export const budgetApi = {
|
||||
setPayers: (tripId: number | string, id: number, payers: { user_id: number; amount: number }[]) => apiClient.put(`/trips/${tripId}/budget/${id}/payers`, { payers }).then(r => r.data),
|
||||
perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data),
|
||||
settlement: (tripId: number | string, base?: string) => apiClient.get(`/trips/${tripId}/budget/settlement`, base ? { params: { base } } : undefined).then(r => r.data),
|
||||
createSettlement: (tripId: number | string, data: { from_user_id: number; to_user_id: number; amount: number }) => apiClient.post(`/trips/${tripId}/budget/settlements`, data).then(r => r.data),
|
||||
updateSettlement: (tripId: number | string, settlementId: number, data: { from_user_id: number; to_user_id: number; amount: number }) => apiClient.put(`/trips/${tripId}/budget/settlements/${settlementId}`, data).then(r => r.data),
|
||||
createSettlement: (tripId: number | string, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string }) => apiClient.post(`/trips/${tripId}/budget/settlements`, data).then(r => r.data),
|
||||
updateSettlement: (tripId: number | string, settlementId: number, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string }) => apiClient.put(`/trips/${tripId}/budget/settlements/${settlementId}`, data).then(r => r.data),
|
||||
deleteSettlement: (tripId: number | string, settlementId: number) => apiClient.delete(`/trips/${tripId}/budget/settlements/${settlementId}`).then(r => r.data),
|
||||
reorderItems: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/budget/reorder/items`, { orderedIds }).then(r => r.data),
|
||||
reorderCategories: (tripId: number | string, orderedCategories: string[]) => apiClient.put(`/trips/${tripId}/budget/reorder/categories`, { orderedCategories } satisfies BudgetReorderCategoriesRequest).then(r => r.data),
|
||||
|
||||
@@ -18,6 +18,9 @@ import type {
|
||||
CollectionStatus,
|
||||
Collection,
|
||||
CollectionPlace,
|
||||
CollectionLabel,
|
||||
CollectionLabelCreateRequest,
|
||||
CollectionLabelUpdateRequest,
|
||||
} from '@trek/shared'
|
||||
|
||||
const ax = apiClient
|
||||
@@ -95,4 +98,15 @@ export const collectionsApi = {
|
||||
ax.post(`${base}/members/remove`, { collection_id: collectionId, user_id: userId }).then((r: AxiosResponse) => r.data),
|
||||
availableUsers: (id: number): Promise<{ users: { id: number; username: string }[] }> =>
|
||||
ax.get(`${base}/${id}/available-users`).then((r: AxiosResponse) => r.data),
|
||||
|
||||
createLabel: (collectionId: number, name: string, color?: string): Promise<CollectionLabel> =>
|
||||
ax.post(`${base}/labels`, { collection_id: collectionId, name, color } satisfies CollectionLabelCreateRequest).then((r: AxiosResponse) => r.data),
|
||||
updateLabel: (labelId: number, body: CollectionLabelUpdateRequest): Promise<CollectionLabel> =>
|
||||
ax.patch(`${base}/labels/${labelId}`, body satisfies CollectionLabelUpdateRequest).then((r: AxiosResponse) => r.data),
|
||||
deleteLabel: (labelId: number): Promise<unknown> =>
|
||||
ax.delete(`${base}/labels/${labelId}`).then((r: AxiosResponse) => r.data),
|
||||
assignLabels: (labelIds: number[], placeIds: number[]): Promise<{ changed: number }> =>
|
||||
ax.post(`${base}/labels/assign`, { label_ids: labelIds, place_ids: placeIds }).then((r: AxiosResponse) => r.data),
|
||||
unassignLabels: (labelIds: number[], placeIds: number[]): Promise<{ changed: number }> =>
|
||||
ax.post(`${base}/labels/unassign`, { label_ids: labelIds, place_ids: placeIds }).then((r: AxiosResponse) => r.data),
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState, type DragEvent } from 'react'
|
||||
import {
|
||||
Blocks, AlertTriangle, PackageOpen, RefreshCw, Trash2, Download, Bug, X, ShieldCheck,
|
||||
Blocks, AlertTriangle, PackageOpen, RefreshCw, Trash2, Download, Bug, X, ShieldCheck, UploadCloud,
|
||||
ArrowUpCircle, Github, ExternalLink, ChevronDown, Check, Lock, Search,
|
||||
SlidersHorizontal, ArrowUpDown, CircleDot, MoreHorizontal, RotateCw, ArrowRight, Database, Users, LayoutDashboard,
|
||||
Radio, Luggage, Plane, Globe, Image, CalendarDays, Map, Bell, Cloud, Camera, Compass,
|
||||
BookOpen, Wallet, Puzzle,
|
||||
BookOpen, Wallet, Puzzle, MapPin, ListChecks, Pencil, Tag, FileText,
|
||||
} from 'lucide-react'
|
||||
import { adminApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@@ -21,6 +21,12 @@ import ToggleSwitch from '../Settings/ToggleSwitch'
|
||||
* tile; the security section at the bottom explains the model honestly.
|
||||
*/
|
||||
|
||||
interface PluginDep { id: string; version: string }
|
||||
interface VersionMismatch { id: string; wanted: string; installed: string }
|
||||
type DependencyStatus = 'ok' | 'addonDisabled' | 'missingPlugin'
|
||||
interface PluginDependencies { requiredAddons: string[]; pluginDependencies: PluginDep[] }
|
||||
interface DependencyIssues { disabledAddons: string[]; missing: PluginDep[]; versionMismatch: VersionMismatch[] }
|
||||
|
||||
interface PluginRow {
|
||||
id: string
|
||||
name: string
|
||||
@@ -35,6 +41,9 @@ interface PluginRow {
|
||||
source_repo: string | null
|
||||
permissions: string
|
||||
capabilities: string
|
||||
dependencies?: PluginDependencies
|
||||
dependencyStatus?: DependencyStatus
|
||||
dependencyIssues?: DependencyIssues
|
||||
}
|
||||
interface RegistryItem {
|
||||
id: string
|
||||
@@ -48,6 +57,8 @@ interface RegistryItem {
|
||||
minTrekVersion: string | null
|
||||
reviewedAt: string | null
|
||||
screenshotUrl: string | null
|
||||
requiredAddons?: string[]
|
||||
pluginDependencies?: PluginDep[]
|
||||
}
|
||||
interface RegistryDetail extends RegistryItem {
|
||||
size: number | null
|
||||
@@ -58,11 +69,21 @@ interface RegistryDetail extends RegistryItem {
|
||||
settings: Array<{ key: string; label: string; inputType: string; scope: string; required: boolean }>
|
||||
license: string | null
|
||||
icon: string | null
|
||||
requiredAddons?: string[]
|
||||
pluginDependencies?: PluginDep[]
|
||||
} | null
|
||||
}
|
||||
|
||||
/** 409 error-body shape from POST /activate when a dependency blocks activation. */
|
||||
interface ActivateErr {
|
||||
response?: {
|
||||
status?: number
|
||||
data?: { code?: string; error?: string; newPermissions?: string[]; newEgress?: string[]; addons?: string[]; missing?: PluginDep[]; versionMismatch?: VersionMismatch[] }
|
||||
}
|
||||
}
|
||||
|
||||
type T = (k: string, p?: Record<string, unknown>) => string
|
||||
type TypeFilter = 'all' | 'widget' | 'page' | 'integration'
|
||||
type TypeFilter = 'all' | 'widget' | 'page' | 'integration' | 'trip-page'
|
||||
type StatusFilter = 'all' | 'on' | 'off' | 'update' | 'err'
|
||||
type SortKey = 'name' | 'recent' | 'updates'
|
||||
|
||||
@@ -83,11 +104,15 @@ const ICON_MAP: Record<string, React.ComponentType<{ size?: number; className?:
|
||||
|
||||
// Known permissions → human-readable i18n key; unknown ones render as raw code.
|
||||
const PERM_KEYS = [
|
||||
'db:own', 'db:read:trips', 'db:read:users', 'ws:broadcast:trip', 'ws:broadcast:user',
|
||||
'hook:photo-provider', 'hook:calendar-source', 'http:outbound',
|
||||
'db:own', 'db:read:trips', 'db:read:users', 'db:read:costs', 'db:read:packing', 'db:read:files', 'db:write:costs',
|
||||
'db:write:places', 'db:write:days', 'db:write:itinerary', 'db:write:trips',
|
||||
'db:meta',
|
||||
'events:subscribe',
|
||||
'ws:broadcast:trip', 'ws:broadcast:user',
|
||||
'hook:photo-provider', 'hook:calendar-source', 'hook:place-detail-provider', 'hook:trip-warning-provider', 'http:outbound',
|
||||
]
|
||||
|
||||
const KNOWN_TYPES = ['widget', 'page', 'integration']
|
||||
const KNOWN_TYPES = ['widget', 'page', 'integration', 'trip-page']
|
||||
|
||||
function isNewer(a: string, b: string): boolean {
|
||||
const nums = (v: string) => v.split('-')[0].split('.').map(n => parseInt(n, 10) || 0)
|
||||
@@ -111,16 +136,50 @@ function deriveCaps(perms: string[], caps: { widget?: { slot?: string } }, t: T)
|
||||
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') })
|
||||
if (caps.widget) out.push({ icon: LayoutDashboard, label: t(caps.widget.slot === 'hero' ? 'admin.plugins.cap.heroWidget' : 'admin.plugins.cap.widget') })
|
||||
if (perms.includes('db:write:costs')) out.push({ icon: Wallet, label: t('admin.plugins.cap.writesCosts') })
|
||||
else if (perms.includes('db:read:costs')) out.push({ icon: Wallet, label: t('admin.plugins.cap.readsCosts') })
|
||||
if (perms.includes('db:read:packing')) out.push({ icon: Luggage, label: t('admin.plugins.cap.readsPacking') })
|
||||
if (perms.includes('db:read:files')) out.push({ icon: FileText, label: t('admin.plugins.cap.readsFiles') })
|
||||
if (perms.includes('db:write:places')) out.push({ icon: MapPin, label: t('admin.plugins.cap.writesPlaces') })
|
||||
if (perms.includes('db:write:days')) out.push({ icon: CalendarDays, label: t('admin.plugins.cap.writesDays') })
|
||||
if (perms.includes('db:write:itinerary')) out.push({ icon: ListChecks, label: t('admin.plugins.cap.writesItinerary') })
|
||||
if (perms.includes('db:write:trips')) out.push({ icon: Pencil, label: t('admin.plugins.cap.writesTrips') })
|
||||
if (perms.includes('db:meta')) out.push({ icon: Tag, label: t('admin.plugins.cap.metadata') })
|
||||
if (caps.widget) {
|
||||
const slotKey = caps.widget.slot === 'hero' ? 'admin.plugins.cap.heroWidget'
|
||||
: caps.widget.slot === 'place-detail' ? 'admin.plugins.cap.placeSlot'
|
||||
: 'admin.plugins.cap.widget'
|
||||
out.push({ icon: LayoutDashboard, label: t(slotKey as never) })
|
||||
}
|
||||
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('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 })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
interface DepChip { icon: React.ComponentType<{ size?: number; className?: string }>; label: string; blocked: boolean }
|
||||
|
||||
// A plugin's declared dependencies as chips — a required addon (amber when that
|
||||
// addon is disabled) or a plugin dependency (amber when missing / version-mismatched).
|
||||
function deriveDeps(p: PluginRow, t: T): DepChip[] {
|
||||
const out: DepChip[] = []
|
||||
const issues = p.dependencyIssues
|
||||
for (const a of p.dependencies?.requiredAddons ?? []) {
|
||||
out.push({ icon: Blocks, label: t('admin.plugins.cap.requiresAddon', { addon: a }), blocked: !!issues?.disabledAddons.includes(a) })
|
||||
}
|
||||
for (const d of p.dependencies?.pluginDependencies ?? []) {
|
||||
const blocked = !!(issues?.missing.some(m => m.id === d.id) || issues?.versionMismatch.some(m => m.id === d.id))
|
||||
out.push({ icon: Puzzle, label: t('admin.plugins.cap.dependsOn', { id: d.id, version: d.version }), blocked })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function PluginIcon({ name, size = 20, className }: { name: string | null; size?: number; className?: string }) {
|
||||
const Icon = (name && Object.prototype.hasOwnProperty.call(ICON_MAP, name) && ICON_MAP[name]) || Blocks
|
||||
return <Icon size={size} className={className} />
|
||||
@@ -135,6 +194,16 @@ function ReviewedBadge({ t, compact }: { t: T; compact?: boolean }) {
|
||||
)
|
||||
}
|
||||
|
||||
/** Marks a manually-uploaded (sideloaded) plugin: no registry, unsigned, not reviewed. */
|
||||
function SideloadedBadge({ t }: { t: T }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-[2px] rounded-md text-[11px] font-medium bg-warning-soft text-warning border border-warning/25"
|
||||
title={t('admin.plugins.sideloadedHint')}>
|
||||
<UploadCloud size={11} /> {t('admin.plugins.sideloaded')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function TypeBadge({ type, t }: { type: string; t: T }) {
|
||||
return (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-md text-[10px] font-semibold uppercase tracking-wide bg-accent-subtle text-content-muted">
|
||||
@@ -157,7 +226,11 @@ export default function AdminPluginsPanel() {
|
||||
const [detailFor, setDetailFor] = useState<RegistryItem | null>(null)
|
||||
const [errorsFor, setErrorsFor] = useState<{ id: string; rows: Array<{ ts: string; level: string; message: string }> } | null>(null)
|
||||
const [confirmUninstall, setConfirmUninstall] = useState<PluginRow | null>(null)
|
||||
const [consentUpdate, setConsentUpdate] = useState<{ plugin: PluginRow; version: string; newPermissions: string[]; newEgress: string[] } | null>(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.
|
||||
const [consentQueue, setConsentQueue] = useState<Array<{ plugin: PluginRow; version: string; newPermissions: string[]; newEgress: string[] }>>([])
|
||||
// Open when enabling a plugin is blocked by missing/outdated plugin dependencies.
|
||||
const [depResolve, setDepResolve] = useState<{ plugin: PluginRow; missing: PluginDep[]; versionMismatch: VersionMismatch[] } | null>(null)
|
||||
const [menu, setMenu] = useState<string | null>(null)
|
||||
|
||||
// Toolbar state.
|
||||
@@ -166,6 +239,11 @@ export default function AdminPluginsPanel() {
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
|
||||
const [sort, setSort] = useState<SortKey>('name')
|
||||
|
||||
// Sideload upload: drag a plugin .zip onto the panel or use the toolbar button.
|
||||
const [dragActive, setDragActive] = useState(false)
|
||||
const uploadInputRef = useRef<HTMLInputElement>(null)
|
||||
const dragDepth = useRef(0)
|
||||
|
||||
const refresh = () => {
|
||||
adminApi.plugins()
|
||||
.then((d: { enabled: boolean; plugins: PluginRow[] }) => {
|
||||
@@ -197,6 +275,43 @@ export default function AdminPluginsPanel() {
|
||||
setView('discover')
|
||||
if (!registry) adminApi.pluginBrowse().then(setRegistry).catch(() => setRegistry([]))
|
||||
}
|
||||
// The rescan/reload button rediscovers locally-installed plugins AND force-pulls
|
||||
// the remote registry (bypassing the 30-min server cache + GitHub's CDN), so a
|
||||
// just-published plugin shows up right away instead of up to ~35 min later.
|
||||
const rescan = () => act('__rescan', async () => {
|
||||
await adminApi.pluginRescan()
|
||||
const items: RegistryItem[] = await adminApi.pluginBrowse(true)
|
||||
setRegistry(items)
|
||||
const map: Record<string, string> = {}
|
||||
items.forEach((i) => { if (i.latest) map[i.id] = i.latest })
|
||||
setLatest(map)
|
||||
}, t('admin.plugins.rescanned'))
|
||||
|
||||
// Sideload a plugin archive (installs INACTIVE — the admin still consents on activation).
|
||||
const uploadPlugin = async (file: File) => {
|
||||
setBusy('__upload'); setMenu(null)
|
||||
try {
|
||||
const res = await adminApi.pluginUpload(file)
|
||||
setView('installed')
|
||||
toast.success(t('admin.plugins.uploaded', { name: res.id }))
|
||||
} catch (e) {
|
||||
toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error || t('admin.plugins.actionError'))
|
||||
} finally {
|
||||
setBusy(null); refresh()
|
||||
}
|
||||
}
|
||||
const pickUpload = () => uploadInputRef.current?.click()
|
||||
const onDragEnter = (e: DragEvent) => {
|
||||
if (!runtimeOn || !Array.from(e.dataTransfer.types).includes('Files')) return
|
||||
e.preventDefault(); dragDepth.current++; setDragActive(true)
|
||||
}
|
||||
const onDragLeave = () => { if (--dragDepth.current <= 0) { dragDepth.current = 0; setDragActive(false) } }
|
||||
const onDrop = (e: DragEvent) => {
|
||||
e.preventDefault(); dragDepth.current = 0; setDragActive(false)
|
||||
if (!runtimeOn) return
|
||||
const f = e.dataTransfer.files?.[0]
|
||||
if (f) void uploadPlugin(f)
|
||||
}
|
||||
const openErrors = (id: string) => {
|
||||
setMenu(null)
|
||||
adminApi.pluginErrors(id)
|
||||
@@ -209,12 +324,72 @@ export default function AdminPluginsPanel() {
|
||||
const restart = (id: string) => act(id, async () => { await adminApi.pluginDeactivate(id); await adminApi.pluginActivate(id) }, t('admin.plugins.restarted'))
|
||||
const installedIds = new Set(plugins.map(p => p.id))
|
||||
|
||||
// Installed-but-disabled direct deps that enabling `p` will auto-enable first.
|
||||
const autoEnabledDeps = (p: PluginRow) =>
|
||||
(p.dependencies?.pluginDependencies ?? [])
|
||||
.map(d => plugins.find(x => x.id === d.id))
|
||||
.filter((x): x is PluginRow => !!x && x.enabled === 0)
|
||||
.map(x => x.name)
|
||||
|
||||
// Shared handling for a failed activation: route each 409 code to the right fix
|
||||
// (consent dialog, download-dependency dialog, or a clear toast).
|
||||
const onActivateError = (p: PluginRow, e: ActivateErr) => {
|
||||
const d = e?.response?.data
|
||||
if (e?.response?.status === 409 && d?.code === 'CONSENT_REQUIRED') {
|
||||
setConsentQueue(qq => [...qq, { plugin: p, version: latest[p.id] ?? p.version ?? '', newPermissions: d.newPermissions ?? [], newEgress: d.newEgress ?? [] }])
|
||||
} else if (e?.response?.status === 409 && d?.code === 'ADDON_DISABLED') {
|
||||
toast.error(t('admin.plugins.dep.addonDisabledToast', { addons: (d.addons ?? []).join(', ') }))
|
||||
} else if (e?.response?.status === 409 && d?.code === 'DEPENDENCY_MISSING') {
|
||||
setDepResolve({ plugin: p, missing: d.missing ?? [], versionMismatch: d.versionMismatch ?? [] })
|
||||
} else {
|
||||
// DEPENDENCY_CYCLE and everything else surface their server message.
|
||||
toast.error(d?.error || t('admin.plugins.actionError'))
|
||||
}
|
||||
}
|
||||
|
||||
const attemptActivate = (p: PluginRow) => {
|
||||
const cascaded = autoEnabledDeps(p)
|
||||
return adminApi.pluginActivate(p.id)
|
||||
.then(() => {
|
||||
toast.success(t('admin.plugins.activated'))
|
||||
if (cascaded.length) toast.success(t('admin.plugins.dep.autoEnabled', { plugins: cascaded.join(', ') }))
|
||||
setDepResolve(null)
|
||||
})
|
||||
.catch((e: ActivateErr) => onActivateError(p, e))
|
||||
}
|
||||
|
||||
// Enable/disable a plugin. Re-enabling one whose update widened its permissions
|
||||
// must NOT grant them silently (409 CONSENT_REQUIRED → consent dialog); a disabled
|
||||
// required addon or a missing plugin dependency (409 ADDON_DISABLED /
|
||||
// DEPENDENCY_MISSING) routes to the right remedy.
|
||||
const toggle = (p: PluginRow) => {
|
||||
if (busy === p.id) return
|
||||
if (p.enabled === 1) { void act(p.id, () => adminApi.pluginDeactivate(p.id), t('admin.plugins.deactivated')); return }
|
||||
setBusy(p.id); setMenu(null)
|
||||
attemptActivate(p).finally(() => { setBusy(null); refresh() })
|
||||
}
|
||||
|
||||
// Download a missing/outdated plugin dependency (latest compatible for its range,
|
||||
// transitively), then retry enabling the plugin that needed it.
|
||||
const resolveDependency = (parent: PluginRow, depId: string, constraint?: string) => {
|
||||
if (busy === parent.id) return
|
||||
setBusy(parent.id)
|
||||
adminApi.pluginInstall(depId, { constraint, withDependencies: true })
|
||||
.then((r: { installed?: string[]; requiredAddons?: string[] }) => {
|
||||
toast.success(t('admin.plugins.dep.downloaded', { id: depId }))
|
||||
if (r?.requiredAddons?.length) toast.error(t('admin.plugins.dep.addonDisabledToast', { addons: r.requiredAddons.join(', ') }))
|
||||
return attemptActivate(parent)
|
||||
})
|
||||
.catch((e: ActivateErr) => onActivateError(parent, e))
|
||||
.finally(() => { setBusy(null); refresh() })
|
||||
}
|
||||
|
||||
const runUpdate = (p: PluginRow) => {
|
||||
setBusy(p.id); setMenu(null)
|
||||
adminApi.pluginUpdate(p.id)
|
||||
.then((r: { version: string; activated: boolean; newPermissions: string[]; newEgress: string[] }) => {
|
||||
if (r.activated || (r.newPermissions.length === 0 && r.newEgress.length === 0)) toast.success(t('admin.plugins.updated'))
|
||||
else setConsentUpdate({ plugin: p, version: r.version, newPermissions: r.newPermissions, newEgress: r.newEgress })
|
||||
else setConsentQueue(qq => [...qq, { plugin: p, version: r.version, newPermissions: r.newPermissions, newEgress: r.newEgress }])
|
||||
})
|
||||
.catch(e => toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error || t('admin.plugins.actionError')))
|
||||
.finally(() => { setBusy(null); refresh() })
|
||||
@@ -263,7 +438,18 @@ export default function AdminPluginsPanel() {
|
||||
const anyFilter = q.trim() !== '' || typeFilter !== 'all' || statusFilter !== 'all'
|
||||
|
||||
return (
|
||||
<div className="relative bg-surface-card border border-edge rounded-2xl shadow-card mb-24 sm:mb-0">
|
||||
<div className="relative bg-surface-card border border-edge rounded-2xl shadow-card mb-24 sm:mb-0"
|
||||
onDragEnter={onDragEnter} onDragOver={e => { if (dragActive) e.preventDefault() }} onDragLeave={onDragLeave} onDrop={onDrop}>
|
||||
{/* Hidden input for the toolbar "Upload plugin" button (drag-drop uses the same handler). */}
|
||||
<input ref={uploadInputRef} type="file" accept=".zip,.tgz,.tar.gz" className="hidden"
|
||||
onChange={e => { const f = e.target.files?.[0]; if (f) void uploadPlugin(f); e.target.value = '' }} />
|
||||
{/* Drag-to-install overlay */}
|
||||
{dragActive && (
|
||||
<div className="absolute inset-0 z-40 rounded-2xl border-2 border-dashed border-accent bg-accent-soft/70 backdrop-blur-[2px] flex flex-col items-center justify-center gap-2.5 pointer-events-none">
|
||||
<UploadCloud size={34} className="text-accent" />
|
||||
<span className="text-sm font-semibold text-accent">{t('admin.plugins.dropToUpload')}</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Click-away layer for any open dropdown (filters or a row's ⋯ menu). */}
|
||||
{menu && <div className="fixed inset-0 z-20" onClick={() => setMenu(null)} />}
|
||||
{/* Header */}
|
||||
@@ -302,10 +488,16 @@ export default function AdminPluginsPanel() {
|
||||
<SegBtn active={view === 'installed'} onClick={() => setView('installed')} label={t('admin.plugins.installed')} count={plugins.length} />
|
||||
<SegBtn active={view === 'discover'} onClick={openDiscover} label={t('admin.plugins.tabDiscover')} count={registry?.length} />
|
||||
</div>
|
||||
<button onClick={() => act('__rescan', adminApi.pluginRescan, t('admin.plugins.rescanned'))} title={t('admin.plugins.rescan')}
|
||||
className="sm:hidden h-[38px] w-[38px] grid place-items-center rounded-xl border border-edge bg-surface-card text-content-muted hover:text-content hover:border-content-faint transition-colors shrink-0">
|
||||
<RefreshCw size={15} />
|
||||
</button>
|
||||
<div className="sm:hidden flex items-center gap-2 shrink-0">
|
||||
<button onClick={pickUpload} title={t('admin.plugins.upload')}
|
||||
className="h-[38px] w-[38px] grid place-items-center rounded-xl border border-edge bg-surface-card text-content-muted hover:text-content hover:border-content-faint transition-colors">
|
||||
<UploadCloud size={15} />
|
||||
</button>
|
||||
<button onClick={rescan} title={t('admin.plugins.rescan')}
|
||||
className="h-[38px] w-[38px] grid place-items-center rounded-xl border border-edge bg-surface-card text-content-muted hover:text-content hover:border-content-faint transition-colors">
|
||||
<RefreshCw size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative w-full sm:flex-1 sm:min-w-[160px]">
|
||||
@@ -322,6 +514,7 @@ export default function AdminPluginsPanel() {
|
||||
options={[
|
||||
['all', t('admin.plugins.allTypes')], ['widget', t('admin.plugins.type.widget')],
|
||||
['integration', t('admin.plugins.type.integration')], ['page', t('admin.plugins.type.page')],
|
||||
['trip-page', t('admin.plugins.type.trip-page')],
|
||||
]}
|
||||
valueLabel={typeFilter === 'all' ? t('admin.plugins.allTypes') : t(`admin.plugins.type.${typeFilter}` as never)}
|
||||
onPick={v => setTypeFilter(v as TypeFilter)} />
|
||||
@@ -341,7 +534,11 @@ export default function AdminPluginsPanel() {
|
||||
valueLabel={sortLabel(sort, t)}
|
||||
onPick={v => setSort(v as SortKey)} />
|
||||
|
||||
<button onClick={() => act('__rescan', adminApi.pluginRescan, t('admin.plugins.rescanned'))} title={t('admin.plugins.rescan')}
|
||||
<button onClick={pickUpload} title={t('admin.plugins.upload')}
|
||||
className="hidden sm:grid h-[38px] w-[38px] place-items-center rounded-xl border border-edge bg-surface-card text-content-muted hover:text-content hover:border-content-faint transition-colors">
|
||||
<UploadCloud size={15} />
|
||||
</button>
|
||||
<button onClick={rescan} title={t('admin.plugins.rescan')}
|
||||
className="hidden sm:grid h-[38px] w-[38px] place-items-center rounded-xl border border-edge bg-surface-card text-content-muted hover:text-content hover:border-content-faint transition-colors">
|
||||
<RefreshCw size={15} />
|
||||
</button>
|
||||
@@ -380,8 +577,7 @@ export default function AdminPluginsPanel() {
|
||||
) : shownInstalled.map(p => (
|
||||
<InstalledRow key={p.id} p={p} t={t} busy={busy} menu={menu} setMenu={setMenu}
|
||||
hasUpdate={updateAvailable(p)} latestVer={latest[p.id]}
|
||||
onToggle={() => busy !== p.id && act(p.id, () => p.enabled === 1 ? adminApi.pluginDeactivate(p.id) : adminApi.pluginActivate(p.id),
|
||||
p.enabled === 1 ? t('admin.plugins.deactivated') : t('admin.plugins.activated'))}
|
||||
onToggle={() => toggle(p)}
|
||||
onUpdate={() => runUpdate(p)} onRestart={() => restart(p.id)}
|
||||
onErrors={() => openErrors(p.id)} onUninstall={() => { setMenu(null); setConfirmUninstall(p) }} />
|
||||
))}
|
||||
@@ -430,14 +626,23 @@ export default function AdminPluginsPanel() {
|
||||
message={t('admin.plugins.uninstallBody')}
|
||||
/>
|
||||
|
||||
{consentUpdate && (
|
||||
{consentQueue[0] && (
|
||||
<UpdateConsentDialog
|
||||
data={consentUpdate} t={t}
|
||||
data={consentQueue[0]} t={t}
|
||||
onApprove={async () => {
|
||||
const c = consentUpdate; setConsentUpdate(null)
|
||||
await act(c.plugin.id, () => adminApi.pluginActivate(c.plugin.id), t('admin.plugins.updated'))
|
||||
const c = consentQueue[0]; setConsentQueue(qq => qq.slice(1))
|
||||
// consent:true — the ONLY path that may widen a plugin's granted rights.
|
||||
await act(c.plugin.id, () => adminApi.pluginActivate(c.plugin.id, true), t('admin.plugins.updated'))
|
||||
}}
|
||||
onLater={() => { setConsentUpdate(null); toast.success(t('admin.plugins.updateKeptOff')) }}
|
||||
onLater={() => { setConsentQueue(qq => qq.slice(1)); toast.success(t('admin.plugins.updateKeptOff')) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{depResolve && (
|
||||
<DependencyResolveDialog
|
||||
data={depResolve} t={t} busy={busy === depResolve.plugin.id} installedIds={installedIds}
|
||||
onDownload={(depId, constraint) => resolveDependency(depResolve.plugin, depId, constraint)}
|
||||
onClose={() => setDepResolve(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -519,6 +724,7 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, onToggl
|
||||
onToggle: () => void; onUpdate: () => void; onRestart: () => void; onErrors: () => void; onUninstall: () => void
|
||||
}) {
|
||||
const caps = deriveCaps(parseJson<string[]>(p.permissions, []), parseJson<{ widget?: { slot?: string } }>(p.capabilities, {}), t)
|
||||
const deps = deriveDeps(p, t)
|
||||
const menuOpen = menu === `row:${p.id}`
|
||||
return (
|
||||
<div className="group relative flex items-center gap-3 sm:gap-4 px-2.5 sm:px-3 py-3.5 rounded-2xl hover:bg-surface-secondary transition-colors">
|
||||
@@ -535,6 +741,7 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, onToggl
|
||||
<span className="text-[14.5px] font-semibold tracking-[-.006em] text-content">{p.name}</span>
|
||||
{p.version && <span className="text-[11.5px] text-content-faint font-medium tabular-nums">v{p.version}</span>}
|
||||
{p.reviewed_at && <ReviewedBadge t={t} compact />}
|
||||
{p.source_repo === 'local:upload' && <SideloadedBadge t={t} />}
|
||||
</div>
|
||||
{p.description && <p className="text-[12.5px] text-content-muted mt-0.5 truncate">{p.description}</p>}
|
||||
{p.status === 'error' && p.last_error ? (
|
||||
@@ -551,6 +758,16 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, onToggl
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{deps.length > 0 && (
|
||||
<div className="hidden sm:flex items-center gap-1.5 flex-wrap mt-1.5">
|
||||
{deps.map((d, i) => (
|
||||
<span key={i} className={`inline-flex items-center gap-1.5 text-[11px] font-medium px-2 py-[3px] rounded-md border ${
|
||||
d.blocked ? 'text-warning border-warning/30 bg-warning-soft' : 'text-content-secondary border-edge-secondary bg-surface-tertiary'}`}>
|
||||
<d.icon size={12} className={d.blocked ? 'text-warning' : 'text-content-muted'} />{d.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 sm:gap-2 shrink-0">
|
||||
@@ -575,7 +792,7 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, onToggl
|
||||
<MenuItem icon={<RotateCw size={14} />} label={t('admin.plugins.restart')} onClick={onRestart} />
|
||||
)}
|
||||
<MenuItem icon={<Bug size={14} />} label={t('admin.plugins.viewErrors')} onClick={onErrors} />
|
||||
{p.source_repo && (
|
||||
{p.source_repo && p.source_repo !== 'local:upload' && (
|
||||
<a href={`https://github.com/${p.source_repo}`} target="_blank" rel="noreferrer" onClick={() => 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">
|
||||
<Github size={14} /> {t('admin.plugins.sourceRepo')}
|
||||
@@ -853,6 +1070,57 @@ function UpdateConsentDialog({ data, t, onApprove, onLater }: {
|
||||
)
|
||||
}
|
||||
|
||||
// Shown when enabling a plugin is blocked by missing/outdated plugin dependencies.
|
||||
// Each dependency gets a one-click download (latest version satisfying its range,
|
||||
// transitively) that then retries enabling the plugin.
|
||||
function DependencyResolveDialog({ data, t, busy, installedIds, onDownload, onClose }: {
|
||||
data: { plugin: PluginRow; missing: PluginDep[]; versionMismatch: VersionMismatch[] }
|
||||
t: T; busy: boolean; installedIds: Set<string>
|
||||
onDownload: (depId: string, constraint?: string) => void; onClose: () => void
|
||||
}) {
|
||||
const rows: Array<{ id: string; constraint: string; installed?: string }> = [
|
||||
...data.missing.map(d => ({ id: d.id, constraint: d.version })),
|
||||
...data.versionMismatch.map(d => ({ id: d.id, constraint: d.wanted, installed: d.installed })),
|
||||
]
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
|
||||
<div className="bg-surface-card border border-edge rounded-2xl w-full max-w-md shadow-modal overflow-hidden" onClick={e => e.stopPropagation()}>
|
||||
<div className="px-5 py-4 border-b border-edge-secondary flex items-start gap-3">
|
||||
<div className="w-9 h-9 rounded-lg bg-warning-soft grid place-items-center shrink-0"><Puzzle size={18} className="text-warning" /></div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-content">{t('admin.plugins.dep.resolveTitle')}</h3>
|
||||
<p className="text-xs text-content-muted mt-1">{t('admin.plugins.dep.resolveBody', { name: data.plugin.name })}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5 space-y-2.5">
|
||||
{rows.map(r => (
|
||||
<div key={r.id} className="flex items-center gap-3 p-3 rounded-xl border border-edge-secondary bg-surface-tertiary">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[13px] font-semibold text-content truncate">{r.id}</div>
|
||||
<div className="text-[11.5px] text-content-muted mt-0.5">
|
||||
{r.installed
|
||||
? t('admin.plugins.dep.mismatch', { wanted: r.constraint, installed: r.installed })
|
||||
: t('admin.plugins.dep.requires', { version: r.constraint })}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => onDownload(r.id, r.constraint)} disabled={busy}
|
||||
className="inline-flex items-center gap-1.5 text-[11.5px] font-semibold px-3 py-1.5 rounded-full bg-accent text-accent-text hover:bg-accent-hover transition-colors disabled:opacity-50 shrink-0">
|
||||
<Download size={13} /> {r.installed ? t('admin.plugins.dep.update') : t('admin.plugins.dep.download')}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{rows.some(r => !installedIds.has(r.id)) && (
|
||||
<p className="text-[11.5px] text-content-faint pt-1">{t('admin.plugins.dep.resolveHint')}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-5 py-3.5 border-t border-edge-secondary bg-surface-secondary flex items-center justify-end">
|
||||
<button onClick={onClose} className="text-xs font-medium px-3.5 py-2 rounded-lg border border-edge text-content-muted hover:text-content hover:bg-surface-tertiary transition-colors">{t('common.cancel')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Footer: a plain-language note on what "Reviewed" means, plus a collapsible
|
||||
// panel that lays out how plugins are contained, the limits, and the worst case.
|
||||
function SecurityInfo({ t }: { t: T }) {
|
||||
|
||||
@@ -2,8 +2,8 @@ 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', 'BDT',
|
||||
'LKR', 'VND', 'CLP', 'COP', 'PEN', 'ARS',
|
||||
'EGP', 'MAD', 'HUF', 'RON', 'BGN', 'HRK', 'ISK', 'RUB', 'UAH', 'KGS',
|
||||
'BDT', 'LKR', 'VND', 'CLP', 'COP', 'PEN', 'ARS',
|
||||
]
|
||||
|
||||
export const SYMBOLS: Record<string, string> = {
|
||||
@@ -13,8 +13,8 @@ export const SYMBOLS: Record<string, string> = {
|
||||
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: '₴', BDT: '৳', LKR: 'Rs', VND: '₫', CLP: 'CL$', COP: 'CO$',
|
||||
PEN: 'S/.', ARS: 'AR$',
|
||||
UAH: '₴', KGS: 'сом', BDT: '৳', LKR: 'Rs', VND: '₫', CLP: 'CL$',
|
||||
COP: 'CO$', PEN: 'S/.', ARS: 'AR$',
|
||||
}
|
||||
|
||||
export const PIE_COLORS = ['#6366f1', '#ec4899', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', '#ef4444', '#14b8a6', '#f97316', '#06b6d4', '#84cc16', '#a855f7']
|
||||
|
||||
@@ -4,6 +4,7 @@ import { http, HttpResponse } from 'msw'
|
||||
import { server } from '../../../tests/helpers/msw/server'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
||||
import { buildUser, buildTrip, buildBudgetItem } from '../../../tests/helpers/factories'
|
||||
import CostsPanel from './CostsPanel'
|
||||
@@ -164,6 +165,28 @@ describe('CostsPanel — settlements in the ledger', () => {
|
||||
expect(screen.getByText('Unfinished')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('sums only unfinished expenses in the Outstanding amount card', async () => {
|
||||
// Display in the trip's own currency so FX conversion is an identity — keeps the asserted sum deterministic.
|
||||
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'EUR' } })
|
||||
const paid = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Dinner' }), total_price: 60, payers: [{ user_id: 1, amount: 60, username: 'alice' }], members: [{ user_id: 1, username: 'alice', paid: 1 }] }
|
||||
const unfinishedA = { ...buildBudgetItem({ trip_id: 1, category: 'lodging', name: 'Hotel' }), total_price: 90, payers: [], members: [{ user_id: 1, username: 'alice', paid: 0 }] }
|
||||
const unfinishedB = { ...buildBudgetItem({ trip_id: 1, category: 'transport', name: 'Taxi' }), total_price: 30, payers: [], members: [{ user_id: 1, username: 'alice', paid: 0 }] }
|
||||
const zero = { ...buildBudgetItem({ trip_id: 1, category: 'misc', name: 'Freebie' }), total_price: 0, payers: [], members: [{ user_id: 1, username: 'alice', paid: 0 }] }
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [paid, unfinishedA, unfinishedB, zero] })),
|
||||
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
|
||||
)
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
// Footer only shows the count once unfinished expenses have loaded.
|
||||
const foot = await screen.findByText('expenses need a payer')
|
||||
expect(foot).toHaveTextContent('2 expenses need a payer') // the two payer-less, non-zero expenses
|
||||
// Sum is 90 + 30 = 120 — the paid (60) and zero-total items are excluded.
|
||||
// Sum is 90 + 30 = 120 — the paid (60) and zero-total items are excluded.
|
||||
const card = screen.getByText('Outstanding amount').closest('div[style*="border-radius: 22"]')
|
||||
expect(card).toHaveTextContent('120') // 120,00 € (locale separator), i.e. 90 + 30
|
||||
})
|
||||
|
||||
it('records a recorded-total expense with nobody to split with (#1286)', async () => {
|
||||
let posted: Record<string, unknown> | null = null
|
||||
server.use(
|
||||
|
||||
@@ -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 } from 'lucide-react'
|
||||
import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, ArrowLeftRight, Check, RotateCcw, Pencil, Trash2, AlertCircle } from 'lucide-react'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
@@ -179,6 +179,9 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
const myShare = shares[me] || 0
|
||||
return convert(myShare, curOf(e))
|
||||
}
|
||||
// "Unfinished": a recorded total nobody has paid yet — counts toward the trip
|
||||
// total but stays out of settlements until who-paid is filled in.
|
||||
const isUnfinished = (e: BudgetItem) => baseTotal(e) > 0 && (e.payers || []).filter(p => p.amount > 0).length === 0
|
||||
|
||||
const totals = useMemo(() => {
|
||||
const totalSpend = budgetItems.reduce((a, e) => a + baseTotal(e), 0)
|
||||
@@ -186,7 +189,9 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
const myShare = budgetItems.reduce((a, e) => a + myShareOf(e), 0)
|
||||
const owe = (settlement?.flows || []).filter(f => f.from.user_id === me).reduce((a, f) => a + f.amount, 0)
|
||||
const owed = (settlement?.flows || []).filter(f => f.to.user_id === me).reduce((a, f) => a + f.amount, 0)
|
||||
return { totalSpend, myPaid, myShare, owe, owed }
|
||||
const outstanding = budgetItems.reduce((a, e) => (isUnfinished(e) ? a + baseTotal(e) : a), 0)
|
||||
const outstandingCount = budgetItems.filter(isUnfinished).length
|
||||
return { totalSpend, myPaid, myShare, owe, owed, outstanding, outstandingCount }
|
||||
}, [budgetItems, settlement, me])
|
||||
|
||||
// ── filtering + day grouping ────────────────────────────────────────────
|
||||
@@ -254,7 +259,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
// ── settle actions ──────────────────────────────────────────────────────
|
||||
const settleFlow = async (fromId: number, toId: number, amount: number) => {
|
||||
try {
|
||||
await budgetApi.createSettlement(tripId, { from_user_id: fromId, to_user_id: toId, amount })
|
||||
await budgetApi.createSettlement(tripId, { from_user_id: fromId, to_user_id: toId, amount, currency: base })
|
||||
loadSettlement()
|
||||
} catch { toast.error(t('common.unknownError')) }
|
||||
}
|
||||
@@ -265,7 +270,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
const flows = settlement?.flows || []
|
||||
if (!flows.length) return
|
||||
try {
|
||||
for (const f of flows) await budgetApi.createSettlement(tripId, { from_user_id: f.from.user_id, to_user_id: f.to.user_id, amount: f.amount })
|
||||
for (const f of flows) await budgetApi.createSettlement(tripId, { from_user_id: f.from.user_id, to_user_id: f.to.user_id, amount: f.amount, currency: base })
|
||||
loadSettlement()
|
||||
} catch { toast.error(t('common.unknownError')) }
|
||||
}
|
||||
@@ -369,7 +374,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
</div>
|
||||
|
||||
{/* ── Summary cards ── */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1.15fr', gap: 16, marginBottom: 36 }} className="costs-summary">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 16, marginBottom: 36 }} className="costs-summary">
|
||||
<SummaryCard label={t('costs.youOwe')} sub={t('costs.youOweSub')} amount={totals.owe} currency={base} locale={locale}
|
||||
icon={<ArrowDown size={18} />} tone="owe"
|
||||
foot={totals.owe > 0.01
|
||||
@@ -380,6 +385,11 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
foot={totals.owed > 0.01
|
||||
? <FlowPills ids={(settlement?.flows || []).filter(f => f.to.user_id === me).map(f => f.from.user_id)} lead={t('costs.from')} Avatar={Avatar} name={personName} />
|
||||
: <span className="text-content-faint">{t('costs.nothingOwed')}</span>} />
|
||||
<SummaryCard label={t('costs.outstanding')} sub={t('costs.outstandingSub')} amount={totals.outstanding} currency={base} locale={locale}
|
||||
icon={<AlertCircle size={18} />} tone="unfinished"
|
||||
foot={totals.outstandingCount > 0
|
||||
? <span><b>{totals.outstandingCount}</b> {t('costs.outstandingItems')}</span>
|
||||
: <span className="text-content-faint">{t('costs.allSettled')}</span>} />
|
||||
<SummaryCard label={t('costs.totalSpend')} sub={t('costs.totalSpendSub')} amount={totals.totalSpend} currency={base} locale={locale}
|
||||
icon={<BarChart3 size={18} />} tone="total"
|
||||
foot={<span style={{ display: 'flex', gap: 16 }}><span>{t('costs.yourShare')} · <b>{fmt0(totals.myShare)}</b></span><span>{t('costs.youPaid')} · <b>{fmt0(totals.myPaid)}</b></span></span>} />
|
||||
@@ -475,7 +485,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
)}
|
||||
|
||||
{(editingSettlement || addingPayment) && (
|
||||
<SettlementModal tripId={tripId} people={people} me={me} editing={editingSettlement}
|
||||
<SettlementModal tripId={tripId} people={people} me={me} editing={editingSettlement} currency={base}
|
||||
onClose={() => { setEditingSettlement(null); setAddingPayment(false) }}
|
||||
onSaved={() => { setEditingSettlement(null); setAddingPayment(false); loadSettlement() }} />
|
||||
)}
|
||||
@@ -511,9 +521,12 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
.costs-root .text-content-faint { color: var(--c-ink3) !important; }
|
||||
.costs-root .exp-actions { opacity: 1; }
|
||||
@media (max-width: 1100px) {
|
||||
.costs-root .costs-summary { grid-template-columns: 1fr !important; }
|
||||
.costs-root .costs-summary { grid-template-columns: 1fr 1fr !important; }
|
||||
.costs-root .costs-grid { grid-template-columns: 1fr !important; }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.costs-root .costs-summary { grid-template-columns: 1fr !important; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
@@ -580,6 +593,18 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Outstanding */}
|
||||
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 11 }}>
|
||||
<div style={{ width: 34, height: 34, borderRadius: 10, display: 'grid', placeItems: 'center', background: '#d9770622', color: '#d97706', flexShrink: 0 }}><AlertCircle size={17} /></div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="text-content" style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{t('costs.outstanding')}</div>
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))' }}>{t('costs.outstandingSub')}</div>
|
||||
</div>
|
||||
<div style={{ marginLeft: 'auto', fontSize: 'calc(27px * var(--fs-scale-title, 1))', fontWeight: 700, letterSpacing: '-0.03em', lineHeight: 1, display: 'flex', alignItems: 'baseline', color: '#d97706' }}>{bigMoney(totals.outstanding, 16, 'var(--c-ink3)')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Settle up */}
|
||||
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14, gap: 8 }}>
|
||||
@@ -645,21 +670,19 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
const cur = curOf(e)
|
||||
const payers = (e.payers || []).filter(p => p.amount > 0)
|
||||
const net = round2(myPaidOf(e) - myShareOf(e))
|
||||
// "Unfinished": a recorded total nobody has paid yet — counts toward the trip
|
||||
// total but stays out of settlements until who-paid is filled in.
|
||||
const isUnfinished = baseTotal(e) > 0 && payers.length === 0
|
||||
const unfinished = isUnfinished(e)
|
||||
return (
|
||||
<div className="bg-surface-card border border-edge exp-row" style={{ display: 'grid', gridTemplateColumns: '46px 1fr auto', gap: 16, alignItems: 'center', borderRadius: 18, padding: '16px 20px' }}>
|
||||
<span style={{ position: 'relative', width: 46, height: 46, borderRadius: 13, display: 'grid', placeItems: 'center', background: c.color + '22', color: c.color }}>
|
||||
<Icon size={21} />
|
||||
{isMobile && isUnfinished && (
|
||||
{isMobile && unfinished && (
|
||||
<span title={t('costs.unfinishedHint')} style={{ position: 'absolute', bottom: -4, right: -4, width: 20, height: 20, borderRadius: '50%', background: '#d97706', color: '#fff', display: 'grid', placeItems: 'center', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 800, lineHeight: 1, border: '2px solid var(--bg-card)' }}>!</span>
|
||||
)}
|
||||
</span>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7, marginBottom: 6 }}>
|
||||
<span className="text-content" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 600 }}>{e.name}</span>
|
||||
{isUnfinished && !isMobile && (
|
||||
{unfinished && !isMobile && (
|
||||
<span title={t('costs.unfinishedHint')} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '2px 8px 2px 6px', borderRadius: 999, background: 'rgba(217,119,6,0.14)', color: '#d97706', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, flexShrink: 0 }}>
|
||||
<span style={{ width: 14, height: 14, borderRadius: '50%', background: '#d97706', color: '#fff', display: 'grid', placeItems: 'center', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 800 }}>!</span>
|
||||
{t('costs.unfinished')}
|
||||
@@ -786,9 +809,9 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
}
|
||||
|
||||
// ── pure subcomponents ─────────────────────────────────────────────────────
|
||||
function SummaryCard({ label, sub, amount, currency, locale, icon, foot, tone }: { label: string; sub: string; amount: number; currency: string; locale: string; icon: React.ReactNode; foot: React.ReactNode; tone: 'owe' | 'owed' | 'total' }) {
|
||||
function SummaryCard({ label, sub, amount, currency, locale, icon, foot, tone }: { label: string; sub: string; amount: number; currency: string; locale: string; icon: React.ReactNode; foot: React.ReactNode; tone: 'owe' | 'owed' | 'total' | 'unfinished' }) {
|
||||
const total = tone === 'total'
|
||||
const accent = tone === 'owe' ? '#dc2626' : tone === 'owed' ? '#16a34a' : undefined
|
||||
const accent = tone === 'owe' ? '#dc2626' : tone === 'owed' ? '#16a34a' : tone === 'unfinished' ? '#d97706' : undefined
|
||||
const muted = total ? 'rgba(255,255,255,0.55)' : 'var(--text-faint)'
|
||||
// formatToParts keeps the design's "big integer + muted symbol/decimals" styling
|
||||
// while letting Intl place the symbol and pick separators per locale + currency.
|
||||
@@ -835,8 +858,8 @@ function FlowPills({ ids, lead, Avatar, name }: { ids: number[]; lead: string; A
|
||||
// Add or edit a settle-up payment (from / to / amount). Reachable inline from the
|
||||
// ledger row and from a manual "Add payment" button, so recording "I sent money to
|
||||
// X" works the same whether or not there's an outstanding expense behind it.
|
||||
function SettlementModal({ tripId, people, me, editing, onClose, onSaved }: {
|
||||
tripId: number; people: TripMember[]; me: number; editing: Settlement | null; onClose: () => void; onSaved: () => void
|
||||
function SettlementModal({ tripId, people, me, editing, currency, onClose, onSaved }: {
|
||||
tripId: number; people: TripMember[]; me: number; editing: Settlement | null; currency: string; onClose: () => void; onSaved: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
@@ -853,7 +876,7 @@ function SettlementModal({ tripId, people, me, editing, onClose, onSaved }: {
|
||||
const save = async () => {
|
||||
if (!valid) return
|
||||
setSaving(true)
|
||||
const data = { from_user_id: Number(fromId), to_user_id: Number(toId), amount: amt }
|
||||
const data = { from_user_id: Number(fromId), to_user_id: Number(toId), amount: amt, currency }
|
||||
try {
|
||||
if (editing) await budgetApi.updateSettlement(tripId, editing.id, data)
|
||||
else await budgetApi.createSettlement(tripId, data)
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Check, Loader2, Settings2, Tags } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import type { CollectionLabel } from '@trek/shared'
|
||||
import type { TranslationFn } from '../../types'
|
||||
|
||||
interface BulkAssignLabelModalProps {
|
||||
isOpen: boolean
|
||||
labels: CollectionLabel[]
|
||||
/** Number of selected places the labels will be added to. */
|
||||
count: number
|
||||
onAssign: (labelIds: number[]) => Promise<void> | void
|
||||
/** Open the label manager to create labels first. */
|
||||
onManage: () => void
|
||||
onClose: () => void
|
||||
t: TranslationFn
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick one or more of the list's labels to add to every selected place. Additive
|
||||
* — it never removes labels a place already has. When the list has no labels yet,
|
||||
* it points the user at the label manager instead.
|
||||
*/
|
||||
export default function BulkAssignLabelModal({ isOpen, labels, count, onAssign, onManage, onClose, t }: BulkAssignLabelModalProps): React.ReactElement {
|
||||
const [picked, setPicked] = useState<number[]>([])
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const toggle = (id: number) => setPicked(picked.includes(id) ? picked.filter(x => x !== id) : [...picked, id])
|
||||
|
||||
const assign = async () => {
|
||||
if (picked.length === 0 || busy) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await onAssign(picked)
|
||||
setPicked([])
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={t('collections.labels.assignN', { count })} size="sm">
|
||||
{labels.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 py-6 text-center">
|
||||
<Tags size={26} className="text-content-faint" />
|
||||
<p className="text-[13px] text-content-faint">{t('collections.labels.emptyHint')}</p>
|
||||
<button type="button" onClick={onManage} className="flex items-center gap-1.5 px-3 py-2 rounded-lg border border-edge text-[13px] text-content hover:bg-surface-hover">
|
||||
<Settings2 size={14} /> {t('collections.labels.manage')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1 max-h-[46vh] overflow-y-auto -mx-1 px-1">
|
||||
{labels.map(l => {
|
||||
const on = picked.includes(l.id)
|
||||
return (
|
||||
<button
|
||||
key={l.id}
|
||||
type="button"
|
||||
onClick={() => toggle(l.id)}
|
||||
className={`flex items-center gap-2.5 px-3 py-2.5 rounded-xl border text-left transition-colors ${on ? 'border-accent bg-accent/10' : 'border-edge bg-surface-card hover:bg-surface-hover'}`}
|
||||
>
|
||||
<span className="w-3.5 h-3.5 rounded-full shrink-0" style={{ background: l.color || '#6366f1' }} />
|
||||
<span className="flex-1 min-w-0 text-[13px] font-medium text-content truncate">{l.name}</span>
|
||||
{on && <Check size={15} className="text-accent shrink-0" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2 border-t border-edge">
|
||||
<button type="button" onClick={onManage} className="mr-auto flex items-center gap-1.5 px-3 py-2 rounded-lg text-[13px] text-content-secondary hover:bg-surface-hover">
|
||||
<Settings2 size={14} /> {t('collections.labels.manage')}
|
||||
</button>
|
||||
<button type="button" onClick={onClose} className="px-3 py-2 rounded-lg border border-edge text-content-secondary text-[13px] hover:bg-surface-hover">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={assign}
|
||||
disabled={picked.length === 0 || busy}
|
||||
className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-accent text-white text-[13px] font-semibold hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{busy ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />} {t('collections.labels.assign')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -29,6 +29,12 @@ function makeProps(overrides: Partial<HarnessProps> = {}): HarnessProps {
|
||||
categoryOptions: CATEGORY_OPTIONS,
|
||||
onStatusFilter: vi.fn(),
|
||||
onCategoryFilter: vi.fn(),
|
||||
showLabels: false,
|
||||
labelOptions: [],
|
||||
labelFilter: [],
|
||||
onLabelFilter: vi.fn(),
|
||||
canManageLabels: false,
|
||||
onManageLabels: vi.fn(),
|
||||
showSelect: true,
|
||||
selectMode: false,
|
||||
onToggleSelect: vi.fn(),
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { ChevronDown, Check, Layers, Tag, CheckSquare } from 'lucide-react'
|
||||
import { ChevronDown, Check, Layers, Tag, Tags, CheckSquare } from 'lucide-react'
|
||||
import type { StatusFilter } from '../../store/collectionStore'
|
||||
import type { TranslationFn } from '../../types'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { STATUS_META, STATUS_ORDER } from '../../pages/collections/collectionsModel'
|
||||
import type { CategoryOption } from '../../pages/collections/collectionsModel'
|
||||
import type { CategoryOption, LabelOption } from '../../pages/collections/collectionsModel'
|
||||
|
||||
interface Opt {
|
||||
key: string | number
|
||||
@@ -69,6 +69,13 @@ interface CollectionFilterBarProps {
|
||||
categoryOptions: CategoryOption[]
|
||||
onStatusFilter: (f: StatusFilter) => void
|
||||
onCategoryFilter: (f: number | 'all') => void
|
||||
// Per-collection labels (hidden on the "All saved" union).
|
||||
showLabels: boolean
|
||||
labelOptions: LabelOption[]
|
||||
labelFilter: number[]
|
||||
onLabelFilter: (ids: number[]) => void
|
||||
canManageLabels: boolean
|
||||
onManageLabels: () => void
|
||||
showSelect: boolean
|
||||
selectMode: boolean
|
||||
onToggleSelect: () => void
|
||||
@@ -82,6 +89,7 @@ interface CollectionFilterBarProps {
|
||||
*/
|
||||
export default function CollectionFilterBar({
|
||||
statusFilter, counts, categoryFilter, categoryOptions, onStatusFilter, onCategoryFilter,
|
||||
showLabels, labelOptions, labelFilter, onLabelFilter, canManageLabels, onManageLabels,
|
||||
showSelect, selectMode, onToggleSelect, t,
|
||||
}: CollectionFilterBarProps): React.ReactElement {
|
||||
const statusOpts: Opt[] = [
|
||||
@@ -112,6 +120,33 @@ export default function CollectionFilterBar({
|
||||
<CheckSquare size={14} /> <span className="col-filter-lbl">{t('collections.select')}</span>
|
||||
</button>
|
||||
)}
|
||||
{showLabels && (labelOptions.length > 0 || canManageLabels) && (
|
||||
<div className="col-labelfilter">
|
||||
{labelOptions.map(l => {
|
||||
const on = labelFilter.includes(l.id)
|
||||
return (
|
||||
<button
|
||||
key={l.id}
|
||||
type="button"
|
||||
className={`col-labelchip${on ? ' on' : ''}`}
|
||||
style={{ ['--label' as string]: l.color ?? 'var(--accent)' }}
|
||||
onClick={() => onLabelFilter(on ? labelFilter.filter(id => id !== l.id) : [...labelFilter, l.id])}
|
||||
aria-pressed={on}
|
||||
>
|
||||
<span className="col-labelchip-dot" />
|
||||
<span className="col-filter-lbl">{l.name}</span>
|
||||
{l.count > 0 && <span className="col-filter-count">{l.count}</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{canManageLabels && (
|
||||
<button type="button" className="col-labelchip col-labelchip-manage" onClick={onManageLabels} title={t('collections.labels.manage')}>
|
||||
<Tags size={13} />
|
||||
<span className="col-filter-lbl">{labelOptions.length ? t('collections.labels.manage') : t('collections.labels.add')}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ function renderList(over: Partial<{
|
||||
render(
|
||||
<CollectionList
|
||||
places={places}
|
||||
labels={[]}
|
||||
selectedPlaceId={over.selectedPlaceId ?? null}
|
||||
selectMode={over.selectMode ?? false}
|
||||
selectedIds={over.selectedIds ?? []}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import React, { useEffect, useMemo, useRef } from 'react'
|
||||
import { Check, MapPin } from 'lucide-react'
|
||||
import type { CollectionPlace, CollectionStatus } from '@trek/shared'
|
||||
import type { CollectionPlace, CollectionStatus, CollectionLabel } from '@trek/shared'
|
||||
import type { TranslationFn } from '../../types'
|
||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
@@ -8,6 +8,7 @@ import StatusBadge from './StatusBadge'
|
||||
|
||||
interface CollectionListProps {
|
||||
places: CollectionPlace[]
|
||||
labels: CollectionLabel[]
|
||||
selectedPlaceId: number | null
|
||||
selectMode: boolean
|
||||
selectedIds: number[]
|
||||
@@ -23,8 +24,9 @@ interface CollectionListProps {
|
||||
* open the place (or toggle it in select mode).
|
||||
*/
|
||||
export default function CollectionList({
|
||||
places, selectedPlaceId, selectMode, selectedIds, onOpenPlace, onStatusChange, onToggleSelect, t,
|
||||
places, labels, selectedPlaceId, selectMode, selectedIds, onOpenPlace, onStatusChange, onToggleSelect, t,
|
||||
}: CollectionListProps): React.ReactElement {
|
||||
const labelsById = useMemo(() => new Map(labels.map(l => [l.id, l])), [labels])
|
||||
// Bring the selected row into view — e.g. when it was picked from the map.
|
||||
const selectedRef = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
@@ -36,6 +38,7 @@ export default function CollectionList({
|
||||
{places.map(place => {
|
||||
const selected = selectedIds.includes(place.id)
|
||||
const active = selectedPlaceId === place.id
|
||||
const placeLabels = (place.label_ids ?? []).map(id => labelsById.get(id)).filter(Boolean) as CollectionLabel[]
|
||||
return (
|
||||
<div
|
||||
key={place.id}
|
||||
@@ -61,6 +64,12 @@ export default function CollectionList({
|
||||
)}
|
||||
</div>
|
||||
<div className="col-lrow-end">
|
||||
{placeLabels.slice(0, 2).map(l => (
|
||||
<span key={l.id} className="col-lrow-label" style={{ ['--label' as string]: l.color || 'var(--accent)' }} title={l.name}>
|
||||
<span className="col-labelchip-dot" /> {l.name}
|
||||
</span>
|
||||
))}
|
||||
{placeLabels.length > 2 && <span className="col-lrow-label more" title={placeLabels.map(l => l.name).join(', ')}>+{placeLabels.length - 2}</span>}
|
||||
{place.category?.name && (() => {
|
||||
const CatIcon = getCategoryIcon(place.category.icon ?? undefined)
|
||||
return (
|
||||
|
||||
@@ -39,6 +39,7 @@ function renderDetail(overrides: Partial<Omit<DetailProps, 't'>> = {}) {
|
||||
canEdit: true,
|
||||
canDelete: true,
|
||||
categories: [],
|
||||
labels: [],
|
||||
anchorRect: null,
|
||||
onClose: vi.fn(),
|
||||
onSetStatus: vi.fn(),
|
||||
|
||||
@@ -2,8 +2,8 @@ import React, { useEffect, useRef, useState } from 'react'
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import { X, Pencil, Copy, Trash2, MapPin, Link2, Plus, ExternalLink, Check, Tag } from 'lucide-react'
|
||||
import type { CollectionPlace, CollectionStatus, CollectionLink } from '@trek/shared'
|
||||
import { X, Pencil, Copy, Trash2, MapPin, Link2, Plus, ExternalLink, Check, Tag, Tags } from 'lucide-react'
|
||||
import type { CollectionPlace, CollectionStatus, CollectionLink, CollectionLabel } from '@trek/shared'
|
||||
import type { Category, TranslationFn } from '../../types'
|
||||
import MarkdownToolbar from '../Journey/MarkdownToolbar'
|
||||
import { mapsApi } from '../../api/client'
|
||||
@@ -22,11 +22,13 @@ interface CollectionPlaceDetailProps {
|
||||
canEdit: boolean
|
||||
canDelete: boolean
|
||||
categories: Category[]
|
||||
/** The active list's custom labels, for the assign chips. */
|
||||
labels: CollectionLabel[]
|
||||
/** When set, dock the sheet over that column (desktop split) instead of centred. */
|
||||
anchorRect?: { left: number; width: number } | null
|
||||
onClose: () => void
|
||||
onSetStatus: (status: CollectionStatus) => void
|
||||
onSave: (patch: { name?: string; description?: string | null; links?: CollectionLink[]; category_id?: number | null }) => Promise<void>
|
||||
onSave: (patch: { name?: string; description?: string | null; links?: CollectionLink[]; category_id?: number | null; label_ids?: number[] }) => Promise<void>
|
||||
onCopyToTrip: () => void
|
||||
onRemove: () => void
|
||||
t: TranslationFn
|
||||
@@ -56,7 +58,7 @@ function StatusSegment({ status, onSet, t }: { status: CollectionStatus; onSet:
|
||||
* is an always-live segmented control (auto-saves).
|
||||
*/
|
||||
export default function CollectionPlaceDetail({
|
||||
place, canEdit, canDelete, categories, anchorRect, onClose, onSetStatus, onSave, onCopyToTrip, onRemove, t,
|
||||
place, canEdit, canDelete, categories, labels, anchorRect, onClose, onSetStatus, onSave, onCopyToTrip, onRemove, t,
|
||||
}: CollectionPlaceDetailProps): React.ReactElement {
|
||||
const toast = useToast()
|
||||
const [editing, setEditing] = useState(false)
|
||||
@@ -64,6 +66,7 @@ export default function CollectionPlaceDetail({
|
||||
const [categoryId, setCategoryId] = useState<number | null>(place.category_id ?? null)
|
||||
const [description, setDescription] = useState(place.description ?? '')
|
||||
const [links, setLinks] = useState<CollectionLink[]>(place.links ?? [])
|
||||
const [labelIds, setLabelIds] = useState<number[]>(place.label_ids ?? [])
|
||||
const [saving, setSaving] = useState(false)
|
||||
// A higher-res photo pulled from the maps provider when the place has none of
|
||||
// its own — the list avatar's little thumbnail is too low-res for the cover.
|
||||
@@ -77,6 +80,7 @@ export default function CollectionPlaceDetail({
|
||||
setCategoryId(place.category_id ?? null)
|
||||
setDescription(place.description ?? '')
|
||||
setLinks(place.links ?? [])
|
||||
setLabelIds(place.label_ids ?? [])
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [place.id])
|
||||
|
||||
@@ -96,13 +100,15 @@ export default function CollectionPlaceDetail({
|
||||
|
||||
const banner = place.image_url || fetchedPhoto
|
||||
const setLink = (i: number, patch: Partial<CollectionLink>) => setLinks(links.map((l, idx) => (idx === i ? { ...l, ...patch } : l)))
|
||||
const resetForm = () => { setEditing(false); setName(place.name); setCategoryId(place.category_id ?? null); setDescription(place.description ?? ''); setLinks(place.links ?? []) }
|
||||
const toggleLabel = (id: number) => setLabelIds(labelIds.includes(id) ? labelIds.filter(x => x !== id) : [...labelIds, id])
|
||||
const resetForm = () => { setEditing(false); setName(place.name); setCategoryId(place.category_id ?? null); setDescription(place.description ?? ''); setLinks(place.links ?? []); setLabelIds(place.label_ids ?? []) }
|
||||
const assignedLabels = labels.filter(l => (place.label_ids ?? []).includes(l.id))
|
||||
|
||||
const save = async () => {
|
||||
const cleanLinks = links.map(l => ({ label: l.label?.trim() || undefined, url: normalizeLinkUrl(l.url) })).filter(l => l.url)
|
||||
setSaving(true)
|
||||
try {
|
||||
await onSave({ name: name.trim() || place.name, description: description.trim() || null, links: cleanLinks, category_id: categoryId })
|
||||
await onSave({ name: name.trim() || place.name, description: description.trim() || null, links: cleanLinks, category_id: categoryId, label_ids: labelIds })
|
||||
setEditing(false)
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
@@ -161,6 +167,22 @@ export default function CollectionPlaceDetail({
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{/* Labels */}
|
||||
{labels.length > 0 && (
|
||||
<div className="col-detail-field">
|
||||
<div className="col-detail-label"><Tags size={12} /> {t('collections.labels.title')}</div>
|
||||
<div className="col-detail-cats">
|
||||
{labels.map(l => {
|
||||
const on = labelIds.includes(l.id)
|
||||
return (
|
||||
<button key={l.id} type="button" onClick={() => toggleLabel(l.id)} className={`col-detail-cat${on ? ' on' : ''}`} style={{ ['--cat' as string]: l.color || '#6366f1' }}>
|
||||
<span className="col-labelchip-dot" /> {l.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Description */}
|
||||
<div className="col-detail-field">
|
||||
<div className="col-detail-label">{t('collections.description')}</div>
|
||||
@@ -184,6 +206,15 @@ export default function CollectionPlaceDetail({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{assignedLabels.length > 0 && (
|
||||
<div className="col-detail-labels">
|
||||
{assignedLabels.map(l => (
|
||||
<span key={l.id} className="col-labelchip on static" style={{ ['--label' as string]: l.color || 'var(--accent)' }}>
|
||||
<span className="col-labelchip-dot" /> {l.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{place.description && (
|
||||
<div className="col-detail-md collab-note-md">
|
||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{place.description}</Markdown>
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Plus, Trash2, Loader2 } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import type { CollectionLabel, CollectionLabelUpdateRequest } from '@trek/shared'
|
||||
import type { TranslationFn } from '../../types'
|
||||
|
||||
const SWATCHES = ['#6366f1', '#0ea5e9', '#10b981', '#f59e0b', '#ef4444', '#ec4899', '#8b5cf6', '#64748b']
|
||||
|
||||
interface LabelManagerProps {
|
||||
isOpen: boolean
|
||||
labels: CollectionLabel[]
|
||||
onCreate: (name: string, color?: string) => Promise<void> | void
|
||||
onUpdate: (labelId: number, body: CollectionLabelUpdateRequest) => Promise<void> | void
|
||||
onDelete: (labelId: number) => Promise<void> | void
|
||||
onClose: () => void
|
||||
t: TranslationFn
|
||||
}
|
||||
|
||||
/** Swatch row shared by the create form and each row's recolor control. */
|
||||
function Swatches({ value, onPick }: { value: string; onPick: (c: string) => void }): React.ReactElement {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{SWATCHES.map(c => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => onPick(c)}
|
||||
className={`w-5 h-5 rounded-full border transition-transform ${value.toLowerCase() === c ? 'border-content scale-110' : 'border-transparent'}`}
|
||||
style={{ background: c }}
|
||||
aria-label={c}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** One existing label: inline rename (save on blur/Enter), recolor, delete. */
|
||||
function LabelRow({ label, onUpdate, onDelete, t }: {
|
||||
label: CollectionLabel
|
||||
onUpdate: LabelManagerProps['onUpdate']
|
||||
onDelete: LabelManagerProps['onDelete']
|
||||
t: TranslationFn
|
||||
}): React.ReactElement {
|
||||
const [name, setName] = useState(label.name)
|
||||
const [color, setColor] = useState(label.color || '#6366f1')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const commitName = async () => {
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed || trimmed === label.name) { setName(label.name); return }
|
||||
setBusy(true)
|
||||
try { await onUpdate(label.id, { name: trimmed }) } finally { setBusy(false) }
|
||||
}
|
||||
const pickColor = async (c: string) => {
|
||||
setColor(c)
|
||||
setBusy(true)
|
||||
try { await onUpdate(label.id, { color: c }) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-2.5 py-2 rounded-xl border border-edge bg-surface-card">
|
||||
<span className="w-3.5 h-3.5 rounded-full shrink-0" style={{ background: color }} />
|
||||
<input
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
onBlur={commitName}
|
||||
onKeyDown={e => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }}
|
||||
maxLength={60}
|
||||
className="flex-1 min-w-0 bg-transparent text-[13px] text-content outline-none"
|
||||
aria-label={t('collections.labels.name')}
|
||||
/>
|
||||
<Swatches value={color} onPick={pickColor} />
|
||||
{busy && <Loader2 size={14} className="animate-spin text-content-faint shrink-0" />}
|
||||
<button type="button" onClick={() => onDelete(label.id)} className="p-1 text-content-faint hover:text-danger shrink-0" aria-label={t('common.delete')}>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Manage a list's custom labels — create, rename, recolor and delete. Available
|
||||
* to any member who can edit the list; the labels are shared by the whole list.
|
||||
*/
|
||||
export default function LabelManager({ isOpen, labels, onCreate, onUpdate, onDelete, onClose, t }: LabelManagerProps): React.ReactElement {
|
||||
const [newName, setNewName] = useState('')
|
||||
const [newColor, setNewColor] = useState(SWATCHES[0])
|
||||
const [adding, setAdding] = useState(false)
|
||||
|
||||
const add = async () => {
|
||||
const trimmed = newName.trim()
|
||||
if (!trimmed || adding) return
|
||||
setAdding(true)
|
||||
try {
|
||||
await onCreate(trimmed, newColor)
|
||||
setNewName('')
|
||||
setNewColor(SWATCHES[0])
|
||||
} finally {
|
||||
setAdding(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={t('collections.labels.manage')} size="sm">
|
||||
<div className="flex flex-col gap-3">
|
||||
{labels.length === 0 ? (
|
||||
<p className="text-center text-[13px] text-content-faint py-4">{t('collections.labels.empty')}</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5 max-h-[46vh] overflow-y-auto -mx-1 px-1">
|
||||
{labels.map(l => <LabelRow key={l.id} label={l} onUpdate={onUpdate} onDelete={onDelete} t={t} />)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2 pt-3 border-t border-edge">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3.5 h-3.5 rounded-full shrink-0" style={{ background: newColor }} />
|
||||
<input
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') add() }}
|
||||
maxLength={60}
|
||||
placeholder={t('collections.labels.namePlaceholder')}
|
||||
className="flex-1 min-w-0 px-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[13px] outline-none focus:border-accent"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={add}
|
||||
disabled={!newName.trim() || adding}
|
||||
className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-accent text-white text-[13px] font-semibold hover:opacity-90 disabled:opacity-50 shrink-0"
|
||||
>
|
||||
{adding ? <Loader2 size={14} className="animate-spin" /> : <Plus size={14} />} {t('collections.labels.add')}
|
||||
</button>
|
||||
</div>
|
||||
<Swatches value={newColor} onPick={setNewColor} />
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isWalletPass } from './FileManager.helpers';
|
||||
|
||||
describe('isWalletPass (#1447)', () => {
|
||||
it('detects by extension when the mime is unreliable', () => {
|
||||
// Browsers frequently send octet-stream / empty for .pkpass uploads
|
||||
expect(isWalletPass('application/octet-stream', 'boarding.pkpass')).toBe(true);
|
||||
expect(isWalletPass(null, 'multi.pkpasses')).toBe(true);
|
||||
expect(isWalletPass('', 'CAPS.PKPASS')).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to the wallet MIME types when there is no extension', () => {
|
||||
expect(isWalletPass('application/vnd.apple.pkpass', 'pass')).toBe(true);
|
||||
expect(isWalletPass('application/vnd.apple.pkpasses', null)).toBe(true);
|
||||
});
|
||||
|
||||
it('is false for non-wallet files', () => {
|
||||
expect(isWalletPass('application/pdf', 'report.pdf')).toBe(false);
|
||||
expect(isWalletPass('image/png', 'photo.png')).toBe(false);
|
||||
expect(isWalletPass('text/markdown', 'notes.md')).toBe(false);
|
||||
expect(isWalletPass(null, null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,18 @@ export function isMarkdown(mimeType?: string | null, name?: string | null) {
|
||||
return !!mimeType && (mimeType === 'text/markdown' || mimeType === 'text/x-markdown')
|
||||
}
|
||||
|
||||
/**
|
||||
* Apple Wallet pass (#1447). Detected by EXTENSION first — browsers often send an
|
||||
* empty / octet-stream MIME for .pkpass — falling back to the wallet MIME types.
|
||||
* Wallet passes must be downloaded so the OS hands them to Apple Wallet rather
|
||||
* than rendered in the in-app PDF preview.
|
||||
*/
|
||||
export function isWalletPass(mimeType?: string | null, name?: string | null) {
|
||||
const ext = (name || '').toLowerCase().split('.').pop()
|
||||
if (ext === 'pkpass' || ext === 'pkpasses') return true
|
||||
return !!mimeType && (mimeType === 'application/vnd.apple.pkpass' || mimeType === 'application/vnd.apple.pkpasses')
|
||||
}
|
||||
|
||||
export function getFileIcon(mimeType?: string | null) {
|
||||
if (!mimeType) return File
|
||||
if (mimeType === 'application/pdf') return FileText
|
||||
|
||||
@@ -15,6 +15,15 @@ vi.mock('../../api/authUrl', () => ({
|
||||
getAuthUrl: vi.fn().mockResolvedValue('http://localhost/signed-url'),
|
||||
}));
|
||||
|
||||
// Mock the blob download/open helpers so we can assert wallet passes are
|
||||
// downloaded (#1447) rather than opened in the in-app PDF preview.
|
||||
vi.mock('../../utils/fileDownload', () => ({
|
||||
openFile: vi.fn().mockResolvedValue(undefined),
|
||||
downloadFile: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
import { openFile as openFileInTab } from '../../utils/fileDownload';
|
||||
|
||||
// Markdown pipeline mocked to render its children verbatim (the unified/ESM
|
||||
// pipeline is heavy in jsdom) — we only assert the markdown text reaches the modal.
|
||||
vi.mock('react-markdown', () => ({
|
||||
@@ -313,6 +322,21 @@ describe('FileManager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-COMP-FILEMANAGER-035: pkpass click downloads via blob helper, not the PDF preview (#1447)', async () => {
|
||||
const files = [buildFile({ id: 1, mime_type: 'application/octet-stream', original_name: 'boarding.pkpass', url: '/uploads/trips/1/boarding.pkpass' })];
|
||||
render(<FileManager {...defaultProps} files={files} />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByText('boarding.pkpass'));
|
||||
|
||||
// Blob helper is called with the file url + name — the OS hands it to Wallet
|
||||
await waitFor(() => {
|
||||
expect(openFileInTab).toHaveBeenCalledWith('/uploads/trips/1/boarding.pkpass', 'boarding.pkpass');
|
||||
});
|
||||
// No PDF preview modal — the filename appears only once (in the list row)
|
||||
expect(screen.getAllByText('boarding.pkpass').length).toBe(1);
|
||||
});
|
||||
|
||||
it('FE-COMP-FILEMANAGER-015: file with uploader name shows avatar chip initials', () => {
|
||||
const files = [buildFile({ uploaded_by_name: 'Alice Smith' })];
|
||||
render(<FileManager {...defaultProps} files={files} />);
|
||||
|
||||
@@ -7,7 +7,8 @@ import type { Place, Reservation, TripFile, Day, AssignmentsMap } from '../../ty
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { getAuthUrl } from '../../api/authUrl'
|
||||
import { isImage, isMedia } from './FileManager.helpers'
|
||||
import { isImage, isMedia, isWalletPass } from './FileManager.helpers'
|
||||
import { openFile as openFileInTab } from '../../utils/fileDownload'
|
||||
|
||||
export interface FileManagerProps {
|
||||
files?: TripFile[]
|
||||
@@ -191,6 +192,10 @@ export function useFileManager({ files = [], onUpload, onDelete, onUpdate, place
|
||||
if (isMedia(file.mime_type)) {
|
||||
const idx = mediaFiles.findIndex(f => f.id === file.id)
|
||||
setLightboxIndex(idx >= 0 ? idx : 0)
|
||||
} else if (isWalletPass(file.mime_type, file.original_name)) {
|
||||
// Download so the OS hands the pass to Apple Wallet (#1447) rather than
|
||||
// forcing it into the in-app PDF preview.
|
||||
openFileInTab(file.url, file.original_name).catch(() => {})
|
||||
} else {
|
||||
setPreviewFile(file)
|
||||
}
|
||||
|
||||
@@ -29,7 +29,9 @@ vi.mock('react-leaflet', () => ({
|
||||
>
|
||||
<button
|
||||
data-testid="marker-hover-trigger"
|
||||
onClick={() => eventHandlers?.mouseover?.({ originalEvent: { clientX: 100, clientY: 100 } })}
|
||||
// A real mouseover never bubbles as a click to the marker, so the
|
||||
// hover-simulation must not trigger the marker's click handler.
|
||||
onClick={(e: any) => { e.stopPropagation(); eventHandlers?.mouseover?.({ originalEvent: { clientX: 100, clientY: 100 } }) }}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
@@ -245,6 +247,44 @@ describe('MapView', () => {
|
||||
expect(mapMock.fitBounds.mock.calls.length).toBeGreaterThan(afterFirst)
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-021: clicking a marker clears the hover tooltip (#1404)', async () => {
|
||||
const user = userEvent.setup()
|
||||
const places = [buildMapPlace({ id: 3, name: 'Eiffel Tower', lat: 48.8584, lng: 2.2945 })]
|
||||
render(<MapView places={places} onMarkerClick={vi.fn()} />)
|
||||
await user.click(screen.getByTestId('marker-hover-trigger'))
|
||||
expect(screen.getByTestId('tooltip')).toBeTruthy()
|
||||
// The recenter that follows the click moves the marker out from under the
|
||||
// cursor — no mouseout will ever fire, so the click itself must clear.
|
||||
fireEvent.click(screen.getByTestId('marker'))
|
||||
expect(screen.queryByTestId('tooltip')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-022: camera movement clears the tooltip and suppresses re-show until it ends (#1404)', async () => {
|
||||
const user = userEvent.setup()
|
||||
const places = [buildMapPlace({ id: 4, name: 'Louvre', lat: 48.86, lng: 2.337 })]
|
||||
render(<MapView places={places} />)
|
||||
await user.click(screen.getByTestId('marker-hover-trigger'))
|
||||
expect(screen.getByTestId('tooltip')).toBeTruthy()
|
||||
|
||||
const findHandler = (event: string) =>
|
||||
mapMock.on.mock.calls.find(c => c[0] === event)?.[1] as (() => void) | undefined
|
||||
const start = findHandler('movestart zoomstart')
|
||||
const end = findHandler('moveend zoomend')
|
||||
expect(start).toBeTypeOf('function')
|
||||
expect(end).toBeTypeOf('function')
|
||||
|
||||
fireEvent.click(screen.getByTestId('marker-hover-trigger')) // ensure hover is showing
|
||||
start!()
|
||||
await waitFor(() => expect(screen.queryByTestId('tooltip')).toBeNull())
|
||||
// during the pan animation a mouseover must not re-show the card
|
||||
fireEvent.click(screen.getByTestId('marker-hover-trigger'))
|
||||
expect(screen.queryByTestId('tooltip')).toBeNull()
|
||||
// once the move ends, hover works again
|
||||
end!()
|
||||
await user.click(screen.getByTestId('marker-hover-trigger'))
|
||||
expect(screen.getByTestId('tooltip')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-020: a day fit expands to include the route once it arrives (#1128)', async () => {
|
||||
const L = ((await import('leaflet')).default) as unknown as { latLngBounds: ReturnType<typeof vi.fn> }
|
||||
const dayPlaces = [
|
||||
|
||||
@@ -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 { useTransportRoutes } from '../../hooks/useTransportRoutes'
|
||||
import type { Reservation } from '../../types'
|
||||
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
|
||||
|
||||
@@ -139,6 +140,23 @@ function createPoiIcon(category: string) {
|
||||
return icon
|
||||
}
|
||||
|
||||
// Clears the hover tooltip the moment the camera starts moving and suppresses
|
||||
// re-showing it until the move ends: after a click-recenter the marker slides
|
||||
// away under a stationary cursor, so the browser never fires mouseout — and
|
||||
// mouseover/mousemove during the pan animation would immediately re-set the
|
||||
// tooltip we just cleared (#1404).
|
||||
function CameraHoverGuard({ movingRef, onMoveStart }: { movingRef: { current: boolean }; onMoveStart: () => void }) {
|
||||
const map = useMap()
|
||||
useEffect(() => {
|
||||
const start = () => { movingRef.current = true; onMoveStart() }
|
||||
const end = () => { movingRef.current = false }
|
||||
map.on('movestart zoomstart', start)
|
||||
map.on('moveend zoomend', end)
|
||||
return () => { map.off('movestart zoomstart', start); map.off('moveend zoomend', end) }
|
||||
}, [map, movingRef, onMoveStart])
|
||||
return null
|
||||
}
|
||||
|
||||
// Emits the current viewport bbox on pan/zoom so the POI-explore pill can fetch
|
||||
// OSM places for the visible area.
|
||||
function ViewportController({ onViewportChange }: { onViewportChange?: (b: { south: number; west: number; north: number; east: number }) => void }) {
|
||||
@@ -451,6 +469,8 @@ export const MapView = memo(function MapView({
|
||||
// day route, so hiding the route hides them too (#1065).
|
||||
return reservations.filter((r: Reservation) => (r.type === 'transit' && showTransitRoutes) || set.has(r.id))
|
||||
}, [reservations, visibleConnectionIds, showTransitRoutes])
|
||||
// Real road geometry for car/bus/taxi/bicycle bookings (straight line until it loads/if it fails).
|
||||
const transportRoutes = useTransportRoutes(visibleReservations)
|
||||
// Dynamic padding: account for sidebars + bottom inspector + day detail panel
|
||||
const paddingOpts = useMemo((): L.FitBoundsOptions => {
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
@@ -465,9 +485,10 @@ export const MapView = memo(function MapView({
|
||||
// Hover state for the single tooltip overlay (replaces per-marker <Tooltip>)
|
||||
const [hoveredPlace, setHoveredPlace] = useState<any>(null)
|
||||
const [tooltipPos, setTooltipPos] = useState<{ x: number; y: number } | null>(null)
|
||||
const mapMovingRef = useRef(false)
|
||||
|
||||
const handleMarkerHover = useCallback((place: any, x: number, y: number) => {
|
||||
if (hoverDisabled) return
|
||||
if (hoverDisabled || mapMovingRef.current) return
|
||||
setHoveredPlace(place)
|
||||
setTooltipPos({ x, y })
|
||||
}, [hoverDisabled])
|
||||
@@ -491,9 +512,18 @@ export const MapView = memo(function MapView({
|
||||
}, [hoveredPlace])
|
||||
|
||||
const handleMarkerClick = useCallback((id: number) => {
|
||||
// Clear the hover card right away: the recenter that follows moves the
|
||||
// marker out from under the cursor, so no mouseout will ever fire (#1404).
|
||||
setHoveredPlace(null)
|
||||
setTooltipPos(null)
|
||||
onMarkerClick?.(id)
|
||||
}, [onMarkerClick])
|
||||
|
||||
const clearHover = useCallback(() => {
|
||||
setHoveredPlace(null)
|
||||
setTooltipPos(null)
|
||||
}, [])
|
||||
|
||||
// photoUrls: only base64 thumbs for smooth map zoom
|
||||
const [photoUrls, setPhotoUrls] = useState<Record<string, string>>(getAllThumbs)
|
||||
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
|
||||
@@ -644,6 +674,7 @@ export const MapView = memo(function MapView({
|
||||
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} />
|
||||
<MapClickHandler onClick={onMapClick} />
|
||||
<MapContextMenuHandler onContextMenu={onMapContextMenu} />
|
||||
<CameraHoverGuard movingRef={mapMovingRef} onMoveStart={clearHover} />
|
||||
<ViewportController onViewportChange={onViewportChange} />
|
||||
<LeafletLocationLayer position={userPosition} mode={trackingMode} />
|
||||
|
||||
@@ -684,6 +715,7 @@ export const MapView = memo(function MapView({
|
||||
showConnections
|
||||
showStats={showReservationStats}
|
||||
onEndpointClick={onReservationClick}
|
||||
roadRoutes={transportRoutes}
|
||||
/>
|
||||
|
||||
{poiMarkers}
|
||||
|
||||
@@ -6,7 +6,10 @@ import { resetAllStores } from '../../../tests/helpers/store'
|
||||
import { buildPlace } from '../../../tests/helpers/factories'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
|
||||
// Stable fake map so fitBounds call counts survive re-renders.
|
||||
// Stable fake map so fitBounds call counts survive re-renders. The canvas
|
||||
// container is a single element so listeners registered by the component are
|
||||
// reachable from tests via dispatchEvent.
|
||||
const glCanvasContainer = vi.hoisted(() => document.createElement('div'))
|
||||
const glMap = vi.hoisted(() => ({
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
@@ -25,10 +28,12 @@ const glMap = vi.hoisted(() => ({
|
||||
setLayoutProperty: vi.fn(),
|
||||
getStyle: vi.fn().mockReturnValue({ layers: [] }),
|
||||
isStyleLoaded: vi.fn().mockReturnValue(true),
|
||||
getCanvasContainer: vi.fn(() => document.createElement('div')),
|
||||
getCanvasContainer: vi.fn(() => glCanvasContainer),
|
||||
getLayer: vi.fn().mockReturnValue(null),
|
||||
queryRenderedFeatures: vi.fn().mockReturnValue([]),
|
||||
querySourceFeatures: vi.fn().mockReturnValue([]),
|
||||
unproject: vi.fn(() => ({ lng: 2.3522, lat: 48.8566 })),
|
||||
getBounds: vi.fn(() => ({ getSouth: () => 0, getWest: () => 0, getNorth: () => 1, getEast: () => 1 })),
|
||||
easeTo: vi.fn(),
|
||||
}))
|
||||
|
||||
@@ -265,4 +270,173 @@ describe('MapViewGL', () => {
|
||||
expect(glMap.addLayer).toHaveBeenCalledWith(expect.objectContaining({ id: 'trip-place-clusters-circle' }))
|
||||
expect(glMap.addLayer).toHaveBeenCalledWith(expect.objectContaining({ id: 'trip-place-clusters-count' }))
|
||||
})
|
||||
|
||||
function touchEvent(type: string, touches: Array<{ clientX: number; clientY: number }>) {
|
||||
const ev = new Event(type, { bubbles: true })
|
||||
Object.defineProperty(ev, 'touches', { value: touches })
|
||||
return ev
|
||||
}
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-006: touch long-press opens Add-Place at the held position (#1398)', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const onContext = vi.fn()
|
||||
render(<MapViewGL places={[]} fitKey={1} onMapContextMenu={onContext} />)
|
||||
await act(async () => {})
|
||||
act(() => {
|
||||
glCanvasContainer.dispatchEvent(touchEvent('touchstart', [{ clientX: 30, clientY: 40 }]))
|
||||
vi.advanceTimersByTime(650)
|
||||
})
|
||||
expect(onContext).toHaveBeenCalledTimes(1)
|
||||
expect(onContext.mock.calls[0][0].latlng).toEqual({ lat: 48.8566, lng: 2.3522 })
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-007: a moving finger (pan) cancels the long-press (#1398)', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const onContext = vi.fn()
|
||||
render(<MapViewGL places={[]} fitKey={1} onMapContextMenu={onContext} />)
|
||||
await act(async () => {})
|
||||
act(() => {
|
||||
glCanvasContainer.dispatchEvent(touchEvent('touchstart', [{ clientX: 30, clientY: 40 }]))
|
||||
glCanvasContainer.dispatchEvent(touchEvent('touchmove', [{ clientX: 60, clientY: 90 }]))
|
||||
vi.advanceTimersByTime(650)
|
||||
})
|
||||
expect(onContext).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-008: a second finger (pinch) cancels the long-press (#1398)', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const onContext = vi.fn()
|
||||
render(<MapViewGL places={[]} fitKey={1} onMapContextMenu={onContext} />)
|
||||
await act(async () => {})
|
||||
act(() => {
|
||||
glCanvasContainer.dispatchEvent(touchEvent('touchstart', [{ clientX: 30, clientY: 40 }]))
|
||||
glCanvasContainer.dispatchEvent(touchEvent('touchstart', [{ clientX: 30, clientY: 40 }, { clientX: 80, clientY: 40 }]))
|
||||
vi.advanceTimersByTime(650)
|
||||
})
|
||||
expect(onContext).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-009: a plain right-click (map contextmenu event) opens Add-Place, deduped (#1398)', async () => {
|
||||
const onContext = vi.fn()
|
||||
render(<MapViewGL places={[]} fitKey={1} onMapContextMenu={onContext} />)
|
||||
await act(async () => {})
|
||||
const handler = glMap.on.mock.calls.find(c => c[0] === 'contextmenu')?.[1] as (e: unknown) => void
|
||||
expect(handler).toBeTypeOf('function')
|
||||
act(() => {
|
||||
handler({ lngLat: { lat: 48.8566, lng: 2.3522 }, originalEvent: new MouseEvent('contextmenu') })
|
||||
// Android long-press fires the native contextmenu on top of our timer —
|
||||
// a second event inside the dedupe window must not open a second form.
|
||||
handler({ lngLat: { lat: 48.8566, lng: 2.3522 }, originalEvent: new MouseEvent('contextmenu') })
|
||||
})
|
||||
expect(onContext).toHaveBeenCalledTimes(1)
|
||||
expect(onContext.mock.calls[0][0].latlng).toEqual({ lat: 48.8566, lng: 2.3522 })
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-012: a right-button rotate/pitch drag does not open Add-Place on release (#1398)', async () => {
|
||||
const onContext = vi.fn()
|
||||
render(<MapViewGL places={[]} fitKey={1} onMapContextMenu={onContext} />)
|
||||
await act(async () => {})
|
||||
const handler = glMap.on.mock.calls.find(c => c[0] === 'contextmenu')?.[1] as (e: unknown) => void
|
||||
act(() => {
|
||||
// mapbox-gl (unlike maplibre) still emits contextmenu after a right-drag
|
||||
// on Windows — the movement guard must drop it.
|
||||
glCanvasContainer.dispatchEvent(new MouseEvent('mousedown', { button: 2, clientX: 10, clientY: 10, bubbles: true }))
|
||||
handler({ lngLat: { lat: 1, lng: 2 }, originalEvent: new MouseEvent('contextmenu', { clientX: 140, clientY: 90 }) })
|
||||
})
|
||||
expect(onContext).not.toHaveBeenCalled()
|
||||
// ...while a stationary right-click still fires.
|
||||
act(() => {
|
||||
glCanvasContainer.dispatchEvent(new MouseEvent('mousedown', { button: 2, clientX: 10, clientY: 10, bubbles: true }))
|
||||
handler({ lngLat: { lat: 1, lng: 2 }, originalEvent: new MouseEvent('contextmenu', { clientX: 11, clientY: 10 }) })
|
||||
})
|
||||
expect(onContext).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-013: a stale long-press suppression never swallows a later real tap (#1398)', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const onContext = vi.fn()
|
||||
const onMapClick = vi.fn()
|
||||
render(<MapViewGL places={[]} fitKey={1} onMapContextMenu={onContext} onMapClick={onMapClick} />)
|
||||
await act(async () => {})
|
||||
// Long-press fires (arms the suppression), but no click follows.
|
||||
act(() => {
|
||||
glCanvasContainer.dispatchEvent(touchEvent('touchstart', [{ clientX: 30, clientY: 40 }]))
|
||||
vi.advanceTimersByTime(650)
|
||||
})
|
||||
expect(onContext).toHaveBeenCalledTimes(1)
|
||||
// The NEXT gesture starts fresh: its tap must reach the map click handler.
|
||||
const clickHandler = glMap.on.mock.calls.find(c => c[0] === 'click')?.[1] as (e: unknown) => void
|
||||
act(() => {
|
||||
glCanvasContainer.dispatchEvent(touchEvent('touchstart', [{ clientX: 80, clientY: 90 }]))
|
||||
glCanvasContainer.dispatchEvent(touchEvent('touchend', []))
|
||||
clickHandler({ lngLat: { lat: 3, lng: 4 }, originalEvent: { target: glCanvasContainer } })
|
||||
})
|
||||
expect(onMapClick).toHaveBeenCalledWith({ latlng: { lat: 3, lng: 4 } })
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-010: middle-click still opens Add-Place (#1398 regression guard)', async () => {
|
||||
const onContext = vi.fn()
|
||||
render(<MapViewGL places={[]} fitKey={1} onMapContextMenu={onContext} />)
|
||||
await act(async () => {})
|
||||
act(() => {
|
||||
glCanvasContainer.dispatchEvent(new MouseEvent('mousedown', { button: 1, bubbles: true }))
|
||||
})
|
||||
expect(onContext).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-011: clicking a marker clears the hover card; movestart clears + suppresses it (#1404)', async () => {
|
||||
// Markers are only reconciled once the style has loaded — fire 'load' like GL-005 does.
|
||||
glMap.on.mockImplementation((event: string, handlerOrLayer: unknown) => {
|
||||
if (event === 'load' && typeof handlerOrLayer === 'function') (handlerOrLayer as () => void)()
|
||||
return glMap
|
||||
})
|
||||
const mapboxgl = (await import('mapbox-gl')).default
|
||||
const places = [buildMapPlace({ id: 7, lat: 48.8584, lng: 2.2945, name: 'Tour Eiffel' })]
|
||||
const { queryByTestId } = render(<MapViewGL places={places} fitKey={1} onMarkerClick={vi.fn()} />)
|
||||
await act(async () => {})
|
||||
|
||||
const markerCall = (mapboxgl.Marker as unknown as ReturnType<typeof vi.fn>).mock.calls
|
||||
.find(c => c[0]?.element)
|
||||
expect(markerCall).toBeTruthy()
|
||||
const el = markerCall![0].element as HTMLElement
|
||||
|
||||
// hover shows the card
|
||||
act(() => { el.dispatchEvent(new MouseEvent('mouseenter', { clientX: 10, clientY: 10 })) })
|
||||
expect(queryByTestId('tooltip')).toBeTruthy()
|
||||
|
||||
// click clears it (the flyTo that follows moves the marker away, no mouseleave will come)
|
||||
act(() => { el.dispatchEvent(new MouseEvent('click', { bubbles: false })) })
|
||||
expect(queryByTestId('tooltip')).toBeNull()
|
||||
|
||||
// hover again, then camera movement clears + suppresses
|
||||
act(() => { el.dispatchEvent(new MouseEvent('mouseenter', { clientX: 10, clientY: 10 })) })
|
||||
expect(queryByTestId('tooltip')).toBeTruthy()
|
||||
const moveStart = glMap.on.mock.calls.find(c => c[0] === 'movestart')?.[1] as () => void
|
||||
const moveEnds = glMap.on.mock.calls.filter(c => c[0] === 'moveend').map(c => c[1] as () => void)
|
||||
act(() => { moveStart() })
|
||||
expect(queryByTestId('tooltip')).toBeNull()
|
||||
// while the camera is moving, a re-fired mouseenter must not bring it back
|
||||
act(() => { el.dispatchEvent(new MouseEvent('mouseenter', { clientX: 10, clientY: 10 })) })
|
||||
expect(queryByTestId('tooltip')).toBeNull()
|
||||
// after the move ends, hover works again
|
||||
act(() => { moveEnds.forEach(fn => fn()) })
|
||||
act(() => { el.dispatchEvent(new MouseEvent('mouseenter', { clientX: 10, clientY: 10 })) })
|
||||
expect(queryByTestId('tooltip')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ import { CATEGORY_ICON_MAP } from '../shared/categoryIcons'
|
||||
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from './mapboxSetup'
|
||||
import { attachLocationMarker, type LocationMarkerHandle } from './locationMarkerMapbox'
|
||||
import { ReservationMapboxOverlay } from './reservationsMapbox'
|
||||
import { useTransportRoutes } from '../../hooks/useTransportRoutes'
|
||||
import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from './glProviders'
|
||||
import LocationButton from './LocationButton'
|
||||
import { useGeolocation } from '../../hooks/useGeolocation'
|
||||
@@ -70,7 +71,7 @@ interface Props {
|
||||
onMarkerClick?: (id: number) => void
|
||||
hoverDisabled?: boolean
|
||||
onMapClick?: (info: { latlng: { lat: number; lng: number } }) => void
|
||||
onMapContextMenu?: ((e: { latlng: { lat: number; lng: number }; originalEvent: MouseEvent }) => void) | null
|
||||
onMapContextMenu?: ((e: { latlng: { lat: number; lng: number }; originalEvent: MouseEvent | TouchEvent }) => void) | null
|
||||
center?: [number, number]
|
||||
zoom?: number
|
||||
fitKey?: number | null
|
||||
@@ -228,6 +229,10 @@ export function MapViewGL({
|
||||
const [hoverPlace, setHoverPlace] = useState<(Place & { category_color?: string | null; category_icon?: string | null; category_name?: string | null }) | null>(null)
|
||||
const [hoverPos, setHoverPos] = useState<{ x: number; y: number } | null>(null)
|
||||
const hoverIdRef = useRef<number | null>(null)
|
||||
// True while the camera is moving (flyTo after a click, pan, zoom). Marker
|
||||
// elements get rebuilt during the move and re-fire mouseenter under a
|
||||
// stationary cursor, which would re-show the card we just cleared (#1404).
|
||||
const camMovingRef = useRef(false)
|
||||
|
||||
// Selecting a place rebuilds its marker element, so the browser never fires
|
||||
// mouseleave on the removed node and the fixed-position hover card gets
|
||||
@@ -448,7 +453,13 @@ export function MapViewGL({
|
||||
setMapReady(true)
|
||||
})
|
||||
|
||||
// Set by the long-press handler below: the touchend tap that follows a
|
||||
// long-press must not count as a normal map click (#1398).
|
||||
let suppressNextClick = false
|
||||
map.on('click', (e) => {
|
||||
// The tap that ends a long-press would otherwise land here and clear
|
||||
// the selection right after the Add-Place form opened (#1398).
|
||||
if (suppressNextClick) { suppressNextClick = false; return }
|
||||
const t = e.originalEvent.target as HTMLElement
|
||||
if (t.closest('.mapboxgl-marker, .maplibregl-marker')) return // markers handle their own click
|
||||
// A click that lands on a cluster bubble is the cluster's to handle
|
||||
@@ -469,19 +480,52 @@ export function MapViewGL({
|
||||
}
|
||||
map.on('moveend', emitViewport)
|
||||
map.once('idle', emitViewport)
|
||||
// In the GL map the right mouse button is reserved for the
|
||||
// built-in rotate/pitch gesture, so we bind the "add place" action
|
||||
// to the middle mouse button (button === 1) instead.
|
||||
// Clear the hover card (and the anchored POI popup) as soon as the camera
|
||||
// starts moving, and keep hover suppressed until it stops: the marker
|
||||
// slides away under a stationary cursor, so mouseleave never fires (#1404).
|
||||
const onCamStart = () => {
|
||||
camMovingRef.current = true
|
||||
hoverIdRef.current = null
|
||||
setHoverPlace(null)
|
||||
setHoverPos(null)
|
||||
popupRef.current?.remove()
|
||||
}
|
||||
const onCamEnd = () => { camMovingRef.current = false }
|
||||
map.on('movestart', onCamStart)
|
||||
map.on('moveend', onCamEnd)
|
||||
// "Add place here" on the GL map (#1398). Three routes into one handler:
|
||||
// middle-click (the original binding), a plain right-click via the map's
|
||||
// own contextmenu event — both GL libs suppress that event while the
|
||||
// right-button rotate/pitch drag is active, so it can't fight the gesture,
|
||||
// and it also covers Mac ctrl-click / two-finger tap — and a touch
|
||||
// long-press, which neither GL lib synthesizes into contextmenu (Leaflet
|
||||
// does, which is why the OSM map already worked on mobile).
|
||||
const canvas = map.getCanvasContainer()
|
||||
let lastContextFire = 0
|
||||
const fireContext = (lngLat: { lat: number; lng: number }, originalEvent: MouseEvent | TouchEvent): boolean => {
|
||||
// Android fires a native contextmenu for a long-press on top of our own
|
||||
// timer — dedupe so the form doesn't open twice.
|
||||
if (Date.now() - lastContextFire < 700) return false
|
||||
lastContextFire = Date.now()
|
||||
onClickRefs.current.context?.({ latlng: { lat: lngLat.lat, lng: lngLat.lng }, originalEvent })
|
||||
return true
|
||||
}
|
||||
// MapLibre swallows the map contextmenu at the end of a right-button
|
||||
// rotate/pitch drag, but mapbox-gl does NOT — and on Windows the DOM
|
||||
// contextmenu arrives after mouseup, so every rotate would end by opening
|
||||
// the Add-Place form. Track the right-button press position and drop a
|
||||
// contextmenu whose pointer travelled like a drag rather than a click.
|
||||
let rightDownAt: { x: number; y: number } | null = null
|
||||
const onAuxDown = (ev: MouseEvent) => {
|
||||
if (ev.button === 2) {
|
||||
rightDownAt = { x: ev.clientX, y: ev.clientY }
|
||||
return
|
||||
}
|
||||
if (ev.button !== 1) return
|
||||
ev.preventDefault()
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const lngLat = map.unproject([ev.clientX - rect.left, ev.clientY - rect.top])
|
||||
onClickRefs.current.context?.({
|
||||
latlng: { lat: lngLat.lat, lng: lngLat.lng },
|
||||
originalEvent: ev,
|
||||
})
|
||||
fireContext({ lat: lngLat.lat, lng: lngLat.lng }, ev)
|
||||
}
|
||||
// Also suppress the browser's native auxclick menu on middle-click.
|
||||
const onAuxClick = (ev: MouseEvent) => {
|
||||
@@ -489,6 +533,49 @@ export function MapViewGL({
|
||||
}
|
||||
canvas.addEventListener('mousedown', onAuxDown)
|
||||
canvas.addEventListener('auxclick', onAuxClick)
|
||||
map.on('contextmenu', (e: { lngLat: { lat: number; lng: number }; originalEvent: MouseEvent }) => {
|
||||
const down = rightDownAt
|
||||
rightDownAt = null
|
||||
if (down && Math.hypot(e.originalEvent.clientX - down.x, e.originalEvent.clientY - down.y) > 5) return
|
||||
fireContext(e.lngLat, e.originalEvent)
|
||||
})
|
||||
// Touch long-press: 600 ms hold (Leaflet's tapHold feel) with a 10 px
|
||||
// move tolerance so slow pans and pinches don't open the form.
|
||||
let lpTimer: number | null = null
|
||||
let lpStart: { x: number; y: number } | null = null
|
||||
const cancelLongPress = () => {
|
||||
if (lpTimer !== null) window.clearTimeout(lpTimer)
|
||||
lpTimer = null
|
||||
lpStart = null
|
||||
}
|
||||
const onTouchStart = (ev: TouchEvent) => {
|
||||
// A fresh gesture clears a stale suppression flag: not every long-press
|
||||
// is followed by a click (finger drag after the hold, Android's native
|
||||
// contextmenu path), and the flag must never swallow a later real tap.
|
||||
suppressNextClick = false
|
||||
if (ev.touches.length !== 1) { cancelLongPress(); return }
|
||||
if ((ev.target as HTMLElement).closest('.mapboxgl-marker, .maplibregl-marker')) return
|
||||
const t = ev.touches[0]
|
||||
lpStart = { x: t.clientX, y: t.clientY }
|
||||
lpTimer = window.setTimeout(() => {
|
||||
lpTimer = null
|
||||
if (!lpStart) return
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const lngLat = map.unproject([lpStart.x - rect.left, lpStart.y - rect.top])
|
||||
lpStart = null
|
||||
// Only suppress the tap when OUR fire opened the form — if the native
|
||||
// contextmenu beat us to it (dedupe), no click needs swallowing.
|
||||
if (fireContext({ lat: lngLat.lat, lng: lngLat.lng }, ev)) suppressNextClick = true
|
||||
}, 600)
|
||||
}
|
||||
const onTouchMove = (ev: TouchEvent) => {
|
||||
const t = ev.touches[0]
|
||||
if (lpStart && (!t || Math.hypot(t.clientX - lpStart.x, t.clientY - lpStart.y) > 10)) cancelLongPress()
|
||||
}
|
||||
canvas.addEventListener('touchstart', onTouchStart, { passive: true })
|
||||
canvas.addEventListener('touchmove', onTouchMove, { passive: true })
|
||||
canvas.addEventListener('touchend', cancelLongPress)
|
||||
canvas.addEventListener('touchcancel', cancelLongPress)
|
||||
|
||||
// Drop follow mode if the user pans the map manually — matches the
|
||||
// Apple Maps behaviour where the blue dot stays but the map no longer
|
||||
@@ -537,6 +624,11 @@ export function MapViewGL({
|
||||
return () => {
|
||||
canvas.removeEventListener('mousedown', onAuxDown)
|
||||
canvas.removeEventListener('auxclick', onAuxClick)
|
||||
canvas.removeEventListener('touchstart', onTouchStart)
|
||||
canvas.removeEventListener('touchmove', onTouchMove)
|
||||
canvas.removeEventListener('touchend', cancelLongPress)
|
||||
canvas.removeEventListener('touchcancel', cancelLongPress)
|
||||
cancelLongPress()
|
||||
markersRef.current.forEach(m => m.remove())
|
||||
markersRef.current.clear()
|
||||
if (popupRef.current) { popupRef.current.remove(); popupRef.current = null }
|
||||
@@ -652,16 +744,21 @@ export function MapViewGL({
|
||||
const el = createMarkerElement(place as Place & { category_color?: string; category_icon?: string }, photoUrl, orderNumbers, selected)
|
||||
el.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation()
|
||||
// Clear the card right away — the flyTo that follows moves the marker
|
||||
// out from under the cursor and mouseleave never fires (#1404).
|
||||
hoverIdRef.current = null
|
||||
setHoverPlace(null)
|
||||
setHoverPos(null)
|
||||
onClickRefs.current.marker?.(place.id)
|
||||
})
|
||||
el.addEventListener('mouseenter', (ev) => {
|
||||
if (hoverDisabledRef.current) return
|
||||
if (hoverDisabledRef.current || camMovingRef.current) return
|
||||
hoverIdRef.current = place.id
|
||||
setHoverPlace(place as Place & { category_color?: string; category_icon?: string; category_name?: string })
|
||||
setHoverPos({ x: (ev as MouseEvent).clientX, y: (ev as MouseEvent).clientY })
|
||||
})
|
||||
el.addEventListener('mousemove', (ev) => {
|
||||
if (hoverDisabledRef.current) return
|
||||
if (hoverDisabledRef.current || camMovingRef.current) return
|
||||
setHoverPos({ x: (ev as MouseEvent).clientX, y: (ev as MouseEvent).clientY })
|
||||
})
|
||||
el.addEventListener('mouseleave', () => {
|
||||
@@ -807,6 +904,8 @@ export function MapViewGL({
|
||||
// day route, so hiding the route hides them too (#1065).
|
||||
return reservations.filter(r => (r.type === 'transit' && showTransitRoutes) || set.has(r.id))
|
||||
}, [reservations, visibleConnectionIds, showTransitRoutes])
|
||||
// Real road geometry for car/bus/taxi/bicycle bookings (straight line until it loads/if it fails).
|
||||
const transportRoutes = useTransportRoutes(visibleReservations)
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
@@ -824,8 +923,8 @@ export function MapViewGL({
|
||||
showStats: showReservationStats,
|
||||
showEndpointLabels,
|
||||
onEndpointClick: (id) => onReservationClickRef.current?.(id),
|
||||
})
|
||||
}, [visibleReservations, showReservationStats, showEndpointLabels, mapReady, glProvider])
|
||||
}, transportRoutes)
|
||||
}, [visibleReservations, transportRoutes, showReservationStats, showEndpointLabels, mapReady, glProvider])
|
||||
|
||||
// Fit bounds on fitKey change — matches the Leaflet BoundsController
|
||||
const paddingOpts = useMemo(() => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import L from 'leaflet'
|
||||
import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route, TramFront } from 'lucide-react'
|
||||
import { escapeHtml } from '@trek/shared'
|
||||
import { getTransitMapSegments, type TransitMapSegment } from './transitGeometry'
|
||||
import { geodesicArcs } from './flightGeodesy'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import type { Reservation, ReservationEndpoint } from '../../types'
|
||||
|
||||
@@ -64,41 +65,6 @@ function endpointIcon(type: TransportType, label: string | null): L.DivIcon {
|
||||
}
|
||||
|
||||
function toRad(d: number) { return d * Math.PI / 180 }
|
||||
function toDeg(r: number) { return r * 180 / Math.PI }
|
||||
|
||||
function greatCircle(a: [number, number], b: [number, number], steps = 256): [number, number][] {
|
||||
const [lat1, lng1] = [toRad(a[0]), toRad(a[1])]
|
||||
const [lat2, lng2] = [toRad(b[0]), toRad(b[1])]
|
||||
const d = 2 * Math.asin(Math.sqrt(Math.sin((lat2 - lat1) / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin((lng2 - lng1) / 2) ** 2))
|
||||
if (d === 0) return [a, b]
|
||||
const pts: [number, number][] = []
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const f = i / steps
|
||||
const A = Math.sin((1 - f) * d) / Math.sin(d)
|
||||
const B = Math.sin(f * d) / Math.sin(d)
|
||||
const x = A * Math.cos(lat1) * Math.cos(lng1) + B * Math.cos(lat2) * Math.cos(lng2)
|
||||
const y = A * Math.cos(lat1) * Math.sin(lng1) + B * Math.cos(lat2) * Math.sin(lng2)
|
||||
const z = A * Math.sin(lat1) + B * Math.sin(lat2)
|
||||
const lat = Math.atan2(z, Math.sqrt(x * x + y * y))
|
||||
const lng = Math.atan2(y, x)
|
||||
pts.push([toDeg(lat), toDeg(lng)])
|
||||
}
|
||||
return pts
|
||||
}
|
||||
|
||||
function splitAntimeridian(points: [number, number][]): [number, number][][] {
|
||||
const segments: [number, number][][] = []
|
||||
let cur: [number, number][] = []
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
if (i > 0 && Math.abs(points[i][1] - points[i - 1][1]) > 180) {
|
||||
if (cur.length > 1) segments.push(cur)
|
||||
cur = []
|
||||
}
|
||||
cur.push(points[i])
|
||||
}
|
||||
if (cur.length > 1) segments.push(cur)
|
||||
return segments
|
||||
}
|
||||
|
||||
function cleanName(name: string): string {
|
||||
return name.replace(/\s*\([^)]*\)/g, '').trim()
|
||||
@@ -342,9 +308,12 @@ interface Props {
|
||||
showConnections: boolean
|
||||
showStats: boolean
|
||||
onEndpointClick?: (reservationId: number) => void
|
||||
// Real road-network geometry for car/bus/taxi/bicycle bookings, keyed by
|
||||
// reservation id. When present it is drawn instead of the straight arc.
|
||||
roadRoutes?: Map<number, [number, number][]>
|
||||
}
|
||||
|
||||
export default function ReservationOverlay({ reservations, showConnections, showStats, onEndpointClick }: Props) {
|
||||
export default function ReservationOverlay({ reservations, showConnections, showStats, onEndpointClick, roadRoutes }: Props) {
|
||||
useEndpointPane()
|
||||
const map = useMap()
|
||||
const [zoom, setZoom] = useState(() => map.getZoom())
|
||||
@@ -375,7 +344,7 @@ export default function ReservationOverlay({ reservations, showConnections, show
|
||||
const a = waypoints[i]
|
||||
const b = waypoints[i + 1]
|
||||
const segArcs = isGeo
|
||||
? splitAntimeridian(greatCircle([a.lat, a.lng], [b.lat, b.lng]))
|
||||
? geodesicArcs([a.lat, a.lng], [b.lat, b.lng], true)
|
||||
: [[[a.lat, a.lng], [b.lat, b.lng]] as [number, number][]]
|
||||
arcs.push(...segArcs)
|
||||
distanceKm += haversineKm([a.lat, a.lng], [b.lat, b.lng])
|
||||
@@ -424,35 +393,41 @@ export default function ReservationOverlay({ reservations, showConnections, show
|
||||
|
||||
return (
|
||||
<>
|
||||
{visibleItems.map(item => item.transitSegs.length > 0
|
||||
? item.transitSegs.map((seg, segIdx) => (
|
||||
<Fragment key={`transit-${item.res.id}-${segIdx}`}>
|
||||
{!seg.walk && (
|
||||
{visibleItems.map(item => {
|
||||
if (item.transitSegs.length > 0) {
|
||||
return item.transitSegs.map((seg, segIdx) => (
|
||||
<Fragment key={`transit-${item.res.id}-${segIdx}`}>
|
||||
{!seg.walk && (
|
||||
<Polyline
|
||||
positions={seg.coords}
|
||||
pathOptions={{ color: '#ffffff', weight: 6, opacity: 0.85, lineCap: 'round', lineJoin: 'round' }}
|
||||
/>
|
||||
)}
|
||||
<Polyline
|
||||
positions={seg.coords}
|
||||
pathOptions={{ color: '#ffffff', weight: 6, opacity: 0.85, lineCap: 'round', lineJoin: 'round' }}
|
||||
pathOptions={seg.walk
|
||||
? { color: '#64748b', weight: 3, opacity: 0.8, dashArray: '1, 7', lineCap: 'round' }
|
||||
: { color: seg.color || TYPE_META.transit.color, weight: 3.5, opacity: 0.95, lineCap: 'round', lineJoin: 'round' }}
|
||||
/>
|
||||
)}
|
||||
<Polyline
|
||||
positions={seg.coords}
|
||||
pathOptions={seg.walk
|
||||
? { color: '#64748b', weight: 3, opacity: 0.8, dashArray: '1, 7', lineCap: 'round' }
|
||||
: { color: seg.color || TYPE_META.transit.color, weight: 3.5, opacity: 0.95, lineCap: 'round', lineJoin: 'round' }}
|
||||
/>
|
||||
</Fragment>
|
||||
</Fragment>
|
||||
))
|
||||
}
|
||||
// Prefer the real road route (car/bus/taxi/bicycle) over the straight arc.
|
||||
const road = roadRoutes?.get(item.res.id)
|
||||
const lines = road && road.length >= 2 ? [road] : item.arcs
|
||||
return lines.map((seg, segIdx) => (
|
||||
<Polyline
|
||||
key={`line-${item.res.id}-${segIdx}`}
|
||||
positions={seg}
|
||||
pathOptions={{
|
||||
color: TYPE_META[item.type].color,
|
||||
weight: 2.5,
|
||||
opacity: item.res.status === 'confirmed' ? 0.75 : 0.55,
|
||||
dashArray: item.res.status === 'confirmed' ? undefined : '6, 6',
|
||||
}}
|
||||
/>
|
||||
))
|
||||
: item.arcs.map((seg, segIdx) => (
|
||||
<Polyline
|
||||
key={`line-${item.res.id}-${segIdx}`}
|
||||
positions={seg}
|
||||
pathOptions={{
|
||||
color: TYPE_META[item.type].color,
|
||||
weight: 2.5,
|
||||
opacity: item.res.status === 'confirmed' ? 0.75 : 0.55,
|
||||
dashArray: item.res.status === 'confirmed' ? undefined : '6, 6',
|
||||
}}
|
||||
/>
|
||||
)))}
|
||||
})}
|
||||
|
||||
{visibleItems.flatMap(item => item.waypoints.map((wp, wi) => (
|
||||
<Marker
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { greatCircle, unwrapLngs, geodesicArcs } from './flightGeodesy'
|
||||
|
||||
const YVR: [number, number] = [49.19, -123.18]
|
||||
const ICN: [number, number] = [37.46, 126.44]
|
||||
const FRA: [number, number] = [50.03, 8.57]
|
||||
const JFK: [number, number] = [40.64, -73.78]
|
||||
|
||||
const maxConsecutiveDeltaLng = (pts: [number, number][]) =>
|
||||
pts.reduce((max, p, i) => (i === 0 ? 0 : Math.max(max, Math.abs(p[1] - pts[i - 1][1]))), 0)
|
||||
|
||||
describe('flightGeodesy (#1411)', () => {
|
||||
it('a date-line crossing unwraps into one continuous arc plus a shifted copy for Leaflet', () => {
|
||||
const arcs = geodesicArcs(YVR, ICN, true)
|
||||
expect(arcs).toHaveLength(2)
|
||||
const [base, shifted] = arcs
|
||||
// continuous: no ±360 jump anywhere
|
||||
expect(maxConsecutiveDeltaLng(base)).toBeLessThan(180)
|
||||
// endpoints: starts at YVR, ends at ICN unwrapped westwards (126.44 - 360)
|
||||
expect(base[0][0]).toBeCloseTo(YVR[0], 5)
|
||||
expect(base[0][1]).toBeCloseTo(YVR[1], 5)
|
||||
expect(base[base.length - 1][1]).toBeCloseTo(ICN[1] - 360, 5)
|
||||
// the copy is the base shifted by exactly +360
|
||||
expect(shifted).toHaveLength(base.length)
|
||||
shifted.forEach(([lat, lng], i) => {
|
||||
expect(lat).toBe(base[i][0])
|
||||
expect(lng).toBeCloseTo(base[i][1] + 360, 10)
|
||||
})
|
||||
})
|
||||
|
||||
it('an eastbound crossing unwraps upwards and shifts by -360', () => {
|
||||
const arcs = geodesicArcs(ICN, YVR, true)
|
||||
expect(arcs).toHaveLength(2)
|
||||
const [base, shifted] = arcs
|
||||
expect(maxConsecutiveDeltaLng(base)).toBeLessThan(180)
|
||||
expect(base[base.length - 1][1]).toBeCloseTo(YVR[1] + 360, 5)
|
||||
expect(shifted[0][1]).toBeCloseTo(base[0][1] - 360, 10)
|
||||
})
|
||||
|
||||
it('a non-crossing flight stays a single in-range arc', () => {
|
||||
const arcs = geodesicArcs(FRA, JFK, true)
|
||||
expect(arcs).toHaveLength(1)
|
||||
for (const [, lng] of arcs[0]) {
|
||||
expect(lng).toBeGreaterThanOrEqual(-180)
|
||||
expect(lng).toBeLessThanOrEqual(180)
|
||||
}
|
||||
})
|
||||
|
||||
it('GL mode always returns exactly one continuous arc (world copies handle the wrap)', () => {
|
||||
const arcs = geodesicArcs(YVR, ICN, false)
|
||||
expect(arcs).toHaveLength(1)
|
||||
expect(maxConsecutiveDeltaLng(arcs[0])).toBeLessThan(180)
|
||||
})
|
||||
|
||||
it('a zero-distance leg passes the endpoints through', () => {
|
||||
expect(greatCircle(YVR, YVR)).toEqual([YVR, YVR])
|
||||
})
|
||||
|
||||
it('unwrapLngs keeps already-continuous input unchanged', () => {
|
||||
const pts: [number, number][] = [[0, 170], [0, 175], [0, 179]]
|
||||
expect(unwrapLngs(pts)).toEqual(pts)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
// Great-circle geometry for transport routes (flights, cruises, ferries),
|
||||
// shared by the Leaflet and Mapbox/MapLibre renderers (#1411).
|
||||
|
||||
const toRad = (d: number) => d * Math.PI / 180
|
||||
const toDeg = (r: number) => r * 180 / Math.PI
|
||||
|
||||
export function greatCircle(a: [number, number], b: [number, number], steps = 256): [number, number][] {
|
||||
const [lat1, lng1] = [toRad(a[0]), toRad(a[1])]
|
||||
const [lat2, lng2] = [toRad(b[0]), toRad(b[1])]
|
||||
const d = 2 * Math.asin(Math.sqrt(Math.sin((lat2 - lat1) / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin((lng2 - lng1) / 2) ** 2))
|
||||
if (d === 0) return [a, b]
|
||||
const pts: [number, number][] = []
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const f = i / steps
|
||||
const A = Math.sin((1 - f) * d) / Math.sin(d)
|
||||
const B = Math.sin(f * d) / Math.sin(d)
|
||||
const x = A * Math.cos(lat1) * Math.cos(lng1) + B * Math.cos(lat2) * Math.cos(lng2)
|
||||
const y = A * Math.cos(lat1) * Math.sin(lng1) + B * Math.cos(lat2) * Math.sin(lng2)
|
||||
const z = A * Math.sin(lat1) + B * Math.sin(lat2)
|
||||
const lat = Math.atan2(z, Math.sqrt(x * x + y * y))
|
||||
const lng = Math.atan2(y, x)
|
||||
pts.push([toDeg(lat), toDeg(lng)])
|
||||
}
|
||||
return pts
|
||||
}
|
||||
|
||||
/**
|
||||
* Make the longitudes of a sampled arc continuous: atan2 normalizes every
|
||||
* sample to [-180, 180], so a date-line crossing shows up as a ±360 jump
|
||||
* between neighbours — which the renderers draw as a line across the whole
|
||||
* map. Carrying a running offset keeps each Δlng under 180°, at the cost of
|
||||
* longitudes leaving the [-180, 180] range (both map libraries project those
|
||||
* linearly, which is exactly what makes the wrap seamless).
|
||||
*/
|
||||
export function unwrapLngs(points: [number, number][]): [number, number][] {
|
||||
let offset = 0
|
||||
return points.map(([lat, lng], i) => {
|
||||
if (i === 0) return [lat, lng] as [number, number]
|
||||
const prev = points[i - 1][1] + offset
|
||||
let cur = lng + offset
|
||||
if (cur - prev > 180) { offset -= 360; cur -= 360 }
|
||||
else if (cur - prev < -180) { offset += 360; cur += 360 }
|
||||
return [lat, cur] as [number, number]
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The polylines to draw for one leg. The base arc is continuous (unwrapped),
|
||||
* so panning across the antimeridian shows one unbroken line. With
|
||||
* `wrapCopies` (Leaflet — its vector layers don't repeat across world
|
||||
* copies), a ±360-shifted duplicate keeps both halves visible in the
|
||||
* standard [-180, 180] view; GL maps repeat features themselves
|
||||
* (renderWorldCopies), so the duplicate would just double the line opacity.
|
||||
*/
|
||||
export function geodesicArcs(a: [number, number], b: [number, number], wrapCopies: boolean): [number, number][][] {
|
||||
const arc = unwrapLngs(greatCircle(a, b))
|
||||
const crosses = arc.some(([, lng]) => lng < -180 || lng > 180)
|
||||
if (!crosses || !wrapCopies) return [arc]
|
||||
const shift = arc.some(([, lng]) => lng < -180) ? 360 : -360
|
||||
return [arc, arc.map(([lat, lng]) => [lat, lng + shift] as [number, number])]
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { ReservationMapboxOverlay } from './reservationsMapbox'
|
||||
import type { Reservation } from '../../types'
|
||||
|
||||
// A minimal mapbox-gl stand-in: a persistent source that records the last
|
||||
// setData, and project() spreading points far enough apart to pass the
|
||||
// per-type pixel-distance visibility filter.
|
||||
function fakeMap() {
|
||||
const source = { setData: vi.fn() }
|
||||
return {
|
||||
_source: source,
|
||||
getSource: () => source,
|
||||
addSource: vi.fn(),
|
||||
addLayer: vi.fn(),
|
||||
getLayer: () => undefined,
|
||||
removeLayer: vi.fn(),
|
||||
removeSource: vi.fn(),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
getZoom: () => 12,
|
||||
project: ([lng, lat]: [number, number]) => ({ x: lng * 1000, y: lat * 1000 }),
|
||||
}
|
||||
}
|
||||
|
||||
const FakeMarker = vi.fn(function () {
|
||||
const marker = {
|
||||
setLngLat: () => marker,
|
||||
addTo: () => marker,
|
||||
remove: vi.fn(),
|
||||
getElement: () => document.createElement('div'),
|
||||
}
|
||||
return marker
|
||||
}) as unknown as new () => unknown
|
||||
|
||||
function carBooking(): Reservation {
|
||||
return {
|
||||
id: 1, type: 'car', status: 'confirmed',
|
||||
endpoints: [
|
||||
{ role: 'from', sequence: 0, name: 'A', code: null, lat: 48.0, lng: 2.0, timezone: null, local_time: null, local_date: null },
|
||||
{ role: 'to', sequence: 1, name: 'B', code: null, lat: 48.2, lng: 2.3, timezone: null, local_time: null, local_date: null },
|
||||
],
|
||||
} as unknown as Reservation
|
||||
}
|
||||
|
||||
const opts = { showConnections: true, showStats: false, showEndpointLabels: false }
|
||||
|
||||
function lastFeatureCoords(map: ReturnType<typeof fakeMap>) {
|
||||
const calls = map._source.setData.mock.calls
|
||||
const data = calls[calls.length - 1]?.[0] as { features: { geometry: { coordinates: [number, number][] } }[] }
|
||||
return data.features[0].geometry.coordinates
|
||||
}
|
||||
|
||||
describe('ReservationMapboxOverlay road routes (#1425)', () => {
|
||||
it('draws the real road geometry when a road route is supplied', () => {
|
||||
const map = fakeMap()
|
||||
const overlay = new ReservationMapboxOverlay(map as never, opts, FakeMarker as never)
|
||||
const road: [number, number][] = [[48.0, 2.0], [48.1, 2.15], [48.2, 2.3]]
|
||||
overlay.update([carBooking()], opts, new Map([[1, road]]))
|
||||
// GeoJSON is [lng, lat]; the routed 3-point line, not the straight 2-point arc.
|
||||
expect(lastFeatureCoords(map)).toEqual([[2.0, 48.0], [2.15, 48.1], [2.3, 48.2]])
|
||||
})
|
||||
|
||||
it('falls back to the straight arc when no road route is supplied', () => {
|
||||
const map = fakeMap()
|
||||
const overlay = new ReservationMapboxOverlay(map as never, opts, FakeMarker as never)
|
||||
overlay.update([carBooking()], opts)
|
||||
expect(lastFeatureCoords(map)).toEqual([[2.0, 48.0], [2.3, 48.2]])
|
||||
})
|
||||
|
||||
it('sets no line features while connections are hidden', () => {
|
||||
const map = fakeMap()
|
||||
const overlay = new ReservationMapboxOverlay(map as never, opts, FakeMarker as never)
|
||||
overlay.update([carBooking()], { ...opts, showConnections: false }, new Map([[1, [[48, 2], [48.2, 2.3]]]]))
|
||||
const calls = map._source.setData.mock.calls
|
||||
const data = calls[calls.length - 1]?.[0] as { features: unknown[] }
|
||||
expect(data.features).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -11,6 +11,7 @@ import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import type mapboxgl from 'mapbox-gl'
|
||||
import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route, TramFront } from 'lucide-react'
|
||||
import { getTransitMapSegments } from './transitGeometry'
|
||||
import { geodesicArcs } from './flightGeodesy'
|
||||
import { escapeHtml } from '@trek/shared'
|
||||
import type { Reservation, ReservationEndpoint } from '../../types'
|
||||
|
||||
@@ -34,43 +35,8 @@ const TYPE_META: Record<TransportType, { icon: typeof Plane; geodesic: boolean }
|
||||
transport_other: { icon: Route, geodesic: false },
|
||||
}
|
||||
|
||||
// ── geometry helpers (ported from ReservationOverlay.tsx) ────────────────
|
||||
// ── geometry helpers (shared with ReservationOverlay via flightGeodesy) ──
|
||||
const toRad = (d: number) => d * Math.PI / 180
|
||||
const toDeg = (r: number) => r * 180 / Math.PI
|
||||
|
||||
function greatCircle(a: [number, number], b: [number, number], steps = 256): [number, number][] {
|
||||
const [lat1, lng1] = [toRad(a[0]), toRad(a[1])]
|
||||
const [lat2, lng2] = [toRad(b[0]), toRad(b[1])]
|
||||
const d = 2 * Math.asin(Math.sqrt(Math.sin((lat2 - lat1) / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin((lng2 - lng1) / 2) ** 2))
|
||||
if (d === 0) return [a, b]
|
||||
const pts: [number, number][] = []
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const f = i / steps
|
||||
const A = Math.sin((1 - f) * d) / Math.sin(d)
|
||||
const B = Math.sin(f * d) / Math.sin(d)
|
||||
const x = A * Math.cos(lat1) * Math.cos(lng1) + B * Math.cos(lat2) * Math.cos(lng2)
|
||||
const y = A * Math.cos(lat1) * Math.sin(lng1) + B * Math.cos(lat2) * Math.sin(lng2)
|
||||
const z = A * Math.sin(lat1) + B * Math.sin(lat2)
|
||||
const lat = Math.atan2(z, Math.sqrt(x * x + y * y))
|
||||
const lng = Math.atan2(y, x)
|
||||
pts.push([toDeg(lat), toDeg(lng)])
|
||||
}
|
||||
return pts
|
||||
}
|
||||
|
||||
function splitAntimeridian(points: [number, number][]): [number, number][][] {
|
||||
const segments: [number, number][][] = []
|
||||
let cur: [number, number][] = []
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
if (i > 0 && Math.abs(points[i][1] - points[i - 1][1]) > 180) {
|
||||
if (cur.length > 1) segments.push(cur)
|
||||
cur = []
|
||||
}
|
||||
cur.push(points[i])
|
||||
}
|
||||
if (cur.length > 1) segments.push(cur)
|
||||
return segments
|
||||
}
|
||||
|
||||
function haversineKm(a: [number, number], b: [number, number]): number {
|
||||
const R = 6371
|
||||
@@ -157,7 +123,10 @@ function buildItems(reservations: Reservation[]): TransportItem[] {
|
||||
const a = waypoints[i]
|
||||
const b = waypoints[i + 1]
|
||||
const segArcs = isGeo
|
||||
? splitAntimeridian(greatCircle([a.lat, a.lng], [b.lat, b.lng]))
|
||||
// GL maps repeat features across world copies themselves, so one
|
||||
// continuous unwrapped arc is enough (a shifted duplicate would
|
||||
// coincide with the wrapped copy and double the line opacity).
|
||||
? geodesicArcs([a.lat, a.lng], [b.lat, b.lng], false)
|
||||
: [[[a.lat, a.lng], [b.lat, b.lng]] as [number, number][]]
|
||||
arcs.push(...segArcs)
|
||||
distanceKm += haversineKm([a.lat, a.lng], [b.lat, b.lng])
|
||||
@@ -234,6 +203,7 @@ type MarkerConstructor = new (options?: { element?: HTMLElement; anchor?: string
|
||||
export class ReservationMapboxOverlay {
|
||||
private map: mapboxgl.Map
|
||||
private items: TransportItem[] = []
|
||||
private roadRoutes: Map<number, [number, number][]> = new Map()
|
||||
private opts: ReservationOverlayOptions
|
||||
private MarkerCtor: MarkerConstructor
|
||||
private endpointMarkers: GlMarker[] = []
|
||||
@@ -252,9 +222,10 @@ export class ReservationMapboxOverlay {
|
||||
map.on('render', this.updateStatsRotation)
|
||||
}
|
||||
|
||||
update(reservations: Reservation[], opts: ReservationOverlayOptions) {
|
||||
update(reservations: Reservation[], opts: ReservationOverlayOptions, roadRoutes?: Map<number, [number, number][]>) {
|
||||
this.opts = opts
|
||||
this.items = buildItems(reservations)
|
||||
this.roadRoutes = roadRoutes ?? new Map()
|
||||
this.render()
|
||||
}
|
||||
|
||||
@@ -357,7 +328,10 @@ export class ReservationMapboxOverlay {
|
||||
},
|
||||
}))
|
||||
}
|
||||
return item.arcs.map(seg => ({
|
||||
// Prefer the real road route (car/bus/taxi/bicycle) over the straight arc.
|
||||
const road = this.roadRoutes.get(item.res.id)
|
||||
const lines = road && road.length >= 2 ? [road] : item.arcs
|
||||
return lines.map(seg => ({
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
resId: item.res.id,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { accommodationsApi, mapsApi } from '../../api/client'
|
||||
import type { Trip, Day, Place, Category, AssignmentsMap, DayNote } from '../../types'
|
||||
import { isDayInAccommodationRange, getDayOrder } from '../../utils/dayOrder'
|
||||
import { splitReservationDateTime } from '../../utils/formatters'
|
||||
import { getFlightLegs } from '../../utils/flightLegs'
|
||||
import { getFlightLegs, getTrainLegs } from '../../utils/flightLegs'
|
||||
|
||||
function renderLucideIcon(icon:LucideIcon, props = {}) {
|
||||
if (!_renderToStaticMarkup) return ''
|
||||
@@ -243,7 +243,21 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
||||
subtitle = [meta.airline, meta.flight_number, route].filter(Boolean).join(' · ')
|
||||
}
|
||||
}
|
||||
else if (r.type === 'train') subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Seat ${meta.seat}` : ''].filter(Boolean).join(' · ')
|
||||
else if (r.type === 'train') {
|
||||
const legs = getTrainLegs(r)
|
||||
if (legs.length > 1) {
|
||||
// Multi-leg: one line per leg so every train number + segment route shows.
|
||||
subtitleLines = legs.map(l =>
|
||||
[l.train_number, l.platform ? `Gl. ${l.platform}` : '',
|
||||
(l.from || l.to) ? [l.from, l.to].filter(Boolean).join(' → ') : '']
|
||||
.filter(Boolean).join(' · '))
|
||||
.filter(Boolean)
|
||||
} else {
|
||||
const stops = (r.endpoints || []).slice().sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0)).map(e => e.code || e.name)
|
||||
const route = stops.length >= 2 ? stops.join(' → ') : ''
|
||||
subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Seat ${meta.seat}` : '', route].filter(Boolean).join(' · ')
|
||||
}
|
||||
}
|
||||
else if (r.type === 'restaurant') subtitle = [meta.party_size ? `${meta.party_size} guests` : ''].filter(Boolean).join(' · ')
|
||||
else if (r.type === 'event') subtitle = [meta.venue].filter(Boolean).join(' · ')
|
||||
else if (r.type === 'tour') subtitle = [meta.operator].filter(Boolean).join(' · ')
|
||||
|
||||
@@ -129,6 +129,7 @@ beforeEach(() => {
|
||||
resetAllStores()
|
||||
vi.clearAllMocks()
|
||||
sessionStorage.clear()
|
||||
localStorage.clear()
|
||||
// Reset mutable day-notes state
|
||||
mockDayNotesState.noteUi = {}
|
||||
mockDayNotesState.dayNotes = {}
|
||||
|
||||
@@ -96,6 +96,9 @@ interface DayPlanSidebarProps {
|
||||
onScrollTopChange?: (top: number) => void
|
||||
/** Mobile: show the route tools footer (Route toggle / Optimize / travel profile) on expanded days, since selecting a day closes the sheet */
|
||||
showRouteToolsWhenExpanded?: boolean
|
||||
/** Mobile: drag & drop reorder is disabled (touch-scroll hijack, #1432); the
|
||||
* grip handle is hidden and the arrow reorder buttons take over instead. */
|
||||
isMobile?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -140,6 +143,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
initialScrollTop,
|
||||
onScrollTopChange,
|
||||
showRouteToolsWhenExpanded = false,
|
||||
isMobile = false,
|
||||
} = props
|
||||
const toast = useToast()
|
||||
const { t, language, locale } = useTranslation()
|
||||
@@ -153,7 +157,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
|
||||
const [expandedDays, setExpandedDays] = useState(() => {
|
||||
try {
|
||||
const saved = sessionStorage.getItem(`day-expanded-${tripId}`)
|
||||
const saved = localStorage.getItem(`day-expanded-${tripId}`)
|
||||
if (saved) return new Set<number>(JSON.parse(saved) as number[])
|
||||
} catch {}
|
||||
return new Set<number>(days.map(d => d.id))
|
||||
@@ -260,7 +264,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
setExpandedDays(prev => {
|
||||
const n = new Set(prev)
|
||||
days.forEach(d => { if (!prev.has(d.id)) n.add(d.id) })
|
||||
try { sessionStorage.setItem(`day-expanded-${tripId}`, JSON.stringify([...n])) } catch {}
|
||||
try { localStorage.setItem(`day-expanded-${tripId}`, JSON.stringify([...n])) } catch {}
|
||||
return n
|
||||
})
|
||||
}
|
||||
@@ -293,7 +297,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
setExpandedDays(prev => {
|
||||
const n = new Set(prev)
|
||||
n.has(dayId) ? n.delete(dayId) : n.add(dayId)
|
||||
try { sessionStorage.setItem(`day-expanded-${tripId}`, JSON.stringify([...n])) } catch {}
|
||||
try { localStorage.setItem(`day-expanded-${tripId}`, JSON.stringify([...n])) } catch {}
|
||||
return n
|
||||
})
|
||||
}
|
||||
@@ -1019,6 +1023,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
initialScrollTop,
|
||||
onScrollTopChange,
|
||||
showRouteToolsWhenExpanded,
|
||||
isMobile,
|
||||
toast,
|
||||
t,
|
||||
language,
|
||||
@@ -1184,6 +1189,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
initialScrollTop,
|
||||
onScrollTopChange,
|
||||
showRouteToolsWhenExpanded,
|
||||
isMobile,
|
||||
toast,
|
||||
t,
|
||||
language,
|
||||
@@ -1648,9 +1654,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
return (
|
||||
<React.Fragment key={`place-${assignment.id}`}>
|
||||
<div
|
||||
draggable={canEditDays}
|
||||
draggable={canEditDays && !isMobile}
|
||||
onDragStart={e => {
|
||||
if (!canEditDays) { e.preventDefault(); return }
|
||||
if (!canEditDays || isMobile) { e.preventDefault(); return }
|
||||
e.dataTransfer.setData('assignmentId', String(assignment.id))
|
||||
e.dataTransfer.setData('fromDayId', String(day.id))
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
@@ -1745,7 +1751,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
opacity: isDraggingThis ? 0.4 : 1,
|
||||
}}
|
||||
>
|
||||
{canEditDays && <div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||
{canEditDays && !isMobile && <div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||
<GripVertical size={13} strokeWidth={1.8} />
|
||||
</div>}
|
||||
<div
|
||||
@@ -1953,8 +1959,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
// Subtitle aus Metadaten zusammensetzen
|
||||
let subtitle = ''
|
||||
if (res.__leg) {
|
||||
// One leg of a multi-leg flight — show this segment's own route.
|
||||
const parts = [res.__leg.airline, res.__leg.flight_number].filter(Boolean)
|
||||
// One leg of a multi-leg flight/train — show this segment's own detail.
|
||||
const parts = res.type === 'train'
|
||||
? [res.__leg.train_number, res.__leg.platform ? `Gl. ${res.__leg.platform}` : '', res.__leg.seat ? `Sitz ${res.__leg.seat}` : ''].filter(Boolean)
|
||||
: [res.__leg.airline, res.__leg.flight_number].filter(Boolean)
|
||||
if (res.__leg.from || res.__leg.to)
|
||||
parts.push([res.__leg.from, res.__leg.to].filter(Boolean).join(' → '))
|
||||
subtitle = parts.join(' · ')
|
||||
@@ -2003,9 +2011,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
const key = inBottom ? `transport-after-${res.id}${ls}-${day.id}` : `transport-${res.id}${ls}-${day.id}`
|
||||
if (dropTargetRef.current !== key) setDropTargetKey(key)
|
||||
}}
|
||||
draggable={canEditDays && spanPhase !== 'middle' && !res.__leg}
|
||||
draggable={canEditDays && spanPhase !== 'middle' && !res.__leg && !isMobile}
|
||||
onDragStart={e => {
|
||||
if (!canEditDays || spanPhase === 'middle' || res.__leg) { e.preventDefault(); return }
|
||||
if (!canEditDays || spanPhase === 'middle' || res.__leg || isMobile) { e.preventDefault(); return }
|
||||
// setData is required for the drag to start reliably (Firefox) and
|
||||
// matches how place/note items initiate their drag.
|
||||
e.dataTransfer.setData('reservationId', String(res.id))
|
||||
@@ -2054,7 +2062,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
opacity: draggingId === res.id ? 0.4 : spanPhase === 'middle' ? 0.65 : 1,
|
||||
}}
|
||||
>
|
||||
{canEditDays && spanPhase !== 'middle' && !res.__leg && (
|
||||
{canEditDays && spanPhase !== 'middle' && !res.__leg && !isMobile && (
|
||||
<div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||
<GripVertical size={13} strokeWidth={1.8} />
|
||||
</div>
|
||||
@@ -2171,8 +2179,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
return (
|
||||
<React.Fragment key={`note-${note.id}`}>
|
||||
<div
|
||||
draggable={canEditDays}
|
||||
onDragStart={e => { if (!canEditDays) { e.preventDefault(); return } e.dataTransfer.setData('noteId', String(note.id)); e.dataTransfer.setData('fromDayId', String(day.id)); e.dataTransfer.effectAllowed = 'move'; dragDataRef.current = { noteId: String(note.id), fromDayId: String(day.id) }; setDraggingId(`note-${note.id}`) }}
|
||||
draggable={canEditDays && !isMobile}
|
||||
onDragStart={e => { if (!canEditDays || isMobile) { e.preventDefault(); return } e.dataTransfer.setData('noteId', String(note.id)); e.dataTransfer.setData('fromDayId', String(day.id)); e.dataTransfer.effectAllowed = 'move'; dragDataRef.current = { noteId: String(note.id), fromDayId: String(day.id) }; setDraggingId(`note-${note.id}`) }}
|
||||
onDragEnd={() => { setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null }}
|
||||
onDragOver={e => { e.preventDefault(); e.stopPropagation(); if (dropTargetKey !== `note-${note.id}`) setDropTargetKey(`note-${note.id}`) }}
|
||||
onDrop={e => {
|
||||
@@ -2240,7 +2248,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
transition: 'background 0.1s', cursor: 'grab', userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
{canEditDays && <div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||
{canEditDays && !isMobile && <div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||
<GripVertical size={13} strokeWidth={1.8} />
|
||||
</div>}
|
||||
<div style={{ width: 28, height: 28, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: '50%', background: 'var(--bg-hover)', overflow: 'hidden' }}>
|
||||
|
||||
@@ -259,8 +259,8 @@ describe('PlaceInspector', () => {
|
||||
onRemoveAssignment={onRemoveAssignment}
|
||||
/>
|
||||
);
|
||||
// Find the remove button — it has "Remove" text (sm:hidden span)
|
||||
const removeBtn = screen.getByText('Remove').closest('button')!;
|
||||
// Find the remove button — it carries the "Remove from Day" label
|
||||
const removeBtn = screen.getByText('Remove from Day').closest('button')!;
|
||||
await user.click(removeBtn);
|
||||
// Component calls onRemoveAssignment(selectedDayId, assignmentInDay.id)
|
||||
expect(onRemoveAssignment).toHaveBeenCalledWith(1, 99);
|
||||
|
||||
@@ -8,7 +8,7 @@ import { X, Clock, MapPin, ExternalLink, Phone, Euro, Edit2, Trash2, Plus, Minus
|
||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||
import GuestBadge from '../shared/GuestBadge'
|
||||
import StatusBadge from '../Collections/StatusBadge'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { mapsApi, pluginsApi } from '../../api/client'
|
||||
import { collectionsApi } from '../../api/collections'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
@@ -16,6 +16,8 @@ import { useSaveToCollectionStore } from '../../store/saveToCollectionStore'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation, translateApiError } from '../../i18n'
|
||||
import { usePluginStore } from '../../store/pluginStore'
|
||||
import PluginFrame from '../Plugins/PluginFrame'
|
||||
import type { Place, Category, Day, Assignment, Reservation, TripFile, AssignmentsMap } from '../../types'
|
||||
import type { CollectionStatus } from '@trek/shared'
|
||||
import { splitReservationDateTime, formatTime } from '../../utils/formatters'
|
||||
@@ -142,6 +144,21 @@ export default function PlaceInspector({
|
||||
leftWidth = 0, rightWidth = 0,
|
||||
collectionStatus, onCopyToTrip, onSetStatus, onRemoveFromList,
|
||||
}: PlaceInspectorProps) {
|
||||
// Plugins that declared a place-detail slot mount at the bottom of this panel,
|
||||
// scoped to the open place (trip mode only). Inline-filter like the other sites.
|
||||
const placeDetailPlugins = usePluginStore((s) => s.plugins).filter((p) => p.type === 'widget' && p.slot === 'place-detail')
|
||||
// Extra native rows contributed by placeDetailProvider plugins (#1429). Fail-safe:
|
||||
// any provider error/timeout is dropped server-side, so this only ever adds rows.
|
||||
const [providerDetails, setProviderDetails] = useState<Array<{ pluginId: string; items: Array<{ label: string; value?: string; url?: string }> }>>([])
|
||||
const placeIdForDetails = mode === 'trip' ? place?.id : undefined
|
||||
useEffect(() => {
|
||||
if (placeIdForDetails == null) { setProviderDetails([]); return }
|
||||
let cancelled = false
|
||||
pluginsApi.placeDetails(placeIdForDetails)
|
||||
.then((d) => { if (!cancelled) setProviderDetails((d.providers || []).filter((p) => Array.isArray(p.items) && p.items.length > 0)) })
|
||||
.catch(() => { if (!cancelled) setProviderDetails([]) })
|
||||
return () => { cancelled = true }
|
||||
}, [placeIdForDetails])
|
||||
const { t, locale, language } = useTranslation()
|
||||
const toast = useToast()
|
||||
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
||||
@@ -356,6 +373,34 @@ export default function PlaceInspector({
|
||||
fileInputRef={fileInputRef} handleFileUpload={handleFileUpload} isUploading={isUploading}
|
||||
distanceUnit={distanceUnit} />
|
||||
|
||||
{/* Extra native rows from placeDetailProvider plugins (#1429). */}
|
||||
{mode === 'trip' && providerDetails.length > 0 && (
|
||||
<div className="bg-surface-hover" style={{ borderRadius: 10, padding: '10px 12px', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{providerDetails.flatMap((p) => p.items.map((it, i) => (
|
||||
<div key={`${p.pluginId}-${i}`} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 8, fontSize: 'calc(12.5px * var(--fs-scale-body, 1))' }}>
|
||||
<span className="text-content-secondary" style={{ fontWeight: 500, flexShrink: 0 }}>{it.label}</span>
|
||||
{it.url
|
||||
? <a href={it.url} target="_blank" rel="noreferrer noopener" className="text-accent" style={{ textDecoration: 'none', textAlign: 'right', overflow: 'hidden', textOverflow: 'ellipsis' }}>{it.value ?? '↗'}</a>
|
||||
: <span className="text-content-muted" style={{ textAlign: 'right' }}>{it.value}</span>}
|
||||
</div>
|
||||
)))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Place-detail plugin slots (#1429): sandboxed, scoped to this place. */}
|
||||
{mode === 'trip' && placeDetailPlugins.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{placeDetailPlugins.map((p) => {
|
||||
const tid = (place as { trip_id?: number | string }).trip_id
|
||||
return (
|
||||
<div key={p.id} className="bg-surface-hover" style={{ borderRadius: 10, overflow: 'hidden' }}>
|
||||
<PluginFrame pluginId={p.id} tripId={tid != null ? String(tid) : null} placeId={String(place.id)} title={p.name} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Footer actions */}
|
||||
@@ -372,7 +417,7 @@ export default function PlaceInspector({
|
||||
{mode === 'trip' && selectedDayId && (
|
||||
assignmentInDay ? (
|
||||
<ActionButton onClick={() => onRemoveAssignment?.(selectedDayId, assignmentInDay.id)} variant="ghost" icon={<Minus size={13} />}
|
||||
label={<><span className="hidden sm:inline">{t('inspector.removeFromDay')}</span><span className="sm:hidden">{t('inspector.remove')}</span></>} />
|
||||
label={<span className="hidden sm:inline">{t('inspector.removeFromDay')}</span>} />
|
||||
) : (
|
||||
<ActionButton onClick={() => onAssignToDay?.(place.id)} variant="primary" icon={<Plus size={13} />} label={t('inspector.addToDay')} />
|
||||
)
|
||||
|
||||
@@ -23,13 +23,13 @@ const PlacesSidebar = React.memo(function PlacesSidebar(props: PlacesSidebarProp
|
||||
} = S
|
||||
return (
|
||||
<div
|
||||
onDragEnter={handleSidebarDragEnter}
|
||||
onDragOver={handleSidebarDragOver}
|
||||
onDragLeave={handleSidebarDragLeave}
|
||||
onDrop={handleSidebarDrop}
|
||||
onDragEnter={isMobile ? undefined : handleSidebarDragEnter}
|
||||
onDragOver={isMobile ? undefined : handleSidebarDragOver}
|
||||
onDragLeave={isMobile ? undefined : handleSidebarDragLeave}
|
||||
onDrop={isMobile ? undefined : handleSidebarDrop}
|
||||
style={{ display: 'flex', flexDirection: 'column', height: '100%', fontFamily: "var(--font-system)", position: 'relative' }}
|
||||
>
|
||||
{sidebarDragOver && <PlacesDropOverlay {...S} />}
|
||||
{!isMobile && sidebarDragOver && <PlacesDropOverlay {...S} />}
|
||||
{/* Kopfbereich */}
|
||||
<PlacesHeader {...S} />
|
||||
|
||||
|
||||
@@ -36,8 +36,9 @@ export const MemoPlaceRow = React.memo(function MemoPlaceRow({
|
||||
ref={element => registerPlaceRow(place.id, element)}
|
||||
aria-selected={isSelected}
|
||||
data-place-id={place.id}
|
||||
draggable={!selectMode}
|
||||
draggable={!selectMode && !isMobile}
|
||||
onDragStart={e => {
|
||||
if (isMobile) { e.preventDefault(); return }
|
||||
e.dataTransfer.setData('placeId', String(place.id))
|
||||
e.dataTransfer.effectAllowed = 'copy'
|
||||
window.__dragData = { placeId: String(place.id) }
|
||||
@@ -55,7 +56,7 @@ export const MemoPlaceRow = React.memo(function MemoPlaceRow({
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '9px 14px 9px 16px',
|
||||
cursor: selectMode ? 'pointer' : 'grab',
|
||||
cursor: selectMode || isMobile ? 'pointer' : 'grab',
|
||||
background: isChecked ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : isSelected ? 'var(--border-faint)' : 'transparent',
|
||||
borderBottom: '1px solid var(--border-faint)',
|
||||
transition: 'background 0.1s',
|
||||
|
||||
@@ -409,6 +409,12 @@ describe('ReservationModal', () => {
|
||||
expect(screen.getByRole('button', { name: /Attach file/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-029b: file input accepts pkpass (#1448)', () => {
|
||||
render(<ReservationModal {...defaultProps} />);
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
expect(fileInput.accept).toContain('.pkpass');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-030: hotel type — saving calls onSave with correct hotel shape', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<ReservationModal {...defaultProps} onSave={onSave} />);
|
||||
|
||||
@@ -682,7 +682,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<input ref={fileInputRef} type="file" accept=".pdf,.doc,.docx,.txt,image/*" style={{ display: 'none' }} onChange={handleFileChange} />
|
||||
<input ref={fileInputRef} type="file" accept=".pdf,.doc,.docx,.txt,.pkpass,.pkpasses,image/*,application/vnd.apple.pkpass,application/vnd.apple.pkpasses" style={{ display: 'none' }} onChange={handleFileChange} />
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{onFileUpload && <button type="button" onClick={() => fileInputRef.current?.click()} disabled={uploadingFile} className="text-content-faint" style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px',
|
||||
|
||||
@@ -71,6 +71,12 @@ describe('TransportModal', () => {
|
||||
expect(screen.getByText(/Add transport/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-002b: file input accepts pkpass (#1448)', () => {
|
||||
render(<TransportModal {...defaultProps} />);
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
expect(fileInput.accept).toContain('.pkpass');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-003: shows "Edit transport" title when editing', () => {
|
||||
const res = buildReservation({ title: 'Paris Flight', type: 'flight' });
|
||||
render(<TransportModal {...defaultProps} reservation={res} />);
|
||||
@@ -390,4 +396,68 @@ describe('TransportModal', () => {
|
||||
render(<TransportModal {...defaultProps} reservation={res} />);
|
||||
expect(screen.queryByRole('button', { name: 'Automated transport' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── Multi-leg trains (#1150) ───────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-025: a train with an added stop saves from/stop/to endpoints + metadata.legs', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<TransportModal {...defaultProps} onSave={onSave} />);
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Train$/i }));
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Berlin → München');
|
||||
// Insert an intermediate station (2 → 3 stations = 2 legs).
|
||||
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
|
||||
const stations = screen.getAllByTestId('location-select');
|
||||
expect(stations).toHaveLength(3);
|
||||
fireEvent.change(stations[0], { target: { value: 'Berlin Hbf' } });
|
||||
fireEvent.change(stations[1], { target: { value: 'Frankfurt Hbf' } });
|
||||
fireEvent.change(stations[2], { target: { value: 'München Hbf' } });
|
||||
// Per-leg train number on the first station (placeholder ICE 123).
|
||||
const trainNumbers = screen.getAllByPlaceholderText('ICE 123');
|
||||
fireEvent.change(trainNumbers[0], { target: { value: 'ICE 100' } });
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
const payload = onSave.mock.calls[0][0];
|
||||
expect(payload.type).toBe('train');
|
||||
expect(payload.endpoints.map((e: { role: string }) => e.role)).toEqual(['from', 'stop', 'to']);
|
||||
expect(payload.endpoints.map((e: { name: string }) => e.name)).toEqual(['Berlin Hbf', 'Frankfurt Hbf', 'München Hbf']);
|
||||
expect(payload.metadata.legs).toHaveLength(2);
|
||||
expect(payload.metadata.legs[0]).toMatchObject({ from: 'Berlin Hbf', to: 'Frankfurt Hbf', train_number: 'ICE 100' });
|
||||
expect(payload.metadata.train_number).toBe('ICE 100'); // flat mirror of leg 0
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-027: a train with a day + train number but no geocoded station still saves them (#1150 regression)', async () => {
|
||||
const days = [{ id: 10, trip_id: 1, day_number: 1, date: '2026-08-01', title: 'Day 1' }] as any;
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<TransportModal {...defaultProps} days={days} selectedDayId={10} onSave={onSave} />);
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Train$/i }));
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'ICE 599');
|
||||
// Fill the train number + a departure time, but never pick a geocoded station.
|
||||
fireEvent.change(screen.getAllByPlaceholderText('ICE 123')[0], { target: { value: 'ICE 599' } });
|
||||
fireEvent.change(screen.getAllByTestId('time-picker')[0], { target: { value: '08:00' } });
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
const payload = onSave.mock.calls[0][0];
|
||||
// The day, time and train number survive even without any map-picked station.
|
||||
expect(payload.day_id).toBe(10);
|
||||
expect(payload.reservation_time).toBe('2026-08-01T08:00');
|
||||
expect(payload.metadata.train_number).toBe('ICE 599');
|
||||
expect(payload.endpoints).toEqual([]); // no geocoded station → no map endpoints, like before
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-026: a two-station train saves flat (no metadata.legs)', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<TransportModal {...defaultProps} onSave={onSave} />);
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Train$/i }));
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Köln → Aachen');
|
||||
const stations = screen.getAllByTestId('location-select');
|
||||
fireEvent.change(stations[0], { target: { value: 'Köln Hbf' } });
|
||||
fireEvent.change(stations[1], { target: { value: 'Aachen Hbf' } });
|
||||
fireEvent.change(screen.getAllByPlaceholderText('ICE 123')[0], { target: { value: 'RE 9' } });
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
const payload = onSave.mock.calls[0][0];
|
||||
expect(payload.endpoints.map((e: { role: string }) => e.role)).toEqual(['from', 'to']);
|
||||
expect(payload.metadata.legs).toBeUndefined();
|
||||
expect(payload.metadata.train_number).toBe('RE 9');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,7 +41,7 @@ function endpointFromAirport(a: Airport, role: 'from' | 'to' | 'stop', sequence:
|
||||
}
|
||||
}
|
||||
|
||||
function endpointFromLocation(l: LocationPoint, role: 'from' | 'to', sequence: number, date: string | null, time: string | null): Omit<ReservationEndpoint, 'id' | 'reservation_id'> {
|
||||
function endpointFromLocation(l: LocationPoint, role: 'from' | 'to' | 'stop', sequence: number, date: string | null, time: string | null): Omit<ReservationEndpoint, 'id' | 'reservation_id'> {
|
||||
return {
|
||||
role, sequence,
|
||||
name: l.name,
|
||||
@@ -88,6 +88,24 @@ function emptyWaypoint(dayId: string | number = ''): WaypointForm {
|
||||
return { airport: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', airline: '', flight_number: '', seat: '' }
|
||||
}
|
||||
|
||||
// ── Multi-leg train stations ───────────────────────────────────────────────
|
||||
// A train mirrors the flight route model, but its waypoints are STATIONS
|
||||
// (location search, no timezone) and each leg carries a train number + platform
|
||||
// instead of an airline + flight number. N stations = N-1 legs.
|
||||
interface StationWaypointForm {
|
||||
location: LocationPoint | null
|
||||
arrDayId: string | number
|
||||
arrTime: string
|
||||
depDayId: string | number
|
||||
depTime: string
|
||||
train_number: string
|
||||
platform: string
|
||||
seat: string
|
||||
}
|
||||
function emptyStationWaypoint(dayId: string | number = ''): StationWaypointForm {
|
||||
return { location: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', train_number: '', platform: '', seat: '' }
|
||||
}
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane },
|
||||
{ value: 'train', labelKey: 'reservations.type.train', Icon: Train },
|
||||
@@ -159,6 +177,8 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
const [toPick, setToPick] = useState<EndpointPick>({})
|
||||
// Flight route as an ordered list of airports (origin .. stops .. destination).
|
||||
const [waypoints, setWaypoints] = useState<WaypointForm[]>([emptyWaypoint(), emptyWaypoint()])
|
||||
// Train route as an ordered list of stations (origin .. stops .. destination).
|
||||
const [trainWaypoints, setTrainWaypoints] = useState<StationWaypointForm[]>([emptyStationWaypoint(), emptyStationWaypoint()])
|
||||
const [uploadingFile, setUploadingFile] = useState(false)
|
||||
const [pendingFiles, setPendingFiles] = useState<File[]>([])
|
||||
const [showFilePicker, setShowFilePicker] = useState(false)
|
||||
@@ -237,6 +257,46 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
wps = [dep, arr]
|
||||
}
|
||||
setWaypoints(wps)
|
||||
} else if (type === 'train') {
|
||||
// Mirror the flight seeding with stations + per-leg train fields. A
|
||||
// current single-leg train (2 endpoints, no metadata.legs) round-trips
|
||||
// through the >=2 branch: the flat train_number/platform/seat land on
|
||||
// the first station, dep/arr day+time from src.day_id/end_day_id.
|
||||
const orderedEps = orderedEndpoints(src)
|
||||
const metaLegs: any[] = Array.isArray(meta.legs) ? meta.legs : []
|
||||
let wps: StationWaypointForm[]
|
||||
if (orderedEps.length >= 2) {
|
||||
wps = orderedEps.map((ep, i) => {
|
||||
const legInto = metaLegs[i - 1]
|
||||
const legOut = metaLegs[i]
|
||||
const isFirst = i === 0
|
||||
const isLast = i === orderedEps.length - 1
|
||||
return {
|
||||
location: locationFromEndpoint(ep),
|
||||
arrDayId: legInto?.arr_day_id ?? (isLast ? (src.end_day_id ?? '') : ''),
|
||||
arrTime: legInto?.arr_time ?? (!isFirst ? (ep.local_time ?? '') : ''),
|
||||
depDayId: legOut?.dep_day_id ?? (isFirst ? (src.day_id ?? '') : ''),
|
||||
depTime: legOut?.dep_time ?? (!isLast ? (ep.local_time ?? '') : ''),
|
||||
train_number: legOut?.train_number ?? (isFirst ? (meta.train_number ?? '') : ''),
|
||||
platform: legOut?.platform ?? (isFirst ? (meta.platform ?? '') : ''),
|
||||
seat: legOut?.seat ?? (isFirst ? (meta.seat ?? '') : ''),
|
||||
}
|
||||
})
|
||||
} else {
|
||||
const dep = emptyStationWaypoint(src.day_id ?? '')
|
||||
dep.location = locationFromEndpoint(from)
|
||||
dep.depTime = splitReservationDateTime(src.reservation_time).time ?? ''
|
||||
dep.train_number = meta.train_number ?? ''
|
||||
dep.platform = meta.platform ?? ''
|
||||
dep.seat = meta.seat ?? ''
|
||||
const arr = emptyStationWaypoint(src.end_day_id ?? src.day_id ?? '')
|
||||
arr.location = locationFromEndpoint(to)
|
||||
arr.arrTime = splitReservationDateTime(src.reservation_end_time).time ?? ''
|
||||
wps = [dep, arr]
|
||||
}
|
||||
setTrainWaypoints(wps)
|
||||
setFromPick({})
|
||||
setToPick({})
|
||||
} else {
|
||||
setFromPick({ location: locationFromEndpoint(from) || undefined })
|
||||
setToPick({ location: locationFromEndpoint(to) || undefined })
|
||||
@@ -247,6 +307,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
setFromPick({})
|
||||
setToPick({})
|
||||
setWaypoints([emptyWaypoint(selectedDayId ?? ''), emptyWaypoint(selectedDayId ?? '')])
|
||||
setTrainWaypoints([emptyStationWaypoint(selectedDayId ?? ''), emptyStationWaypoint(selectedDayId ?? '')])
|
||||
}
|
||||
}, [isOpen, reservation, prefill, selectedDayId, budgetItems])
|
||||
|
||||
@@ -270,6 +331,15 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
const flightWps = form.type === 'flight' ? waypoints.filter(w => w.airport) : []
|
||||
const firstWp = flightWps[0]
|
||||
const lastWp = flightWps[flightWps.length - 1]
|
||||
// Train route: the first/last waypoint drive the span + flat metadata
|
||||
// (day/time/train number are entered independently of geocoding, exactly
|
||||
// like the old form fields, so a train with no map-picked station still
|
||||
// saves its day/time/train number). Only geocoded stations become map
|
||||
// endpoints + legs, mirroring the old "push only if the location is set".
|
||||
const trainWps = form.type === 'train' ? trainWaypoints : []
|
||||
const firstTrainWp = trainWps[0]
|
||||
const lastTrainWp = trainWps[trainWps.length - 1]
|
||||
const trainStations = form.type === 'train' ? trainWaypoints.filter(w => w.location) : []
|
||||
// Per-leg day-plan positions are owned by the day planner, not this form — keep
|
||||
// them when re-saving so editing a flight doesn't reset where its legs sit.
|
||||
const origLegs: any[] = reservation ? (parseReservationMetadata(reservation).legs || []) : []
|
||||
@@ -308,9 +378,31 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
}
|
||||
if (firstWp?.seat) metadata.seat = firstWp.seat
|
||||
} else if (form.type === 'train') {
|
||||
if (form.meta_train_number) metadata.train_number = form.meta_train_number
|
||||
if (form.meta_platform) metadata.platform = form.meta_platform
|
||||
if (form.meta_seat) metadata.seat = form.meta_seat
|
||||
// Flat keys mirror the first leg so legacy readers keep working; a
|
||||
// 2-station train emits exactly {train_number?,platform?,seat?} — the
|
||||
// same shape it saved before this feature.
|
||||
if (firstTrainWp?.train_number) metadata.train_number = firstTrainWp.train_number
|
||||
if (firstTrainWp?.platform) metadata.platform = firstTrainWp.platform
|
||||
if (firstTrainWp?.seat) metadata.seat = firstTrainWp.seat
|
||||
// Per-leg detail only for a true multi-leg train (>2 geocoded stations);
|
||||
// a simple train keeps the same flat metadata it saved before.
|
||||
if (trainStations.length > 2) {
|
||||
metadata.legs = trainStations.slice(0, -1).map((w, i) => {
|
||||
const next = trainStations[i + 1]
|
||||
return {
|
||||
from: w.location!.name,
|
||||
to: next.location!.name,
|
||||
...(w.train_number ? { train_number: w.train_number } : {}),
|
||||
...(w.platform ? { platform: w.platform } : {}),
|
||||
...(w.seat ? { seat: w.seat } : {}),
|
||||
dep_day_id: w.depDayId ? Number(w.depDayId) : null,
|
||||
dep_time: w.depTime || null,
|
||||
arr_day_id: next.arrDayId ? Number(next.arrDayId) : null,
|
||||
arr_time: next.arrTime || null,
|
||||
...(origLegs[i]?.day_positions ? { day_positions: origLegs[i].day_positions } : {}),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A transit itinerary (#1065) lives in metadata.transit + 'stop' endpoints,
|
||||
@@ -340,6 +432,18 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
const time = isLast ? w.arrTime : w.depTime
|
||||
endpoints.push(endpointFromAirport(w.airport!, role, i, dayDate(dId), time || null))
|
||||
})
|
||||
} else if (form.type === 'train') {
|
||||
trainStations.forEach((w, i) => {
|
||||
const isFirst = i === 0
|
||||
const isLast = i === trainStations.length - 1
|
||||
const role: 'from' | 'to' | 'stop' = isFirst ? 'from' : isLast ? 'to' : 'stop'
|
||||
const dId = isLast ? w.arrDayId : w.depDayId
|
||||
const time = isLast ? w.arrTime : w.depTime
|
||||
// The destination date falls back to the departure day (as the old flat
|
||||
// path did via `endDay ?? startDay`) when the arrival day is left blank.
|
||||
const date = dayDate(dId) ?? (isLast ? dayDate(firstTrainWp?.depDayId ?? '') : null)
|
||||
endpoints.push(endpointFromLocation(w.location!, role, i, date, time || null))
|
||||
})
|
||||
} else {
|
||||
if (fromPick.location) endpoints.push(endpointFromLocation(fromPick.location, 'from', 0, startDate, form.departure_time || null))
|
||||
// Keep the itinerary's transfer stops while the route is unchanged (#1065).
|
||||
@@ -354,22 +458,30 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
if (toPick.location) endpoints.push(endpointFromLocation(toPick.location, 'to', stops.length + 1, endDate, form.arrival_time || null))
|
||||
}
|
||||
|
||||
// Flights derive their span from the first/last waypoint; other transports
|
||||
// keep using the single departure/arrival form fields unchanged.
|
||||
// Flights and trains derive their span from the first/last waypoint; other
|
||||
// transports keep using the single departure/arrival form fields unchanged.
|
||||
const flightDepDay = firstWp && firstWp.depDayId ? Number(firstWp.depDayId) : null
|
||||
const flightArrDay = lastWp && lastWp.arrDayId ? Number(lastWp.arrDayId) : null
|
||||
const trainDepDay = firstTrainWp && firstTrainWp.depDayId ? Number(firstTrainWp.depDayId) : null
|
||||
const trainArrDay = lastTrainWp && lastTrainWp.arrDayId ? Number(lastTrainWp.arrDayId) : null
|
||||
const payload = {
|
||||
title: form.title,
|
||||
type: form.type,
|
||||
status: form.status,
|
||||
day_id: form.type === 'flight' ? flightDepDay : (form.start_day_id ? Number(form.start_day_id) : null),
|
||||
end_day_id: form.type === 'flight' ? flightArrDay : (form.end_day_id ? Number(form.end_day_id) : null),
|
||||
day_id: form.type === 'flight' ? flightDepDay : form.type === 'train' ? trainDepDay : (form.start_day_id ? Number(form.start_day_id) : null),
|
||||
end_day_id: form.type === 'flight' ? flightArrDay : form.type === 'train' ? trainArrDay : (form.end_day_id ? Number(form.end_day_id) : null),
|
||||
reservation_time: form.type === 'flight'
|
||||
? buildTime(days.find(d => d.id === flightDepDay), firstWp?.depTime || '')
|
||||
: buildTime(startDay, form.departure_time),
|
||||
: form.type === 'train'
|
||||
? buildTime(days.find(d => d.id === trainDepDay), firstTrainWp?.depTime || '')
|
||||
: buildTime(startDay, form.departure_time),
|
||||
reservation_end_time: form.type === 'flight'
|
||||
? buildTime(days.find(d => d.id === flightArrDay), lastWp?.arrTime || '')
|
||||
: buildTime(endDay ?? startDay, form.arrival_time),
|
||||
: form.type === 'train'
|
||||
// Fall back to the departure day so a same-day train (arrival day left
|
||||
// blank) still gets its date, matching the non-flight `endDay ?? startDay`.
|
||||
? buildTime(days.find(d => d.id === trainArrDay) ?? days.find(d => d.id === trainDepDay), lastTrainWp?.arrTime || '')
|
||||
: buildTime(endDay ?? startDay, form.arrival_time),
|
||||
location: null,
|
||||
confirmation_number: form.confirmation_number || null,
|
||||
notes: form.notes || null,
|
||||
@@ -653,6 +765,80 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : form.type === 'train' ? (
|
||||
/* ── Train route: ordered stations (origin · stops · destination) ── */
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<label className={labelClass}>{t('reservations.layover.route')}</label>
|
||||
{trainWaypoints.map((wp, i) => {
|
||||
const isFirst = i === 0
|
||||
const isLast = i === trainWaypoints.length - 1
|
||||
const updateWp = (patch: Partial<StationWaypointForm>) => setTrainWaypoints(prev => prev.map((w, j) => (j === i ? { ...w, ...patch } : w)))
|
||||
const roleLabel = isFirst ? t('reservations.meta.from') : isLast ? t('reservations.meta.to') : t('reservations.layover.stop')
|
||||
return (
|
||||
<div key={i} style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div className="bg-surface-card" style={{ border: '1px solid var(--border-primary)', borderRadius: 10, padding: 10, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span className="text-content-faint" style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.03em', flexShrink: 0 }}>{roleLabel}</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<LocationSelect value={wp.location} onChange={l => updateWp({ location: l || null })} />
|
||||
</div>
|
||||
{!isFirst && !isLast && (
|
||||
<button type="button" onClick={() => setTrainWaypoints(prev => prev.filter((_, j) => j !== i))} aria-label={t('common.delete')} className="text-content-faint" style={{ background: 'none', border: 'none', cursor: 'pointer', display: 'flex', padding: 4, flexShrink: 0 }}>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{!isFirst && (
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label className={labelClass}>{t('reservations.arrivalDate')}</label>
|
||||
<CustomSelect value={wp.arrDayId} onChange={v => updateWp({ arrDayId: v })} placeholder={t('dayplan.dayN', { n: '?' })} options={dayOptions} size="sm" />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label className={labelClass}>{t('reservations.arrivalTime')}</label>
|
||||
<CustomTimePicker value={wp.arrTime} onChange={v => updateWp({ arrTime: v })} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isLast && (
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label className={labelClass}>{t('reservations.departureDate')}</label>
|
||||
<CustomSelect value={wp.depDayId} onChange={v => updateWp({ depDayId: v })} placeholder={t('dayplan.dayN', { n: '?' })} options={dayOptions} size="sm" />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label className={labelClass}>{t('reservations.departureTime')}</label>
|
||||
<CustomTimePicker value={wp.depTime} onChange={v => updateWp({ depTime: v })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className={labelClass}>{t('reservations.meta.trainNumber')}</label>
|
||||
<input type="text" value={wp.train_number} onChange={e => updateWp({ train_number: e.target.value })} placeholder="ICE 123" className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>{t('reservations.meta.platform')}</label>
|
||||
<input type="text" value={wp.platform} onChange={e => updateWp({ platform: e.target.value })} placeholder="12" className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>{t('reservations.meta.seat')}</label>
|
||||
<input type="text" value={wp.seat} onChange={e => updateWp({ seat: e.target.value })} placeholder="42A" className={inputClass} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!isLast && (
|
||||
<button type="button" onClick={() => setTrainWaypoints(prev => [...prev.slice(0, i + 1), emptyStationWaypoint(prev[i]?.depDayId || ''), ...prev.slice(i + 1)])}
|
||||
className="text-content-faint hover:text-content-secondary" style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5, padding: '6px 10px', border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
<Plus size={12} /> {t('reservations.layover.addStop')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* From / To endpoints (non-flight) */}
|
||||
@@ -694,25 +880,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
)}
|
||||
|
||||
{/* Train-specific fields */}
|
||||
{form.type === 'train' && (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className={labelClass}>{t('reservations.meta.trainNumber')}</label>
|
||||
<input type="text" value={form.meta_train_number} onChange={e => set('meta_train_number', e.target.value)}
|
||||
placeholder="ICE 123" className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>{t('reservations.meta.platform')}</label>
|
||||
<input type="text" value={form.meta_platform} onChange={e => set('meta_platform', e.target.value)}
|
||||
placeholder="12" className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>{t('reservations.meta.seat')}</label>
|
||||
<input type="text" value={form.meta_seat} onChange={e => set('meta_seat', e.target.value)}
|
||||
placeholder="42A" className={inputClass} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Train number / platform / seat are per-leg now (in the route above). */}
|
||||
|
||||
{/* Booking Code + Status */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
@@ -778,7 +946,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<input ref={fileInputRef} type="file" accept=".pdf,.doc,.docx,.txt,image/*" style={{ display: 'none' }} onChange={handleFileChange} />
|
||||
<input ref={fileInputRef} type="file" accept=".pdf,.doc,.docx,.txt,.pkpass,.pkpasses,image/*,application/vnd.apple.pkpass,application/vnd.apple.pkpasses" style={{ display: 'none' }} onChange={handleFileChange} />
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{onFileUpload && <button type="button" onClick={() => fileInputRef.current?.click()} disabled={uploadingFile} className="text-content-faint" style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px',
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { AlertTriangle, Info, AlertCircle } from 'lucide-react'
|
||||
import { pluginsApi } from '../../api/client'
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
type Warning = { pluginId: string; level: 'info' | 'warning' | 'error'; message: string }
|
||||
|
||||
const STYLE = {
|
||||
info: { Icon: Info, color: 'var(--info)', bg: 'var(--info-soft)' },
|
||||
warning: { Icon: AlertTriangle, color: 'var(--warning)', bg: 'var(--warning-soft)' },
|
||||
error: { Icon: AlertCircle, color: 'var(--danger)', bg: 'var(--danger-soft)' },
|
||||
} as const
|
||||
|
||||
export default function TripWarningsBanner({ tripId }: { tripId: number }) {
|
||||
const [warnings, setWarnings] = useState<Warning[]>([])
|
||||
useEffect(() => {
|
||||
if (!Number.isFinite(tripId)) { setWarnings([]); return }
|
||||
let cancelled = false
|
||||
pluginsApi.tripWarnings(tripId)
|
||||
.then((d) => { if (!cancelled) setWarnings(d.warnings || []) })
|
||||
.catch(() => { if (!cancelled) setWarnings([]) })
|
||||
return () => { cancelled = true }
|
||||
}, [tripId])
|
||||
|
||||
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.
|
||||
return (
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, zIndex: 6, pointerEvents: 'none', display: 'flex', flexDirection: 'column', gap: 6, padding: '8px 16px' }}>
|
||||
{warnings.map((w, i) => {
|
||||
const s = STYLE[w.level] ?? STYLE.warning
|
||||
return (
|
||||
<div key={`${w.pluginId}-${i}`} style={{ pointerEvents: 'auto', display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', borderRadius: 10, background: s.bg, color: s.color, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, boxShadow: 'var(--shadow-card)' }}>
|
||||
<s.Icon size={15} style={{ flexShrink: 0 }} />
|
||||
<span>{w.message}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -9,7 +9,8 @@ const invoke = vi.fn((..._args: unknown[]) => Promise.resolve({ ok: true }));
|
||||
vi.mock('react-router-dom', () => ({ useNavigate: () => navigate }));
|
||||
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
|
||||
vi.mock('../../i18n', () => ({ useTranslation: () => ({ locale: 'en' }) }));
|
||||
vi.mock('../../store/authStore', () => ({ useAuthStore: (sel: (s: unknown) => unknown) => sel({ user: { id: 7 } }) }));
|
||||
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) } }));
|
||||
|
||||
function fromFrame(frame: HTMLIFrameElement, data: unknown) {
|
||||
@@ -64,4 +65,40 @@ describe('PluginFrame', () => {
|
||||
await waitFor(() => expect(invoke).toHaveBeenCalledWith('demo', '/status', { method: 'GET', body: undefined }));
|
||||
await waitFor(() => expect(posted.some((m) => (m as { type?: string }).type === 'trek:response')).toBe(true));
|
||||
});
|
||||
|
||||
it('FE-PLUGINS-FRAME-005: context carries theme tokens, formats and non-secret display identity', () => {
|
||||
const { container } = render(<PluginFrame pluginId="demo" />);
|
||||
const iframe = container.querySelector('iframe')!;
|
||||
const posted: Array<Record<string, unknown>> = [];
|
||||
(iframe.contentWindow as unknown as { postMessage: (m: unknown) => void }).postMessage = (m: unknown) => posted.push(m as Record<string, unknown>);
|
||||
|
||||
fromFrame(iframe, { type: 'trek:context:request' });
|
||||
|
||||
const ctx = posted.find((m) => m.type === 'trek:context') as Record<string, unknown> | undefined;
|
||||
expect(ctx).toBeTruthy();
|
||||
expect(ctx!.tokens).toBeTruthy(); // resolved design tokens (empty {} in jsdom, but present)
|
||||
expect(ctx!.formats).toMatchObject({ currency: 'EUR', timeFormat: '24h', distanceUnit: 'metric' });
|
||||
// Display identity is present but carries NO secret (no email, role only as a boolean).
|
||||
expect(ctx!.user).toMatchObject({ name: 'ada', isAdmin: true });
|
||||
expect(JSON.stringify(ctx)).not.toContain('@'); // no email leaked
|
||||
});
|
||||
|
||||
it('FE-PLUGINS-FRAME-006: context mirrors the host appearance state (scheme/density/flags)', () => {
|
||||
const { container } = render(<PluginFrame pluginId="demo" />);
|
||||
const iframe = container.querySelector('iframe')!;
|
||||
const posted: Array<Record<string, unknown>> = [];
|
||||
(iframe.contentWindow as unknown as { postMessage: (m: unknown) => void }).postMessage = (m: unknown) => posted.push(m as Record<string, unknown>);
|
||||
|
||||
fromFrame(iframe, { type: 'trek:context:request' });
|
||||
|
||||
const ctx = posted.find((m) => m.type === 'trek:context') as Record<string, unknown> | undefined;
|
||||
expect(ctx).toBeTruthy();
|
||||
// A plugin can honour the same accent/density/accessibility choices as the host.
|
||||
expect(ctx!.appearance).toMatchObject({
|
||||
scheme: 'default',
|
||||
density: 'comfortable',
|
||||
noTransparency: false,
|
||||
reducedMotion: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,68 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { pluginsApi } from '../../api/client'
|
||||
|
||||
// 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
|
||||
// re-match on a theme toggle / accent change) instead of hard-coding a mirror of
|
||||
// the palette that drifts. Names mirror index.css so a plugin can apply them
|
||||
// verbatim as CSS variables. This is the whole GLOBAL (:root/.dark) palette — the
|
||||
// part that a user can recolour via appearance settings (accent scheme, custom
|
||||
// accent, high-contrast) flows through here live. The glassy `.trek-dash` layer
|
||||
// (--glass-*/--r-*/--sh-*) is intentionally NOT read here: it is scoped to the
|
||||
// dashboard subtree, so it resolves EMPTY at documentElement — the SDK design kit
|
||||
// bakes those values instead (they don't vary with the accent, only light/dark).
|
||||
const TOKEN_VARS = [
|
||||
// surfaces
|
||||
'--bg-primary', '--bg-secondary', '--bg-tertiary', '--bg-elevated',
|
||||
'--bg-card', '--bg-input', '--bg-hover', '--bg-selected', '--bg-inverse',
|
||||
// text
|
||||
'--text-primary', '--text-secondary', '--text-muted', '--text-faint', '--text-inverse',
|
||||
// borders
|
||||
'--border-primary', '--border-secondary', '--border-faint',
|
||||
// accent (recoloured by the chosen scheme / custom accent)
|
||||
'--accent', '--accent-text', '--accent-on', '--accent-hover', '--accent-subtle',
|
||||
// semantic + soft fills
|
||||
'--success', '--success-soft', '--danger', '--danger-soft',
|
||||
'--warning', '--warning-soft', '--info', '--info-soft',
|
||||
// shadows
|
||||
'--shadow-card', '--shadow-elevated', '--shadow-sm', '--shadow-md', '--shadow-lg',
|
||||
// radii, type, misc
|
||||
'--radius-sm', '--radius-md', '--radius-lg', '--radius-xl',
|
||||
'--font-system', '--font-subtext', '--overlay', '--ease-out-quint',
|
||||
]
|
||||
function readThemeTokens(): Record<string, string> {
|
||||
const cs = getComputedStyle(document.documentElement)
|
||||
const out: Record<string, string> = {}
|
||||
for (const v of TOKEN_VARS) {
|
||||
const val = cs.getPropertyValue(v).trim()
|
||||
if (val) out[v] = val
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* The host's current appearance state, mirrored from the attributes applyAppearance
|
||||
* writes on <html>, so a plugin can honour the same accessibility/appearance choices
|
||||
* inside its own sandboxed document (it can't read the parent DOM). All booleans/enums
|
||||
* — nothing secret.
|
||||
*/
|
||||
function readAppearance() {
|
||||
const el = document.documentElement
|
||||
return {
|
||||
scheme: el.dataset.scheme || 'default',
|
||||
density: el.dataset.density === 'compact' ? 'compact' : 'comfortable',
|
||||
noTransparency: el.hasAttribute('data-no-transparency'),
|
||||
reducedMotion:
|
||||
el.hasAttribute('data-reduce-motion') ||
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a plugin's sandboxed page/widget iframe and hosts the trekBridge
|
||||
* (#plugins, M3).
|
||||
@@ -21,6 +79,8 @@ import { pluginsApi } from '../../api/client'
|
||||
interface PluginFrameProps {
|
||||
pluginId: string
|
||||
tripId?: string | null
|
||||
/** The place in view — set for a place-detail slot so the plugin can scope to it. */
|
||||
placeId?: string | null
|
||||
className?: string
|
||||
title?: string
|
||||
}
|
||||
@@ -33,32 +93,71 @@ type Inbound =
|
||||
| { type: 'trek:resize'; height?: number }
|
||||
| { type: 'trek:invoke'; requestId: string; sub: string; method?: string; body?: unknown }
|
||||
|
||||
export default function PluginFrame({ pluginId, tripId = null, className, title }: PluginFrameProps) {
|
||||
export default function PluginFrame({ pluginId, tripId = null, placeId = null, className, title }: PluginFrameProps) {
|
||||
const frameRef = useRef<HTMLIFrameElement | null>(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
|
||||
// the bridge once a second document loads. NOTE: this is best-effort — the load
|
||||
// event fires at end-of-document, so a navigated attacker doc that posts during
|
||||
// its own load (or holds it open) can still reach the bridge for one exchange.
|
||||
// The exposure is bounded (only this plugin's own routes + the trek:context
|
||||
// ids the plugin already had; never the httpOnly cookie); fully closing it
|
||||
// would require not running plugin client JS at all.
|
||||
const loadsRef = useRef(0)
|
||||
const { locale } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const toast = useToast()
|
||||
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<number | null>(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
|
||||
// boots before the effect's listener attaches, the plugin never learns the theme
|
||||
// and falls back to the OS scheme (dark mode looking "off" until a toggle).
|
||||
const postFrame = useCallback((msg: unknown) => frameRef.current?.contentWindow?.postMessage(msg, '*'), [])
|
||||
const buildContext = useCallback(() => ({
|
||||
type: 'trek:context',
|
||||
tripId,
|
||||
placeId,
|
||||
userId: userId != null ? String(userId) : null,
|
||||
theme: document.documentElement.classList.contains('dark') ? 'dark' : 'light',
|
||||
locale,
|
||||
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),
|
||||
// how TREK formats things, the resolved theme tokens, and the appearance state
|
||||
// (accent scheme, density, reduced-motion / no-transparency) so a plugin can
|
||||
// mirror the same look and accessibility choices as the host.
|
||||
user: userName != null ? { name: userName, avatar: userAvatar, isAdmin } : null,
|
||||
appearance: readAppearance(),
|
||||
formats: {
|
||||
locale,
|
||||
currency: settings.default_currency,
|
||||
timeFormat: settings.time_format,
|
||||
distanceUnit: settings.distance_unit,
|
||||
temperatureUnit: settings.temperature_unit,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
},
|
||||
tokens: readThemeTokens(),
|
||||
}), [tripId, placeId, userId, locale, userName, userAvatar, isAdmin, settings])
|
||||
|
||||
useEffect(() => {
|
||||
const frame = frameRef.current
|
||||
if (!frame) return
|
||||
|
||||
const post = (msg: unknown) => frame.contentWindow?.postMessage(msg, '*') // opaque frame -> targetOrigin must be '*'
|
||||
|
||||
const context = () => ({
|
||||
type: 'trek:context',
|
||||
tripId,
|
||||
userId: userId != null ? String(userId) : null,
|
||||
theme: document.documentElement.classList.contains('dark') ? 'dark' : 'light',
|
||||
locale,
|
||||
hostOrigin: window.location.origin,
|
||||
})
|
||||
const post = postFrame
|
||||
const context = buildContext
|
||||
|
||||
const onMessage = async (ev: MessageEvent) => {
|
||||
// The ONLY trusted identity: the message came from OUR iframe's window.
|
||||
if (ev.source !== frame.contentWindow) return
|
||||
// …AND that window still holds the original plugin document (loaded once).
|
||||
// A 2nd load means the frame navigated elsewhere — stop bridging to it.
|
||||
if (loadsRef.current > 1) return
|
||||
const msg = ev.data as Inbound
|
||||
if (!msg || typeof msg !== 'object') return
|
||||
|
||||
@@ -97,13 +196,50 @@ export default function PluginFrame({ pluginId, tripId = null, className, title
|
||||
}
|
||||
|
||||
window.addEventListener('message', onMessage)
|
||||
return () => window.removeEventListener('message', onMessage)
|
||||
}, [pluginId, tripId, userId, locale, navigate, toast])
|
||||
|
||||
// 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 <html> element for anything
|
||||
// applyAppearance touches (the `dark` class, the data-* appearance attributes,
|
||||
// and inline style for the custom-accent vars) and re-post the context when the
|
||||
// resulting look actually changes, so plugins restyle live. A compact signature
|
||||
// dedupes: unrelated mutations don't trigger a repost. (Plugins re-apply on
|
||||
// trek:context.)
|
||||
const htmlEl = document.documentElement
|
||||
const appearanceSig = () => {
|
||||
const cs = getComputedStyle(htmlEl)
|
||||
return [
|
||||
htmlEl.classList.contains('dark'),
|
||||
htmlEl.dataset.scheme || '',
|
||||
htmlEl.dataset.density || '',
|
||||
htmlEl.hasAttribute('data-no-transparency'),
|
||||
htmlEl.hasAttribute('data-reduce-motion'),
|
||||
cs.getPropertyValue('--accent').trim(),
|
||||
].join('|')
|
||||
}
|
||||
let prevSig = appearanceSig()
|
||||
const themeObserver = new MutationObserver(() => {
|
||||
const sig = appearanceSig()
|
||||
if (sig === prevSig) return
|
||||
prevSig = sig
|
||||
if (loadsRef.current <= 1) post(context())
|
||||
})
|
||||
themeObserver.observe(htmlEl, {
|
||||
attributes: true,
|
||||
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 (
|
||||
<iframe
|
||||
ref={frameRef}
|
||||
src={`/plugin-frame/${pluginId}/index.html`}
|
||||
// Deliver the context as soon as the document is parsed (the plugin sets up its
|
||||
// message listener during parse), closing the trek:ready race so the theme is
|
||||
// right on first paint. A 2nd load is a self-navigation — don't bridge to it.
|
||||
onLoad={() => { loadsRef.current += 1; if (loadsRef.current === 1) postFrame(buildContext()) }}
|
||||
sandbox="allow-scripts allow-forms"
|
||||
referrerPolicy="no-referrer"
|
||||
loading="lazy"
|
||||
|
||||
@@ -1,21 +1,46 @@
|
||||
import { Blocks } from 'lucide-react'
|
||||
import PluginFrame from './PluginFrame'
|
||||
import type { ActivePlugin } from '../../store/pluginStore'
|
||||
|
||||
/**
|
||||
* Renders active `widget` plugins as dashboard cards (#plugins, M8). Each is a
|
||||
* sandboxed PluginFrame; the widget talks to TREK only over the bridge.
|
||||
*
|
||||
* The card mirrors the native dashboard tools (glassy `.tool` surface + uppercase
|
||||
* title) so plugins sit alongside them seamlessly, and the body auto-sizes to the
|
||||
* height the widget reports over trek:resize — no fixed height that would clip a
|
||||
* taller widget's controls.
|
||||
*/
|
||||
export default function PluginWidgets({ plugins, tripId = null }: { plugins: ActivePlugin[]; tripId?: string | null }) {
|
||||
if (plugins.length === 0) return null
|
||||
return (
|
||||
<>
|
||||
{plugins.map((p) => (
|
||||
<div key={p.id} className="bg-surface-card border border-edge rounded-xl overflow-hidden">
|
||||
<div className="px-3 py-2 text-xs font-medium text-content-muted border-b border-edge-secondary flex items-center gap-1.5">
|
||||
{p.name}
|
||||
<div
|
||||
key={p.id}
|
||||
style={{
|
||||
background: 'var(--glass-bg)',
|
||||
border: '1px solid var(--glass-border)',
|
||||
borderRadius: 'var(--r-xl, 20px)',
|
||||
boxShadow: 'var(--glass-shadow), var(--glass-highlight)',
|
||||
backdropFilter: 'var(--glass-blur)',
|
||||
WebkitBackdropFilter: 'var(--glass-blur)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, padding: '16px 20px 8px',
|
||||
fontSize: 13, fontWeight: 500, textTransform: 'uppercase', letterSpacing: '0.14em',
|
||||
color: 'var(--ink-3)',
|
||||
}}
|
||||
>
|
||||
<Blocks size={14} style={{ flexShrink: 0 }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>
|
||||
</div>
|
||||
<div style={{ height: 180 }}>
|
||||
<PluginFrame pluginId={p.id} tripId={tripId} title={p.name} className="w-full h-full" />
|
||||
{/* min-height is just a pre-resize floor; trek:resize drives the real height. */}
|
||||
<div style={{ minHeight: 60 }}>
|
||||
<PluginFrame pluginId={p.id} tripId={tripId} title={p.name} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -51,7 +51,7 @@ export default function Modal({
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[200] flex items-start sm:items-center justify-center px-4 trek-modal-backdrop trek-backdrop-enter bg-[rgba(15,23,42,0.5)]"
|
||||
className="fixed inset-0 z-[10000] flex items-start sm:items-center justify-center px-4 trek-modal-backdrop trek-backdrop-enter bg-[rgba(15,23,42,0.5)]"
|
||||
style={{ paddingTop: 70, paddingBottom: 'calc(20px + var(--bottom-nav-h))', overflow: 'hidden' }}
|
||||
onMouseDown={e => { mouseDownTarget.current = e.target }}
|
||||
onClick={e => {
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { Reservation, ReservationEndpoint } from '../types'
|
||||
import { calculateRouteWithLegs } from '../components/Map/RouteCalculator'
|
||||
|
||||
/**
|
||||
* Real road-network geometry for road-based transport bookings (car, bus, taxi,
|
||||
* bicycle), so their map lines follow actual streets instead of a straight
|
||||
* as-the-crow-flies line — the same idea as the real transit paths (#1065),
|
||||
* but routed on demand rather than stored on the reservation.
|
||||
*
|
||||
* Trains and "other transport" keep their straight line (rail/unknown modes
|
||||
* aren't road-routable); flights/cruises/ferries use the great-circle arc.
|
||||
* Any routing failure falls back to the straight line the overlay already draws.
|
||||
*/
|
||||
|
||||
const ROAD_PROFILE: Record<string, 'driving' | 'cycling'> = {
|
||||
car: 'driving',
|
||||
bus: 'driving',
|
||||
taxi: 'driving',
|
||||
bicycle: 'cycling',
|
||||
}
|
||||
|
||||
// Beyond this straight-line distance a car/taxi/bike (or even coach) booking is
|
||||
// almost always a data quirk or an inter-continental hop the road router can't
|
||||
// resolve — keep the straight line and don't hammer the public OSRM demo.
|
||||
const MAX_ROUTE_KM = 2000
|
||||
|
||||
function haversineKm(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number {
|
||||
const R = 6371
|
||||
const dLat = (b.lat - a.lat) * Math.PI / 180
|
||||
const dLng = (b.lng - a.lng) * Math.PI / 180
|
||||
const la1 = a.lat * Math.PI / 180
|
||||
const la2 = b.lat * Math.PI / 180
|
||||
const h = Math.sin(dLat / 2) ** 2 + Math.cos(la1) * Math.cos(la2) * Math.sin(dLng / 2) ** 2
|
||||
return 2 * R * Math.asin(Math.sqrt(h))
|
||||
}
|
||||
|
||||
function orderedWaypoints(r: Reservation): ReservationEndpoint[] {
|
||||
return (r.endpoints || [])
|
||||
.filter(e => e.role === 'from' || e.role === 'to' || e.role === 'stop')
|
||||
.slice()
|
||||
.sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a map of reservation id → routed [lat, lng] polyline for the
|
||||
* road-based bookings among `reservations`. Missing entries mean "not routed
|
||||
* (yet / not routable)" — the caller should draw its straight line for those.
|
||||
* Routing runs once per reservation waypoint-set and is cached across the app
|
||||
* by RouteCalculator, so day switches and re-renders don't re-fetch.
|
||||
*/
|
||||
export function useTransportRoutes(reservations: Reservation[]): Map<number, [number, number][]> {
|
||||
const [routes, setRoutes] = useState<Map<number, [number, number][]>>(new Map())
|
||||
// id → waypoint signature already fetched/attempted, so an unchanged booking
|
||||
// is never re-requested even as the reservations array identity churns.
|
||||
const attemptedRef = useRef<Map<number, string>>(new Map())
|
||||
// Aborted only on unmount — a reservations change must not cancel an
|
||||
// in-flight route we still want.
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
if (!abortRef.current) abortRef.current = new AbortController()
|
||||
useEffect(() => () => abortRef.current?.abort(), [])
|
||||
|
||||
useEffect(() => {
|
||||
const jobs: { id: number; profile: 'driving' | 'cycling'; points: { lat: number; lng: number }[] }[] = []
|
||||
for (const r of reservations) {
|
||||
const profile = ROAD_PROFILE[r.type]
|
||||
if (!profile) continue
|
||||
const wps = orderedWaypoints(r)
|
||||
if (wps.length < 2) continue
|
||||
let dist = 0
|
||||
for (let i = 0; i < wps.length - 1; i++) dist += haversineKm(wps[i], wps[i + 1])
|
||||
if (dist > MAX_ROUTE_KM) continue
|
||||
const key = `${profile}:${wps.map(w => `${w.lat},${w.lng}`).join('|')}`
|
||||
if (attemptedRef.current.get(r.id) === key) continue
|
||||
attemptedRef.current.set(r.id, key)
|
||||
jobs.push({ id: r.id, profile, points: wps.map(w => ({ lat: w.lat, lng: w.lng })) })
|
||||
}
|
||||
if (!jobs.length) return
|
||||
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
// Sequential to stay gentle on the shared public router.
|
||||
for (const job of jobs) {
|
||||
try {
|
||||
const result = await calculateRouteWithLegs(job.points, { signal: abortRef.current?.signal, profile: job.profile })
|
||||
if (cancelled) return
|
||||
if (result.coordinates.length >= 2) {
|
||||
setRoutes(prev => {
|
||||
const next = new Map(prev)
|
||||
next.set(job.id, result.coordinates)
|
||||
return next
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Leave it unrouted — the overlay keeps the straight line.
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => { cancelled = true }
|
||||
}, [reservations])
|
||||
|
||||
return routes
|
||||
}
|
||||
@@ -703,7 +703,8 @@ describe('AdminPage', () => {
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByRole('button', { name: /settings/i }));
|
||||
|
||||
const keyInput = await screen.findByPlaceholderText('Enter key...');
|
||||
// Maps is the first 'Enter key...' input (Unsplash is the second).
|
||||
const keyInput = (await screen.findAllByPlaceholderText('Enter key...'))[0];
|
||||
|
||||
// Type a value — covers the onChange handler
|
||||
fireEvent.change(keyInput, { target: { value: 'test-api-key-abc123' } });
|
||||
@@ -978,6 +979,35 @@ describe('AdminPage', () => {
|
||||
expect(capturedBody).toMatchObject({ maps_api_key: 'test-maps-key-123' });
|
||||
});
|
||||
});
|
||||
|
||||
it('typing in the Unsplash API key and clicking Save sends unsplash_api_key', async () => {
|
||||
let capturedBody: Record<string, unknown> | undefined;
|
||||
server.use(
|
||||
http.put('/api/auth/me/api-keys', async ({ request }) => {
|
||||
capturedBody = (await request.json()) as Record<string, unknown>;
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
|
||||
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
||||
render(<AdminPage />);
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByRole('button', { name: /settings/i }));
|
||||
|
||||
const apiKeysHeading = await screen.findByRole('heading', { name: /^api keys$/i });
|
||||
const apiKeysCard = apiKeysHeading.closest<HTMLElement>('.bg-white');
|
||||
|
||||
// The Unsplash key is the second 'Enter key...' input (after Maps).
|
||||
const keyInputs = within(apiKeysCard!).getAllByPlaceholderText('Enter key...');
|
||||
fireEvent.change(keyInputs[1], { target: { value: 'test-unsplash-key' } });
|
||||
|
||||
fireEvent.click(within(apiKeysCard!).getByRole('button', { name: /^save$/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedBody?.unsplash_api_key).toBe('test-unsplash-key');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-PAGE-ADMIN-044: Validate API key in Settings tab', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import { List as ListIcon, Map as MapIcon, Search, Bookmark, CheckCheck, X, Trash2, Copy, CopyPlus, FolderInput, Plus } from 'lucide-react'
|
||||
import { List as ListIcon, Map as MapIcon, Search, Bookmark, CheckCheck, X, Trash2, Copy, CopyPlus, FolderInput, Plus, Tags } from 'lucide-react'
|
||||
import Navbar from '../components/Layout/Navbar'
|
||||
import Modal from '../components/shared/Modal'
|
||||
import ListsRail from '../components/Collections/ListsRail'
|
||||
@@ -13,6 +13,8 @@ import MoveToListModal from '../components/Collections/MoveToListModal'
|
||||
import ShareCollectionModal from '../components/Collections/ShareCollectionModal'
|
||||
import AddPlaceToCollectionModal from '../components/Collections/AddPlaceToCollectionModal'
|
||||
import CollectionPlaceDetail from '../components/Collections/CollectionPlaceDetail'
|
||||
import LabelManager from '../components/Collections/LabelManager'
|
||||
import BulkAssignLabelModal from '../components/Collections/BulkAssignLabelModal'
|
||||
import { useCollections } from './collections/useCollections'
|
||||
import '../styles/dashboard.css'
|
||||
import '../styles/collections.css'
|
||||
@@ -41,6 +43,9 @@ export default function CollectionsPage(): React.ReactElement {
|
||||
const hasPlaces = c.places.length > 0
|
||||
const noLists = !c.loading && c.collections.length === 0
|
||||
const showSelect = c.isAllSaved || c.activeCollection != null
|
||||
// Labels are per-collection, so only on a real (non "All saved") list.
|
||||
const isRealList = !c.isAllSaved && typeof c.activeId === 'number'
|
||||
const canManageLabels = isRealList && c.canEdit
|
||||
|
||||
// Selecting a place toggles it, so clicking it again — or the map background —
|
||||
// clears it. Below the desktop breakpoint the list and map are separate views;
|
||||
@@ -66,6 +71,7 @@ export default function CollectionsPage(): React.ReactElement {
|
||||
const listEl = (
|
||||
<CollectionList
|
||||
places={c.visiblePlaces}
|
||||
labels={c.labels}
|
||||
selectedPlaceId={c.selectedPlaceId}
|
||||
selectMode={c.selectMode}
|
||||
selectedIds={c.selectedIds}
|
||||
@@ -103,6 +109,12 @@ export default function CollectionsPage(): React.ReactElement {
|
||||
categoryOptions={c.categoryOptions}
|
||||
onStatusFilter={c.setStatusFilter}
|
||||
onCategoryFilter={c.setCategoryFilter}
|
||||
showLabels={isRealList}
|
||||
labelOptions={c.labelOptions}
|
||||
labelFilter={c.labelFilter}
|
||||
onLabelFilter={c.setLabelFilter}
|
||||
canManageLabels={canManageLabels}
|
||||
onManageLabels={() => c.setShowLabelManager(true)}
|
||||
showSelect={showSelect}
|
||||
selectMode={c.selectMode}
|
||||
onToggleSelect={() => c.setSelectMode(!c.selectMode)}
|
||||
@@ -231,6 +243,11 @@ export default function CollectionsPage(): React.ReactElement {
|
||||
</button>
|
||||
<span className="lbl">{t('collections.selectedCount', { count: c.selectedIds.length })}</span>
|
||||
<div className="col-toolbar-spacer" />
|
||||
{c.canEdit && isRealList && (
|
||||
<button type="button" onClick={() => c.setLabelPickerOpen(true)} disabled={c.selectedIds.length === 0} className="col-selbar-btn">
|
||||
<Tags size={14} /> {t('collections.labels.assign')}
|
||||
</button>
|
||||
)}
|
||||
{c.canEdit && (
|
||||
<button type="button" onClick={() => c.setListPickerMode('move')} disabled={c.selectedIds.length === 0} className="col-selbar-btn">
|
||||
<FolderInput size={14} /> {t('collections.moveToList')}
|
||||
@@ -282,6 +299,7 @@ export default function CollectionsPage(): React.ReactElement {
|
||||
canEdit={c.canEdit}
|
||||
canDelete={c.canDelete}
|
||||
categories={c.categories}
|
||||
labels={c.labels}
|
||||
anchorRect={desktopSplit ? c.listColRect : null}
|
||||
onClose={c.handleCloseDetail}
|
||||
onSetStatus={c.handleDetailStatus}
|
||||
@@ -344,6 +362,32 @@ export default function CollectionsPage(): React.ReactElement {
|
||||
{/* Create / edit a list — name, colour, cover, description, links */}
|
||||
<ListEditorModal target={c.editorTarget} onClose={() => c.setEditorTarget(null)} onCreated={c.handleEditorCreated} onRequestDelete={c.setConfirmDeleteList} t={t} />
|
||||
|
||||
{/* Manage the list's custom labels (editor+) */}
|
||||
{canManageLabels && (
|
||||
<LabelManager
|
||||
isOpen={c.showLabelManager}
|
||||
labels={c.labels}
|
||||
onCreate={c.handleCreateLabel}
|
||||
onUpdate={c.handleUpdateLabel}
|
||||
onDelete={c.handleDeleteLabel}
|
||||
onClose={() => c.setShowLabelManager(false)}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Bulk-assign labels to the current selection */}
|
||||
{canManageLabels && (
|
||||
<BulkAssignLabelModal
|
||||
isOpen={c.labelPickerOpen}
|
||||
labels={c.labels}
|
||||
count={c.selectedIds.length}
|
||||
onAssign={c.handleBulkAssignLabels}
|
||||
onManage={() => { c.setLabelPickerOpen(false); c.setShowLabelManager(true) }}
|
||||
onClose={() => c.setLabelPickerOpen(false)}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete-list confirm */}
|
||||
<Modal
|
||||
isOpen={c.confirmDeleteList != null}
|
||||
|
||||
@@ -901,5 +901,38 @@ describe('DashboardPage', () => {
|
||||
expect(s.dashboard_fx_to).toBe('CHF');
|
||||
expect(s.dashboard_timezones).toEqual(['America/New_York']);
|
||||
});
|
||||
|
||||
it('keeps the legacy localStorage keys when the server write fails, so nothing is lost (#1311)', async () => {
|
||||
// The write to persist the migrated values fails (server briefly unavailable during a
|
||||
// docker upgrade). The legacy localStorage source must survive so the next load retries —
|
||||
// the old code deleted it unconditionally, permanently losing the values.
|
||||
server.use(
|
||||
http.put('/api/settings', () => new HttpResponse(null, { status: 500 })),
|
||||
http.post('/api/settings/bulk', () => new HttpResponse(null, { status: 500 })),
|
||||
);
|
||||
localStorage.setItem('trek_fx_from', 'CAD');
|
||||
localStorage.setItem('trek_fx_to', 'CHF');
|
||||
localStorage.setItem('trek_dashboard_tz', JSON.stringify(['America/New_York']));
|
||||
seedStore(useSettingsStore, { settings: buildSettings(), isLoaded: true });
|
||||
render(<DashboardPage />);
|
||||
// The optimistic store update proves the migration effect ran; the failed write must NOT
|
||||
// have removed the localStorage source.
|
||||
await waitFor(() => {
|
||||
expect(useSettingsStore.getState().settings.dashboard_fx_from).toBe('CAD');
|
||||
});
|
||||
expect(localStorage.getItem('trek_fx_from')).toBe('CAD');
|
||||
expect(localStorage.getItem('trek_fx_to')).toBe('CHF');
|
||||
expect(localStorage.getItem('trek_dashboard_tz')).toBe(JSON.stringify(['America/New_York']));
|
||||
});
|
||||
|
||||
it('drops a malformed legacy timezone value instead of retrying forever (#1311)', async () => {
|
||||
localStorage.setItem('trek_dashboard_tz', 'not-json');
|
||||
seedStore(useSettingsStore, { settings: buildSettings(), isLoaded: true });
|
||||
render(<DashboardPage />);
|
||||
await waitFor(() => {
|
||||
expect(localStorage.getItem('trek_dashboard_tz')).toBeNull();
|
||||
});
|
||||
expect(useSettingsStore.getState().settings.dashboard_timezones).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -618,10 +618,15 @@ function CurrencyTool(): React.ReactElement {
|
||||
const lf = localStorage.getItem('trek_fx_from')
|
||||
const lt = localStorage.getItem('trek_fx_to')
|
||||
if (!lf && !lt) return
|
||||
if (lf) updateSetting('dashboard_fx_from', lf).catch(() => {})
|
||||
if (lt) updateSetting('dashboard_fx_to', lt).catch(() => {})
|
||||
localStorage.removeItem('trek_fx_from')
|
||||
localStorage.removeItem('trek_fx_to')
|
||||
const writes: Promise<void>[] = []
|
||||
if (lf) writes.push(updateSetting('dashboard_fx_from', lf))
|
||||
if (lt) writes.push(updateSetting('dashboard_fx_to', lt))
|
||||
// Only drop the localStorage source once the server has durably stored the values, so a
|
||||
// failed write during a (docker) upgrade can't destroy the only copy (#1311). Retry next load.
|
||||
Promise.all(writes).then(() => {
|
||||
localStorage.removeItem('trek_fx_from')
|
||||
localStorage.removeItem('trek_fx_to')
|
||||
}).catch(() => { /* keep localStorage; retry on next load */ })
|
||||
}, [isLoaded, updateSetting])
|
||||
|
||||
const currencies = rates ? Object.keys(rates).sort() : FX_FALLBACK
|
||||
@@ -697,11 +702,15 @@ function TimezoneTool({ locale }: { locale: string }): React.ReactElement {
|
||||
if (!isLoaded) return
|
||||
const raw = localStorage.getItem('trek_dashboard_tz')
|
||||
if (!raw) return
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (Array.isArray(parsed)) updateSetting('dashboard_timezones', parsed).catch(() => {})
|
||||
} catch { /* ignore malformed storage */ }
|
||||
localStorage.removeItem('trek_dashboard_tz')
|
||||
let parsed: unknown
|
||||
// A malformed/non-array value can never be written, so drop it now to avoid retrying forever.
|
||||
try { parsed = JSON.parse(raw) } catch { localStorage.removeItem('trek_dashboard_tz'); return }
|
||||
if (!Array.isArray(parsed)) { localStorage.removeItem('trek_dashboard_tz'); return }
|
||||
// Only drop the localStorage source once the server has durably stored the value, so a failed
|
||||
// write during a (docker) upgrade can't destroy the only copy (#1311). Retry next load.
|
||||
updateSetting('dashboard_timezones', parsed)
|
||||
.then(() => { localStorage.removeItem('trek_dashboard_tz') })
|
||||
.catch(() => { /* keep localStorage; retry on next load */ })
|
||||
}, [isLoaded, updateSetting])
|
||||
|
||||
const allZones = React.useMemo<string[]>(() => {
|
||||
|
||||
@@ -12,7 +12,7 @@ import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { Clock, MapPin, FileText, Train, Plane, Bus, Car, Ship, Ticket, Hotel, Map, Luggage, Wallet, MessageCircle } from 'lucide-react'
|
||||
import { isDayInAccommodationRange } from '../utils/dayOrder'
|
||||
import { getTransportForDay, getMergedItems } from '../utils/dayMerge'
|
||||
import { getFlightLegs } from '../utils/flightLegs'
|
||||
import { getFlightLegs, getTrainLegs } from '../utils/flightLegs'
|
||||
import { splitReservationDateTime } from '../utils/formatters'
|
||||
|
||||
const TRANSPORT_ICONS = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship }
|
||||
@@ -226,7 +226,14 @@ export default function SharedTripPage() {
|
||||
sub = [meta.airline, meta.flight_number, meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport} → ${meta.arrival_airport}` : ''].filter(Boolean).join(' · ')
|
||||
}
|
||||
}
|
||||
else if (r.type === 'train') sub = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : ''].filter(Boolean).join(' · ')
|
||||
else if (r.type === 'train') {
|
||||
if (r.__leg) {
|
||||
// One leg of a multi-leg train — show this segment's own train/route.
|
||||
sub = [r.__leg.train_number, r.__leg.platform ? `Gl. ${r.__leg.platform}` : '', (r.__leg.from || r.__leg.to) ? [r.__leg.from, r.__leg.to].filter(Boolean).join(' → ') : ''].filter(Boolean).join(' · ')
|
||||
} else {
|
||||
sub = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : ''].filter(Boolean).join(' · ')
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div key={r.__leg ? `t-${r.id}-leg${r.__leg.index}` : `t-${r.id}`} className="bg-[rgba(59,130,246,0.06)]" style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 8px', borderRadius: 6, border: '1px solid rgba(59,130,246,0.15)' }}>
|
||||
<div className="bg-[rgba(59,130,246,0.12)]" style={{ width: 24, height: 24, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
@@ -298,8 +305,11 @@ export default function SharedTripPage() {
|
||||
? getFlightLegs(r).map((leg, i) => (
|
||||
<span key={i}>{[leg.airline, leg.flight_number, (leg.from || leg.to) ? [leg.from, leg.to].filter(Boolean).join(' → ') : ''].filter(Boolean).join(' ')}</span>
|
||||
))
|
||||
: meta.airline && <span>{meta.airline} {meta.flight_number || ''}</span>}
|
||||
{meta.train_number && <span>{meta.train_number}</span>}
|
||||
: r.type === 'train'
|
||||
? getTrainLegs(r).map((leg, i) => (
|
||||
<span key={i}>{[leg.train_number, leg.platform ? `${t('reservations.meta.platform')} ${leg.platform}` : '', (leg.from || leg.to) ? [leg.from, leg.to].filter(Boolean).join(' → ') : ''].filter(Boolean).join(' ')}</span>
|
||||
))
|
||||
: meta.airline && <span>{meta.airline} {meta.flight_number || ''}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<span className={r.status === 'confirmed' ? 'bg-[rgba(22,163,74,0.1)] text-[#16a34a]' : 'bg-[rgba(217,119,6,0.1)] text-[#d97706]'} style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', padding: '2px 8px', borderRadius: 20, fontWeight: 600 }}>
|
||||
|
||||
@@ -30,6 +30,8 @@ import CostsPanel, { ExpenseModal, type ExpensePrefill } from '../components/Bud
|
||||
import type { BookingExpenseRequest } from '../components/Planner/BookingCostsSection.types'
|
||||
import type { BudgetItem } from '../types'
|
||||
import CollabPanel from '../components/Collab/CollabPanel'
|
||||
import PluginFrame from '../components/Plugins/PluginFrame'
|
||||
import TripWarningsBanner from '../components/Planner/TripWarningsBanner'
|
||||
import Navbar from '../components/Layout/Navbar'
|
||||
import { useToast } from '../components/shared/Toast'
|
||||
import { Map, X, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train } from 'lucide-react'
|
||||
@@ -298,6 +300,9 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
{/* Offset by navbar + tab bar (44px) */}
|
||||
<div style={{ position: 'fixed', top: 'calc(var(--nav-h) + 44px)', left: 0, right: 0, bottom: 0, overflow: 'hidden', overscrollBehavior: 'contain' }}>
|
||||
|
||||
{/* Plugin validation/warning contributions (#1429) — non-blocking overlay. */}
|
||||
<TripWarningsBanner tripId={tripId} />
|
||||
|
||||
{activeTab === 'plan' && (
|
||||
<div style={{ position: 'absolute', inset: 0 }}>
|
||||
<MapView
|
||||
@@ -376,6 +381,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
opacity: leftCollapsed ? 0 : 1,
|
||||
}}>
|
||||
<DayPlanSidebar
|
||||
isMobile={isMobile}
|
||||
tripId={tripId}
|
||||
trip={trip}
|
||||
days={days}
|
||||
@@ -624,7 +630,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
{mobileSidebarOpen === 'left'
|
||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(false); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onOpenTransit={(r) => { setTransitJourney(r); setMobileSidebarOpen(null) }} onPlanTransit={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded />
|
||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(false); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onOpenTransit={(r) => { setTransitJourney(r); setMobileSidebarOpen(null) }} onPlanTransit={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded isMobile />
|
||||
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { openPlaceEditor(place); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} onBulkChangeCategory={(ids, catId) => confirmChangeCategory(ids, catId)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
|
||||
}
|
||||
</div>
|
||||
@@ -709,6 +715,12 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
<CollabPanel tripId={tripId} tripMembers={tripMembers} collabFeatures={collabFeatures} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab.startsWith('plugin:') && (
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 'var(--bottom-nav-h)', overflow: 'hidden' }}>
|
||||
<PluginFrame pluginId={activeTab.slice('plugin:'.length)} tripId={String(tripId)} className="w-full h-full" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingPlace ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripActions.addCategory?.(cat)} isMobile={isMobile} />
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
|
||||
passkeyLogin, setPasskeyLogin, passkeyConfigured,
|
||||
webauthnRpId, setWebauthnRpId, webauthnOrigins, setWebauthnOrigins, savingWebauthn, handleSaveWebauthn,
|
||||
allowedFileTypes, setAllowedFileTypes, savingFileTypes, setSavingFileTypes,
|
||||
mapsKey, setMapsKey, showKeys, savingKeys, validating, validation,
|
||||
mapsKey, setMapsKey, unsplashKey, setUnsplashKey, showKeys, savingKeys, validating, validation,
|
||||
setShowRotateJwtModal,
|
||||
handleToggleAuthSetting, handleToggleRequireMfa,
|
||||
toggleKey, handleSaveApiKeys, handleValidateKey,
|
||||
@@ -304,6 +304,30 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Unsplash Key */}
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-slate-700 mb-1.5">
|
||||
{t('admin.unsplashKey')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showKeys.unsplash ? 'text' : 'password'}
|
||||
value={unsplashKey}
|
||||
onChange={e => setUnsplashKey(e.target.value)}
|
||||
placeholder={t('settings.keyPlaceholder')}
|
||||
className="w-full pr-10 px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleKey('unsplash')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
|
||||
>
|
||||
{showKeys.unsplash ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-1">{t('admin.unsplashKeyHint')}</p>
|
||||
</div>
|
||||
|
||||
{/* Place Photos Toggle */}
|
||||
<div className="flex items-center justify-between gap-4 py-3 border-t border-slate-100">
|
||||
<div>
|
||||
|
||||
@@ -97,6 +97,7 @@ export function useAdmin() {
|
||||
// API Keys
|
||||
const [mapsKey, setMapsKey] = useState<string>('')
|
||||
const [weatherKey, setWeatherKey] = useState<string>('')
|
||||
const [unsplashKey, setUnsplashKey] = useState<string>('')
|
||||
const [showKeys, setShowKeys] = useState<Record<string, boolean>>({})
|
||||
const [savingKeys, setSavingKeys] = useState<boolean>(false)
|
||||
const [validating, setValidating] = useState<Record<string, boolean>>({})
|
||||
@@ -166,6 +167,7 @@ export function useAdmin() {
|
||||
const data = await authApi.getSettings()
|
||||
setMapsKey(data.settings?.maps_api_key || '')
|
||||
setWeatherKey(data.settings?.openweather_api_key || '')
|
||||
setUnsplashKey(data.settings?.unsplash_api_key || '')
|
||||
} catch (err: unknown) {
|
||||
// ignore
|
||||
}
|
||||
@@ -220,6 +222,7 @@ export function useAdmin() {
|
||||
await updateApiKeys({
|
||||
maps_api_key: mapsKey,
|
||||
openweather_api_key: weatherKey,
|
||||
unsplash_api_key: unsplashKey,
|
||||
})
|
||||
toast.success(t('admin.keySaved'))
|
||||
} catch (err: unknown) {
|
||||
@@ -233,7 +236,7 @@ export function useAdmin() {
|
||||
setValidating({ maps: true, weather: true })
|
||||
try {
|
||||
// Save first so validation uses the current values
|
||||
await updateApiKeys({ maps_api_key: mapsKey, openweather_api_key: weatherKey })
|
||||
await updateApiKeys({ maps_api_key: mapsKey, openweather_api_key: weatherKey, unsplash_api_key: unsplashKey })
|
||||
const result = await authApi.validateKeys()
|
||||
setValidation(result)
|
||||
} catch (err: unknown) {
|
||||
@@ -247,7 +250,7 @@ export function useAdmin() {
|
||||
setValidating(prev => ({ ...prev, [keyType]: true }))
|
||||
try {
|
||||
// Save first so validation uses the current values
|
||||
await updateApiKeys({ maps_api_key: mapsKey, openweather_api_key: weatherKey })
|
||||
await updateApiKeys({ maps_api_key: mapsKey, openweather_api_key: weatherKey, unsplash_api_key: unsplashKey })
|
||||
const result = await authApi.validateKeys()
|
||||
setValidation(prev => ({ ...prev, [keyType]: result[keyType] }))
|
||||
} catch (err: unknown) {
|
||||
@@ -378,7 +381,7 @@ export function useAdmin() {
|
||||
invites, setInvites, inviteTrips, showCreateInvite, setShowCreateInvite, inviteForm, setInviteForm,
|
||||
allowedFileTypes, setAllowedFileTypes, savingFileTypes, setSavingFileTypes,
|
||||
smtpValues, setSmtpValues, smtpLoaded,
|
||||
mapsKey, setMapsKey, weatherKey, setWeatherKey,
|
||||
mapsKey, setMapsKey, weatherKey, setWeatherKey, unsplashKey, setUnsplashKey,
|
||||
showKeys, setShowKeys, savingKeys, validating, validation,
|
||||
updateInfo, setUpdateInfo, showUpdateModal, setShowUpdateModal,
|
||||
showRotateJwtModal, setShowRotateJwtModal, rotatingJwt, setRotatingJwt,
|
||||
|
||||
@@ -7,9 +7,11 @@ import {
|
||||
sortPlaces,
|
||||
statusCounts,
|
||||
presentCategories,
|
||||
presentLabels,
|
||||
mappablePlaces,
|
||||
normalizeLinkUrl,
|
||||
} from './collectionsModel';
|
||||
import type { CollectionLabel } from '@trek/shared';
|
||||
|
||||
// ── Inline CollectionPlace-ish builder ────────────────────────────────────────
|
||||
// Only the fields the helpers actually read are meaningful; the rest satisfy the
|
||||
@@ -32,6 +34,7 @@ interface PlaceLike {
|
||||
notes?: string | null;
|
||||
sort_order?: number;
|
||||
created_at?: string;
|
||||
label_ids?: number[];
|
||||
}
|
||||
function cp(overrides: PlaceLike): CollectionPlace {
|
||||
return {
|
||||
@@ -319,4 +322,46 @@ describe('collectionsModel', () => {
|
||||
expect(normalizeLinkUrl('//booking.com')).toBe('https://booking.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('labels', () => {
|
||||
const places = [
|
||||
cp({ id: 1, name: 'Berlin gate', status: 'idea', label_ids: [10] }),
|
||||
cp({ id: 2, name: 'Hamburg port', status: 'idea', label_ids: [11] }),
|
||||
cp({ id: 3, name: 'Both', status: 'idea', label_ids: [10, 11] }),
|
||||
cp({ id: 4, name: 'None', status: 'idea', label_ids: [] }),
|
||||
];
|
||||
const labels: CollectionLabel[] = [
|
||||
{ id: 10, collection_id: 1, name: 'Berlin', color: '#f00' },
|
||||
{ id: 11, collection_id: 1, name: 'Hamburg', color: '#00f' },
|
||||
{ id: 12, collection_id: 1, name: 'Unused', color: null },
|
||||
];
|
||||
|
||||
it('FE-COLLECTIONS-MODEL-031: an empty label filter keeps every place', () => {
|
||||
expect(filterPlaces(places, 'all', '', 'all', [])).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('FE-COLLECTIONS-MODEL-032: a single label keeps places carrying it (incl. multi-label)', () => {
|
||||
const out = filterPlaces(places, 'all', '', 'all', [10]).map(p => p.id);
|
||||
expect(out).toEqual([1, 3]);
|
||||
});
|
||||
|
||||
it('FE-COLLECTIONS-MODEL-033: multiple labels are OR — any match passes', () => {
|
||||
const out = filterPlaces(places, 'all', '', 'all', [10, 11]).map(p => p.id);
|
||||
expect(out).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('FE-COLLECTIONS-MODEL-034: the label filter composes with status + search', () => {
|
||||
const mixed = [
|
||||
cp({ id: 5, name: 'Museum', status: 'visited', label_ids: [10] }),
|
||||
cp({ id: 6, name: 'Museum', status: 'idea', label_ids: [10] }),
|
||||
];
|
||||
const out = filterPlaces(mixed, 'visited', 'mus', 'all', [10]).map(p => p.id);
|
||||
expect(out).toEqual([5]);
|
||||
});
|
||||
|
||||
it('FE-COLLECTIONS-MODEL-035: presentLabels keeps definition order with per-label counts, incl. zero', () => {
|
||||
const opts = presentLabels(labels, places);
|
||||
expect(opts.map(o => [o.id, o.count])).toEqual([[10, 2], [11, 2], [12, 0]]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Circle, Bookmark, CheckCircle2 } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import type { CollectionPlace, CollectionStatus } from '@trek/shared'
|
||||
import type { CollectionPlace, CollectionStatus, CollectionLabel } from '@trek/shared'
|
||||
import { COLLECTION_STATUSES } from '@trek/shared'
|
||||
import type { StatusFilter } from '../../store/collectionStore'
|
||||
|
||||
@@ -44,18 +44,23 @@ export function sortPlaces(places: CollectionPlace[]): CollectionPlace[] {
|
||||
})
|
||||
}
|
||||
|
||||
/** Apply the active status filter + free-text search (name/address/notes). */
|
||||
/** Apply the active status filter + free-text search (name/address/notes) +
|
||||
* category + per-collection label filter. The label filter is OR semantics: a
|
||||
* place matches if it carries ANY of the selected labels. This one function
|
||||
* drives BOTH the list and the map, so every filter stays in lockstep. */
|
||||
export function filterPlaces(
|
||||
places: CollectionPlace[],
|
||||
statusFilter: StatusFilter,
|
||||
search: string,
|
||||
categoryFilter: number | 'all' = 'all',
|
||||
labelFilter: number[] = [],
|
||||
): CollectionPlace[] {
|
||||
const q = search.trim().toLowerCase()
|
||||
return places.filter(p => {
|
||||
if (!p) return false
|
||||
if (statusFilter !== 'all' && p.status !== statusFilter) return false
|
||||
if (categoryFilter !== 'all' && (p.category_id ?? null) !== categoryFilter) return false
|
||||
if (labelFilter.length && !labelFilter.some(id => (p.label_ids ?? []).includes(id))) return false
|
||||
if (!q) return true
|
||||
return (
|
||||
p.name.toLowerCase().includes(q) ||
|
||||
@@ -86,6 +91,20 @@ export function presentCategories(places: CollectionPlace[]): CategoryOption[] {
|
||||
return [...byId.values()].sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
export interface LabelOption { id: number; name: string; color: string | null; count: number }
|
||||
|
||||
/** The collection's labels in definition order, each with how many of the given
|
||||
* places carry it. Zero-count labels are kept so the manager/filter still lists
|
||||
* a freshly-created label. */
|
||||
export function presentLabels(labels: CollectionLabel[], places: CollectionPlace[]): LabelOption[] {
|
||||
const counts = new Map<number, number>()
|
||||
for (const p of places) {
|
||||
if (!p) continue
|
||||
for (const id of p.label_ids ?? []) counts.set(id, (counts.get(id) ?? 0) + 1)
|
||||
}
|
||||
return labels.map(l => ({ id: l.id, name: l.name, color: l.color ?? null, count: counts.get(l.id) ?? 0 }))
|
||||
}
|
||||
|
||||
/** Only the places that can render on a map. */
|
||||
export function mappablePlaces(places: CollectionPlace[]): CollectionPlace[] {
|
||||
return places.filter(p => p && typeof p.lat === 'number' && typeof p.lng === 'number')
|
||||
|
||||
@@ -13,7 +13,8 @@ import type { ActiveCollectionId } from '../../store/collectionStore'
|
||||
import { categoriesApi } from '../../api/client'
|
||||
import type { Collection, CollectionStatus } from '@trek/shared'
|
||||
import type { Category, Place } from '../../types'
|
||||
import { filterPlaces, sortPlaces, statusCounts, mappablePlaces, presentCategories } from './collectionsModel'
|
||||
import { filterPlaces, sortPlaces, statusCounts, mappablePlaces, presentCategories, presentLabels } from './collectionsModel'
|
||||
import type { CollectionLabelUpdateRequest } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* Collections page logic — owns the page-local UI state (new/edit-list forms,
|
||||
@@ -48,15 +49,16 @@ export function useCollections() {
|
||||
|
||||
const store = useCollectionStore()
|
||||
const {
|
||||
collections, activeId, places, members, incomingInvites,
|
||||
view, statusFilter, categoryFilter, search, selectedPlaceId, selectMode, selectedIds,
|
||||
collections, activeId, places, members, labels, incomingInvites,
|
||||
view, statusFilter, categoryFilter, labelFilter, search, selectedPlaceId, selectMode, selectedIds,
|
||||
loading, placesLoading,
|
||||
loadAll, setActive, refreshActive, loadCollection,
|
||||
deleteCollection,
|
||||
setStatus, updatePlace, deletePlace, deleteMany, copyToTrip, clearSelection,
|
||||
moveToList, duplicateToList, setSelectedIds,
|
||||
createLabel, updateLabel, deleteLabel, assignLabels,
|
||||
acceptInvite, declineInvite,
|
||||
setView, setStatusFilter, setCategoryFilter, setSearch, setSelectedPlaceId, setSelectMode, toggleSelect,
|
||||
setView, setStatusFilter, setCategoryFilter, setLabelFilter, setSearch, setSelectedPlaceId, setSelectMode, toggleSelect,
|
||||
} = store
|
||||
|
||||
// ── Page-local UI state ─────────────────────────────────────────────
|
||||
@@ -163,12 +165,15 @@ export function useCollections() {
|
||||
const ownedLists = useMemo(() => collections.filter(c => c.is_owner !== false), [collections])
|
||||
const sharedLists = useMemo(() => collections.filter(c => c.is_owner === false), [collections])
|
||||
|
||||
// Labels are per-collection, so never apply them on the "All saved" union.
|
||||
const visiblePlaces = useMemo(
|
||||
() => sortPlaces(filterPlaces(places, statusFilter, search, categoryFilter)),
|
||||
[places, statusFilter, search, categoryFilter],
|
||||
() => sortPlaces(filterPlaces(places, statusFilter, search, categoryFilter, isAllSaved ? [] : labelFilter)),
|
||||
[places, statusFilter, search, categoryFilter, isAllSaved, labelFilter],
|
||||
)
|
||||
// Categories actually present in this list, for the category filter dropdown.
|
||||
const categoryOptions = useMemo(() => presentCategories(places), [places])
|
||||
// The active list's labels (with per-label counts) for the filter + manager.
|
||||
const labelOptions = useMemo(() => presentLabels(labels, places), [labels, places])
|
||||
// Stable reference so the map doesn't tear down + rebuild every marker on each
|
||||
// unrelated re-render (which would swallow marker clicks mid-rebuild).
|
||||
const mappable = useMemo(() => mappablePlaces(visiblePlaces), [visiblePlaces])
|
||||
@@ -254,6 +259,43 @@ export function useCollections() {
|
||||
}
|
||||
}, [selectedIds, duplicateToList, toast, t])
|
||||
|
||||
// ── Labels ──────────────────────────────────────────────────────────
|
||||
const [showLabelManager, setShowLabelManager] = useState(false)
|
||||
const [labelPickerOpen, setLabelPickerOpen] = useState(false)
|
||||
|
||||
const handleCreateLabel = useCallback(async (name: string, color?: string) => {
|
||||
try { await createLabel(name, color) }
|
||||
catch (err) { toast.error(getApiErrorMessage(err, t('common.error'))) }
|
||||
}, [createLabel, toast, t])
|
||||
|
||||
const handleUpdateLabel = useCallback(async (labelId: number, body: CollectionLabelUpdateRequest) => {
|
||||
try { await updateLabel(labelId, body) }
|
||||
catch (err) { toast.error(getApiErrorMessage(err, t('common.error'))) }
|
||||
}, [updateLabel, toast, t])
|
||||
|
||||
const handleDeleteLabel = useCallback(async (labelId: number) => {
|
||||
try { await deleteLabel(labelId) }
|
||||
catch (err) { toast.error(getApiErrorMessage(err, t('common.error'))) }
|
||||
}, [deleteLabel, toast, t])
|
||||
|
||||
// Bulk-assign one or more labels to the current selection.
|
||||
const handleBulkAssignLabels = useCallback(async (labelIds: number[]) => {
|
||||
if (selectedIds.length === 0 || labelIds.length === 0) return
|
||||
try {
|
||||
await assignLabels(labelIds, selectedIds)
|
||||
toast.success(t('collections.labels.assignedCount', { count: selectedIds.length }))
|
||||
setLabelPickerOpen(false)
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
}
|
||||
}, [assignLabels, selectedIds, toast, t])
|
||||
|
||||
// Replace a single place's labels (from the detail sheet chips).
|
||||
const handleAssignPlaceLabels = useCallback(async (placeId: number, labelIds: number[]) => {
|
||||
try { await updatePlace(placeId, { label_ids: labelIds }) }
|
||||
catch (err) { toast.error(getApiErrorMessage(err, t('common.error'))) }
|
||||
}, [updatePlace, toast, t])
|
||||
|
||||
const handleAcceptInvite = useCallback(async (collectionId: number) => {
|
||||
try {
|
||||
await acceptInvite(collectionId)
|
||||
@@ -337,10 +379,14 @@ export function useCollections() {
|
||||
canShare, shareMemberCount,
|
||||
activeId, places, visiblePlaces, mappable, members, incomingInvites, counts,
|
||||
view, statusFilter, categoryFilter, categoryOptions, search, selectedPlaceId, selectMode, selectedIds,
|
||||
labels, labelFilter, labelOptions,
|
||||
loading, placesLoading,
|
||||
// store setters
|
||||
setView, setStatusFilter, setCategoryFilter, setSearch, setSelectedPlaceId, setSelectMode, toggleSelect,
|
||||
setView, setStatusFilter, setCategoryFilter, setLabelFilter, setSearch, setSelectedPlaceId, setSelectMode, toggleSelect,
|
||||
updatePlace,
|
||||
// labels
|
||||
showLabelManager, setShowLabelManager, labelPickerOpen, setLabelPickerOpen,
|
||||
handleCreateLabel, handleUpdateLabel, handleDeleteLabel, handleBulkAssignLabels, handleAssignPlaceLabels,
|
||||
// local UI state
|
||||
editorTarget, setEditorTarget, handleEditorCreated,
|
||||
showAddPlace, setShowAddPlace, handlePlaceAdded,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { getCached, fetchPhoto } from '../../services/photoService'
|
||||
import { useToast } from '../../components/shared/Toast'
|
||||
import { Map, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train } from 'lucide-react'
|
||||
import { Map, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train, Blocks } from 'lucide-react'
|
||||
import { useTranslation, translateApiError } from '../../i18n'
|
||||
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, healthApi, airtrailApi, mapsApi, placesApi } from '../../api/client'
|
||||
import { parsedItemToDraft, isTransportItem, type BookingReviewDraft } from '../../components/Planner/parsedItemToDraft'
|
||||
@@ -21,6 +21,7 @@ import { useRouteCalculation } from '../../hooks/useRouteCalculation'
|
||||
import { usePlaceSelection } from '../../hooks/usePlaceSelection'
|
||||
import { usePlannerHistory } from '../../hooks/usePlannerHistory'
|
||||
import { useAirtrailConnection } from '../../hooks/useAirtrailConnection'
|
||||
import { usePluginStore } from '../../store/pluginStore'
|
||||
import type { Accommodation, TripMember, Day, Place, Reservation } from '../../types'
|
||||
import { resolvePoolAssignmentId } from './tripPlannerModel'
|
||||
|
||||
@@ -42,6 +43,9 @@ export function useTripPlanner() {
|
||||
const toast = useToast()
|
||||
const { t, language } = useTranslation()
|
||||
const { settings } = useSettingsStore()
|
||||
// trip-page plugins mount as tabs inside this trip planner (tripId-scoped).
|
||||
const allPlugins = usePluginStore(s => s.plugins)
|
||||
const pluginsLoaded = usePluginStore(s => s.loaded)
|
||||
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
|
||||
const trip = useTripStore(s => s.trip)
|
||||
const days = useTripStore(s => s.days)
|
||||
@@ -94,6 +98,9 @@ export function useTripPlanner() {
|
||||
|
||||
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other'])
|
||||
|
||||
const tripPagePlugins = allPlugins.filter(p => p.type === 'trip-page')
|
||||
const tripPluginIds = tripPagePlugins.map(p => p.id).join(',')
|
||||
|
||||
const TRIP_TABS = [
|
||||
{ id: 'plan', label: t('trip.tabs.plan'), icon: Map },
|
||||
{ id: 'transports', label: t('trip.tabs.transports'), icon: Train },
|
||||
@@ -102,6 +109,7 @@ export function useTripPlanner() {
|
||||
...(enabledAddons.budget ? [{ id: 'finanzplan', label: t('trip.tabs.budget'), icon: Wallet }] : []),
|
||||
...(enabledAddons.documents ? [{ id: 'dateien', label: t('trip.tabs.files'), icon: FolderOpen }] : []),
|
||||
...(enabledAddons.collab ? [{ id: 'collab', label: t('admin.addons.catalog.collab.name'), icon: Users }] : []),
|
||||
...tripPagePlugins.map(p => ({ id: `plugin:${p.id}`, label: p.name, icon: Blocks })),
|
||||
]
|
||||
|
||||
const [activeTab, setActiveTab] = useState<string>(() => {
|
||||
@@ -110,12 +118,14 @@ export function useTripPlanner() {
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
// Don't evict a saved plugin tab before the plugin feed has loaded.
|
||||
if (activeTab.startsWith('plugin:') && !pluginsLoaded) return
|
||||
const validTabIds = TRIP_TABS.map(t => t.id)
|
||||
if (!validTabIds.includes(activeTab)) {
|
||||
setActiveTab('plan')
|
||||
sessionStorage.setItem(`trip-tab-${tripId}`, 'plan')
|
||||
}
|
||||
}, [enabledAddons])
|
||||
}, [enabledAddons, tripPluginIds, pluginsLoaded])
|
||||
|
||||
const handleTabChange = (tabId: string): void => {
|
||||
setActiveTab(tabId)
|
||||
|
||||
@@ -10,6 +10,8 @@ import type {
|
||||
CollectionCreateRequest,
|
||||
CollectionUpdateRequest,
|
||||
CollectionPlaceUpdateRequest,
|
||||
CollectionLabel,
|
||||
CollectionLabelUpdateRequest,
|
||||
} from '@trek/shared'
|
||||
|
||||
/** A pending invitation the current user has received (derived server-side). */
|
||||
@@ -27,10 +29,12 @@ interface CollectionState {
|
||||
activeId: ActiveCollectionId
|
||||
places: CollectionPlace[]
|
||||
members: CollectionMember[]
|
||||
labels: CollectionLabel[]
|
||||
incomingInvites: IncomingCollectionInvite[]
|
||||
view: CollectionView
|
||||
statusFilter: StatusFilter
|
||||
categoryFilter: number | 'all'
|
||||
labelFilter: number[]
|
||||
search: string
|
||||
selectedPlaceId: number | null
|
||||
selectMode: boolean
|
||||
@@ -57,6 +61,11 @@ interface CollectionState {
|
||||
moveToList: (placeIds: number[], targetId: number) => Promise<void>
|
||||
duplicateToList: (placeIds: number[], targetId: number) => Promise<void>
|
||||
|
||||
createLabel: (name: string, color?: string) => Promise<void>
|
||||
updateLabel: (labelId: number, body: CollectionLabelUpdateRequest) => Promise<void>
|
||||
deleteLabel: (labelId: number) => Promise<void>
|
||||
assignLabels: (labelIds: number[], placeIds: number[], remove?: boolean) => Promise<void>
|
||||
|
||||
invite: (collectionId: number, userId: number, role?: CollectionRole) => Promise<void>
|
||||
setMemberRole: (collectionId: number, userId: number, role: CollectionRole) => Promise<void>
|
||||
acceptInvite: (collectionId: number) => Promise<void>
|
||||
@@ -68,6 +77,7 @@ interface CollectionState {
|
||||
setView: (view: CollectionView) => void
|
||||
setStatusFilter: (filter: StatusFilter) => void
|
||||
setCategoryFilter: (filter: number | 'all') => void
|
||||
setLabelFilter: (labelIds: number[]) => void
|
||||
setSearch: (search: string) => void
|
||||
setSelectedPlaceId: (id: number | null) => void
|
||||
setSelectMode: (on: boolean) => void
|
||||
@@ -81,10 +91,12 @@ export const useCollectionStore = create<CollectionState>((set, get) => ({
|
||||
activeId: null,
|
||||
places: [],
|
||||
members: [],
|
||||
labels: [],
|
||||
incomingInvites: [],
|
||||
view: 'list',
|
||||
statusFilter: 'all',
|
||||
categoryFilter: 'all',
|
||||
labelFilter: [],
|
||||
search: '',
|
||||
selectedPlaceId: null,
|
||||
selectMode: false,
|
||||
@@ -110,26 +122,28 @@ export const useCollectionStore = create<CollectionState>((set, get) => ({
|
||||
activeId: id,
|
||||
places: data.places,
|
||||
members: data.collection.members ?? [],
|
||||
labels: data.collection.labels ?? [],
|
||||
})
|
||||
} catch {
|
||||
// The list may have been left / removed / deleted out from under us (a WS
|
||||
// event or the URL sync can re-request an id we just lost access to). Clear
|
||||
// it instead of leaving an uncaught 403/404 rejection; the route sync then
|
||||
// bounces to /collections.
|
||||
if (get().activeId === id) set({ activeId: null, places: [], members: [] })
|
||||
if (get().activeId === id) set({ activeId: null, places: [], members: [], labels: [] })
|
||||
} finally {
|
||||
set({ placesLoading: false })
|
||||
}
|
||||
},
|
||||
|
||||
setActive: async (id: ActiveCollectionId) => {
|
||||
set({ selectMode: false, selectedIds: [], selectedPlaceId: null })
|
||||
// Labels are per-collection, so their filter can't carry across lists.
|
||||
set({ selectMode: false, selectedIds: [], selectedPlaceId: null, labelFilter: [] })
|
||||
if (id === null) {
|
||||
set({ activeId: null, places: [], members: [] })
|
||||
set({ activeId: null, places: [], members: [], labels: [] })
|
||||
return
|
||||
}
|
||||
if (id === ALL_SAVED) {
|
||||
set({ activeId: ALL_SAVED, members: [], placesLoading: true })
|
||||
set({ activeId: ALL_SAVED, members: [], labels: [], placesLoading: true })
|
||||
try {
|
||||
// Client-side union of every list the user owns or co-owns (no server change).
|
||||
// On first load the lists may not be fetched yet (loadAll still in flight),
|
||||
@@ -277,6 +291,51 @@ export const useCollectionStore = create<CollectionState>((set, get) => ({
|
||||
await get().loadAll()
|
||||
},
|
||||
|
||||
createLabel: async (name: string, color?: string) => {
|
||||
const active = get().activeId
|
||||
if (typeof active !== 'number') return
|
||||
await collectionsApi.createLabel(active, name, color)
|
||||
await get().loadCollection(active)
|
||||
},
|
||||
|
||||
updateLabel: async (labelId: number, body: CollectionLabelUpdateRequest) => {
|
||||
// optimistic recolor/rename
|
||||
set({ labels: get().labels.map(l => (l.id === labelId ? { ...l, ...body } : l)) })
|
||||
await collectionsApi.updateLabel(labelId, body)
|
||||
const active = get().activeId
|
||||
if (typeof active === 'number') await get().loadCollection(active)
|
||||
},
|
||||
|
||||
deleteLabel: async (labelId: number) => {
|
||||
// optimistic: drop the label + its assignments + any active filter on it
|
||||
set({
|
||||
labels: get().labels.filter(l => l.id !== labelId),
|
||||
labelFilter: get().labelFilter.filter(id => id !== labelId),
|
||||
places: get().places.map(p => ({ ...p, label_ids: (p.label_ids ?? []).filter(id => id !== labelId) })),
|
||||
})
|
||||
await collectionsApi.deleteLabel(labelId)
|
||||
const active = get().activeId
|
||||
if (typeof active === 'number') await get().loadCollection(active)
|
||||
},
|
||||
|
||||
assignLabels: async (labelIds: number[], placeIds: number[], remove = false) => {
|
||||
const idSet = new Set(placeIds)
|
||||
// optimistic per-place label_ids update
|
||||
set({
|
||||
places: get().places.map(p => {
|
||||
if (!idSet.has(p.id)) return p
|
||||
const current = new Set(p.label_ids ?? [])
|
||||
if (remove) labelIds.forEach(id => current.delete(id))
|
||||
else labelIds.forEach(id => current.add(id))
|
||||
return { ...p, label_ids: [...current] }
|
||||
}),
|
||||
})
|
||||
if (remove) await collectionsApi.unassignLabels(labelIds, placeIds)
|
||||
else await collectionsApi.assignLabels(labelIds, placeIds)
|
||||
const active = get().activeId
|
||||
if (typeof active === 'number') await get().loadCollection(active)
|
||||
},
|
||||
|
||||
invite: async (collectionId: number, userId: number, role?: CollectionRole) => {
|
||||
await collectionsApi.invite(collectionId, userId, role)
|
||||
if (get().activeId === collectionId) await get().loadCollection(collectionId)
|
||||
@@ -316,6 +375,7 @@ export const useCollectionStore = create<CollectionState>((set, get) => ({
|
||||
setView: (view: CollectionView) => set({ view }),
|
||||
setStatusFilter: (filter: StatusFilter) => set({ statusFilter: filter }),
|
||||
setCategoryFilter: (filter: number | 'all') => set({ categoryFilter: filter }),
|
||||
setLabelFilter: (labelIds: number[]) => set({ labelFilter: labelIds }),
|
||||
setSearch: (search: string) => set({ search }),
|
||||
setSelectedPlaceId: (id: number | null) => set({ selectedPlaceId: id }),
|
||||
setSelectMode: (on: boolean) => set({ selectMode: on, selectedIds: on ? get().selectedIds : [] }),
|
||||
|
||||
@@ -17,6 +17,7 @@ describe('pluginStore', () => {
|
||||
plugins: [
|
||||
{ id: 'flights', name: 'Flights', type: 'widget', icon: 'Plane' },
|
||||
{ id: 'report', name: 'Report', type: 'page', icon: 'FileText' },
|
||||
{ id: 'diary', name: 'Diary', type: 'trip-page', icon: 'Book' },
|
||||
],
|
||||
}),
|
||||
),
|
||||
@@ -26,9 +27,10 @@ describe('pluginStore', () => {
|
||||
|
||||
const s = usePluginStore.getState();
|
||||
expect(s.loaded).toBe(true);
|
||||
expect(s.plugins).toHaveLength(2);
|
||||
expect(s.plugins).toHaveLength(3);
|
||||
expect(s.pages().map((p) => p.id)).toEqual(['report']);
|
||||
expect(s.widgets().map((p) => p.id)).toEqual(['flights']);
|
||||
expect(s.tripPages().map((p) => p.id)).toEqual(['diary']);
|
||||
expect(s.getById('flights')?.name).toBe('Flights');
|
||||
expect(s.getById('nope')).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -10,9 +10,9 @@ import { pluginsApi } from '../api/client'
|
||||
export interface ActivePlugin {
|
||||
id: string
|
||||
name: string
|
||||
type: 'integration' | 'page' | 'widget'
|
||||
type: 'integration' | 'page' | 'widget' | 'trip-page'
|
||||
icon: string | null
|
||||
slot?: 'sidebar' | 'hero'
|
||||
slot?: 'sidebar' | 'hero' | 'place-detail'
|
||||
}
|
||||
|
||||
interface PluginState {
|
||||
@@ -23,6 +23,8 @@ interface PluginState {
|
||||
pages: () => ActivePlugin[]
|
||||
widgets: () => ActivePlugin[]
|
||||
heroWidgets: () => ActivePlugin[]
|
||||
tripPages: () => ActivePlugin[]
|
||||
placeDetailWidgets: () => ActivePlugin[]
|
||||
}
|
||||
|
||||
export const usePluginStore = create<PluginState>((set, get) => ({
|
||||
@@ -40,6 +42,8 @@ export const usePluginStore = create<PluginState>((set, get) => ({
|
||||
|
||||
getById: (id) => get().plugins.find((p) => p.id === id),
|
||||
pages: () => get().plugins.filter((p) => p.type === 'page'),
|
||||
widgets: () => get().plugins.filter((p) => p.type === 'widget' && p.slot !== 'hero'),
|
||||
widgets: () => get().plugins.filter((p) => p.type === 'widget' && p.slot !== 'hero' && p.slot !== 'place-detail'),
|
||||
heroWidgets: () => get().plugins.filter((p) => p.type === 'widget' && p.slot === 'hero'),
|
||||
tripPages: () => get().plugins.filter((p) => p.type === 'trip-page'),
|
||||
placeDetailWidgets: () => get().plugins.filter((p) => p.type === 'widget' && p.slot === 'place-detail'),
|
||||
}))
|
||||
|
||||
@@ -270,6 +270,20 @@
|
||||
.trek-dash .col-lrow-cat svg { flex-shrink: 0; }
|
||||
.trek-dash .col-lrow-div { width: 1px; height: 18px; flex-shrink: 0; background: var(--line-2); }
|
||||
|
||||
/* Per-collection label chips — list rows, filter bar and detail read view */
|
||||
.trek-dash .col-lrow-label { display: inline-flex; align-items: center; gap: 5px; max-width: 120px; padding: 3px 8px; border-radius: 999px; font-size: 11px; font-weight: 600; white-space: nowrap; overflow: hidden; color: var(--label, var(--accent)); background: color-mix(in oklch, var(--label, var(--accent)) 14%, transparent); }
|
||||
.trek-dash .col-lrow-label.more { color: var(--ink-3); background: var(--surface-2); }
|
||||
.trek-dash .col-labelchip-dot { width: 8px; height: 8px; border-radius: 999px; flex-shrink: 0; background: var(--label, var(--accent)); }
|
||||
.trek-dash .col-labelfilter { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
||||
.trek-dash .col-labelchip { display: inline-flex; align-items: center; gap: 6px; max-width: 180px; padding: 6px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; white-space: nowrap; color: var(--ink-2); background: var(--surface); border: 1px solid var(--line-2); transition: background .12s, border-color .12s, color .12s; }
|
||||
.trek-dash .col-labelchip:hover { background: var(--surface-2); color: var(--ink); }
|
||||
.trek-dash .col-labelchip .col-filter-lbl { overflow: hidden; text-overflow: ellipsis; }
|
||||
.trek-dash .col-labelchip.on { color: var(--label, var(--accent)); background: color-mix(in oklch, var(--label, var(--accent)) 16%, transparent); border-color: color-mix(in oklch, var(--label, var(--accent)) 42%, transparent); }
|
||||
.trek-dash .col-labelchip.on .col-labelchip-dot { background: var(--label, var(--accent)); }
|
||||
.trek-dash .col-labelchip.static { cursor: default; color: var(--label, var(--accent)); background: color-mix(in oklch, var(--label, var(--accent)) 14%, transparent); border-color: transparent; }
|
||||
.trek-dash .col-labelchip-manage { color: var(--ink-3); border-style: dashed; }
|
||||
.trek-dash .col-detail-labels { display: flex; flex-wrap: wrap; gap: 7px; margin: 2px 0 10px; }
|
||||
|
||||
/* ----------------- filter bar (status + category dropdowns) ----------------- */
|
||||
.trek-dash .col-filterbar { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; }
|
||||
.trek-dash .col-filter { position: relative; }
|
||||
|
||||
@@ -128,6 +128,32 @@ describe('getTransportForDay', () => {
|
||||
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [42], days })).toHaveLength(0)
|
||||
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [99], days })).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('expands a multi-leg TRAIN into one row per leg with train detail on __leg (#1150)', () => {
|
||||
const reservations = [{
|
||||
id: 40, type: 'train', day_id: 1, end_day_id: 2,
|
||||
metadata: JSON.stringify({
|
||||
train_number: 'ICE 100',
|
||||
legs: [
|
||||
{ from: 'Berlin', to: 'Frankfurt', train_number: 'ICE 100', platform: '5', dep_day_id: 1, dep_time: '08:00', arr_day_id: 1, arr_time: '12:00' },
|
||||
{ from: 'Frankfurt', to: 'München', train_number: 'ICE 500', platform: '9', dep_day_id: 2, dep_time: '09:00', arr_day_id: 2, arr_time: '12:00' },
|
||||
],
|
||||
}),
|
||||
}]
|
||||
const day1 = getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })
|
||||
expect(day1).toHaveLength(1)
|
||||
expect(day1[0].__leg).toMatchObject({ index: 0, total: 2, from: 'Berlin', to: 'Frankfurt', train_number: 'ICE 100', platform: '5' })
|
||||
const day2 = getTransportForDay({ reservations, dayId: 2, dayAssignmentIds: [], days })
|
||||
expect(day2).toHaveLength(1)
|
||||
expect(day2[0].__leg).toMatchObject({ index: 1, from: 'Frankfurt', to: 'München', train_number: 'ICE 500', platform: '9' })
|
||||
})
|
||||
|
||||
it('leaves a single-leg train untouched (no __leg)', () => {
|
||||
const reservations = [{ id: 41, type: 'train', day_id: 1, end_day_id: 1, metadata: JSON.stringify({ train_number: 'RE 1' }) }]
|
||||
const rows = getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0].__leg).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMergedItems', () => {
|
||||
|
||||
@@ -66,23 +66,23 @@ export function getDisplayTimeForDay(
|
||||
return r.reservation_time || null
|
||||
}
|
||||
|
||||
/** Per-leg detail of a multi-leg flight, or null for single-leg / non-flight. */
|
||||
function parseFlightLegs(r: any): any[] | null {
|
||||
if (r?.type !== 'flight') return null
|
||||
/** Per-leg detail of a multi-leg flight or train, or null for single-leg / other. */
|
||||
function parseMultiLegs(r: any): any[] | null {
|
||||
if (r?.type !== 'flight' && r?.type !== 'train') return null
|
||||
let meta = r.metadata
|
||||
if (typeof meta === 'string') { try { meta = JSON.parse(meta || '{}') } catch { meta = {} } }
|
||||
// Defensive: recover metadata that was accidentally double-encoded by an earlier
|
||||
// bug (a JSON string of a JSON string) so already-saved flights heal on read.
|
||||
// bug (a JSON string of a JSON string) so already-saved bookings heal on read.
|
||||
if (typeof meta === 'string') { try { meta = JSON.parse(meta || '{}') } catch { meta = {} } }
|
||||
if (meta && Array.isArray(meta.legs) && meta.legs.length > 1) return meta.legs
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand a multi-leg flight into one synthetic reservation per leg that touches
|
||||
* `dayId`, each with its own day span + departure/arrival time so it slots into
|
||||
* the timeline independently. A single-leg flight (or any other reservation) is
|
||||
* returned untouched, so existing behaviour is unchanged.
|
||||
* Expand a multi-leg flight/train into one synthetic reservation per leg that
|
||||
* touches `dayId`, each with its own day span + departure/arrival time so it
|
||||
* slots into the timeline independently. A single-leg booking (or any other
|
||||
* reservation) is returned untouched, so existing behaviour is unchanged.
|
||||
*/
|
||||
export function expandFlightLegsForDay(
|
||||
r: any,
|
||||
@@ -90,7 +90,7 @@ export function expandFlightLegsForDay(
|
||||
getDayOrder: (id: number) => number,
|
||||
days: Array<{ id: number; date?: string | null }>
|
||||
): any[] {
|
||||
const legs = parseFlightLegs(r)
|
||||
const legs = parseMultiLegs(r)
|
||||
if (!legs) return [r]
|
||||
const dateOf = (id: number | null): string | null => (id == null ? null : (days.find(d => d.id === id)?.date ?? null))
|
||||
const thisOrder = getDayOrder(dayId)
|
||||
@@ -114,7 +114,14 @@ export function expandFlightLegsForDay(
|
||||
// dropped between legs and persist; absent → falls back to time ordering.
|
||||
day_positions: leg.day_positions || undefined,
|
||||
day_plan_position: undefined,
|
||||
__leg: { index: i, total: legs.length, from: leg.from ?? null, to: leg.to ?? null, airline: leg.airline ?? null, flight_number: leg.flight_number ?? null },
|
||||
__leg: {
|
||||
index: i, total: legs.length,
|
||||
from: leg.from ?? null, to: leg.to ?? null,
|
||||
airline: leg.airline ?? null, flight_number: leg.flight_number ?? null,
|
||||
// Train legs carry their own per-leg detail; added only for trains so a
|
||||
// flight's __leg object stays byte-identical to before.
|
||||
...(r.type === 'train' ? { train_number: leg.train_number ?? null, platform: leg.platform ?? null, seat: leg.seat ?? null } : {}),
|
||||
},
|
||||
})
|
||||
})
|
||||
return out
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { getFlightLegs, getTrainLegs, isMultiLegTrain } from './flightLegs'
|
||||
import type { Reservation } from '../types'
|
||||
|
||||
function res(partial: Partial<Reservation>): Reservation {
|
||||
return { id: 1, type: 'train', status: 'confirmed', ...partial } as unknown as Reservation
|
||||
}
|
||||
|
||||
const ep = (role: 'from' | 'to' | 'stop', seq: number, name: string, extra: Record<string, unknown> = {}) =>
|
||||
({ role, sequence: seq, name, code: null, lat: 0, lng: 0, timezone: null, local_time: null, local_date: null, ...extra })
|
||||
|
||||
describe('getTrainLegs (#1150)', () => {
|
||||
it('reads ordered legs from metadata.legs', () => {
|
||||
const r = res({
|
||||
metadata: JSON.stringify({
|
||||
train_number: 'ICE 100', platform: '5',
|
||||
legs: [
|
||||
{ from: 'Berlin Hbf', to: 'Frankfurt Hbf', train_number: 'ICE 100', platform: '5', dep_time: '08:00', arr_time: '12:00' },
|
||||
{ from: 'Frankfurt Hbf', to: 'München Hbf', train_number: 'ICE 500', platform: '9', dep_time: '12:30', arr_time: '15:30' },
|
||||
],
|
||||
}),
|
||||
})
|
||||
const legs = getTrainLegs(r)
|
||||
expect(legs).toHaveLength(2)
|
||||
expect(legs[0]).toMatchObject({ from: 'Berlin Hbf', to: 'Frankfurt Hbf', train_number: 'ICE 100', platform: '5' })
|
||||
expect(legs[1]).toMatchObject({ from: 'Frankfurt Hbf', to: 'München Hbf', train_number: 'ICE 500', platform: '9' })
|
||||
expect(isMultiLegTrain(r)).toBe(true)
|
||||
})
|
||||
|
||||
it('derives a single leg from endpoints + flat metadata (legacy train)', () => {
|
||||
const r = res({
|
||||
day_id: 3, end_day_id: 3,
|
||||
metadata: JSON.stringify({ train_number: 'RE 42', platform: '2' }),
|
||||
endpoints: [ep('from', 0, 'Köln Hbf', { local_time: '09:00' }), ep('to', 1, 'Aachen Hbf', { local_time: '10:00' })],
|
||||
})
|
||||
const legs = getTrainLegs(r)
|
||||
expect(legs).toHaveLength(1)
|
||||
expect(legs[0]).toMatchObject({ from: 'Köln Hbf', to: 'Aachen Hbf', train_number: 'RE 42', platform: '2', dep_time: '09:00', arr_time: '10:00' })
|
||||
expect(isMultiLegTrain(r)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns [] for a train with no stations and no train number', () => {
|
||||
expect(getTrainLegs(res({ metadata: '{}' }))).toEqual([])
|
||||
})
|
||||
|
||||
it('does not disturb getFlightLegs for flights', () => {
|
||||
const flight = res({ type: 'flight', metadata: JSON.stringify({ departure_airport: 'FRA', arrival_airport: 'JFK', airline: 'LH', flight_number: 'LH 400' }) })
|
||||
const legs = getFlightLegs(flight)
|
||||
expect(legs).toHaveLength(1)
|
||||
expect(legs[0]).toMatchObject({ from: 'FRA', to: 'JFK', airline: 'LH', flight_number: 'LH 400' })
|
||||
})
|
||||
})
|
||||
@@ -84,11 +84,71 @@ export function getFlightLegs(r: Reservation): FlightLeg[] {
|
||||
}]
|
||||
}
|
||||
|
||||
/**
|
||||
* A train booking mirrors the flight leg model (#1150), but its stops are
|
||||
* STATIONS (labels, not IATA codes) and each leg carries a train number +
|
||||
* platform instead of an airline + flight number.
|
||||
*/
|
||||
export interface TrainLeg {
|
||||
from: string | null // station label (or null)
|
||||
to: string | null
|
||||
train_number?: string
|
||||
platform?: string
|
||||
seat?: string
|
||||
dep_day_id?: number | null
|
||||
dep_time?: string | null
|
||||
arr_day_id?: number | null
|
||||
arr_time?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered legs of a train booking. Prefers `metadata.legs`; otherwise derives a
|
||||
* single leg from the endpoints + flat metadata, so single-leg trains — and
|
||||
* trains created before this feature — still work.
|
||||
*/
|
||||
export function getTrainLegs(r: Reservation): TrainLeg[] {
|
||||
const meta = parseReservationMetadata(r)
|
||||
if (Array.isArray(meta.legs) && meta.legs.length > 0) {
|
||||
return meta.legs.map((l: any): TrainLeg => ({
|
||||
from: l.from ?? null,
|
||||
to: l.to ?? null,
|
||||
train_number: l.train_number || undefined,
|
||||
platform: l.platform || undefined,
|
||||
seat: l.seat || undefined,
|
||||
dep_day_id: l.dep_day_id ?? null,
|
||||
dep_time: l.dep_time ?? null,
|
||||
arr_day_id: l.arr_day_id ?? null,
|
||||
arr_time: l.arr_time ?? null,
|
||||
}))
|
||||
}
|
||||
const eps = orderedEndpoints(r)
|
||||
const first = eps[0]
|
||||
const last = eps[eps.length - 1]
|
||||
const fromLabel = first ? (first.code || first.name) : null
|
||||
const toLabel = last ? (last.code || last.name) : null
|
||||
if (!fromLabel && !toLabel && !meta.train_number) return []
|
||||
return [{
|
||||
from: fromLabel,
|
||||
to: toLabel,
|
||||
train_number: meta.train_number || undefined,
|
||||
platform: meta.platform || undefined,
|
||||
seat: meta.seat || undefined,
|
||||
dep_day_id: r.day_id ?? null,
|
||||
dep_time: first?.local_time ?? null,
|
||||
arr_day_id: r.end_day_id ?? r.day_id ?? null,
|
||||
arr_time: last?.local_time ?? null,
|
||||
}]
|
||||
}
|
||||
|
||||
/** Number of flight segments. 1 for a simple from -> to booking. */
|
||||
export function legCount(r: Reservation): number {
|
||||
return getFlightLegs(r).length
|
||||
}
|
||||
|
||||
export function isMultiLegTrain(r: Reservation): boolean {
|
||||
return r.type === 'train' && getTrainLegs(r).length > 1
|
||||
}
|
||||
|
||||
export function isMultiLegFlight(r: Reservation): boolean {
|
||||
return r.type === 'flight' && legCount(r) > 1
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { splitReservationDateTime, resolveDayId } from './formatters'
|
||||
import { splitReservationDateTime, resolveDayId, formatMoney } from './formatters'
|
||||
import { CURRENCIES, SYMBOLS } from '../components/Budget/BudgetPanel.constants'
|
||||
import type { Day } from '../types'
|
||||
|
||||
const days = [
|
||||
@@ -25,6 +26,19 @@ describe('resolveDayId', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('KGS currency (#1400)', () => {
|
||||
it('is selectable everywhere the shared currency list feeds', () => {
|
||||
expect(CURRENCIES).toContain('KGS')
|
||||
expect(SYMBOLS.KGS).toBeTruthy()
|
||||
})
|
||||
it('formats without throwing', () => {
|
||||
// Lenient: older ICU builds may lack ru-KG/KGS display data and fall back.
|
||||
const out = formatMoney(1234.56, 'KGS', 'en')
|
||||
expect(out).toMatch(/сом|KGS/)
|
||||
expect(out).toContain('234')
|
||||
})
|
||||
})
|
||||
|
||||
describe('splitReservationDateTime', () => {
|
||||
it('parses full ISO datetime', () => {
|
||||
expect(splitReservationDateTime('2026-06-25T10:00')).toEqual({ date: '2026-06-25', time: '10:00' })
|
||||
|
||||
@@ -52,9 +52,9 @@ const CURRENCY_LOCALE: Record<string, string> = {
|
||||
PHP: 'en-PH', SGD: 'en-SG', KRW: 'ko-KR', CNY: 'zh-CN', HKD: 'en-HK',
|
||||
TWD: 'zh-TW', ZAR: 'en-ZA', AED: 'en-AE', SAR: 'en-SA', ILS: 'he-IL',
|
||||
EGP: 'en-EG', MAD: 'fr-MA', HUF: 'hu-HU', RON: 'ro-RO', BGN: 'bg-BG',
|
||||
HRK: 'hr-HR', ISK: 'is-IS', RUB: 'ru-RU', UAH: 'uk-UA', BDT: 'en-BD',
|
||||
LKR: 'en-LK', VND: 'vi-VN', CLP: 'es-CL', COP: 'es-CO', PEN: 'es-PE',
|
||||
ARS: 'es-AR',
|
||||
HRK: 'hr-HR', ISK: 'is-IS', RUB: 'ru-RU', UAH: 'uk-UA', KGS: 'ru-KG',
|
||||
BDT: 'en-BD', LKR: 'en-LK', VND: 'vi-VN', CLP: 'es-CL', COP: 'es-CO',
|
||||
PEN: 'es-PE', ARS: 'es-AR',
|
||||
}
|
||||
|
||||
export function currencyLocale(currency: string): string {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { Reservation } from '../../../src/types'
|
||||
|
||||
const { calculateRouteWithLegs } = vi.hoisted(() => ({ calculateRouteWithLegs: vi.fn() }))
|
||||
vi.mock('../../../src/components/Map/RouteCalculator', () => ({ calculateRouteWithLegs }))
|
||||
|
||||
import { useTransportRoutes } from '../../../src/hooks/useTransportRoutes'
|
||||
|
||||
function booking(id: number, type: string, from: [number, number], to: [number, number]): Reservation {
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
status: 'confirmed',
|
||||
endpoints: [
|
||||
{ role: 'from', sequence: 0, name: 'A', code: null, lat: from[0], lng: from[1], timezone: null, local_time: null, local_date: null },
|
||||
{ role: 'to', sequence: 1, name: 'B', code: null, lat: to[0], lng: to[1], timezone: null, local_time: null, local_date: null },
|
||||
],
|
||||
} as unknown as Reservation
|
||||
}
|
||||
|
||||
const PARIS: [number, number] = [48.8566, 2.3522]
|
||||
const VERSAILLES: [number, number] = [48.8049, 2.1204]
|
||||
|
||||
beforeEach(() => {
|
||||
calculateRouteWithLegs.mockReset()
|
||||
calculateRouteWithLegs.mockResolvedValue({ coordinates: [PARIS, [48.83, 2.24], VERSAILLES], distance: 20000, duration: 1500, legs: [] })
|
||||
})
|
||||
|
||||
describe('useTransportRoutes (#1425 real road routes)', () => {
|
||||
it('routes a car booking with the driving profile and returns its geometry', async () => {
|
||||
const res = [booking(1, 'car', PARIS, VERSAILLES)]
|
||||
const { result } = renderHook(() => useTransportRoutes(res))
|
||||
await waitFor(() => expect(result.current.get(1)).toBeTruthy())
|
||||
expect(result.current.get(1)).toHaveLength(3)
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledWith(expect.any(Array), expect.objectContaining({ profile: 'driving' }))
|
||||
})
|
||||
|
||||
it('routes a bicycle booking with the cycling profile', async () => {
|
||||
const res = [booking(2, 'bicycle', PARIS, VERSAILLES)]
|
||||
renderHook(() => useTransportRoutes(res))
|
||||
await waitFor(() => expect(calculateRouteWithLegs).toHaveBeenCalled())
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledWith(expect.any(Array), expect.objectContaining({ profile: 'cycling' }))
|
||||
})
|
||||
|
||||
it('does not route non-road types (flight, train, transit)', async () => {
|
||||
const res = [
|
||||
booking(3, 'flight', PARIS, VERSAILLES),
|
||||
booking(4, 'train', PARIS, VERSAILLES),
|
||||
booking(5, 'transit', PARIS, VERSAILLES),
|
||||
]
|
||||
renderHook(() => useTransportRoutes(res))
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
expect(calculateRouteWithLegs).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips routing beyond the sanity distance cap (keeps the straight line)', async () => {
|
||||
// Paris → Tokyo as a "car" booking: ~9700 km, well past the 2000 km cap.
|
||||
const res = [booking(6, 'car', PARIS, [35.68, 139.69])]
|
||||
renderHook(() => useTransportRoutes(res))
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
expect(calculateRouteWithLegs).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back silently (no entry) when routing throws', async () => {
|
||||
calculateRouteWithLegs.mockRejectedValueOnce(new Error('OSRM down'))
|
||||
const res = [booking(7, 'taxi', PARIS, VERSAILLES)]
|
||||
const { result } = renderHook(() => useTransportRoutes(res))
|
||||
await waitFor(() => expect(calculateRouteWithLegs).toHaveBeenCalled())
|
||||
await new Promise(r => setTimeout(r, 10))
|
||||
expect(result.current.get(7)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not re-request an unchanged booking when the array identity changes', async () => {
|
||||
const res1 = [booking(8, 'car', PARIS, VERSAILLES)]
|
||||
const { rerender } = renderHook(({ r }) => useTransportRoutes(r), { initialProps: { r: res1 } })
|
||||
await waitFor(() => expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1))
|
||||
// New array, same booking coordinates → no second fetch.
|
||||
rerender({ r: [booking(8, 'car', PARIS, VERSAILLES)] })
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vite';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
@@ -11,7 +11,14 @@ export default defineConfig({
|
||||
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024,
|
||||
globPatterns: ['**/*.{js,css,html,svg,png,woff,woff2,ttf}'],
|
||||
navigateFallback: 'index.html',
|
||||
navigateFallbackDenylist: [/^\/api/, /^\/uploads/, /^\/mcp/, /^\/oauth\//, /^\/.well-known\//, /^\/plugin-frame\//],
|
||||
navigateFallbackDenylist: [
|
||||
/^\/api/,
|
||||
/^\/uploads/,
|
||||
/^\/mcp/,
|
||||
/^\/oauth\//,
|
||||
/^\/.well-known\//,
|
||||
/^\/plugin-frame\//,
|
||||
],
|
||||
runtimeCaching: [
|
||||
{
|
||||
// Carto map tiles (default provider)
|
||||
@@ -129,6 +136,10 @@ export default defineConfig({
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/plugin-frame': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/uploads': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
@@ -164,6 +175,6 @@ export default defineConfig({
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -45,6 +45,7 @@ services:
|
||||
# - MCP_RATE_LIMIT=300 # Max MCP API requests per user per minute (default: 300)
|
||||
# - MCP_MAX_SESSION_PER_USER=20 # Max concurrent MCP sessions per user (default: 20)
|
||||
# - KITINERARY_EXTRACTOR_PATH= # Optional. Full path to kitinerary-extractor binary. Auto-detected from PATH and /usr/lib/*/libexec/kf6/ when unset.
|
||||
# - UNSPLASH_ACCESS_KEY= # Optional. Unsplash Access Key for trip-cover / place-image search. Without one, TREK uses Unsplash's unauthenticated endpoint, which some datacenter/VPS IPs are blocked from (#1449). Free key at unsplash.com/developers. Can also be set per-admin in Admin > Settings; the env var overrides that.
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./uploads:/app/uploads
|
||||
|
||||
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 205 KiB |
|
Before Width: | Height: | Size: 275 KiB After Width: | Height: | Size: 583 KiB |
|
Before Width: | Height: | Size: 321 KiB After Width: | Height: | Size: 306 KiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 908 KiB After Width: | Height: | Size: 2.2 MiB |
|
Before Width: | Height: | Size: 2.1 MiB |
|
Before Width: | Height: | Size: 928 KiB After Width: | Height: | Size: 2.5 MiB |
|
Before Width: | Height: | Size: 132 KiB After Width: | Height: | Size: 276 KiB |
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@trek/root",
|
||||
"version": "3.1.3",
|
||||
"version": "3.2.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@trek/root",
|
||||
"version": "3.1.3",
|
||||
"version": "3.2.1",
|
||||
"workspaces": [
|
||||
"client",
|
||||
"server",
|
||||
@@ -25,7 +25,7 @@
|
||||
},
|
||||
"client": {
|
||||
"name": "@trek/client",
|
||||
"version": "3.1.3",
|
||||
"version": "3.2.1",
|
||||
"dependencies": {
|
||||
"@fontsource/geist-sans": "^5.2.5",
|
||||
"@fontsource/poppins": "^5.2.7",
|
||||
@@ -4127,6 +4127,12 @@
|
||||
"pbf": "bin/pbf"
|
||||
}
|
||||
},
|
||||
"node_modules/@microsoft/tsdoc": {
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.16.0.tgz",
|
||||
"integrity": "sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk": {
|
||||
"version": "1.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
|
||||
@@ -4757,6 +4763,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/mapped-types": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.1.1.tgz",
|
||||
"integrity": "sha512-SCCoMEJ6jdeI5h/N+KCVF1+pmg/hmEkNA5nHTS8Gvww7T/LCl4o1gFLinw2iQ60w7slFkszHcGLKGdazVI4F8A==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": "^10.0.0 || ^11.0.0",
|
||||
"class-transformer": "^0.4.0 || ^0.5.0",
|
||||
"class-validator": "^0.13.0 || ^0.14.0 || ^0.15.0",
|
||||
"reflect-metadata": "^0.1.12 || ^0.2.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"class-transformer": {
|
||||
"optional": true
|
||||
},
|
||||
"class-validator": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/platform-express": {
|
||||
"version": "11.1.27",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.27.tgz",
|
||||
@@ -5070,6 +5096,39 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/swagger": {
|
||||
"version": "11.4.5",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.4.5.tgz",
|
||||
"integrity": "sha512-lvndlJmWBVDOUT0uEtLi6sSpW1syK2/nbAlHBhiELBORMpJGe9+EiWAT9qHtB10jW91L2Jmlwkr0/lttsYZrig==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@microsoft/tsdoc": "0.16.0",
|
||||
"@nestjs/mapped-types": "2.1.1",
|
||||
"js-yaml": "4.3.0",
|
||||
"lodash": "4.18.1",
|
||||
"path-to-regexp": "8.4.2",
|
||||
"swagger-ui-dist": "5.32.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@fastify/static": "^8.0.0 || ^9.0.0",
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"class-transformer": "*",
|
||||
"class-validator": "*",
|
||||
"reflect-metadata": "^0.1.12 || ^0.2.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@fastify/static": {
|
||||
"optional": true
|
||||
},
|
||||
"class-transformer": {
|
||||
"optional": true
|
||||
},
|
||||
"class-validator": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/testing": {
|
||||
"version": "11.1.27",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-11.1.27.tgz",
|
||||
@@ -6443,6 +6502,13 @@
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@scarf/scarf": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz",
|
||||
"integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@simplewebauthn/browser": {
|
||||
"version": "13.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz",
|
||||
@@ -8106,7 +8172,6 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||
"dev": true,
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/aria-query": {
|
||||
@@ -12885,10 +12950,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
|
||||
"integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
|
||||
"dev": true,
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
|
||||
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -18531,6 +18595,15 @@
|
||||
"integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/swagger-ui-dist": {
|
||||
"version": "5.32.8",
|
||||
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.8.tgz",
|
||||
"integrity": "sha512-dgMdWXIgnI4zX4OPhKEdWnlDODbgm8W3AX0Ivn/BBqcUh6xZsBxhZMnvk6DJyRz1BTrj8dPxtarmEGgkz30oyA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@scarf/scarf": "=1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/symbol-tree": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||
@@ -21156,12 +21229,13 @@
|
||||
},
|
||||
"server": {
|
||||
"name": "@trek/server",
|
||||
"version": "3.1.3",
|
||||
"version": "3.2.1",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.28.0",
|
||||
"@nestjs/common": "^11.1.24",
|
||||
"@nestjs/core": "^11.1.24",
|
||||
"@nestjs/platform-express": "^11.1.24",
|
||||
"@nestjs/swagger": "^11.4.5",
|
||||
"@simplewebauthn/server": "^13.1.2",
|
||||
"@trek/shared": "*",
|
||||
"archiver": "^6.0.1",
|
||||
@@ -21514,7 +21588,7 @@
|
||||
},
|
||||
"shared": {
|
||||
"name": "@trek/shared",
|
||||
"version": "3.1.3",
|
||||
"version": "3.2.1",
|
||||
"dependencies": {
|
||||
"isomorphic-dompurify": "^3.15.0",
|
||||
"zod": "^4.3.6"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@trek/root",
|
||||
"private": true,
|
||||
"version": "3.1.3",
|
||||
"version": "3.2.1",
|
||||
"workspaces": [
|
||||
"client",
|
||||
"server",
|
||||
@@ -28,6 +28,10 @@
|
||||
"concurrently": "^10.0.3",
|
||||
"unrun": "^0.3.1"
|
||||
},
|
||||
"comment:scarfSettings": "swagger-ui-dist ships @scarf/scarf install-time analytics; TREK sends no telemetry, so it stays off.",
|
||||
"scarfSettings": {
|
||||
"enabled": false
|
||||
},
|
||||
"comment:overrides": "Force a single React 19 across the workspace so the test renderer (@testing-library/react) and the app share one react-dom.",
|
||||
"overrides": {
|
||||
"react": "19.2.6",
|
||||
|
||||
@@ -5,11 +5,17 @@ The SDK for building [TREK](https://github.com/mauriceboe/TREK) plugins.
|
||||
## Scaffold a plugin
|
||||
|
||||
```bash
|
||||
npx trek-plugin-sdk create # interactive wizard (id, type, permissions)
|
||||
npx trek-plugin-sdk # no command? a guided menu of everything below
|
||||
npx trek-plugin-sdk create # interactive wizard (id, location, type, permissions)
|
||||
npx trek-plugin-sdk create my-plugin --type widget # or non-interactive
|
||||
cd my-plugin
|
||||
```
|
||||
|
||||
The wizard also offers to initialize a git repo and install dependencies for you.
|
||||
In a non-interactive shell (CI, pipes) every command stays flag-driven with plain
|
||||
output — no prompts, and machine output (`entry` JSON, `pack --json`, PR URLs) stays
|
||||
on stdout.
|
||||
|
||||
## Develop with a live reload loop
|
||||
|
||||
`dev` runs your plugin locally — no full TREK needed. It injects a `ctx` that
|
||||
@@ -21,9 +27,34 @@ SQLite file, serves your routes and your page/widget UI, and reloads on save.
|
||||
npx trek-plugin-sdk dev # http://localhost:4317 — dashboard, routes, UI
|
||||
```
|
||||
|
||||
Hit a route as an unauthenticated request with `?_anon=1`. Drop a
|
||||
`dev-fixtures.json` (trips, users, config) next to your manifest to feed
|
||||
`ctx.trips` / `ctx.users`.
|
||||
Open **`/preview`** to see a page/widget rendered in a real sandboxed frame with a
|
||||
theme/accent/appearance toggle (`trek.invoke()` is proxied to your routes). Hit a route
|
||||
as an unauthenticated request with `?_anon=1`. Drop a `dev-fixtures.json` (trips, users,
|
||||
config) next to your manifest to feed `ctx.trips` / `ctx.users`.
|
||||
|
||||
## Build a native UI (page / widget)
|
||||
|
||||
The UI is a sandboxed, opaque-origin iframe that can't load TREK's stylesheet — so the
|
||||
SDK ships it. Put **one line** in your `client/index.html` `<head>`:
|
||||
|
||||
```html
|
||||
<!-- trek:ui -->
|
||||
```
|
||||
|
||||
`dev` and `pack` expand it into the inlined **design kit**: token-driven styles that
|
||||
follow the host's theme and accent (glass, cards, `.trek-btn`, `.trek-input`,
|
||||
`.trek-chip`, `.trek-row`, hover), plus a `window.trek` bridge:
|
||||
|
||||
```js
|
||||
trek.onContext((ctx) => { /* ctx.theme, ctx.tokens, ctx.appearance, ctx.user, ctx.tripId */ })
|
||||
const data = await trek.invoke('/status') // calls your own route, host-proxied
|
||||
trek.notify('success', 'Saved')
|
||||
```
|
||||
|
||||
The kit applies the theme, mirrors the appearance flags (reduced-motion,
|
||||
no-transparency) and auto-reports your height. See the
|
||||
[Plugin Development wiki](https://github.com/mauriceboe/TREK/wiki/Plugin-Development)
|
||||
for the full component + token reference.
|
||||
|
||||
## Write a plugin
|
||||
|
||||
@@ -99,6 +130,7 @@ losing it means you can't ship signed updates.
|
||||
- `PLUGIN_API_VERSION` — embed as `apiVersion` in your manifest.
|
||||
- `validateManifest(json)` — the manifest rules the server loader uses.
|
||||
- `createMockHost(opts)` (from `trek-plugin-sdk/testing`).
|
||||
- `TREK_UI_CSS`, `TREK_THEME_JS`, `TREK_UI_MARKER`, `injectTrekUi(html)` — the design kit, for authors who inline it themselves (a bundler, a custom build). Most plugins just use the `<!-- trek:ui -->` marker instead.
|
||||
|
||||
## Commands
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# Trip Doctor
|
||||
|
||||
> A hooks-only example plugin that enriches TREK's own planner — no UI of its own.
|
||||
|
||||
Trip Doctor is the companion example for the **provider hooks** and **entity
|
||||
metadata** added in #1429. It shows how a plugin can contribute *data* that TREK
|
||||
renders natively, instead of shipping its own iframe widget.
|
||||
|
||||
## What it does
|
||||
|
||||
- **Warnings** — scans the open trip and flags places with no map location, plus a
|
||||
tip when the trip has no bookings yet. These appear as a non-blocking banner at
|
||||
the top of the trip planner (via the `warningProvider` hook).
|
||||
- **Place notes** — pin a private note on any place; it shows up as a "Note" row at
|
||||
the foot of the place-detail panel (via the `placeDetailProvider` hook). Notes are
|
||||
stored in the plugin's own per-plugin metadata (`ctx.meta`), not in a schema fork.
|
||||
- **`POST /pin`** `{ placeId, note }` — set or clear a place's note. Call it from a
|
||||
companion widget, a keyboard shortcut, or `curl`.
|
||||
|
||||
## Permissions
|
||||
|
||||
| Permission | Why |
|
||||
|---|---|
|
||||
| `db:read:trips` | Read the trip's places + reservations to find gaps. Membership-checked by the host — the plugin only ever sees a trip the current user can access. |
|
||||
| `db:meta` | Store/read the plugin's own note on a place. Isolated to this plugin; writes require the user's `place_edit` permission. |
|
||||
| `hook:trip-warning-provider` | Contribute the validation warnings shown in the planner. |
|
||||
| `hook:place-detail-provider` | Contribute the "Note" row shown on a place. |
|
||||
|
||||
No network access. Every read and write is bound by the host to the person viewing
|
||||
the planner — Trip Doctor never passes a user id, and a hook that errors or runs
|
||||
long is dropped without affecting the planner.
|
||||
|
||||
## Run it
|
||||
|
||||
```bash
|
||||
npx @trek/plugin-sdk dev # hot-reload against a local TREK
|
||||
npx @trek/plugin-sdk pack # build a signed .trekplugin bundle
|
||||
```
|
||||
|
||||
See the [Plugin Cookbook](https://github.com/mauriceboe/TREK/wiki/Plugin-Cookbook)
|
||||
for the individual recipes this example is built from.
|
||||
@@ -0,0 +1,73 @@
|
||||
// Trip Doctor — a hooks-only TREK plugin that showcases the #1429 capabilities.
|
||||
//
|
||||
// It has no UI of its own. Instead it feeds data into TREK's own planner surfaces
|
||||
// through two provider hooks, and stores its notes in its own per-plugin metadata:
|
||||
//
|
||||
// • warningProvider → validation banner in the trip planner
|
||||
// • placeDetailProvider → an extra "Note" row at the foot of the place panel
|
||||
// • POST /pin → pin/clear that note (ctx.meta), from your own UI or a
|
||||
// keyboard-shortcut plugin
|
||||
//
|
||||
// SECURITY: the plugin never passes a user id. The host binds the person viewing
|
||||
// the planner to the invocation, membership-checks the trip/place against THEM,
|
||||
// and (for meta writes) requires their place_edit permission. A hook that is slow
|
||||
// or throws is dropped by the host, so it can never break or stall the planner.
|
||||
|
||||
module.exports = {
|
||||
hooks: {
|
||||
// Core calls this for the open trip; return the problems to surface.
|
||||
warningProvider: {
|
||||
async getWarnings(tripId, ctx) {
|
||||
const warnings = [];
|
||||
|
||||
const places = await ctx.trips.getPlaces(tripId);
|
||||
for (const p of places) {
|
||||
if (p.lat == null || p.lng == null) {
|
||||
warnings.push({
|
||||
level: 'warning',
|
||||
message: `"${p.name || 'A place'}" has no map location`,
|
||||
placeId: p.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const reservations = await ctx.trips.getReservations(tripId);
|
||||
if (reservations.length === 0) {
|
||||
warnings.push({ level: 'info', message: 'No bookings added to this trip yet' });
|
||||
}
|
||||
|
||||
return warnings;
|
||||
},
|
||||
},
|
||||
|
||||
// Core calls this for the open place; return the pinned note (if any).
|
||||
placeDetailProvider: {
|
||||
async getDetails(placeId, ctx) {
|
||||
const note = await ctx.meta.get('place', placeId, 'note');
|
||||
return note ? [{ label: 'Note', value: String(note) }] : [];
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
routes: [
|
||||
{
|
||||
// POST /pin { placeId, note } — pin a note on a place, or clear it (empty note).
|
||||
method: 'POST',
|
||||
path: '/pin',
|
||||
auth: true,
|
||||
async handler(req, ctx) {
|
||||
const json = { 'content-type': 'application/json' };
|
||||
const { placeId, note } = req.body || {};
|
||||
if (!placeId) {
|
||||
return { status: 400, headers: json, body: JSON.stringify({ error: 'placeId required' }) };
|
||||
}
|
||||
if (note && String(note).trim()) {
|
||||
await ctx.meta.set('place', Number(placeId), 'note', String(note).trim());
|
||||
} else {
|
||||
await ctx.meta.delete('place', Number(placeId), 'note');
|
||||
}
|
||||
return { status: 200, headers: json, body: JSON.stringify({ ok: true }) };
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"id": "trip-doctor",
|
||||
"name": "Trip Doctor",
|
||||
"version": "1.0.0",
|
||||
"apiVersion": 1,
|
||||
"author": "TREK",
|
||||
"description": "Checks a trip for common gaps (places with no map location, no bookings yet) and lets you pin a private note on any place. A hooks-only plugin — it has no UI of its own; TREK renders everything natively.",
|
||||
"homepage": "https://github.com/mauriceboe/trek-plugin-trip-doctor",
|
||||
"license": "MIT",
|
||||
"icon": "Stethoscope",
|
||||
"type": "integration",
|
||||
"trek": ">=3.2.0 <4.0.0",
|
||||
"nativeModules": false,
|
||||
"permissions": [
|
||||
"db:read:trips",
|
||||
"db:meta",
|
||||
"hook:trip-warning-provider",
|
||||
"hook:place-detail-provider"
|
||||
],
|
||||
"routes": [
|
||||
{ "method": "POST", "path": "/pin", "auth": true }
|
||||
]
|
||||
}
|
||||
@@ -1,37 +1,86 @@
|
||||
{
|
||||
"name": "trek-plugin-sdk",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"description": "SDK for building TREK plugins: types, definePlugin, a mock host, manifest validation, and the create-trek-plugin / trek-plugin CLIs.",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"author": "mauriceboe",
|
||||
"maintainers": [
|
||||
{
|
||||
"name": "mauriceboe",
|
||||
"url": "https://github.com/mauriceboe"
|
||||
},
|
||||
{
|
||||
"name": "jubnl",
|
||||
"url": "https://github.com/jubnl"
|
||||
}
|
||||
],
|
||||
"homepage": "https://github.com/mauriceboe/TREK/wiki/Plugin-Development",
|
||||
"repository": { "type": "git", "url": "git+https://github.com/mauriceboe/TREK.git", "directory": "plugin-sdk" },
|
||||
"bugs": { "url": "https://github.com/mauriceboe/TREK/issues" },
|
||||
"engines": { "node": ">=18" },
|
||||
"publishConfig": { "access": "public" },
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/mauriceboe/TREK.git",
|
||||
"directory": "plugin-sdk"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/mauriceboe/TREK/issues"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
|
||||
"./testing": { "types": "./dist/mock-host.d.ts", "import": "./dist/mock-host.js" }
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/cjs/index.d.ts",
|
||||
"default": "./dist/cjs/index.js"
|
||||
}
|
||||
},
|
||||
"./testing": {
|
||||
"import": {
|
||||
"types": "./dist/mock-host.d.ts",
|
||||
"default": "./dist/mock-host.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/cjs/mock-host.d.ts",
|
||||
"default": "./dist/cjs/mock-host.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"bin": {
|
||||
"trek-plugin-sdk": "./dist/cli/trek-plugin.js",
|
||||
"trek-plugin": "./dist/cli/trek-plugin.js",
|
||||
"create-trek-plugin": "./dist/cli/create.js"
|
||||
},
|
||||
"files": ["dist"],
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.7.0",
|
||||
"semver": "^7.7.4"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"build": "tsc -p tsconfig.json && tsc -p tsconfig.cjs.json && node scripts/finish-cjs.mjs",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "vitest run",
|
||||
"prepublishOnly": "npm run build && npm test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vitest": "^2.1.0"
|
||||
"@types/node": "^22.20.0",
|
||||
"@types/semver": "^7.7.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.9"
|
||||
},
|
||||
"keywords": ["trek", "plugin", "sdk"]
|
||||
"keywords": [
|
||||
"trek",
|
||||
"plugin",
|
||||
"sdk"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// The package root says `"type": "module"`, so the CommonJS second build in
|
||||
// dist/cjs needs its own scope marker or Node would parse those files as ESM.
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const dir = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'dist', 'cjs');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), '{"type":"commonjs"}\n');
|
||||
@@ -1,22 +1,46 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* create-trek-plugin <name> [--type integration|page|widget] (#plugins, M6).
|
||||
* create-trek-plugin <name> [--type integration|page|widget|trip-page] (#plugins, M6).
|
||||
* Scaffolds a working plugin: manifest, an isolated server entry using
|
||||
* definePlugin, a README you must fill in, and (page/widget) a starter iframe.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import readline from 'node:readline';
|
||||
import os from 'node:os';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createRequire } from 'node:module';
|
||||
import {
|
||||
intro, outro, note, logSuccess, logWarn, spinner,
|
||||
promptText, promptSelect, promptMultiselect, promptConfirm,
|
||||
PERMISSION_CATALOG,
|
||||
} from './ui.js';
|
||||
import { KNOWN_ADDONS } from '../manifest.js';
|
||||
|
||||
/** This package's own version, for the scaffold's devDependency range. */
|
||||
function sdkVersionRange(): string {
|
||||
try {
|
||||
const pkg = createRequire(import.meta.url)('../../package.json') as { version?: string };
|
||||
return pkg.version ? `^${pkg.version}` : '^1';
|
||||
} catch {
|
||||
return '^1';
|
||||
}
|
||||
}
|
||||
|
||||
export interface ScaffoldOptions {
|
||||
author?: string;
|
||||
description?: string;
|
||||
permissions?: string[];
|
||||
/** External hosts the plugin may call — required by the manifest when `http:outbound` is granted. */
|
||||
egress?: string[];
|
||||
/** Addon ids that must be enabled for this plugin to activate. */
|
||||
requiredAddons?: string[];
|
||||
/** Other plugins this one depends on, each pinned by a semver range. */
|
||||
pluginDependencies?: Array<{ id: string; version: string }>;
|
||||
}
|
||||
|
||||
export function scaffold(name: string, type: string, targetDir: string, opts: ScaffoldOptions = {}): void {
|
||||
if (!/^[a-z][a-z0-9-]{2,39}$/.test(name)) throw new Error(`invalid plugin id "${name}" (lowercase slug, 3–40 chars)`);
|
||||
if (!['integration', 'page', 'widget'].includes(type)) throw new Error(`invalid type "${type}"`);
|
||||
if (!['integration', 'page', 'widget', 'trip-page'].includes(type)) throw new Error(`invalid type "${type}"`);
|
||||
|
||||
const root = path.join(targetDir, name);
|
||||
if (fs.existsSync(root)) throw new Error(`${root} already exists`);
|
||||
@@ -31,29 +55,46 @@ export function scaffold(name: string, type: string, targetDir: string, opts: Sc
|
||||
author: opts.author || 'Your Name',
|
||||
description: opts.description || 'Describe what your plugin does.',
|
||||
type,
|
||||
trek: '>=3.2.0 <4.0.0',
|
||||
trek: '>=3.2.1 <4.0.0',
|
||||
nativeModules: false,
|
||||
permissions: perms,
|
||||
// Dependency declarations (empty by default). `requiredAddons` lists addon ids
|
||||
// that must be enabled to activate; `pluginDependencies` lists other plugins
|
||||
// ({ id, version-range }) that must be installed + satisfied first.
|
||||
requiredAddons: opts.requiredAddons ?? [],
|
||||
pluginDependencies: opts.pluginDependencies ?? [],
|
||||
routes: [{ method: 'GET', path: '/hello', auth: true }],
|
||||
};
|
||||
if (opts.egress?.length) manifest.egress = opts.egress;
|
||||
if (type === 'page') manifest.capabilities = { nav: { label: manifest.name, icon: 'Blocks', position: 'main' } };
|
||||
if (type === 'widget') manifest.capabilities = { widget: { title: manifest.name, defaultSize: 'medium' } };
|
||||
|
||||
fs.writeFileSync(path.join(root, 'trek-plugin.json'), JSON.stringify(manifest, null, 2) + '\n');
|
||||
fs.writeFileSync(path.join(root, 'server', 'index.js'), SERVER_JS);
|
||||
fs.writeFileSync(path.join(root, 'README.md'), readme(name));
|
||||
fs.writeFileSync(path.join(root, 'server', 'index.js'), SERVER_JS(perms.includes('db:own')));
|
||||
fs.writeFileSync(path.join(root, 'README.md'), readme(name, opts.description ?? '> One sentence: what this plugin does.', perms));
|
||||
// `type: commonjs` pins how the entry is parsed everywhere (dev, tests, TREK);
|
||||
// the SDK is a devDependency ONLY (types + mock host) — at runtime both the
|
||||
// dev server and TREK inject it, so it is never vendored into the artifact.
|
||||
fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({
|
||||
name,
|
||||
version: '1.0.0',
|
||||
private: true,
|
||||
type: 'commonjs',
|
||||
scripts: { dev: 'npx -y trek-plugin-sdk dev', pack: 'npx -y trek-plugin-sdk pack' },
|
||||
devDependencies: { 'trek-plugin-sdk': sdkVersionRange() },
|
||||
}, null, 2) + '\n');
|
||||
if (type !== 'integration') {
|
||||
fs.mkdirSync(path.join(root, 'client'), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, 'client', 'index.html'), CLIENT_HTML);
|
||||
}
|
||||
}
|
||||
|
||||
const SERVER_JS = `// Built plugin entry — runs in an isolated child process.
|
||||
const SERVER_JS = (has_db: boolean) => `// Built plugin entry — runs in an isolated child process.
|
||||
const { definePlugin } = require('trek-plugin-sdk');
|
||||
|
||||
module.exports = definePlugin({
|
||||
async onLoad(ctx) {
|
||||
await ctx.db.migrate('001_init', 'CREATE TABLE IF NOT EXISTS kv (k TEXT PRIMARY KEY, v TEXT)');
|
||||
${has_db ? 'await ctx.db.migrate(\'001_init\', \'CREATE TABLE IF NOT EXISTS kv (k TEXT PRIMARY KEY, v TEXT)\');' : ''}
|
||||
ctx.log.info('plugin loaded');
|
||||
},
|
||||
routes: [
|
||||
@@ -68,26 +109,57 @@ module.exports = definePlugin({
|
||||
});
|
||||
`;
|
||||
|
||||
// The `<!-- trek:ui -->` marker is expanded by `dev` and `pack` into the inlined
|
||||
// design kit (native styles + a `window.trek` bridge). The source stays this one
|
||||
// line, so the starter is already themed, glassy and wired on first run.
|
||||
const CLIENT_HTML = `<!doctype html>
|
||||
<html><head><meta charset="utf-8" /><title>Plugin</title></head>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Plugin</title>
|
||||
<!-- trek:ui -->
|
||||
</head>
|
||||
<body>
|
||||
<h1>Hello from your plugin</h1>
|
||||
<div class="trek-glass trek-stack" style="margin: 16px">
|
||||
<div class="trek-title">Your plugin</div>
|
||||
<p class="trek-muted" id="hello">Click below to call your /hello route.</p>
|
||||
<div class="trek-cluster">
|
||||
<button class="trek-btn trek-btn--primary" id="ping">Say hello</button>
|
||||
<span class="trek-chip trek-chip--accent" id="who">not connected</span>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
// The frame is sandboxed (opaque origin) — talk to TREK only via postMessage.
|
||||
window.parent.postMessage({ type: 'trek:ready' }, '*');
|
||||
window.addEventListener('message', (e) => {
|
||||
if (e.data && e.data.type === 'trek:context') {
|
||||
document.body.dataset.theme = e.data.theme;
|
||||
// The design kit is inlined above (window.trek + native styles). The frame is
|
||||
// sandboxed at an opaque origin — reach TREK only through window.trek.
|
||||
trek.onContext(function (ctx) {
|
||||
document.getElementById('who').textContent = (ctx.user ? ctx.user.name + ' \\u00b7 ' : '') + ctx.theme;
|
||||
});
|
||||
document.getElementById('ping').addEventListener('click', async function () {
|
||||
try {
|
||||
var data = await trek.invoke('/hello');
|
||||
document.getElementById('hello').textContent = 'Hello, ' + ((data && data.hello) || 'traveller') + '!';
|
||||
} catch (e) {
|
||||
trek.notify('error', e.message);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body></html>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
function readme(name: string): string {
|
||||
/** One markdown table row per granted scope, with the catalog's description as the "Why". */
|
||||
function permissionRows(scopes: string[]): string {
|
||||
const rows = (scopes.length ? scopes : ['db:own']).map(
|
||||
(s) => `| \`${s}\` | 'Describe why this plugin needs it.' |`,
|
||||
);
|
||||
return rows.join('\n');
|
||||
}
|
||||
|
||||
function readme(name: string, description: string, scopes: string[]): string {
|
||||
return `# ${name}
|
||||
|
||||
> One sentence: what this plugin does.
|
||||
${description}
|
||||
|
||||

|
||||
|
||||
@@ -105,7 +177,7 @@ looks best (the card crops the edges).
|
||||
|
||||
| Permission | Why |
|
||||
|---|---|
|
||||
| \`db:own\` | store the plugin's own data |
|
||||
${permissionRows(scopes)}
|
||||
|
||||
## Setup
|
||||
|
||||
@@ -118,38 +190,147 @@ one. Replace this line with your license (for example, MIT).
|
||||
`;
|
||||
}
|
||||
|
||||
const KNOWN_PERMISSIONS = [
|
||||
'db:own', 'db:read:trips', 'db:read:users', 'ws:broadcast:trip', 'ws:broadcast:user',
|
||||
'hook:photo-provider', 'hook:calendar-source', 'http:outbound',
|
||||
];
|
||||
const SLUG = /^[a-z][a-z0-9-]{2,39}$/;
|
||||
|
||||
/** Interactive scaffold: prompt for the details, then create the plugin. Returns the chosen name. */
|
||||
export async function interactiveScaffold(targetDir: string, presetName?: string): Promise<string> {
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const ask = (q: string, def?: string) =>
|
||||
new Promise<string>((resolve) => rl.question(def ? `${q} (${def}) ` : `${q} `, (a) => resolve(a.trim() || def || '')));
|
||||
/** Resolve a user-typed directory: expand a leading `~`, then make it absolute. */
|
||||
function resolveDir(input: string): string {
|
||||
const raw = (input || '.').trim();
|
||||
const expanded = raw === '~' || raw.startsWith('~/') ? path.join(os.homedir(), raw.slice(1)) : raw;
|
||||
return path.resolve(expanded);
|
||||
}
|
||||
|
||||
/** True when `dir` sits inside an existing git work tree (so we don't offer to nest a repo). */
|
||||
function insideGitRepo(dir: string): boolean {
|
||||
try {
|
||||
let name = presetName || '';
|
||||
while (!/^[a-z][a-z0-9-]{2,39}$/.test(name)) {
|
||||
name = await ask('Plugin id (lowercase-slug):');
|
||||
if (!/^[a-z][a-z0-9-]{2,39}$/.test(name)) console.log(' → must be a lowercase slug, 3–40 chars (e.g. flight-tracker)');
|
||||
}
|
||||
let type = '';
|
||||
while (!['integration', 'page', 'widget'].includes(type)) {
|
||||
type = (await ask('Type — integration | page | widget:', 'integration')).toLowerCase();
|
||||
}
|
||||
const author = await ask('Author:', 'Your Name');
|
||||
const description = await ask('One-line description:', 'Describe what your plugin does.');
|
||||
console.log(`\n Permissions (space/comma separated). Available:\n ${KNOWN_PERMISSIONS.join(', ')}`);
|
||||
const permsRaw = await ask('Permissions:', 'db:own');
|
||||
const permissions = permsRaw.split(/[\s,]+/).filter(Boolean).filter((p) => KNOWN_PERMISSIONS.includes(p) || p.startsWith('http:outbound:'));
|
||||
scaffold(name, type, targetDir, { author, description, permissions });
|
||||
return name;
|
||||
} finally {
|
||||
rl.close();
|
||||
execFileSync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: dir, stdio: 'ignore' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive scaffold: a Clack wizard that collects the details, writes the
|
||||
* plugin, and offers to set up git + install deps. Returns the created plugin id.
|
||||
* Only ever called when stdout is a TTY (the dispatcher guards this).
|
||||
*/
|
||||
export async function interactiveScaffold(defaultDir: string, presetName?: string): Promise<string> {
|
||||
intro('create-trek-plugin');
|
||||
|
||||
const id = presetName && SLUG.test(presetName)
|
||||
? presetName
|
||||
: await promptText({
|
||||
message: 'Plugin id',
|
||||
placeholder: 'flight-tracker',
|
||||
initialValue: presetName ?? '',
|
||||
validate: (v) => (SLUG.test((v ?? '').trim()) ? undefined : 'lowercase slug, 3–40 chars (e.g. flight-tracker)'),
|
||||
}).then((v) => v.trim());
|
||||
|
||||
const location = await promptText({
|
||||
message: 'Where should the plugin be created?',
|
||||
placeholder: `. (creates ./${id}/ here)`,
|
||||
defaultValue: defaultDir,
|
||||
validate: (v) => (fs.existsSync(path.join(resolveDir(v || defaultDir), id))
|
||||
? `${path.join(v || '.', id)} already exists`
|
||||
: undefined),
|
||||
});
|
||||
const parentDir = resolveDir(location || defaultDir);
|
||||
const dest = path.join(parentDir, id);
|
||||
|
||||
const type = await promptSelect<string>({
|
||||
message: 'What kind of plugin is this?',
|
||||
initialValue: 'integration',
|
||||
options: [
|
||||
{ value: 'integration', label: 'integration', hint: 'server-only: routes, hooks, background work' },
|
||||
{ value: 'page', label: 'page', hint: 'adds a full navigation page (sandboxed iframe UI)' },
|
||||
{ value: 'widget', label: 'widget', hint: 'adds a dashboard widget (sandboxed iframe UI)' },
|
||||
{ value: 'trip-page', label: 'trip-page', hint: 'adds a tab inside every trip (sandboxed iframe UI)' },
|
||||
],
|
||||
});
|
||||
|
||||
const author = await promptText({ message: 'Author', placeholder: 'Your Name', defaultValue: 'Your Name' });
|
||||
const description = await promptText({
|
||||
message: 'One-line description',
|
||||
placeholder: 'Describe what your plugin does.',
|
||||
defaultValue: 'Describe what your plugin does.',
|
||||
});
|
||||
|
||||
const permissions = await promptMultiselect<string>({
|
||||
message: 'Which permissions does it need?',
|
||||
options: PERMISSION_CATALOG.map((p) => ({ value: p.value, label: p.label, hint: p.hint })),
|
||||
initialValues: ['db:own'],
|
||||
required: false,
|
||||
});
|
||||
|
||||
let egress: string[] | undefined;
|
||||
if (permissions.includes('http:outbound')) {
|
||||
const raw = await promptText({
|
||||
message: 'External hosts it may call (comma-separated)',
|
||||
placeholder: 'api.example.com, api.other.com',
|
||||
validate: (v) => ((v ?? '').split(',').map((s) => s.trim()).filter(Boolean).length ? undefined : 'list at least one host'),
|
||||
});
|
||||
egress = raw.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
const requiredAddons = await promptMultiselect<string>({
|
||||
message: 'Requires any TREK addons enabled? (optional — the plugin can only activate when these are on)',
|
||||
options: KNOWN_ADDONS.map((a) => ({ value: a, label: a })),
|
||||
initialValues: [],
|
||||
required: false,
|
||||
});
|
||||
|
||||
note(
|
||||
[
|
||||
`id ${id}`,
|
||||
`type ${type}`,
|
||||
`location ${dest}`,
|
||||
`author ${author}`,
|
||||
`permissions ${permissions.join(', ') || '(none)'}`,
|
||||
egress ? `egress ${egress.join(', ')}` : undefined,
|
||||
requiredAddons.length ? `addons ${requiredAddons.join(', ')}` : undefined,
|
||||
].filter(Boolean).join('\n'),
|
||||
'Review',
|
||||
);
|
||||
|
||||
const confirmed = await promptConfirm({ message: `Create the plugin at ${dest}?`, initialValue: true });
|
||||
if (!confirmed) {
|
||||
outro('Cancelled — nothing was written.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
scaffold(id, type, parentDir, { author, description, permissions, egress, requiredAddons });
|
||||
logSuccess(`Created ${dest}`);
|
||||
|
||||
if (!insideGitRepo(parentDir)) {
|
||||
const doGit = await promptConfirm({ message: 'Initialize a git repository?', initialValue: true });
|
||||
if (doGit) {
|
||||
try {
|
||||
execFileSync('git', ['init'], { cwd: dest, stdio: 'ignore' });
|
||||
logSuccess('Initialized a git repository');
|
||||
} catch {
|
||||
logWarn('Could not initialize git — run `git init` yourself later.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const doInstall = await promptConfirm({ message: 'Install dependencies now?', initialValue: true });
|
||||
if (doInstall) {
|
||||
const s = spinner();
|
||||
s.start('Installing dependencies');
|
||||
try {
|
||||
execFileSync('npm', ['install'], { cwd: dest, stdio: 'ignore' });
|
||||
s.stop('Dependencies installed');
|
||||
} catch {
|
||||
s.stop('Could not install dependencies');
|
||||
logWarn('Run `npm install` in the plugin directory later.');
|
||||
}
|
||||
}
|
||||
|
||||
const cd = path.relative(process.cwd(), dest) || dest;
|
||||
outro(`Next steps:\n cd ${cd}\n npx trek-plugin-sdk dev`);
|
||||
return id;
|
||||
}
|
||||
|
||||
// CLI entry
|
||||
if (process.argv[1] && process.argv[1].endsWith('create.js')) {
|
||||
const args = process.argv.slice(2);
|
||||
@@ -157,12 +338,12 @@ if (process.argv[1] && process.argv[1].endsWith('create.js')) {
|
||||
const typeIdx = args.indexOf('--type');
|
||||
const type = typeIdx >= 0 ? args[typeIdx + 1] : 'integration';
|
||||
if (!name) {
|
||||
console.error('usage: create-trek-plugin <name> [--type integration|page|widget]');
|
||||
console.error('usage: create-trek-plugin <name> [--type integration|page|widget|trip-page]');
|
||||
process.exit(2);
|
||||
}
|
||||
try {
|
||||
scaffold(name, type, process.cwd());
|
||||
console.log(`Created ${name}/ — fill in the README, build server/index.js, then \`npx trek-plugin validate ${name}\`.`);
|
||||
console.log(`Created ${name}/ — fill in the README, build server/index.js, then \`npx trek-plugin-sdk validate ${name}\`.`);
|
||||
} catch (e) {
|
||||
console.error(e instanceof Error ? e.message : e);
|
||||
process.exit(1);
|
||||
|
||||
@@ -13,6 +13,9 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import http from 'node:http';
|
||||
import { createRequire } from 'node:module';
|
||||
import * as sdk from '../index.js';
|
||||
import { injectTrekUi } from '../ui/kit.js';
|
||||
import { readJsonFile } from './json.js';
|
||||
|
||||
interface Fixtures {
|
||||
config?: Record<string, unknown>;
|
||||
@@ -24,8 +27,17 @@ interface PluginLike { onLoad?: (ctx: unknown) => unknown; routes?: PluginRouteL
|
||||
|
||||
class PermissionDenied extends Error {}
|
||||
|
||||
/**
|
||||
* Accept both bind shapes the real host accepts: spread positional args AND a
|
||||
* single array of them (better-sqlite3 binds an array; node:sqlite does not,
|
||||
* so a plugin using the array form would fail only in dev).
|
||||
*/
|
||||
function flatBind(args: unknown[]): unknown[] {
|
||||
return args.length === 1 && Array.isArray(args[0]) ? (args[0] as unknown[]) : args;
|
||||
}
|
||||
|
||||
/** A dev db backed by node:sqlite if available, else an in-memory recorder that returns []. */
|
||||
function createDevDb(dbFile: string): { db: PluginContextDb; note: string; close: () => void } {
|
||||
export function createDevDb(dbFile: string): { db: PluginContextDb; note: string; close: () => void } {
|
||||
try {
|
||||
// node:sqlite is available on Node 22.5+ (experimental). Fall back gracefully if not.
|
||||
const { DatabaseSync } = createRequire(import.meta.url)('node:sqlite') as { DatabaseSync: new (p: string) => SqliteDb };
|
||||
@@ -36,9 +48,9 @@ function createDevDb(dbFile: string): { db: PluginContextDb; note: string; close
|
||||
note: `db:own → real SQLite at ${dbFile}`,
|
||||
close: () => { try { sq.close(); } catch { /* ignore */ } },
|
||||
db: {
|
||||
async query(sql: string, ...args: unknown[]) { return sq.prepare(sql).all(...args) as unknown[]; },
|
||||
async query(sql: string, ...args: unknown[]) { return sq.prepare(sql).all(...flatBind(args)) as unknown[]; },
|
||||
async exec(sql: string, ...args: unknown[]) {
|
||||
if (args.length) { const r = sq.prepare(sql).run(...args); return { changes: Number(r.changes ?? 0) }; }
|
||||
if (args.length) { const r = sq.prepare(sql).run(...flatBind(args)); return { changes: Number(r.changes ?? 0) }; }
|
||||
sq.exec(sql); return { changes: 0 };
|
||||
},
|
||||
async migrate(id: string, sql: string) { if (applied.has(id)) return { applied: false }; sq.exec(sql); applied.add(id); return { applied: true }; },
|
||||
@@ -92,9 +104,37 @@ function createDevContext(id: string, grants: Set<string>, fx: Fixtures, db: Plu
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve `require('trek-plugin-sdk')` from THIS package, exactly like the TREK
|
||||
* child process does at runtime — so `dev` works on a fresh scaffold with no
|
||||
* npm install, and what loads here is what loads in production. That parity is
|
||||
* the point: the injected surface is the SAME minimal frozen shim the prod
|
||||
* child serves (definePlugin + PLUGIN_API_VERSION), and subpaths fail with the
|
||||
* same pointed error. validateManifest/createMockHost stay available where they
|
||||
* belong — in your tests, which load the installed package without this patch.
|
||||
*/
|
||||
let sdkInjected = false;
|
||||
export function installSdkInjection(): void {
|
||||
if (sdkInjected) return;
|
||||
const nodeModule = createRequire(import.meta.url)('node:module') as {
|
||||
_load: (request: string, parent: unknown, isMain: boolean) => unknown;
|
||||
};
|
||||
const realLoad = nodeModule._load;
|
||||
if (typeof realLoad !== 'function') return;
|
||||
sdkInjected = true;
|
||||
const shim = Object.freeze({ definePlugin: sdk.definePlugin, PLUGIN_API_VERSION: sdk.PLUGIN_API_VERSION });
|
||||
nodeModule._load = function (request: string, parent: unknown, isMain: boolean): unknown {
|
||||
if (request === 'trek-plugin-sdk') return shim;
|
||||
if (request.startsWith('trek-plugin-sdk/')) {
|
||||
throw new Error(`${request} is a build/test-time module — only 'trek-plugin-sdk' itself is injected inside TREK`);
|
||||
}
|
||||
return realLoad.call(this, request, parent, isMain);
|
||||
};
|
||||
}
|
||||
|
||||
function loadFixtures(dir: string): Fixtures {
|
||||
const p = path.join(dir, 'dev-fixtures.json');
|
||||
if (fs.existsSync(p)) { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { console.warn('warning: dev-fixtures.json is not valid JSON — ignoring'); } }
|
||||
if (fs.existsSync(p)) { try { return readJsonFile<Fixtures>(p); } catch { console.warn('warning: dev-fixtures.json is not valid JSON — ignoring'); } }
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -114,12 +154,13 @@ export async function runDev(dir: string, opts: { port?: number } = {}): Promise
|
||||
const abs = path.resolve(dir);
|
||||
const manifestPath = path.join(abs, 'trek-plugin.json');
|
||||
if (!fs.existsSync(manifestPath)) throw new Error(`no trek-plugin.json in ${abs}`);
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as Record<string, unknown>;
|
||||
const manifest = readJsonFile<Record<string, unknown>>(manifestPath);
|
||||
const id = String(manifest.id);
|
||||
const grants = new Set(Array.isArray(manifest.permissions) ? (manifest.permissions as string[]) : []);
|
||||
const fx = loadFixtures(abs);
|
||||
const { db, note: dbNote, close: closeDb } = createDevDb(path.join(abs, '.trek-dev', 'db.sqlite'));
|
||||
const broadcasts: unknown[] = [];
|
||||
installSdkInjection();
|
||||
const req = createRequire(path.join(abs, 'server', 'index.js'));
|
||||
|
||||
let plugin: PluginLike = {};
|
||||
@@ -136,7 +177,12 @@ export async function runDev(dir: string, opts: { port?: number } = {}): Promise
|
||||
await plugin.onLoad?.(ctx);
|
||||
version++;
|
||||
console.log(` ↻ loaded ${plugin.routes?.length ?? 0} route(s)`);
|
||||
} catch (e) { console.error(' ✗ plugin failed to load:', e instanceof Error ? e.message : e); }
|
||||
} catch (e) {
|
||||
// A plugin whose load/onLoad throws would fail activation in TREK — don't
|
||||
// keep serving its routes here, or dev would hide exactly that failure.
|
||||
plugin = {};
|
||||
console.error(' ✗ plugin failed to load:', e instanceof Error ? e.message : e);
|
||||
}
|
||||
};
|
||||
await load();
|
||||
|
||||
@@ -149,23 +195,36 @@ export async function runDev(dir: string, opts: { port?: number } = {}): Promise
|
||||
|
||||
async function handle(request: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
||||
const url = new URL(request.url || '/', 'http://localhost');
|
||||
const send = (status: number, body: string, type = 'text/plain; charset=utf-8', headers: Record<string, string> = {}) => {
|
||||
const send = (status: number, body: string | Buffer, type = 'text/plain; charset=utf-8', headers: Record<string, string> = {}) => {
|
||||
res.writeHead(status, { 'content-type': type, ...headers }); res.end(body);
|
||||
};
|
||||
|
||||
if (url.pathname === '/__dev/version') return send(200, String(version));
|
||||
if (url.pathname === '/') return send(200, dashboard(id, String(manifest.type), plugin.routes ?? [], dbNote, broadcasts.length), 'text/html; charset=utf-8');
|
||||
|
||||
// Faithful host preview: embeds /ui in a sandboxed opaque-origin iframe (exactly
|
||||
// like TREK) and plays the host — posts trek:context (with a theme/accent toggle),
|
||||
// proxies trek:invoke to your /api routes, and honours resize/notify/navigate. This
|
||||
// is where the design kit actually renders themed.
|
||||
if (url.pathname === '/preview' && String(manifest.type) !== 'integration') {
|
||||
return send(200, preview(id, String(manifest.type)), 'text/html; charset=utf-8');
|
||||
}
|
||||
|
||||
// Static plugin UI at /ui (page/widget client bundle)
|
||||
if (url.pathname === '/ui' || url.pathname.startsWith('/ui/')) {
|
||||
const relFile = url.pathname === '/ui' ? 'index.html' : url.pathname.slice('/ui/'.length);
|
||||
const file = path.join(abs, 'client', relFile);
|
||||
if (!file.startsWith(path.join(abs, 'client'))) return send(403, 'forbidden');
|
||||
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) return send(404, 'not found — build your client/ bundle');
|
||||
let data: Buffer | string = fs.readFileSync(file);
|
||||
const raw = fs.readFileSync(file);
|
||||
const type = contentType(file);
|
||||
if (type.startsWith('text/html')) data = String(data).replace('</body>', `${LIVE_RELOAD}</body>`);
|
||||
return send(200, data.toString(), type);
|
||||
// Only the HTML doc is rewritten (expand the `<!-- trek:ui -->` marker into the
|
||||
// design kit, then inject live-reload) as a string; every other asset is sent as
|
||||
// the raw Buffer — a UTF-8 round-trip corrupts binary files.
|
||||
const body: string | Buffer = type.startsWith('text/html')
|
||||
? injectTrekUi(String(raw)).replace('</body>', `${LIVE_RELOAD}</body>`)
|
||||
: raw;
|
||||
return send(200, body, type);
|
||||
}
|
||||
|
||||
// Plugin API routes mounted under /api/<route path>
|
||||
@@ -191,13 +250,20 @@ export async function runDev(dir: string, opts: { port?: number } = {}): Promise
|
||||
}
|
||||
|
||||
const port = opts.port ?? 4317;
|
||||
await new Promise<void>((resolve) => server.listen(port, resolve));
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', (e: NodeJS.ErrnoException) =>
|
||||
reject(e.code === 'EADDRINUSE' ? new Error(`port ${port} is already in use — pass --port <n> to pick another`) : e));
|
||||
server.listen(port, resolve);
|
||||
});
|
||||
const routes = plugin.routes ?? [];
|
||||
console.log(`\n trek-plugin dev — ${id} (${String(manifest.type)})`);
|
||||
console.log(` ${dbNote}`);
|
||||
console.log(` granted: ${[...grants].join(', ') || '(none)'}`);
|
||||
console.log(`\n ▸ http://localhost:${port}/ dashboard`);
|
||||
if (String(manifest.type) !== 'integration') console.log(` ▸ http://localhost:${port}/ui your plugin UI`);
|
||||
if (String(manifest.type) !== 'integration') {
|
||||
console.log(` ▸ http://localhost:${port}/preview themed host preview`);
|
||||
console.log(` ▸ http://localhost:${port}/ui your plugin UI (raw)`);
|
||||
}
|
||||
for (const r of routes) console.log(` ▸ ${r.method.padEnd(4)} http://localhost:${port}/api${r.path}${r.auth === false ? ' (public)' : ''}`);
|
||||
console.log('\n editing server/ or client/ hot-reloads. Ctrl+C to stop.\n');
|
||||
|
||||
@@ -231,7 +297,9 @@ function dashboard(id: string, type: string, routes: PluginRouteLike[], dbNote:
|
||||
const rows = routes.length
|
||||
? routes.map((r) => `<tr><td><code>${r.method}</code></td><td><a href="/api${r.path}">/api${r.path}</a></td><td>${r.auth === false ? 'public' : 'auth'}</td></tr>`).join('')
|
||||
: '<tr><td colspan="3" style="opacity:.6">no routes declared</td></tr>';
|
||||
const ui = type !== 'integration' ? `<p><a href="/ui">▸ open your plugin UI (/ui)</a></p>` : '';
|
||||
const ui = type !== 'integration'
|
||||
? `<p><a href="/preview">▸ open the themed host preview (/preview)</a> · <a href="/ui">raw /ui</a></p>`
|
||||
: '';
|
||||
return `<!doctype html><meta charset="utf-8"><title>${id} · trek-plugin dev</title>
|
||||
<style>body{font:15px/1.5 system-ui,sans-serif;max-width:720px;margin:3rem auto;padding:0 1rem;color:#111}
|
||||
h1{font-size:1.4rem}code{background:#f3f4f6;padding:.1em .35em;border-radius:4px}
|
||||
@@ -244,3 +312,89 @@ ${ui}
|
||||
<p class="muted">Add <code>?_anon=1</code> to a route to hit it as an unauthenticated request. Edit <code>server/</code> or <code>client/</code> and this reloads.</p>
|
||||
${LIVE_RELOAD}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A faithful host preview for a page/widget plugin. Loads /ui in a sandboxed,
|
||||
* opaque-origin iframe (no allow-same-origin — exactly TREK's isolation) and plays
|
||||
* the host over postMessage: it sends trek:context (with a theme/accent/appearance
|
||||
* toggle), proxies trek:invoke to the dev server's /api routes with the dev user,
|
||||
* and surfaces resize/notify/navigate. This is where the design kit renders themed.
|
||||
*/
|
||||
function preview(id: string, type: string): string {
|
||||
const maxW = type === 'widget' ? '440px' : '1000px';
|
||||
return `<!doctype html><html lang="en"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1"><title>${id} · preview</title>
|
||||
<style>
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;font:14px/1.5 system-ui,-apple-system,sans-serif;background:#f4f5f7;color:#111827}
|
||||
body.dark{background:#0e0e11;color:#e5e7eb}
|
||||
header{display:flex;gap:14px;align-items:center;flex-wrap:wrap;padding:12px 16px;border-bottom:1px solid #e5e7eb;position:sticky;top:0;background:inherit;z-index:2}
|
||||
body.dark header{border-color:#27272a}
|
||||
header strong{font-weight:600}.sp{flex:1}
|
||||
label{display:inline-flex;align-items:center;gap:6px;font-size:12.5px;opacity:.85}
|
||||
select,button{font:inherit;font-size:13px;padding:5px 10px;border-radius:8px;border:1px solid #d1d5db;background:#fff;color:inherit;cursor:pointer}
|
||||
body.dark select,body.dark button{background:#1c1c21;border-color:#3f3f46;color:#e5e7eb}
|
||||
.stage{padding:28px 20px;display:flex;justify-content:center}
|
||||
.wrap{width:100%;max-width:${maxW}}
|
||||
iframe{width:100%;border:0;background:transparent;min-height:120px;display:block}
|
||||
.hint{font-size:12px;opacity:.55;text-align:center;padding:0 16px 20px}
|
||||
.toast{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);background:#111827;color:#fff;padding:9px 16px;border-radius:10px;font-size:13px;opacity:0;transition:opacity .2s;pointer-events:none;z-index:3}
|
||||
.toast.on{opacity:.96}
|
||||
</style></head>
|
||||
<body>
|
||||
<header>
|
||||
<strong>${id}</strong><span style="opacity:.5">· ${type} preview</span>
|
||||
<span class="sp"></span>
|
||||
<label>Theme <select id="theme"><option value="light">light</option><option value="dark">dark</option></select></label>
|
||||
<label>Accent <select id="accent"><option value="default">default</option><option value="indigo">indigo</option><option value="teal">teal</option><option value="rose">rose</option></select></label>
|
||||
<label><input type="checkbox" id="rm"> reduce motion</label>
|
||||
<label><input type="checkbox" id="nt"> no transparency</label>
|
||||
<label><input type="checkbox" id="trip" checked> trip context</label>
|
||||
</header>
|
||||
<div class="stage"><div class="wrap"><iframe id="f" src="/ui" sandbox="allow-scripts allow-forms" referrerpolicy="no-referrer" title="${id}"></iframe></div></div>
|
||||
<p class="hint">The frame runs sandboxed at an opaque origin, exactly like TREK — <code>trek.invoke()</code> is proxied to your /api routes as the dev user.</p>
|
||||
<div class="toast" id="toast"></div>
|
||||
<script>
|
||||
var f=document.getElementById('f');
|
||||
var LA={"default":{"--accent":"#111827","--accent-text":"#fff","--accent-hover":"#1f2937","--accent-subtle":"#f1f5f9"},
|
||||
indigo:{"--accent":"#4f46e5","--accent-text":"#fff","--accent-hover":"#4338ca","--accent-subtle":"#eef2ff"},
|
||||
teal:{"--accent":"#0d9488","--accent-text":"#fff","--accent-hover":"#0f766e","--accent-subtle":"#f0fdfa"},
|
||||
rose:{"--accent":"#e11d48","--accent-text":"#fff","--accent-hover":"#be123c","--accent-subtle":"#fff1f2"}};
|
||||
var DA={"default":{"--accent":"#e4e4e7","--accent-text":"#09090b","--accent-hover":"#d4d4d8","--accent-subtle":"rgba(255,255,255,.08)"},
|
||||
indigo:{"--accent":"#818cf8","--accent-text":"#09090b","--accent-hover":"#a5b4fc","--accent-subtle":"rgba(129,140,248,.18)"},
|
||||
teal:{"--accent":"#2dd4bf","--accent-text":"#09090b","--accent-hover":"#5eead4","--accent-subtle":"rgba(45,212,191,.18)"},
|
||||
rose:{"--accent":"#fb7185","--accent-text":"#09090b","--accent-hover":"#fda4af","--accent-subtle":"rgba(251,113,133,.18)"}};
|
||||
function val(id){return document.getElementById(id);}
|
||||
function ctx(){
|
||||
var theme=val("theme").value, accent=val("accent").value;
|
||||
document.body.classList.toggle("dark", theme==="dark");
|
||||
var tokens={}; var src=theme==="dark"?DA[accent]:LA[accent]; for(var k in src){tokens[k]=src[k];}
|
||||
return {type:"trek:context",theme:theme,locale:"en",hostOrigin:location.origin,
|
||||
tripId: val("trip").checked?42:null, userId:"1",
|
||||
user:{name:"Dev User",avatar:null,isAdmin:true},
|
||||
appearance:{scheme:accent,density:"comfortable",reducedMotion:val("rm").checked,noTransparency:val("nt").checked},
|
||||
formats:{locale:"en",currency:"EUR",timeFormat:"24h",distanceUnit:"metric",temperatureUnit:"celsius",timezone:Intl.DateTimeFormat().resolvedOptions().timeZone},
|
||||
tokens:tokens};
|
||||
}
|
||||
function postCtx(){ if(f.contentWindow) f.contentWindow.postMessage(ctx(),"*"); }
|
||||
var tt; function toast(msg){var el=document.getElementById("toast");el.textContent=msg;el.classList.add("on");clearTimeout(tt);tt=setTimeout(function(){el.classList.remove("on");},2200);}
|
||||
window.addEventListener("message", function(ev){
|
||||
if(ev.source!==f.contentWindow) return;
|
||||
var m=ev.data; if(!m||typeof m!=="object") return;
|
||||
if(m.type==="trek:ready"||m.type==="trek:context:request"){ postCtx(); }
|
||||
else if(m.type==="trek:resize"){ if(m.height>0) f.style.height=Math.min(m.height,2000)+"px"; }
|
||||
else if(m.type==="trek:notify"){ toast((m.level?("["+m.level+"] "):"")+(m.message||"")); }
|
||||
else if(m.type==="trek:navigate"){ toast("navigate \\u2192 "+m.to); }
|
||||
else if(m.type==="trek:invoke"){
|
||||
fetch("/api"+m.sub,{method:m.method||"GET",headers:{"content-type":"application/json"},body:m.body!=null?JSON.stringify(m.body):undefined})
|
||||
.then(function(r){var ct=r.headers.get("content-type")||"";return (ct.indexOf("json")>=0?r.json():r.text());})
|
||||
.then(function(data){ f.contentWindow.postMessage({type:"trek:response",requestId:m.requestId,data:data},"*"); })
|
||||
.catch(function(e){ f.contentWindow.postMessage({type:"trek:error",requestId:m.requestId,code:"error",message:String(e&&e.message||e)},"*"); });
|
||||
}
|
||||
});
|
||||
["theme","accent","rm","nt","trip"].forEach(function(id){ val(id).addEventListener("change",postCtx); });
|
||||
f.addEventListener("load", function(){ f.style.height="120px"; postCtx(); });
|
||||
var __v; setInterval(function(){ fetch("/__dev/version").then(function(r){return r.text();}).then(function(v){ if(__v&&v!==__v){ f.src=f.src; } __v=v; }).catch(function(){}); },1000);
|
||||
</script>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { loadPrivateKey, signArtifact, publicKeyBase64 } from './sign.js';
|
||||
import { readJsonFile } from './json.js';
|
||||
|
||||
interface Version {
|
||||
version: string; gitTag: string; commitSha: string; downloadUrl: string;
|
||||
@@ -47,7 +48,7 @@ export function buildEntry(opts: {
|
||||
/** Optional Ed25519 private-key file — signs the artifact and pins the author key. */
|
||||
signKeyPath?: string;
|
||||
}): Entry {
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(opts.dir, 'trek-plugin.json'), 'utf8')) as Record<string, unknown>;
|
||||
const manifest = readJsonFile<Record<string, unknown>>(path.join(opts.dir, 'trek-plugin.json'));
|
||||
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(opts.repo)) throw new Error(`--repo must be "owner/name", got "${opts.repo}"`);
|
||||
const minTrek = minTrekFrom(manifest.trek);
|
||||
if (!minTrek) throw new Error('manifest has no "trek" version range to derive minTrekVersion from (e.g. "trek": ">=3.2.0 <4.0.0")');
|
||||
@@ -77,7 +78,7 @@ export function buildEntry(opts: {
|
||||
}
|
||||
|
||||
if (opts.mergePath) {
|
||||
const existing = JSON.parse(fs.readFileSync(opts.mergePath, 'utf8')) as Entry;
|
||||
const existing = readJsonFile<Entry>(opts.mergePath);
|
||||
if (authorPublicKey && existing.authorPublicKey && existing.authorPublicKey !== authorPublicKey) {
|
||||
throw new Error('this signing key differs from the one already published for this plugin — TREK would reject the update. Use the original key.');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import fs from 'node:fs';
|
||||
|
||||
/**
|
||||
* Read + parse a JSON file, tolerating a UTF-8 BOM (0xFEFF) — Windows editors
|
||||
* love to add one, and a bare JSON.parse then fails with a cryptic
|
||||
* "Unexpected token" that points at an invisible character.
|
||||
*/
|
||||
export function readJsonFile<T = unknown>(p: string): T {
|
||||
const text = fs.readFileSync(p, 'utf8');
|
||||
return JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text) as T;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* The top-level menu shown when `trek-plugin-sdk` is run with no command in a
|
||||
* terminal (#plugins). It only picks WHICH command to run — the dispatcher then
|
||||
* runs it through the same interactive path a named command would take, so each
|
||||
* command still prompts for whatever it needs.
|
||||
*/
|
||||
import { intro, outro, promptSelect } from './ui.js';
|
||||
|
||||
export interface MenuItem {
|
||||
value: string;
|
||||
label: string;
|
||||
hint: string;
|
||||
}
|
||||
|
||||
/** The everyday author journey. `advanced`/`exit` are control entries. */
|
||||
export const PRIMARY_MENU: MenuItem[] = [
|
||||
{ value: 'create', label: 'Create a plugin', hint: 'Scaffold a new plugin' },
|
||||
{ value: 'dev', label: 'Run the dev server', hint: 'Live-reload your plugin locally' },
|
||||
{ value: 'validate', label: 'Validate', hint: 'Check the manifest + layout' },
|
||||
{ value: 'pack', label: 'Pack', hint: 'Build plugin.zip' },
|
||||
{ value: 'publish', label: 'Publish', hint: 'Release + open the registry PR' },
|
||||
{ value: 'advanced', label: 'Advanced…', hint: 'Signing, registry entry, preflight' },
|
||||
{ value: 'exit', label: 'Exit', hint: '' },
|
||||
];
|
||||
|
||||
/** The less-common signing/registry commands, behind the `advanced` entry. */
|
||||
export const ADVANCED_MENU: MenuItem[] = [
|
||||
{ value: 'keygen', label: 'Generate a signing key', hint: 'Create an Ed25519 signing key' },
|
||||
{ value: 'sign', label: 'Sign an artifact', hint: 'Print a signature + public key' },
|
||||
{ value: 'entry', label: 'Registry entry', hint: 'Print the ready-to-PR registry JSON' },
|
||||
{ value: 'preflight', label: 'Preflight', hint: 'Run the registry CI checks locally' },
|
||||
{ value: 'submit', label: 'Submit', hint: 'Open the registry PR' },
|
||||
{ value: 'release', label: 'Release', hint: 'Pack → GitHub release → print entry' },
|
||||
{ value: 'back', label: '← Back', hint: '' },
|
||||
];
|
||||
|
||||
/** Every value the menu can yield, control entries included. */
|
||||
const MENU_VALUES = new Set([...PRIMARY_MENU, ...ADVANCED_MENU].map((m) => m.value));
|
||||
|
||||
/** Pure: a menu value maps to itself when known, else `undefined`. Unit-tested. */
|
||||
export function resolveMenuChoice(value: string): string | undefined {
|
||||
return MENU_VALUES.has(value) ? value : undefined;
|
||||
}
|
||||
|
||||
const toOptions = (items: MenuItem[]) =>
|
||||
items.map(({ value, label, hint }) => (hint ? { value, label, hint } : { value, label }));
|
||||
|
||||
/**
|
||||
* Show the menu and return the chosen command name for the dispatcher to run,
|
||||
* or `null` if the user chose Exit. Loops back from the Advanced submenu.
|
||||
*/
|
||||
export async function runMenu(): Promise<string | null> {
|
||||
intro('trek-plugin-sdk');
|
||||
for (;;) {
|
||||
const choice = await promptSelect<string>({
|
||||
message: 'What would you like to do?',
|
||||
options: toOptions(PRIMARY_MENU),
|
||||
});
|
||||
if (choice === 'exit') {
|
||||
outro('Nothing to do — bye!');
|
||||
return null;
|
||||
}
|
||||
if (choice === 'advanced') {
|
||||
const adv = await promptSelect<string>({ message: 'Advanced commands', options: toOptions(ADVANCED_MENU) });
|
||||
if (adv === 'back') continue;
|
||||
return adv;
|
||||
}
|
||||
return choice;
|
||||
}
|
||||
}
|
||||