mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 14:06:27 +00:00
3.2.1 (#1433)
* fix(plugins): prevent arbitrary api access * fix(planner): disable drag & drop on mobile so the places list scrolls (#1432) On touch devices the draggable rows hijack the scroll gesture, so dragging to scroll started an HTML5 drag and popped up the file-import overlay instead of scrolling. Gate the draggable rows and the sidebar file-drop handlers on !isMobile across the places sidebar and the day plan (places, transports, notes), and hide the grip handle — the arrow reorder buttons take over there. * fix(inspector): make the remove-from-day button icon-only on mobile * fix(collections): show the save picker above the mobile place detail * fix(plugins): seal IPC parent/child for good * test(inspector): match the icon-only remove-from-day button * feat(sdk): switch plain ts for clack/prompts interactive session * feat(plugins): force-refresh the registry from the rescan button The registry is cached for 30 min server-side and GitHub serves it with a 5-min CDN cache, so a freshly published plugin could take up to ~35 min to appear. The rescan/reload button now force-pulls the registry: it bypasses the in-memory cache and appends a cache-buster + no-cache headers to beat the CDN, and refreshes the browse grid immediately. * feat(sdk): bump plugin version * fix(stored settings): prevent local storage drop when update not successful * feat(plugins): sideload plugins by uploading a .zip Adds an admin "Upload plugin" button + drag-and-drop to the plugins panel for installing a plugin archive directly — handy for testing a build before it goes to the registry. It reuses the registry install pipeline (slip/bomb-safe extract, strict manifest validation, native-binary scan) via a new POST /admin/plugins/upload, and only skips the registry sha256/signature checks that a sideload can't have. Sideloaded plugins are flagged (source "local:upload", a "Sideloaded" badge, no GitHub link, no auto-update) and always land INACTIVE — replacing a running or active plugin stops it and clears the active flag first, so new code never runs without a fresh activation + permission consent. * fix(planner): keep the day-plan collapse state after fully closing the page The expanded/collapsed days were stored in sessionStorage, which survives a reload but is wiped when the tab or window is closed — so every fresh open re-expanded all days, which is tedious to re-collapse on long (10+ day) trips. Store it in localStorage instead so a collapsed layout sticks until it's changed. * fix(i18n): correct Vietnamese translation of 'Disabled' (#1438) 'Tàn tật' means physically handicapped/disabled-person, not the off/disabled state of a toggle. Replace with 'Tắt' (off), matching the existing 'admin.plugins.stateOff' translation. Affects admin.notifications.none and admin.addons.disabled. * add the code of conduct * fix(plugins): let widgets follow the in-app dark-mode toggle The plugin frame is sandboxed at an opaque origin (no parent DOM access) and we only sent the context — including the theme — once, on trek:ready. So toggling dark mode in TREK left already-mounted widgets on the old theme until a reload. Watch the <html> `dark` class and re-post the context when it flips; plugins already re-apply the theme on trek:context. * fix(plugins): deliver widget context on load so the theme is right on first paint * fix(plugins): give widget cards the native glassy look and auto-height Widget plugins rendered in a plain card with a fixed 180px body, so they looked foreign next to the glassy dashboard tools and taller widgets had their controls clipped. Mirror the native `.tool` surface (glass background/border/blur, uppercase title) and let the body grow to the height the widget reports over trek:resize. * feat(plugins): add read/rwite costs * feat(plugin): better readme/index.js * feat(plugin): better readme/index.js * feat(plugins): hand widgets TREK's theme tokens, formats and display identity Extends trek:context with a non-secret `tokens` map (TREK's resolved CSS design tokens for the current theme), `formats` (currency/date/units/timezone) and a `user` display object (name/avatar/isAdmin — never the email, role only as a boolean). Re-sent on every theme toggle. A widget can now apply the tokens and match the host exactly, in both themes and under a custom appearance, instead of hard-coding a palette that drifts — so plugins feel native, not bolted-on. * feat(plugins): hand plugins the full palette and appearance state The theme context only carried a ~19-token subset read off <html> and only followed the dark-mode toggle. Widen it to the whole global (:root/.dark) palette — surfaces, text, borders, the accent family, semantic + soft fills, shadows, radii and fonts — so a plugin tracks the user's chosen accent scheme, custom accent and high-contrast live, not just light/dark. Also send an `appearance` block (scheme, density, reduced-motion, no-transparency) mirrored from the attributes applyAppearance writes on <html>, and re-post the context whenever any of those actually change (a small signature dedupes unrelated mutations) so plugins restyle in step with the app. * feat(plugins): ship a design kit so plugin UIs look native A plugin's UI is a sandboxed, opaque-origin iframe that can't load TREK's stylesheet — so authors had to re-derive the whole look by hand, and most didn't. Ship it instead: a token-driven stylesheet (glass, hover, buttons, inputs, chips, rows) that consumes the tokens the host already sends and swaps light/dark, plus a small bootstrap that applies those tokens, mirrors the appearance flags, auto-reports the frame height and exposes a `window.trek` helper over the existing bridge. Both are plain strings meant to be inlined (the CSP forbids external assets for an opaque frame); `injectTrekUi` expands a `<!-- trek:ui -->` marker. No new capability — only a native look. * feat(plugins): deliver the design kit — native scaffold + inline on dev/pack A new page/widget scaffolds a native, glassy starter that talks over window.trek. The source keeps a single `<!-- trek:ui -->` line; `dev` (when it serves /ui) and `pack` (as the file enters the archive) expand it into the inlined kit — so the file stays a one-line opt-in and a rebuild always ships the current kit. Existing plugins opt in the same way, by dropping the marker. * feat(plugins): faithful themed host preview in dev `dev` served the plugin UI raw at /ui — top-level, with no host — so the theme, context and bridge never fired and authors couldn't see the design kit render. Add /preview: it embeds /ui in a sandboxed opaque-origin iframe (exactly TREK's isolation) and plays the host — posts trek:context with a theme/accent/appearance toggle, proxies trek:invoke to your /api routes as the dev user, and surfaces resize/notify/navigate. /ui stays as the raw doc for debugging. * docs(plugins): document the design kit, window.trek and the token contract Rewrite the client section of the Plugin Development wiki kit-first: the `<!-- trek:ui -->` marker, the component classes, the `window.trek` bridge, the `/preview` host preview, the full `trek:context` payload (now the whole palette plus an `appearance` block) and how to apply tokens by hand. Add a "Build a native UI" section + the new exports to the SDK README. * feat(budget): add 'Outstanding amount' card * fix(translations): finish translating new keys * feat(plugins): trip-page plugins — a plugin tab inside every trip Adds a `trip-page` plugin type whose sandboxed iframe mounts as a tab in the trip planner (Plan / Transports / … / <plugin>), scoped to the open trip, with no dashboard nav entry. This is the most-asked planner-extension request from discussion #1429 (a plugin that lives in the trip, e.g. SimMesg20's budget planner). It reuses PluginFrame and the existing tab system — the frame already receives the current tripId over trek:context — so there is no bridge or security change: only the manifest type enum (server + SDK), the client feed classification (pluginStore.tripPages), and one render branch in the planner. The SDK scaffolds it with `create --type trip-page`. * fix(apple wallet): support for .pkpasses * feat(plugins): permission-gated write APIs for the planner (#1429) Plugins can now WRITE core planner data, not just read it, through curated, membership-checked methods — so downstream features can live in plugins instead of long-lived core patches. Four new scopes: db:write:places (create/update/delete places), db:write:days (days), db:write:itinerary (assign/unassign a place on a day) and db:write:trips (update trip fields). Each ctx method mirrors costs.create: it validates the input against the SAME @trek/shared schema the web app uses, binds the acting user host-side (a job/onLoad has none, so its writes are refused), checks trip access AND the app's edit permission (place_edit / day_edit / trip_edit), delegates to the real services, broadcasts the same events so open sessions update live, and records the write in the tamper-evident capability audit. No new route, no sandbox or CSP change — the isolation boundary is unchanged; a plugin can only change what its user could change by hand. Consent UI + permission labels in all 22 locales, SDK types + mock host, and the wikis are updated. * docs(plugin): ensure wiki correctness * feat(plugins): plugin metadata on core entities — db:meta (#1429) Plugins can now attach their OWN namespaced key/value data to a trip, place or day without forking the core schema (#1429, request 2). New `db:meta` scope + `ctx.meta.get/set/list/delete`. Storage is one plugin_entity_metadata table (migration 161) keyed (plugin_id, entity_type, entity_id, key) — a plugin only ever sees its own rows. Every call is membership-checked: the entity must belong to a trip the host-bound acting user can access. Quotas guard the shared volume (≤64KB per value, ≤100 keys per entity); rows are purged on uninstall-with-delete-data and recorded in the capability audit. SDK types + mock host, a consent chip + labels in all 22 locales, and the wikis. No new route, no sandbox change. * feat(plugins): place-detail plugin slot in the trip planner (#1429) A widget plugin can declare `capabilities.widget.slot: 'place-detail'` to mount its sandboxed frame inside the trip planner's place-detail panel, scoped to the open place — the frame receives the `placeId` in trek:context alongside the tripId. This is the UI half of the place-detail-providers ask (reviews/ratings/popular times shown on a place). It reuses the existing widget mechanism: PluginFrame gains an optional placeId, the feed/store learn the new slot, and PlaceInspector renders the slot at the foot of its body in trip mode. Admin chip + label in all 22 locales, wiki updated. No sandbox or permission change. * fix(plugins): green the server tests + harden the new capability surface The in-memory uninstall fixture was missing the new plugin_entity_metadata table, so uninstall's DELETE threw "no such table" and failed the server test job. Add the table to the fixture schema. Self-review hardening of the write/metadata surface: - trips.update now reproduces the web UI's per-field gate: is_archived needs trip_archive and cover_image needs trip_cover_upload, not just trip_edit — so a member who may only edit can't archive or re-cover a trip. - Plugin metadata WRITES now also require the entity's edit permission (place_edit/day_edit/trip_edit), not just trip access, so a read-only member can't overwrite or delete metadata another user created. Reads stay access-gated. - Cap the metadata key length (<=256 chars) alongside the value/count quotas — the key was attacker-controlled and uncapped, defeating the disk-DoS guard. * test(plugins): cover the new write/metadata deps to hold the coverage gate The new create-rpc-host write + metadata deps were untested, dropping the src/nest branch coverage below the 80% gate. Add a seeded in-memory core db plus mocked core services to exercise every dep end-to-end: places/days/itinerary create/update/delete + not-found paths, trips.update with the archive/cover per-field gates and the Validation/NotFound/unknown-error mapping, metadata CRUD + key/value/count caps + access checks, the costs deps, and users.getById scoping. Plus rpc-host cases for meta writes on place/day and a no-acting-user refusal. Tests only — no production code change. * fix(costs): freeze FX on every cost + settlement write path (#1445) Settled foreign-currency costs kept re-opening with a few-cent residual when live rates drifted. The #1335 freeze only ran on the REST create/ update path, so two gaps remained: - Foreign-currency items created via MCP create_budget_item or booking- import bypassed the freeze and stored exchange_rate = 1, so settlement re-converted them with live rates. Promote freezeForeignRate into the shared budgetService and call it from every write path. - Settle-up transfers were stored currency-less and re-converted with live rates on each recompute. Add currency + exchange_rate to budget_settlements (migration), freeze the display-currency rate at settle time, and convert with it in calculateSettlement. Legacy rows (currency = NULL / rate = 1) keep live-rate behaviour until re-edited. Also expose guarded cost update/delete to plugins: costs.update and costs.delete under db:write:costs, gated exactly like costs.create (addon + trip access + the acting user's budget_edit permission). updateCost reuses BudgetService.update so a plugin write re-freezes the FX rate too; both broadcast the same budget:updated / budget:deleted events the REST controller emits. Wired through the host, the runtime SDK context and the published trek-plugin-sdk (types + mock host). * feat(plugins): provider hooks — placeDetailProvider, wired (#1429) Turn "hooks" from a declared-but-dead surface into a real host→plugin capability. Add an invoke.hook branch to the child + a supervisor hook registry (providersOf) + PluginRuntimeService.invokeHook, reusing the existing invoke transport and its timeout (a short 5s deadline so a slow provider can't delay a response; a job/onLoad has no user, host-bound as ever). Also fixes a real bug: the in-repo runtime SDK copy was missing the `hooks` field entirely and could not even parse a plugin that declared one — synced it with the published SDK. The first wired hook is placeDetailProvider: a plugin returns extra rows ({label,value?,url?}) for a place, and TREK renders them natively at the foot of the place-detail panel. Consumer is a new, additive, fail-safe endpoint GET /api/place-details/:placeId (membership-checked; any provider that errors or times out is simply skipped — it never breaks the panel). New hook:place-detail- provider scope + consent chip in all 22 locales. SDK types (both copies), a controller test, and the wiki. photoProvider/calendarSource stay reserved but the transport now exists for them. No sandbox or CSP change. * fix(files): handle pkpass in booking uploads and files-tab open (#1447, #1448) Both bugs were client-only; the server already allows .pkpass and serves it as application/vnd.apple.pkpass. #1448: the reservation/transport attachment inputs hard-coded an accept list that omitted pkpass, so macOS grayed it out. Add .pkpass/.pkpasses (+ wallet MIME types) to the accept attribute in both modals. #1447: the files-tab open path routed every non-media/non-markdown file into the in-app PDF preview object. Add isWalletPass() and route wallet passes through the shared blob openFile helper (as bookings already do), which downloads them so the OS hands them to Apple Wallet. * feat(plugins): validation/warning contributions via warningProvider hook (#1429) Second wired provider hook, reusing the invoke.hook infra from the last commit. A plugin implements warningProvider.getWarnings(tripId, ctx) → {level, message, dayId?, placeId?}[] to flag problems on a trip (overpacked day, place closed on its planned date, missing booking, …). TREK surfaces them as a non-blocking overlay banner at the top of the trip planner (the wrapper ignores pointer events so it never covers the map/panels; only the pills are interactive). Consumer is a new additive, fail-safe endpoint GET /api/trip-warnings/:tripId (membership-checked; a provider that errors or times out contributes nothing and never blocks the planner). New hook:trip-warning-provider scope + consent chip in all 22 locales, SDK types (both copies), a controller test, and the wiki. This is the validation half of the scheduling+validation block; feeding durations/travel times back into core recalculation stays out (it would touch core planner computation — deliberately deferred to keep the no-breaking-changes guarantee). * fix(plugins): enforce the hook:* grant on provider dispatch (#1429 audit) The adversarial audit of the #1429 additions found one real (medium) gap: the hook:* permission was never enforced at runtime. providersOf() selected provider plugins purely by the hooks their CODE declares (sup.hooks, reported by the child as Object.keys(def.hooks)) and never intersected that with sup.granted — so a plugin that merely implemented placeDetailProvider/warningProvider got wired in as a provider even when the admin never consented to hook:place-detail-provider / hook:trip-warning-provider. The downstream capability router still held (the hook's ctx can only do what the plugin's OTHER grants allow), but a plugin could obtain an auto-triggered, user-bound execution context on a passive UI browse without the hook being consented — a consent-integrity gap that contradicts the documented invariant. Gate it host-side: a hookName→permission map, and providersOf now returns a plugin only if it is active, implements the hook, AND holds the matching hook:* grant. An unmapped hook resolves to nobody. invokeHook additionally re-checks membership in providersOf (defense-in-depth against a direct caller). Unit test proves the grant/implements/active intersection. * docs(plugins): add the Plugin Cookbook + a trip-doctor example (#1429 eco) Fosters plugin authoring by turning the new #1429 capabilities into copy-paste recipes. New wiki page Plugin-Cookbook (read a trip, write to the itinerary, tag an entity with metadata, contribute native place details, raise trip warnings, broadcast, match the TREK look) linked in the sidebar, plus a complete runnable example — trip-doctor — a hooks-only plugin that showcases warningProvider + placeDetailProvider + ctx.meta with zero UI of its own. Manifest validates against the SDK. Docs/example only; no product code. * fix(collections): don't reset saved-place status to 'idea' on edit (#1437) The update schema reused collectionStatusSchema, whose .default('idea') survives .optional() — so a PATCH that omits status had 'idea' injected by the validation pipe and written to the DB, clobbering 'want'/'visited'. Strip the default on the update field with .removeDefault(), keeping the .catch guard. Add a shared schema regression test and an e2e round-trip. * docs(plugin): ensure plugin scopes are the same everywhere * feat(plugins): read scopes for packing + files (#1429 eco) Extend the read side of the capability model beyond trips/costs: db:read:packing → ctx.packing.list(tripId) and db:read:files → ctx.files.list(tripId). Both mirror the existing trip reads exactly — the host membership-checks the trip against the invocation's user (tripRead) before delegating to the same packingService/ fileService the REST paths use (so bags/assignees hydrate and trash is excluded), and each is a separate scope (packing doesn't unlock files). ctx types in both SDK copies + mock-host, consent labels + cap chips in all 22 locales, rpc-host + create-rpc-host tests, and the wiki (perm table + cookbook recipe). * feat(plugins): core event subscriptions (#1429 eco) A plugin can react to core activity by declaring events: [{ on, handler }] + the events:subscribe grant. websocket.broadcast announces every CORE trip event (name + tripId ONLY, never the payload) through a tiny dependency-free relay (plugin-event-sink); the runtime registers a sink in onModuleInit and the supervisor fans each event out to subscribed, granted, active plugins via a fire-and-forget invoke.event on a short timeout — so a slow subscriber can never block a core write. Safety by construction: handlers run with NO user (like a job) so trip reads are refused — they react to the fact, using the plugin's own ctx.db/ws/outbound; the grant is enforced host-side (deliverEvent checks events:subscribe); plugin:* re- broadcasts are never delivered back, so handlers can't loop; and only the event name + tripId cross the boundary. SDK types (both copies), consent label + cap chip in all 22 locales, supervisor gating + broadcast-tap tests, and the wiki + cookbook. The relay lives in its own module (not websocket) so it doesn't drag `ws` into the runtime and tests that mock ./websocket don't strip the sink. * feat(plugin-sdk): typed ctx returns + native trek.ui DOM helpers (#1429 eco) Two author-DX wins, SDK-only. Typed reads/writes: ctx.trips.getById/getPlaces/getReservations, packing.list, files.list, costs.*, places/days/itinerary writes and users.getById now return proper entity types (Trip, Place, Day, Reservation, PackingItem, TripFile, BudgetItem, Assignment, User) instead of unknown — real autocomplete for authors. Only `id` is guaranteed and every shape keeps an index signature, so it mirrors the raw DB row honestly (no column hidden, no false guarantees). mock-host matches. Native UI helpers: window.trek now carries `trek.ui` — a tiny bundler-free DOM builder (el/button/card/chip/input/mount) that emits the kit's trek-* classes, so a widget builds themed UI with no CSS and no build step. Ships inlined via the same <!-- trek:ui --> marker. Wiki updated. * fix(plugins): scope packing.list to the acting user's #858 visibility (eco audit) The final eco audit found one real (medium) gap: the db:read:packing delegate called packingService.listItems(tripId) with NO userId, which takes the UNFILTERED branch and returns every member's private (is_private=1) packing items — leaking another member's personal/surprise-gift items to a plugin the normal UI/REST hides them from. The handler had the host-bound acting user but dropped it when delegating. Thread it through: tripRead now hands the membership-checked userId to the read callback, packing.list forwards it to listPackingItems(tripId, userId), and the service applies its three-tier #858 filter — a plugin now sees exactly what its user sees. files.list is unaffected (no per-user file visibility). Tests assert the user is passed. The other three audited surfaces (event subscriptions, trek.ui, and the regression sweep of the capability boundary) were clean. * security(plugins): prevent open redirect * fix(plugins): resolve PR #1433 full-audit findings (code + tests) The comprehensive PR audit confirmed 21 findings; this fixes the code/test ones I own: - ctx.users.getById was DEAD: the runtime SDK omitted the _inv tag, so actingUser never bound and every call hit RESOURCE_FORBIDDEN. Add _inv (the test had codified the bug — corrected). - Plugin place writes bypassed the REST STRING_LIMITS (a 100k-char name the web app rejects). Mirror the caps (name 200 / description 2000 / address 500 / notes 2000). - packing.list / files.list were missing from the capability audit log while every other core read is audited — add them to isAuditable + auditResource. - SDK lockstep: CalendarSource.getEvents drifted (published Date vs runtime string); the host->plugin boundary is JSON, so align both to string. - Admin panel didn't know the new trip-page plugin type (unlocalised badge, missing filter) — add it to KNOWN_TYPES + the type filter + a 22-locale label. - Tests for previously-uncovered paths: the child-side invoke.hook/invoke.event dispatch (real fork, hook + event + non-matching-subscription), and invokeHook's defense-in-depth grant re-check. Julien's settlement-FX-refreeze finding is his budget code (flagged, not touched). * docs(plugins): correct the wiki against the shipped capability surface (#1433 audit) Fixes the 11 doc findings from the PR audit — every corrected claim was cross-checked against the code: - Plugin-Development: CSP connect-src is built from granted http:outbound:<host>, not egress[]; dropped the stale "costs.create is the first and only core mutation"; documented costs.update/delete + ctx.packing/ctx.files; the manifest permission table gained the six missing scopes (db:write:places/days/itinerary/trips, db:meta, hook:trip-warning-provider); the widget slot table gained place-detail. - Plugin-Cookbook: days.create no longer passes a title the schema drops; broadcastToUser uses the real (userId, event, data) signature; fixed the broken #the-trek-ui-design-kit anchor and noted window.trek.ui. - Plugin-Permissions: added db:read:packing, db:read:files and events:subscribe; the provider hooks are implemented in `hooks: {...}` on the definition, not on ctx. * fix(unsplash) allow api key usage * fix(guests): scope guest display names per-trip, not globally (#1446) A guest is a per-trip person, but their name lived in the globally UNIQUE users.username, so uniqueGuestUsername() auto-renamed a second "Jake" (on any other trip) to "Jake 2". Add a non-unique users.display_name: a guest now stores the human name there and gets a uuid-based username that is never shown, and every member view (members list, day-assignment participants, budget members/payers, packing recipients/contributors/bags/assignees) COALESCEs display_name over username. Rename updates display_name with no dedup. Real users are unchanged (display_name NULL → COALESCE falls through to username). Migration adds the nullable column; existing guests keep their current username via the COALESCE fallback. This also unblocks ctx.users.getById (the audit's #4 fix), whose projection selects display_name. Tests: two "Jake" guests on two trips both keep the name; the two codified-the-old-behaviour guest tests corrected. * fix(costs): don't re-freeze a settlement's FX rate on an unrelated edit (#1445) The full audit found that updateSettlement called freezeForeignRate without the "currency unchanged" guard the item path has, so any edit of a foreign-currency settlement (e.g. correcting from/to) re-fetched the LIVE rate and overwrote the frozen one — re-opening an already-balanced position with a small residual, the exact drift #1445 was meant to prevent. freezeForeignRate's unchanged-check was item-centric (it queried budget_items), which a settlement (a different table) can't use. Give it an explicit existingCurrency param; updateSettlement now reads the settlement's stored currency and passes it, so an edit that doesn't change the currency keeps the frozen rate (the service UPDATE already preserves exchange_rate when it's left unset). Tests cover both: unchanged currency keeps the rate, a real currency change re-freezes. * feat(plugins): add inter plugin dependency support and addon dependency support * feat(plugins): add inter plugin dependency support and addon dependency support * docs(plugins) inter dependencies --------- Co-authored-by: Maurice <mauriceboe@icloud.com> Co-authored-by: trongbinhnguyen <43725147+trongbinh15@users.noreply.github.com>
This commit is contained in:
@@ -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">
|
||||
@@ -160,6 +229,8 @@ export default function AdminPluginsPanel() {
|
||||
// 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.
|
||||
@@ -168,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[] }) => {
|
||||
@@ -199,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)
|
||||
@@ -211,23 +324,63 @@ 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 — the server answers 409 CONSENT_REQUIRED and we
|
||||
// route through the same consent dialog the Update button uses.
|
||||
// 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)
|
||||
adminApi.pluginActivate(p.id)
|
||||
.then(() => toast.success(t('admin.plugins.activated')))
|
||||
.catch((e: { response?: { status?: number; data?: { code?: string; error?: string; newPermissions?: string[]; newEgress?: string[] } } }) => {
|
||||
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 {
|
||||
toast.error(d?.error || t('admin.plugins.actionError'))
|
||||
}
|
||||
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() })
|
||||
}
|
||||
|
||||
@@ -285,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 */}
|
||||
@@ -324,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]">
|
||||
@@ -344,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)} />
|
||||
@@ -363,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>
|
||||
@@ -462,6 +637,14 @@ export default function AdminPluginsPanel() {
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -541,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">
|
||||
@@ -557,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 ? (
|
||||
@@ -573,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">
|
||||
@@ -597,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')}
|
||||
@@ -875,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 }) {
|
||||
|
||||
@@ -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,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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -2005,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))
|
||||
@@ -2056,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>
|
||||
@@ -2173,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 => {
|
||||
@@ -2242,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} />);
|
||||
|
||||
@@ -946,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,7 +93,7 @@ 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
|
||||
@@ -48,22 +108,49 @@ export default function PluginFrame({ pluginId, tripId = null, className, title
|
||||
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.
|
||||
@@ -109,14 +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`}
|
||||
onLoad={() => { loadsRef.current += 1 }}
|
||||
// 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 => {
|
||||
|
||||
Reference in New Issue
Block a user