Compare commits

..

24 Commits

Author SHA1 Message Date
jubnl a63e16fb65 fix(airtrail): don't use cabin class as seat on import
When an AirTrail flight has a cabin class but no seat number, the mapper
fell back to the class for metadata.seat, so reservations showed e.g.
"economy" as the seat. Use only the seat number; leave the seat blank
otherwise. The class is still surfaced separately in the import picker.

Closes #1246
2026-06-18 14:12:41 +02:00
jubnl dfe98a057c chore(prettier) prettier this file 2026-06-18 14:05:19 +02:00
jubnl d5850041a7 fix(costs): rework the cost panel UX wise and apply prettier on the shared package 2026-06-18 13:59:10 +02:00
jubnl ad6e1ddcc8 fix(airtrail): add back missing tests 2026-06-18 10:04:33 +02:00
jubnl 66f661e2a1 fix(airtrail): gate airtrail update behind a user setting, on airtrail update: rebuild payload from fresh data to prevent any data loss 2026-06-18 09:59:14 +02:00
Maurice 17b4f72be6 fix(dashboard): never crash on a malformed reservation date
A reservation with an invalid date blanked the whole My Trips page: the old
Upcoming widget did new Date(value).toISOString(), which throws "Invalid time
value" (fixed in #1222 by reading the string parts). Also guard splitDate so a
bad date renders a dash instead of "Invalid Date" or throwing.
2026-06-17 23:26:59 +02:00
Maurice 7aefeb4c53 fix(atlas): give every sub-national region a distinct code (#1217)
geoBoundaries fills shapeISO with the bare country code for some countries (every
Spanish region got "ESP", every Chinese "CHN", also Chile/Oman), so marking one
region lit up the whole country. build-atlas-geo.mjs now keeps shapeISO only when
it is a real "XX-..." subdivision code and otherwise synthesizes a unique
per-country id from the region name. Regenerated admin1.geojson.gz: Spain/China/
Chile/Oman now carry distinct region codes (countries with real codes, e.g.
Germany, are unchanged).
2026-06-17 23:19:51 +02:00
Maurice 63fb5a9c89 feat(admin): let admins set a default currency for new users
Adds a currency picker to Admin > User Defaults. Stored as the default_currency
user-default, so users who have not picked their own currency inherit it in
Costs.
2026-06-17 23:12:30 +02:00
Maurice 17245c5a8c fix(atlas): keep the continent breakdown in sync on mark/unmark (#1225)
The optimistic mark/unmark updates bumped the country total but never the
per-continent counts, so the continent column froze until a full reload. Move
the country to continent map into @trek/shared (single source for server and
client) and adjust the matching continent count at every optimistic site: the
country confirm flow plus the choose / region mark and region unmark handlers.
2026-06-17 23:12:30 +02:00
Maurice 6ab4989c38 fix(planner): let a booking's day follow its date when edited (#1237)
Preserving the old day_id on edit left a re-dated booking on its previous start
day while end_day_id followed the new date, so it spanned both. Stop sending
day_id from the edit modal entirely - the server derives both ends from the
booking's date (and keeps the current day when there is no date), so a re-dated
booking moves cleanly to the matching day.
2026-06-17 22:38:58 +02:00
Maurice ea7f7fd9f3 fix(planner): derive a booking day from its date when none is set (#1237)
The client always sends day_id on a reservation update, so the server only
derived it from reservation_time when the field was absent. A non-transport
booking saved without a selected day (Book tab) therefore got day_id null and
vanished from the Plan, even though its date matched a day. Derive the day from
reservation_time whenever day_id is null, mirroring create.
2026-06-17 22:32:03 +02:00
Maurice 00738c8dbc fix(planner): keep a reservation on its day when edited (#1237)
Editing a booking forced its day_id to the globally selected day, which is null
when editing from the Book tab - so the booking lost its day and vanished from
the Plan. Preserve the reservation own day_id on edit instead.
2026-06-17 22:27:54 +02:00
Maurice 438f71bbc6 test(reservations): align syncBudgetOnUpdate unit tests with no-wipe + type-sync
The service now leaves a linked expense alone when no budget entry is on the
payload (only an explicit total_price 0 deletes it) and syncs the category on a
booking type change. Update the unit tests accordingly - the old "price cleared"
case passed entry: undefined, which is now a no-op and left a mocked return
queued that leaked into the next test.
2026-06-17 22:27:53 +02:00
Maurice c15c89ca61 feat(costs): create an expense from a booking, fix editing total-only items
Replace the inline price + budget-category fields in the Transport and
Reservation booking modals with a "Create expense" flow: the modal saves the
booking, then opens the full Costs editor prefilled (name + category mapped from
the booking type) and linked to the reservation. A booking with a linked expense
shows it inline with edit / remove.

Also fix the Costs editor so an expense with a recorded total but no payers
(transport-derived or pre-rework items) opens with its amount, lets you set the
currency, and saves - it previously showed 0 everywhere and could not be saved.
Legacy / localized categories now map to the fixed keys, and changing a booking's
type keeps its linked expense category in sync (unless it was manually set).

- shared: reservation_id on budget create, typeToCostCategory helper, i18n keys
- server: createBudgetItem stores reservation_id; keep total_price for payerless
  items; a booking update no longer wipes its linked expense and syncs the
  category on type change
- client: shared BookingCostsSection, exported ExpenseModal with prefill and an
  editable total, page-level save-then-open wiring
2026-06-17 22:11:56 +02:00
Maurice f98058a3af feat(backup): make the upload size limit configurable
The restore upload was capped at a hard-coded 500 MB, so instances whose
backup archive (uploads/ included) grew past that got a 413 "File too large"
with no way to raise it. Add a BACKUP_UPLOAD_LIMIT_MB env var (default 500,
invalid values warn and fall back), documented in .env.example.
2026-06-17 21:00:36 +02:00
Maurice 39a3ee7ce7 fix(collab): show poll option labels in the UI
The poll API formatted each option as { label, voters }, but the React poll
component renders opt.text - so every option button came out blank. Emit text
alongside label (kept for any other consumer) so options render again.
2026-06-17 21:00:19 +02:00
Maurice e09849d5b4 fix(oidc): keep dots in generated usernames
The OIDC username sanitizer stripped dots because they were missing from the
allowed character class, so a name claim like "first.last" became "firstlast".
Dots are valid usernames (the profile validator already allows
^[a-zA-Z0-9_.-]+$), so add the dot to the sanitizer.
2026-06-17 21:00:04 +02:00
Maurice b3fc5411ca fix(atlas): cursor-following tooltips and removing countries from search
Two related Atlas fixes:

- Country tooltips were bound with sticky:false, which anchors them at the
  feature's bounds centre. For countries with overseas territories (e.g.
  France) that centre sits far out in the ocean, so the tooltip popped up
  nowhere near the area being hovered. Make them sticky so they track the
  cursor.

- Selecting an already-visited country from the search bar always opened the
  "Mark / Bucket" dialog, with no way to remove it. Tiny countries like
  Vatican City or Singapore are hard to hit on the map, so search was the only
  way in. Mirror the map-click behaviour: a manually-marked country opens the
  Remove confirmation, a trip/place-backed one opens its detail.
2026-06-17 15:23:54 +02:00
Maurice f524909008 fix(dashboard): show the correct reservation date regardless of timezone
The upcoming-reservations widget built the date with new Date(reservation_time)
.toISOString(), which reinterprets the stored naive local time as UTC and can
roll the displayed day forward in non-UTC timezones (e.g. a 23:30 reservation
showing the next day). Read the date and time straight from the stored string
parts via splitReservationDateTime, and format the time with the shared
formatTime helper so it also honours the user's 12h/24h preference.
2026-06-17 15:23:35 +02:00
Maurice 264cf7d384 fix(vacay): keep the mode toolbar above the mobile bottom nav
The floating Vacation/Company toolbar was pinned at bottom-3 with z-30, so on
mobile it landed in the same band as the fixed bottom nav (z-60) and got hidden
behind it - and could scroll out of reach entirely. Pin it above the nav with
the shared --bottom-nav-h variable (0px on desktop, so nothing changes there)
and reserve matching space below the calendar grid so it never gets swallowed.
2026-06-17 15:23:23 +02:00
Maurice cb7ce7f229 fix(docker): ship the encryption-key migration script in the image
The production image only copied server/dist, so the documented rotation
command `node --import tsx scripts/migrate-encryption.ts` failed inside the
container with a module-not-found error - the raw .ts was never present. The
script runs via tsx straight from source and only pulls node builtins plus
better-sqlite3 (both prod deps), so copying the single file into
/app/server/scripts is enough to make the rotation work again.
2026-06-17 15:04:29 +02:00
Maurice d40c5ce7a6 fix(demo): skip first-run admin seed in demo mode
When DEMO_MODE is on, the demo seeder creates its own admin (admin@trek.app,
username "admin") right after the generic seeds run. The first-run admin
bootstrap was grabbing username "admin" first, so the demo seeder hit the
UNIQUE(username) constraint and aborted before the demo user was ever created
- which surfaced as a 500 "Demo user not found" on demo-login. Skip the
generic admin bootstrap when demo mode owns the admin account.
2026-06-17 15:01:41 +02:00
jubnl 2d79254c33 feat(pdf): add legs to pdf export 2026-06-17 11:05:35 +02:00
jubnl e6fcbc7789 fix(shared-view): render each leg of multi-leg flights correctly
The read-only shared view showed the overall trip start/end airports and
the first leg's flight number on every leg of a multi-leg flight. The Day
Plan already expands legs (each carries __leg), but the renderer ignored it
and read flat top-level metadata; the Bookings tab had the same bug.

- Day Plan: use __leg for per-leg airline/flight number/route, plus dep-arr time
- Bookings tab: list each leg via getFlightLegs()
- unique React keys for multi-leg rows

Closes #1219
2026-06-17 10:44:05 +02:00
828 changed files with 4915 additions and 33406 deletions
+1 -1
View File
@@ -30,8 +30,8 @@ Thumbs.db
sonar-project.properties sonar-project.properties
server/tests/ server/tests/
server/vitest.config.ts server/vitest.config.ts
server/reset-admin.js
**/*.test.ts **/*.test.ts
**/*.spec.ts
wiki/ wiki/
scripts/ scripts/
charts/ charts/
-2
View File
@@ -66,5 +66,3 @@ test-data
.run .run
.full-review .full-review
# Wiki offline snapshot is baked in at build, not committed (duplicates wiki/)
server/assets/wiki/
+14 -4
View File
@@ -46,11 +46,23 @@ COPY package.json package-lock.json ./
COPY shared/package.json ./shared/ COPY shared/package.json ./shared/
COPY server/package.json ./server/ COPY server/package.json ./server/
# better-sqlite3 native addon requires build tools (purged after compile).
# kitinerary-extractor for booking-confirmation import:
# amd64 — static binary from KDE CDN (glibc 2.17+; wget stays for healthcheck)
# arm64 — apt package (KDE publishes no arm64 static binary)
RUN apt-get update && \ RUN apt-get update && \
apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates python3 build-essential \ apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates python3 build-essential && \
libkitinerary-bin && \
npm ci --workspace=server --omit=dev && \ npm ci --workspace=server --omit=dev && \
ARCH=$(dpkg --print-architecture) && \
if [ "$ARCH" = "amd64" ]; then \
wget -qO /tmp/ki.tgz https://cdn.kde.org/ci-builds/pim/kitinerary/release-26.04/linux/kitinerary-extractor-x86_64-26.04.2.tgz && \
echo "ba5cfb4a2353157c8f54cbeaea0097c5bf2c3a810e0342f63d6e524826176628 /tmp/ki.tgz" | sha256sum -c && \
tar -xz -C /usr/local -f /tmp/ki.tgz bin/kitinerary-extractor share/locale && \
rm /tmp/ki.tgz; \
else \
apt-get install -y --no-install-recommends libkitinerary-bin && \
ln -sf "$(find /usr/lib -name kitinerary-extractor -type f | head -1)" /usr/local/bin/kitinerary-extractor; \ ln -sf "$(find /usr/lib -name kitinerary-extractor -type f | head -1)" /usr/local/bin/kitinerary-extractor; \
fi && \
apt-get purge -y python3 build-essential && \ apt-get purge -y python3 build-essential && \
apt-get autoremove -y && \ apt-get autoremove -y && \
rm -rf /var/lib/apt/lists/* /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx rm -rf /var/lib/apt/lists/* /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
@@ -77,8 +89,6 @@ COPY server/tsconfig.json ./server/
# raw .ts source — it never enters dist, so it must be copied in explicitly or # raw .ts source — it never enters dist, so it must be copied in explicitly or
# `node --import tsx scripts/migrate-encryption.ts` fails with module-not-found. # `node --import tsx scripts/migrate-encryption.ts` fails with module-not-found.
COPY server/scripts/migrate-encryption.ts ./server/scripts/migrate-encryption.ts COPY server/scripts/migrate-encryption.ts ./server/scripts/migrate-encryption.ts
# Admin recovery script (node server/reset-admin.js) for locked-out installs.
COPY server/reset-admin.js ./server/reset-admin.js
COPY --from=shared-builder /app/shared/dist ./shared/dist COPY --from=shared-builder /app/shared/dist ./shared/dist
COPY --from=client-builder /app/client/dist ./server/public COPY --from=client-builder /app/client/dist ./server/public
COPY --from=client-builder /app/client/public/fonts ./server/public/fonts COPY --from=client-builder /app/client/public/fonts ./server/public/fonts
-9
View File
@@ -1,9 +0,0 @@
<?xml version="1.0"?>
<CommunityApplications>
<Profile>TREK is a self-hosted, real-time collaborative travel planner. Plan trips together with interactive maps, budgets, bookings, packing lists, day-by-day itineraries and file management — every change syncs instantly across everyone in your group. Includes OIDC/SSO, TOTP MFA, dark mode, PWA support, multi-language UI and a modular addon system (Vacay, Atlas, Collab, Budget, Packing, Journey). Maintained by mauriceboe — support and bug reports via GitHub Issues.</Profile>
<Icon>https://raw.githubusercontent.com/mauriceboe/TREK/main/docs/trek-icon.png</Icon>
<WebPage>https://github.com/mauriceboe/TREK</WebPage>
<Forum>https://github.com/mauriceboe/TREK/issues</Forum>
<DonateLink>https://ko-fi.com/mauriceboe</DonateLink>
<DonateText>Support TREK development</DonateText>
</CommunityApplications>
+1 -3
View File
@@ -39,9 +39,7 @@ See `values.yaml` for more options.
## Notes ## Notes
- Ingress is off by default. Enable and configure hosts for your domain. - Ingress is off by default. Enable and configure hosts for your domain.
- PVCs use the cluster's default StorageClass. Set `persistence.data.storageClassName` and/or `persistence.uploads.storageClassName` to bind a specific class. - PVCs require a default StorageClass or specify one as needed.
- To use your own PVCs, set `persistence.data.existingClaim` and/or `persistence.uploads.existingClaim`. The other values for that volume (size, storageClassName, annotations) are then ignored.
- With `persistence.enabled: false`, the data and uploads volumes use an `emptyDir` — storage is ephemeral and lost on pod restart. Intended for testing only.
- `JWT_SECRET` is managed entirely by the server — auto-generated into the data PVC on first start and rotatable via the admin panel (Settings → Danger Zone). No Helm configuration needed. - `JWT_SECRET` is managed entirely by the server — auto-generated into the data PVC on first start and rotatable via the admin panel (Settings → Danger Zone). No Helm configuration needed.
- `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Recommended: set via `secretEnv.ENCRYPTION_KEY` or `existingSecret`. If left empty, the server falls back automatically: existing installs use `data/.jwt_secret` (no action needed on upgrade); fresh installs auto-generate a key persisted to the data PVC. - `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Recommended: set via `secretEnv.ENCRYPTION_KEY` or `existingSecret`. If left empty, the server falls back automatically: existing installs use `data/.jwt_secret` (no action needed on upgrade); fresh installs auto-generate a key persisted to the data PVC.
- If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically. - If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically.
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2 apiVersion: v2
name: trek name: trek
version: 3.1.3 version: 3.1.0
description: Minimal Helm chart for TREK app description: Minimal Helm chart for TREK app
appVersion: "3.1.3" appVersion: "3.1.0"
-6
View File
@@ -21,9 +21,3 @@
4. Only one method should be used at a time. If both `generateEncryptionKey` and `existingSecret` are 4. Only one method should be used at a time. If both `generateEncryptionKey` and `existingSecret` are
set, `existingSecret` takes precedence. Ensure the referenced secret and key exist in the namespace. set, `existingSecret` takes precedence. Ensure the referenced secret and key exist in the namespace.
5. Persistence:
- To bind your own PVCs, set `persistence.data.existingClaim` and/or `persistence.uploads.existingClaim`.
The other values for that volume (size, storageClassName, annotations) are then ignored.
- With `persistence.enabled=false` the volumes use an emptyDir — storage is ephemeral and is lost
when the pod restarts. Use only for testing.
-6
View File
@@ -70,9 +70,3 @@ data:
{{- if .Values.env.MCP_RATE_LIMIT }} {{- if .Values.env.MCP_RATE_LIMIT }}
MCP_RATE_LIMIT: {{ .Values.env.MCP_RATE_LIMIT | quote }} MCP_RATE_LIMIT: {{ .Values.env.MCP_RATE_LIMIT | quote }}
{{- end }} {{- end }}
{{- if .Values.env.OVERPASS_URL }}
OVERPASS_URL: {{ .Values.env.OVERPASS_URL | quote }}
{{- end }}
{{- if .Values.env.OVERPASS_TIMEOUT_MS }}
OVERPASS_TIMEOUT_MS: {{ .Values.env.OVERPASS_TIMEOUT_MS | quote }}
{{- end }}
+2 -10
View File
@@ -82,16 +82,8 @@ spec:
periodSeconds: 10 periodSeconds: 10
volumes: volumes:
- name: data - name: data
{{- if .Values.persistence.enabled }}
persistentVolumeClaim: persistentVolumeClaim:
claimName: {{ default (printf "%s-data" (include "trek.fullname" .)) .Values.persistence.data.existingClaim }} claimName: {{ include "trek.fullname" . }}-data
{{- else }}
emptyDir: {}
{{- end }}
- name: uploads - name: uploads
{{- if .Values.persistence.enabled }}
persistentVolumeClaim: persistentVolumeClaim:
claimName: {{ default (printf "%s-uploads" (include "trek.fullname" .)) .Values.persistence.uploads.existingClaim }} claimName: {{ include "trek.fullname" . }}-uploads
{{- else }}
emptyDir: {}
{{- end }}
+1 -17
View File
@@ -1,42 +1,26 @@
{{- if and .Values.persistence.enabled (not .Values.persistence.data.existingClaim) }} {{- if .Values.persistence.enabled }}
apiVersion: v1 apiVersion: v1
kind: PersistentVolumeClaim kind: PersistentVolumeClaim
metadata: metadata:
name: {{ include "trek.fullname" . }}-data name: {{ include "trek.fullname" . }}-data
labels: labels:
app: {{ include "trek.name" . }} app: {{ include "trek.name" . }}
{{- with .Values.persistence.data.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec: spec:
accessModes: accessModes:
- ReadWriteOnce - ReadWriteOnce
{{- with .Values.persistence.data.storageClassName }}
storageClassName: {{ . | quote }}
{{- end }}
resources: resources:
requests: requests:
storage: {{ .Values.persistence.data.size }} storage: {{ .Values.persistence.data.size }}
{{- end }}
--- ---
{{- if and .Values.persistence.enabled (not .Values.persistence.uploads.existingClaim) }}
apiVersion: v1 apiVersion: v1
kind: PersistentVolumeClaim kind: PersistentVolumeClaim
metadata: metadata:
name: {{ include "trek.fullname" . }}-uploads name: {{ include "trek.fullname" . }}-uploads
labels: labels:
app: {{ include "trek.name" . }} app: {{ include "trek.name" . }}
{{- with .Values.persistence.uploads.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec: spec:
accessModes: accessModes:
- ReadWriteOnce - ReadWriteOnce
{{- with .Values.persistence.uploads.storageClassName }}
storageClassName: {{ . | quote }}
{{- end }}
resources: resources:
requests: requests:
storage: {{ .Values.persistence.uploads.size }} storage: {{ .Values.persistence.uploads.size }}
-16
View File
@@ -67,12 +67,6 @@ env:
# Max MCP API requests per user per minute. Defaults to 300. # Max MCP API requests per user per minute. Defaults to 300.
# MCP_MAX_SESSION_PER_USER: "20" # MCP_MAX_SESSION_PER_USER: "20"
# Max concurrent MCP sessions per user. Defaults to 20. # Max concurrent MCP sessions per user. Defaults to 20.
# OVERPASS_URL: ""
# Custom Overpass endpoint(s) for the map POI "explore" search, comma-separated. When set, REPLACES the bundled
# public mirrors — point it at an internal/self-hosted Overpass instance when the public mirrors are unreachable
# from the cluster (e.g. locked-down egress). Non-http(s) entries are ignored.
# OVERPASS_TIMEOUT_MS: "12000"
# Per-endpoint timeout (ms) for Overpass POI requests. Raise it for a slow self-hosted Overpass instance. Defaults to 12000.
# Secret environment variables stored in a Kubernetes Secret. # Secret environment variables stored in a Kubernetes Secret.
@@ -101,21 +95,11 @@ existingSecret: ""
existingSecretKey: ENCRYPTION_KEY existingSecretKey: ENCRYPTION_KEY
persistence: persistence:
# When disabled, volumes fall back to an ephemeral emptyDir (data lost on pod restart).
enabled: true enabled: true
data: data:
size: 1Gi size: 1Gi
# Leave empty to use the cluster's default StorageClass; set to bind a specific class.
storageClassName: ""
# Bind an existing PVC. The other values (size, storageClassName, annotations) are then ignored.
existingClaim: ""
annotations: {}
uploads: uploads:
size: 1Gi size: 1Gi
storageClassName: ""
# Specify an existing PVC to bind. The other values are then ignored.
existingClaim: ""
annotations: {}
resources: resources:
requests: requests:
+1 -5
View File
@@ -5,10 +5,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<title>TREK</title> <title>TREK</title>
<!-- Pre-paint appearance (FOUC fix). External classic script so it runs
before first paint AND complies with the prod CSP (script-src 'self'). -->
<script src="/theme-boot.js"></script>
<!-- PWA / iOS --> <!-- PWA / iOS -->
<meta name="theme-color" content="#09090b" /> <meta name="theme-color" content="#09090b" />
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
@@ -17,7 +13,7 @@
<link rel="apple-touch-icon" href="/icons/apple-touch-icon-180x180.png" /> <link rel="apple-touch-icon" href="/icons/apple-touch-icon-180x180.png" />
<!-- Favicon --> <!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/icons/icon.svg" /> <link rel="icon" type="image/svg+xml" href="/icons/icon-dark.svg" />
<!-- Fonts --> <!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
+2 -6
View File
@@ -1,6 +1,6 @@
{ {
"name": "@trek/client", "name": "@trek/client",
"version": "3.1.3", "version": "3.1.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -17,8 +17,6 @@
"lint": "eslint .", "lint": "eslint .",
"lint:check": "eslint .", "lint:check": "eslint .",
"lint:pages": "node scripts/check-page-pattern.mjs", "lint:pages": "node scripts/check-page-pattern.mjs",
"theme:lint": "node scripts/theme-lint.mjs",
"theme:lint:strict": "node scripts/theme-lint.mjs --strict",
"e2e": "playwright test", "e2e": "playwright test",
"e2e:report": "playwright show-report", "e2e:report": "playwright show-report",
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.css\"", "format": "prettier --write \"src/**/*.tsx\" \"src/**/*.css\"",
@@ -36,9 +34,7 @@
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"lucide-react": "^0.344.0", "lucide-react": "^0.344.0",
"mapbox-gl": "^3.22.0", "mapbox-gl": "^3.22.0",
"maplibre-gl": "^5.24.0",
"marked": "^18.0.0", "marked": "^18.0.0",
"plyr": "^3.8.4",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
"react-dropzone": "^14.4.1", "react-dropzone": "^14.4.1",
@@ -85,7 +81,7 @@
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"typescript": "^6.0.2", "typescript": "^6.0.2",
"typescript-eslint": "^8.58.2", "typescript-eslint": "^8.58.2",
"vite": "8.1.0", "vite": "^8.0.16",
"vite-plugin-pwa": "^1.3.0", "vite-plugin-pwa": "^1.3.0",
"vitest": "^4.1.9" "vitest": "^4.1.9"
} }
-58
View File
@@ -1,58 +0,0 @@
/*
* Pre-paint appearance boot — kills the flash of default/wrong theme (FOUC).
*
* Loaded as an external, render-blocking CLASSIC script in <head> (NOT a module)
* so it runs before first paint AND complies with the production CSP
* (script-src 'self'; inline scripts are blocked). It reads the compact snapshot
* written by client/src/theme/applyAppearance.ts and applies it verbatim. Keep
* this in sync with that module's snapshot shape + apply logic.
*
* It must never throw — any failure silently falls back to the default look.
*/
(function () {
try {
var raw = localStorage.getItem('trek_appearance');
if (!raw) return;
var s = JSON.parse(raw);
if (!s || s.v !== 1) return;
var root = document.documentElement;
var path = location.pathname;
var isShared = path.indexOf('/shared/') === 0 || path.indexOf('/public/') === 0;
var dark;
if (isShared) dark = false;
else if (s.darkMode === 'auto') dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
else dark = s.darkMode === true || s.darkMode === 'dark';
root.classList.toggle('dark', dark);
var scheme = isShared ? 'default' : s.scheme;
if (scheme && scheme !== 'default') root.setAttribute('data-scheme', scheme);
if (!isShared && s.noTransparency) root.setAttribute('data-no-transparency', '');
if (s.density === 'compact') root.setAttribute('data-density', 'compact');
if (s.reduceMotion) root.setAttribute('data-reduce-motion', '');
if (!isShared && scheme === 'custom' && s.accent) {
root.style.setProperty('--accent-custom-light', s.accent.light);
root.style.setProperty('--accent-custom-dark', s.accent.dark);
if (s.accentText) {
root.style.setProperty('--accent-custom-text-light', s.accentText.light);
root.style.setProperty('--accent-custom-text-dark', s.accentText.dark);
}
}
var ts = s.typeScale || {};
var fs = typeof s.fontScale === 'number' ? s.fontScale : 1;
setScale('--fs-scale-title', fs * (ts.title || 1));
setScale('--fs-scale-subtitle', fs * (ts.subtitle || 1));
setScale('--fs-scale-body', fs * (ts.body || 1));
setScale('--fs-scale-caption', fs * (ts.caption || 1));
if (fs !== 1) root.style.fontSize = fs * 100 + '%';
function setScale(name, v) {
if (typeof v === 'number' && v !== 1) root.style.setProperty(name, String(v));
}
} catch (e) {
/* never block boot */
}
})();
-73
View File
@@ -1,73 +0,0 @@
#!/usr/bin/env node
/*
* theme:lint — guards the appearance token system.
*
* Flags styling that bypasses the design tokens and therefore won't follow a
* user's chosen scheme / transparency / text-size:
* - inline color literals (color: '#111', background: 'rgba(...)', boxShadow: '...rgba...')
* - inline numeric fontSize (fontSize: 13)
* - arbitrary-value Tailwind color classes (bg-[#..], text-[rgba(..)])
*
* ALLOWED (never flagged): var(--token) inline styles, bg-[var(--..)] classes,
* and genuinely dynamic values (data-driven colors, computed sizes/positions).
*
* Mirrors the i18n:parity gate. Default mode reports a baseline and exits 0;
* `--strict` exits non-zero when any violations remain (for once the backlog is
* burned down, or wired to changed files only). Add `theme-lint-disable` in a
* line comment to suppress an intentional exception (map/PDF/brand colors).
*/
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';
let SRC = new URL('../src', import.meta.url).pathname;
if (process.platform === 'win32' && SRC.startsWith('/')) SRC = SRC.slice(1);
// Surfaces where CSS variables genuinely cannot reach (injected map HTML, WebGL
// paint, standalone PDF documents) — colors there must stay literal.
const EXEMPT = [
/Mapbox/i, /placePopup/i, /marker/i, /popup/i, /TripPDF/, /JourneyBookPDF/,
/MapViewGL/, /MapView\./, /JourneyMapGL/, /reservationsMapbox/, /useAtlas/,
/ReservationOverlay/, /\.test\./, /\.spec\./,
];
const ARB_CLASS = /\b(?:bg|text|border|ring|fill|stroke|from|via|to|shadow|outline|decoration|divide|caret)-\[\s*(?:#|rgba?\(|hsla?\(|oklch\()/;
const INLINE_COLOR = /(?:color|background|backgroundColor|borderColor|border|borderTop|borderBottom|borderLeft|borderRight|boxShadow|fill|stroke|outline|textDecorationColor)\s*:\s*['"`]?\s*(?:#[0-9a-fA-F]{3,8}\b|rgba?\(|hsla?\(|oklch\()/;
const INLINE_FONTSIZE = /fontSize\s*:\s*['"`]?\d/;
function walk(dir, files = []) {
for (const name of readdirSync(dir)) {
const p = join(dir, name);
if (statSync(p).isDirectory()) walk(p, files);
else if (/\.(ts|tsx)$/.test(name)) files.push(p);
}
return files;
}
const strict = process.argv.includes('--strict');
const offenders = [];
let total = 0;
for (const f of walk(SRC)) {
if (EXEMPT.some((re) => re.test(f))) continue;
let count = 0;
for (const line of readFileSync(f, 'utf8').split('\n')) {
if (line.includes('theme-lint-disable')) continue;
if (ARB_CLASS.test(line) || INLINE_COLOR.test(line) || INLINE_FONTSIZE.test(line)) count++;
}
if (count) {
offenders.push([relative(SRC, f).replace(/\\/g, '/'), count]);
total += count;
}
}
offenders.sort((a, b) => b[1] - a[1]);
console.log(`theme:lint — ${total} hardcoded-style hits across ${offenders.length} files (map/PDF excluded).`);
for (const [f, c] of offenders.slice(0, 20)) console.log(` ${String(c).padStart(4)} ${f}`);
if (offenders.length > 20) console.log(` … and ${offenders.length - 20} more files.`);
console.log('\nNew/changed code must use tokens (bg-surface / text-content / bg-accent / var(--..)) and the');
console.log('text-title/subtitle/body/caption tiers — never inline #hex, never bg-[#..]. See src/theme/README.md.');
if (strict && total > 0) {
console.error(`\n✖ theme:lint:strict — ${total} violations remain.`);
process.exit(1);
}
+20 -31
View File
@@ -2,7 +2,6 @@ import React, { useEffect, ReactNode } from 'react'
import { Routes, Route, Navigate, useLocation } from 'react-router-dom' import { Routes, Route, Navigate, useLocation } from 'react-router-dom'
import { useAuthStore } from './store/authStore' import { useAuthStore } from './store/authStore'
import { useSettingsStore } from './store/settingsStore' import { useSettingsStore } from './store/settingsStore'
import { applyAppearance } from './theme/applyAppearance'
import { useAddonStore } from './store/addonStore' import { useAddonStore } from './store/addonStore'
import LoginPage from './pages/LoginPage' import LoginPage from './pages/LoginPage'
import ForgotPasswordPage from './pages/ForgotPasswordPage' import ForgotPasswordPage from './pages/ForgotPasswordPage'
@@ -13,7 +12,6 @@ import FilesPage from './pages/FilesPage'
import AdminPage from './pages/AdminPage' import AdminPage from './pages/AdminPage'
import SettingsPage from './pages/SettingsPage' import SettingsPage from './pages/SettingsPage'
import VacayPage from './pages/VacayPage' import VacayPage from './pages/VacayPage'
import HelpPage from './pages/HelpPage'
import AtlasPage from './pages/AtlasPage' import AtlasPage from './pages/AtlasPage'
import JourneyPage from './pages/JourneyPage' import JourneyPage from './pages/JourneyPage'
import JourneyDetailPage from './pages/JourneyDetailPage' import JourneyDetailPage from './pages/JourneyDetailPage'
@@ -22,7 +20,6 @@ import SharedTripPage from './pages/SharedTripPage'
import InAppNotificationsPage from './pages/InAppNotificationsPage.tsx' import InAppNotificationsPage from './pages/InAppNotificationsPage.tsx'
import OAuthAuthorizePage from './pages/OAuthAuthorizePage' import OAuthAuthorizePage from './pages/OAuthAuthorizePage'
import { ToastContainer } from './components/shared/Toast' import { ToastContainer } from './components/shared/Toast'
import BackgroundTasksWidget from './components/BackgroundTasks/BackgroundTasksWidget'
import BottomNav from './components/Layout/BottomNav' import BottomNav from './components/Layout/BottomNav'
import { TranslationProvider, useTranslation } from './i18n' import { TranslationProvider, useTranslation } from './i18n'
import { authApi } from './api/client' import { authApi } from './api/client'
@@ -177,21 +174,30 @@ export default function App() {
const isSharedPage = location.pathname.startsWith('/shared/') const isSharedPage = location.pathname.startsWith('/shared/')
useEffect(() => { useEffect(() => {
const run = () => // Shared page always forces light mode
applyAppearance({ if (isSharedPage) {
darkMode: settings.dark_mode, document.documentElement.classList.remove('dark')
appearance: settings.appearance, const meta = document.querySelector('meta[name="theme-color"]')
isSharedPage, if (meta) meta.setAttribute('content', '#ffffff')
}) return
run() }
// Re-resolve on OS theme change while in auto mode.
if (!isSharedPage && settings.dark_mode === 'auto') { const mode = settings.dark_mode
const applyDark = (isDark: boolean) => {
document.documentElement.classList.toggle('dark', isDark)
const meta = document.querySelector('meta[name="theme-color"]')
if (meta) meta.setAttribute('content', isDark ? '#09090b' : '#ffffff')
}
if (mode === 'auto') {
const mq = window.matchMedia('(prefers-color-scheme: dark)') const mq = window.matchMedia('(prefers-color-scheme: dark)')
const handler = () => run() applyDark(mq.matches)
const handler = (e: MediaQueryListEvent) => applyDark(e.matches)
mq.addEventListener('change', handler) mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler) return () => mq.removeEventListener('change', handler)
} }
}, [settings.dark_mode, settings.appearance, isSharedPage]) applyDark(mode === true || mode === 'dark')
}, [settings.dark_mode, isSharedPage])
const isAuthPage = location.pathname.startsWith('/login') const isAuthPage = location.pathname.startsWith('/login')
|| location.pathname.startsWith('/register') || location.pathname.startsWith('/register')
@@ -202,7 +208,6 @@ export default function App() {
<TranslationProvider> <TranslationProvider>
{!isAuthPage && <SystemNoticeHost />} {!isAuthPage && <SystemNoticeHost />}
<ToastContainer /> <ToastContainer />
{!isAuthPage && <BackgroundTasksWidget />}
<OfflineBanner /> <OfflineBanner />
<Routes> <Routes>
<Route path="/" element={<RootRedirect />} /> <Route path="/" element={<RootRedirect />} />
@@ -222,22 +227,6 @@ export default function App() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="/help"
element={
<ProtectedRoute>
<HelpPage />
</ProtectedRoute>
}
/>
<Route
path="/help/:slug"
element={
<ProtectedRoute>
<HelpPage />
</ProtectedRoute>
}
/>
<Route <Route
path="/trips/:id" path="/trips/:id"
element={ element={
+15 -101
View File
@@ -15,8 +15,7 @@ import {
type RegisterRequest, type LoginRequest, type ForgotPasswordRequest, type RegisterRequest, type LoginRequest, type ForgotPasswordRequest,
type ResetPasswordRequest, type ChangePasswordRequest, type ResetPasswordRequest, type ChangePasswordRequest,
type MfaVerifyLoginRequest, type MfaEnableRequest, type McpTokenCreateRequest, type MfaVerifyLoginRequest, type MfaEnableRequest, type McpTokenCreateRequest,
type TripAddMemberRequest, type TripTransferOwnershipRequest, type TripAddMemberRequest, type AssignmentReorderRequest,
type TripCreateGuestRequest, type TripRenameGuestRequest, type AssignmentReorderRequest,
type PackingReorderRequest, type PackingCreateBagRequest, type TodoReorderRequest, type PackingReorderRequest, type PackingCreateBagRequest, type TodoReorderRequest,
type TripCreateRequest, type TripUpdateRequest, type TripCopyRequest, type TripCreateRequest, type TripUpdateRequest, type TripCopyRequest,
type DayCreateRequest, type DayUpdateRequest, type DayReorderRequest, type DayCreateRequest, type DayUpdateRequest, type DayReorderRequest,
@@ -24,11 +23,10 @@ import {
type ReservationCreateRequest, type ReservationUpdateRequest, type ReservationCreateRequest, type ReservationUpdateRequest,
type AccommodationCreateRequest, type AccommodationUpdateRequest, type AccommodationCreateRequest, type AccommodationUpdateRequest,
type BudgetCreateItemRequest, type BudgetUpdateItemRequest, type BudgetCreateItemRequest, type BudgetUpdateItemRequest,
type PackingCreateItemRequest, type PackingUpdateItemRequest, type PackingSetSharingRequest, type PackingCreateItemRequest, type PackingUpdateItemRequest,
type TodoCreateItemRequest, type TodoUpdateItemRequest, type TodoCreateItemRequest, type TodoUpdateItemRequest,
type AssignmentCreateRequest, type AssignmentParticipantsRequest, type AssignmentTimeRequest, type AssignmentCreateRequest, type AssignmentParticipantsRequest, type AssignmentTimeRequest,
type PlaceBulkDeleteRequest, type PlaceBulkDeleteRequest,
type PlaceBulkUpdateRequest,
type DayNoteCreateRequest, type DayNoteUpdateRequest, type DayNoteCreateRequest, type DayNoteUpdateRequest,
type PackingImportRequest, type PackingBagMembersRequest, type PackingUpdateBagRequest, type PackingImportRequest, type PackingBagMembersRequest, type PackingUpdateBagRequest,
type PackingCategoryAssigneesRequest, type PackingCategoryAssigneesRequest,
@@ -43,10 +41,9 @@ import {
type BookingImportPreviewItem, type BookingImportPreviewItem,
type BookingImportPreviewResponse, type BookingImportPreviewResponse,
type BookingImportConfirmResponse, type BookingImportConfirmResponse,
type BookingImportMode,
} from '@trek/shared' } from '@trek/shared'
import { getSocketId } from './websocket' import { getSocketId } from './websocket'
import { probeNow } from '../sync/connectivity' import { isReachable, probeNow } from '../sync/connectivity'
/** /**
* Validate a response payload against its @trek/shared Zod schema — but only in * Validate a response payload against its @trek/shared Zod schema — but only in
@@ -103,7 +100,6 @@ const RATE_LIMIT_MESSAGES: Record<string, string> = {
ja: '試行回数が多すぎます。時間をおいて再度お試しください。', ja: '試行回数が多すぎます。時間をおいて再度お試しください。',
ko: '시도 횟수가 너무 많습니다. 잠시 후 다시 시도해 주세요.', ko: '시도 횟수가 너무 많습니다. 잠시 후 다시 시도해 주세요.',
uk: 'Занадто багато спроб. Спробуйте пізніше.', uk: 'Занадто багато спроб. Спробуйте пізніше.',
sv: 'För många försök. Prova igen senare.',
} }
function translateRateLimit(): string { function translateRateLimit(): string {
@@ -178,17 +174,13 @@ apiClient.interceptors.response.use(
// distinguish a proxy auth challenge from a genuine outage. If the server // distinguish a proxy auth challenge from a genuine outage. If the server
// is reachable, a top-level reload lets the edge proxy run its auth flow. // is reachable, a top-level reload lets the edge proxy run its auth flow.
if (!error.response && navigator.onLine) { if (!error.response && navigator.onLine) {
// Only an actual edge-proxy auth wall warrants tearing down the SW to await probeNow()
// reauth: a reachable proxy (CF Access / Pangolin) that intercepts /api // Both the original request and the health probe failed while the device
// with a cross-origin redirect or an HTML login page. A genuine offline // has a network interface. This matches the proxy-auth-challenge pattern
// boot ALSO lands here — navigator.onLine reflects a network interface, // (CF Access / Pangolin intercept all requests and CORS-block XHR).
// not reachability, and is routinely true on mobile while offline. So // Guard with sessionStorage to prevent reload loops (server genuinely
// gate strictly on a positive proxy signal; on plain offline do nothing // down would also land here, but only reloads once).
// and let the request reject so the cached shell + IndexedDB serve the if (!isReachable()) {
// app. Unregistering the SW here reloaded into a dead network and broke
// PWA offline mode (#1346).
const state = await probeNow()
if (state === 'proxy-wall') {
const { pathname } = window.location const { pathname } = window.location
if (!isAuthPublicPath(pathname) && !sessionStorage.getItem('proxy_reauth_attempted')) { if (!isAuthPublicPath(pathname) && !sessionStorage.getItem('proxy_reauth_attempted')) {
sessionStorage.setItem('proxy_reauth_attempted', '1') sessionStorage.setItem('proxy_reauth_attempted', '1')
@@ -335,16 +327,11 @@ export const tripsApi = {
update: (id: number | string, data: TripUpdateRequest) => apiClient.put(`/trips/${id}`, data).then(r => r.data), update: (id: number | string, data: TripUpdateRequest) => apiClient.put(`/trips/${id}`, data).then(r => r.data),
delete: (id: number | string) => apiClient.delete(`/trips/${id}`).then(r => r.data), delete: (id: number | string) => apiClient.delete(`/trips/${id}`).then(r => r.data),
uploadCover: (id: number | string, formData: FormData) => apiClient.post(`/trips/${id}/cover`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data), uploadCover: (id: number | string, formData: FormData) => apiClient.post(`/trips/${id}/cover`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data),
searchCoverImages: (query: string) => apiClient.get('/trips/cover-images/search', { params: { query } }).then(r => r.data),
archive: (id: number | string) => apiClient.put(`/trips/${id}`, { is_archived: true }).then(r => r.data), archive: (id: number | string) => apiClient.put(`/trips/${id}`, { is_archived: true }).then(r => r.data),
unarchive: (id: number | string) => apiClient.put(`/trips/${id}`, { is_archived: false }).then(r => r.data), unarchive: (id: number | string) => apiClient.put(`/trips/${id}`, { is_archived: false }).then(r => r.data),
getMembers: (id: number | string) => apiClient.get(`/trips/${id}/members`).then(r => r.data), getMembers: (id: number | string) => apiClient.get(`/trips/${id}/members`).then(r => r.data),
addMember: (id: number | string, identifier: string) => apiClient.post(`/trips/${id}/members`, { identifier } satisfies TripAddMemberRequest).then(r => r.data), addMember: (id: number | string, identifier: string) => apiClient.post(`/trips/${id}/members`, { identifier } satisfies TripAddMemberRequest).then(r => r.data),
removeMember: (id: number | string, userId: number) => apiClient.delete(`/trips/${id}/members/${userId}`).then(r => r.data), removeMember: (id: number | string, userId: number) => apiClient.delete(`/trips/${id}/members/${userId}`).then(r => r.data),
transferOwnership: (id: number | string, newOwnerId: number) => apiClient.post(`/trips/${id}/transfer`, { newOwnerId } satisfies TripTransferOwnershipRequest).then(r => r.data),
createGuest: (id: number | string, name: string) => apiClient.post(`/trips/${id}/guests`, { name } satisfies TripCreateGuestRequest).then(r => r.data),
renameGuest: (id: number | string, userId: number, name: string) => apiClient.put(`/trips/${id}/guests/${userId}`, { name } satisfies TripRenameGuestRequest).then(r => r.data),
deleteGuest: (id: number | string, userId: number) => apiClient.delete(`/trips/${id}/guests/${userId}`).then(r => r.data),
copy: (id: number | string, data?: TripCopyRequest) => apiClient.post(`/trips/${id}/copy`, data || {}).then(r => r.data), copy: (id: number | string, data?: TripCopyRequest) => apiClient.post(`/trips/${id}/copy`, data || {}).then(r => r.data),
bundle: (id: number | string) => apiClient.get(`/trips/${id}/bundle`).then(r => r.data), bundle: (id: number | string) => apiClient.get(`/trips/${id}/bundle`).then(r => r.data),
} }
@@ -385,8 +372,6 @@ export const placesApi = {
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url, enrich } satisfies PlaceImportListRequest).then(r => r.data), apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url, enrich } satisfies PlaceImportListRequest).then(r => r.data),
bulkDelete: (tripId: number | string, ids: number[]) => bulkDelete: (tripId: number | string, ids: number[]) =>
apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids } satisfies PlaceBulkDeleteRequest).then(r => r.data), apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids } satisfies PlaceBulkDeleteRequest).then(r => r.data),
bulkUpdate: (tripId: number | string, ids: number[], data: Omit<PlaceBulkUpdateRequest, 'ids'>) =>
apiClient.post(`/trips/${tripId}/places/bulk-update`, { ids, ...data } satisfies PlaceBulkUpdateRequest).then(r => r.data),
} }
export const assignmentsApi = { export const assignmentsApi = {
@@ -408,10 +393,6 @@ export const packingApi = {
update: (tripId: number | string, id: number, data: PackingUpdateItemRequest) => apiClient.put(`/trips/${tripId}/packing/${id}`, data).then(r => r.data), update: (tripId: number | string, id: number, data: PackingUpdateItemRequest) => apiClient.put(`/trips/${tripId}/packing/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/packing/${id}`).then(r => r.data), delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/packing/${id}`).then(r => r.data),
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/packing/reorder`, { orderedIds } satisfies PackingReorderRequest).then(r => r.data), reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/packing/reorder`, { orderedIds } satisfies PackingReorderRequest).then(r => r.data),
setSharing: (tripId: number | string, id: number, data: PackingSetSharingRequest) => apiClient.put(`/trips/${tripId}/packing/${id}/sharing`, data).then(r => r.data),
clone: (tripId: number | string, id: number) => apiClient.post(`/trips/${tripId}/packing/${id}/clone`).then(r => r.data),
addContributor: (tripId: number | string, id: number) => apiClient.post(`/trips/${tripId}/packing/${id}/contributors`).then(r => r.data),
removeContributor: (tripId: number | string, id: number, userId: number) => apiClient.delete(`/trips/${tripId}/packing/${id}/contributors/${userId}`).then(r => r.data),
getCategoryAssignees: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/category-assignees`).then(r => r.data), getCategoryAssignees: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/category-assignees`).then(r => r.data),
setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds } satisfies PackingCategoryAssigneesRequest).then(r => r.data), setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds } satisfies PackingCategoryAssigneesRequest).then(r => r.data),
listTemplates: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/templates`).then(r => r.data), listTemplates: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/templates`).then(r => r.data),
@@ -460,41 +441,6 @@ export const adminApi = {
updateOidc: (data: Record<string, unknown>) => apiClient.put('/admin/oidc', data).then(r => r.data), updateOidc: (data: Record<string, unknown>) => apiClient.put('/admin/oidc', data).then(r => r.data),
addons: () => apiClient.get('/admin/addons').then(r => r.data), addons: () => apiClient.get('/admin/addons').then(r => r.data),
updateAddon: (id: number | string, data: Record<string, unknown>) => apiClient.put(`/admin/addons/${id}`, data).then(r => r.data), updateAddon: (id: number | string, data: Record<string, unknown>) => apiClient.put(`/admin/addons/${id}`, data).then(r => r.data),
// Local LLM (Ollama) management for the AI-parsing addon.
llmLocalModels: (baseUrl: string): Promise<{ models: { name: string; size: number }[] }> =>
apiClient.get('/admin/llm/local/models', { params: { baseUrl } }).then(r => r.data),
/** Pull a model, streaming Ollama's NDJSON progress to `onProgress`. */
llmLocalPull: async (
baseUrl: string,
model: string,
onProgress: (p: { status?: string; total?: number; completed?: number; error?: string }) => void,
): Promise<void> => {
const res = await fetch('/api/admin/llm/local/pull', {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ baseUrl, model }),
})
if (!res.ok || !res.body) {
let msg = `Pull failed (${res.status})`
try { msg = (await res.json())?.error ?? msg } catch { /* non-json */ }
throw new Error(msg)
}
const reader = res.body.getReader()
const dec = new TextDecoder()
let buf = ''
for (;;) {
const { done, value } = await reader.read()
if (done) break
buf += dec.decode(value, { stream: true })
const lines = buf.split('\n')
buf = lines.pop() ?? ''
for (const line of lines) {
if (!line.trim()) continue
try { onProgress(JSON.parse(line)) } catch { /* skip partial */ }
}
}
},
checkVersion: () => apiClient.get('/admin/version-check').then(r => r.data), checkVersion: () => apiClient.get('/admin/version-check').then(r => r.data),
getBagTracking: () => apiClient.get('/admin/bag-tracking').then(r => r.data), getBagTracking: () => apiClient.get('/admin/bag-tracking').then(r => r.data),
updateBagTracking: (enabled: boolean) => apiClient.put('/admin/bag-tracking', { enabled }).then(r => r.data), updateBagTracking: (enabled: boolean) => apiClient.put('/admin/bag-tracking', { enabled }).then(r => r.data),
@@ -591,16 +537,9 @@ export const journeyApi = {
onUploadProgress: opts?.onUploadProgress, onUploadProgress: opts?.onUploadProgress,
signal: opts?.signal, signal: opts?.signal,
}).then(r => r.data), }).then(r => r.data),
uploadGalleryVideo: (journeyId: number, formData: FormData, opts?: { onUploadProgress?: (e: import('axios').AxiosProgressEvent) => void; idempotencyKey?: string; signal?: AbortSignal }) => addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}) } satisfies JourneyProviderPhotosRequest).then(r => r.data),
apiClient.post(`/journeys/${journeyId}/gallery/video`, formData, {
headers: { 'Content-Type': undefined as any, ...(opts?.idempotencyKey ? { 'X-Idempotency-Key': opts.idempotencyKey } : {}) },
timeout: 0,
onUploadProgress: opts?.onUploadProgress,
signal: opts?.signal,
}).then(r => r.data),
addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string, mediaTypes?: string[]) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}), ...(mediaTypes ? { media_types: mediaTypes } : {}) } satisfies JourneyProviderPhotosRequest).then(r => r.data),
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data), addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string, mediaTypes?: string[]) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}), ...(mediaTypes ? { media_types: mediaTypes } : {}) }).then(r => r.data), addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
linkPhoto: (entryId: number, journeyPhotoId: number) => apiClient.post(`/journeys/entries/${entryId}/link-photo`, { journey_photo_id: journeyPhotoId }).then(r => r.data), linkPhoto: (entryId: number, journeyPhotoId: number) => apiClient.post(`/journeys/entries/${entryId}/link-photo`, { journey_photo_id: journeyPhotoId }).then(r => r.data),
unlinkPhoto: (entryId: number, journeyPhotoId: number) => apiClient.delete(`/journeys/entries/${entryId}/photos/${journeyPhotoId}`).then(r => r.data), unlinkPhoto: (entryId: number, journeyPhotoId: number) => apiClient.delete(`/journeys/entries/${entryId}/photos/${journeyPhotoId}`).then(r => r.data),
deleteGalleryPhoto: (journeyId: number, journeyPhotoId: number) => apiClient.delete(`/journeys/${journeyId}/gallery/${journeyPhotoId}`).then(r => r.data), deleteGalleryPhoto: (journeyId: number, journeyPhotoId: number) => apiClient.delete(`/journeys/${journeyId}/gallery/${journeyPhotoId}`).then(r => r.data),
@@ -685,31 +624,17 @@ export const reservationsApi = {
update: (tripId: number | string, id: number, data: ReservationUpdateRequest) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data), update: (tripId: number | string, id: number, data: ReservationUpdateRequest) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/reservations/${id}`).then(r => r.data), delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/reservations/${id}`).then(r => r.data),
updatePositions: (tripId: number | string, positions: { id: number; day_plan_position: number }[], dayId?: number) => apiClient.put(`/trips/${tripId}/reservations/positions`, { positions, day_id: dayId }).then(r => r.data), updatePositions: (tripId: number | string, positions: { id: number; day_plan_position: number }[], dayId?: number) => apiClient.put(`/trips/${tripId}/reservations/positions`, { positions, day_id: dayId }).then(r => r.data),
importBookingPreview: (tripId: number | string, files: File[], mode: BookingImportMode = 'no-ai'): Promise<BookingImportPreviewResponse> => { importBookingPreview: (tripId: number | string, files: File[]): Promise<BookingImportPreviewResponse> => {
const fd = new FormData() const fd = new FormData()
for (const f of files) fd.append('files', f) for (const f of files) fd.append('files', f)
fd.append('mode', mode) return apiClient.post(`/trips/${tripId}/reservations/import/booking`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
// No client-side timeout: kitinerary + LLM extraction routinely exceeds the
// global 8s default (a cold local model alone can take ~45s).
return apiClient.post(`/trips/${tripId}/reservations/import/booking`, fd, { headers: { 'Content-Type': 'multipart/form-data' }, timeout: 0 }).then(r => r.data)
}, },
importBookingConfirm: (tripId: number | string, items: BookingImportPreviewItem[]): Promise<BookingImportConfirmResponse> => importBookingConfirm: (tripId: number | string, items: BookingImportPreviewItem[]): Promise<BookingImportConfirmResponse> =>
apiClient.post(`/trips/${tripId}/reservations/import/booking/confirm`, { items }).then(r => r.data), apiClient.post(`/trips/${tripId}/reservations/import/booking/confirm`, { items }).then(r => r.data),
// Start a background parse: returns a job id at once; progress + result arrive
// over the WebSocket (import:progress / import:done / import:error).
importBookingAsync: (tripId: number | string, files: File[], mode: BookingImportMode = 'no-ai'): Promise<{ jobId: string }> => {
const fd = new FormData()
for (const f of files) fd.append('files', f)
fd.append('mode', mode)
return apiClient.post(`/trips/${tripId}/reservations/import/booking/async`, fd, { headers: { 'Content-Type': 'multipart/form-data' }, timeout: 0 }).then(r => r.data)
},
// Poll a background job — recovery path when a WebSocket push was missed.
importJobStatus: (tripId: number | string, jobId: string): Promise<{ status: 'running' | 'done' | 'error'; done: number; total: number; result?: BookingImportPreviewResponse; error?: string }> =>
apiClient.get(`/trips/${tripId}/reservations/import/jobs/${jobId}`).then(r => r.data),
} }
export const healthApi = { export const healthApi = {
features: (): Promise<{ bookingImport: boolean; aiParsing: boolean }> => apiClient.get('/health/features').then(r => r.data), features: (): Promise<{ bookingImport: boolean }> => apiClient.get('/health/features').then(r => r.data),
} }
export const weatherApi = { export const weatherApi = {
@@ -722,17 +647,6 @@ export const configApi = {
apiClient.get('/config').then(r => r.data), apiClient.get('/config').then(r => r.data),
} }
export interface HelpNavItem { title: string; slug: string }
export interface HelpNavSection { title: string; pages: HelpNavItem[] }
export interface HelpPageData { slug: string; title: string; markdown: string }
export const helpApi = {
index: (): Promise<{ sections: HelpNavSection[] }> =>
apiClient.get('/help/index').then(r => r.data),
page: (slug: string): Promise<HelpPageData> =>
apiClient.get(`/help/page/${encodeURIComponent(slug)}`).then(r => r.data),
}
export const settingsApi = { export const settingsApi = {
get: () => apiClient.get('/settings').then(r => r.data), get: () => apiClient.get('/settings').then(r => r.data),
set: (key: string, value: unknown) => { set: (key: string, value: unknown) => {
+2 -227
View File
@@ -4,8 +4,7 @@ import { useTranslation } from '../../i18n'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
import { useAddonStore } from '../../store/addonStore' import { useAddonStore } from '../../store/addonStore'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane, Server, Cloud } from 'lucide-react' import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane } from 'lucide-react'
import CustomSelect from '../shared/CustomSelect'
const ICON_MAP = { const ICON_MAP = {
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Plane, ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Plane,
@@ -299,12 +298,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
</span> </span>
</div> </div>
{integrationAddons.map(addon => ( {integrationAddons.map(addon => (
<div key={addon.id}> <AddonRow key={addon.id} addon={addon} onToggle={handleToggle} t={t} />
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
{addon.id === 'llm_parsing' && addon.enabled && (
<LlmParsingConfig addon={addon} />
)}
</div>
))} ))}
</div> </div>
)} )}
@@ -315,225 +309,6 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
) )
} }
const MASKED = '••••••••'
const DEFAULT_OLLAMA_URL = 'http://localhost:11434/v1'
/** Curated models the local extractor is tuned for, pullable via Ollama. The router drives
* one model per document via Ollama's grammar-constrained `format`; "thinking" is disabled
* automatically, so the Qwen3 family works without any tuning. A host only needs one. */
const RECOMMENDED_MODELS: { id: string; label: string; note: string; recommended: boolean; vision: boolean }[] = [
{ id: 'qwen3:8b', label: 'Qwen3 — 8B', note: 'Recommended · best extraction quality & speed on CPU (thinking auto-disabled) · Apache-2.0', recommended: true, vision: false },
]
/**
* Instance-wide AI-parsing config. When set, applies to the whole instance and
* overrides per-user config (see server llmConfig.ts). The API key is masked on
* read; an unchanged mask is treated as a no-op by the server. For the local
* provider, it also lists installed Ollama models and can pull NuExtract models.
*/
function LlmParsingConfig({ addon }: { addon: Addon }) {
const toast = useToast()
const cfg = (addon.config ?? {}) as Record<string, unknown>
const [provider, setProvider] = useState<string>((cfg.provider as string) ?? 'local')
const [model, setModel] = useState<string>((cfg.model as string) ?? '')
const [baseUrl, setBaseUrl] = useState<string>((cfg.baseUrl as string) ?? '')
const [apiKey, setApiKey] = useState<string>((cfg.apiKey as string) ?? '')
const [saving, setSaving] = useState(false)
// Local-provider model management.
const [installed, setInstalled] = useState<string[]>([])
const [modelsErr, setModelsErr] = useState('')
const [loadingModels, setLoadingModels] = useState(false)
const [pulling, setPulling] = useState<string | null>(null)
const [pullPct, setPullPct] = useState(0)
const [pullStatus, setPullStatus] = useState('')
const effectiveUrl = baseUrl.trim() || DEFAULT_OLLAMA_URL
const isInstalled = (id: string) => installed.some(n => n === id || n.startsWith(id + ':') || n.startsWith(id))
const loadModels = async () => {
if (provider !== 'local') return
setLoadingModels(true)
setModelsErr('')
try {
const res = await adminApi.llmLocalModels(effectiveUrl)
setInstalled(res.models.map(m => m.name))
} catch (e: unknown) {
setModelsErr(e instanceof Error ? e.message : 'Could not reach the local LLM server')
setInstalled([])
} finally {
setLoadingModels(false)
}
}
// Load installed models when the local provider is active.
useEffect(() => {
if (provider === 'local') loadModels()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [provider])
const pull = async (id: string) => {
if (pulling) return
setPulling(id)
setPullPct(0)
setPullStatus('starting…')
try {
await adminApi.llmLocalPull(effectiveUrl, id, (p) => {
if (p.error) throw new Error(p.error)
if (p.status) setPullStatus(p.status)
if (p.total && p.completed != null) setPullPct(Math.round((p.completed / p.total) * 100))
})
toast.success('Model pulled')
setModel(id)
await loadModels()
} catch (e: unknown) {
toast.error(e instanceof Error ? e.message : 'Pull failed')
} finally {
setPulling(null)
setPullPct(0)
setPullStatus('')
}
}
const save = async () => {
setSaving(true)
try {
// Send the masked sentinel unchanged so the server keeps the stored key.
await adminApi.updateAddon(addon.id, { config: { provider, model: model.trim(), baseUrl: baseUrl.trim(), apiKey, multimodal: cfg.multimodal === true } })
toast.success('Saved')
} catch {
toast.error('Failed to save')
} finally {
setSaving(false)
}
}
const fieldCls = 'w-full rounded-lg border border-edge-secondary bg-surface px-3 py-2 text-sm text-content placeholder:text-content-faint transition-colors focus:border-edge focus:outline-none'
const labelCls = 'mb-1.5 block text-xs font-medium text-content-secondary'
const sectionCls = 'text-[11px] font-semibold uppercase tracking-wide text-content-faint'
const providerOptions = [
{ value: 'local', label: 'Local · OpenAI-compatible', icon: <Server size={14} />, badge: 'Ollama' },
{ value: 'openai', label: 'OpenAI', icon: <Cloud size={14} /> },
{ value: 'anthropic', label: 'Anthropic', icon: <Sparkles size={14} /> },
]
return (
<div className="border-b border-edge-secondary bg-surface-secondary py-5 pr-6 pl-[70px]">
<div className="max-w-2xl space-y-6">
<p className="text-xs text-content-faint">
Set instance-wide config (applies to all users). Leave blank to let each user configure their own provider.
</p>
{/* Connection */}
<section className="space-y-3">
<div className={sectionCls}>Connection</div>
<div>
<span className={labelCls}>Provider</span>
<CustomSelect value={provider} onChange={v => setProvider(String(v))} options={providerOptions} />
</div>
{provider !== 'anthropic' && (
<label className="block">
<span className={labelCls}>Base URL</span>
<input type="url" autoComplete="off" className={fieldCls} value={baseUrl} onChange={e => setBaseUrl(e.target.value)} onBlur={loadModels} placeholder={provider === 'local' ? 'http://localhost:11434/v1' : 'https://api.openai.com/v1'} />
</label>
)}
<label className="block">
<span className={labelCls}>API key</span>
<input type="password" className={fieldCls} value={apiKey} onChange={e => setApiKey(e.target.value)} placeholder={apiKey === MASKED ? MASKED : provider === 'local' ? '(often not required)' : 'sk-…'} />
</label>
{provider === 'anthropic' && (
<p className="text-xs text-content-faint">Anthropic reads PDFs (including scans) natively. Local/OpenAI models receive extracted text scanned PDFs need Anthropic.</p>
)}
</section>
{/* Model */}
<section className="space-y-3">
<div className={sectionCls}>Model</div>
<label className="block">
<input autoComplete="off" className={fieldCls} value={model} onChange={e => setModel(e.target.value)} placeholder={provider === 'anthropic' ? 'claude-opus-4-8' : provider === 'openai' ? 'gpt-4o' : 'select or pull below'} />
</label>
{/* Local model management (Ollama) */}
{provider === 'local' && (
<div className="space-y-3 rounded-lg border border-edge-secondary bg-surface p-3">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-content-secondary">Installed on the server</span>
<button onClick={loadModels} disabled={loadingModels} className="text-xs text-content-muted underline disabled:opacity-60">
{loadingModels ? 'Loading…' : 'Refresh'}
</button>
</div>
{modelsErr && <p className="text-xs text-rose-600">{modelsErr}</p>}
{!modelsErr && installed.length === 0 && !loadingModels && (
<p className="text-xs text-content-faint">No models installed yet pull one below.</p>
)}
{installed.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{installed.map(name => (
<button
key={name}
title={name}
onClick={() => setModel(name)}
className={`max-w-full truncate rounded-full border px-2.5 py-1 text-xs transition-colors ${model === name ? 'border-transparent bg-accent text-accent-text' : 'border-edge-secondary text-content-secondary hover:border-edge'}`}
>
{name}
</button>
))}
</div>
)}
<div className="border-t border-edge-secondary pt-3">
<div className="mb-2 text-xs font-medium text-content-secondary">Pull a recommended model</div>
<div className="space-y-1">
{RECOMMENDED_MODELS.map(m => {
const installedHere = isInstalled(m.id)
const isPulling = pulling === m.id
const active = model === m.id
return (
<div key={m.id} className={`flex items-center gap-3 rounded-lg border px-3 py-2 transition-colors ${active ? 'border-edge-secondary bg-surface-secondary' : 'border-transparent'}`}>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm text-content">{m.label}</span>
{m.recommended && (
<span className="rounded-md bg-[rgba(16,185,129,0.15)] px-1.5 py-px text-[10px] font-semibold text-emerald-600">Recommended</span>
)}
</div>
<div className="text-xs text-content-faint">{m.note}</div>
{isPulling && (
<div className="mt-1.5">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-surface-tertiary">
<div className="h-full bg-accent transition-[width] duration-200" style={{ width: `${pullPct}%` }} />
</div>
<div className="mt-0.5 text-[10px] text-content-faint">{pullStatus}{pullPct ? ` · ${pullPct}%` : ''}</div>
</div>
)}
</div>
{installedHere ? (
<button onClick={() => setModel(m.id)} disabled={active} className={`shrink-0 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${active ? 'bg-surface-tertiary text-content-muted' : 'border border-edge-secondary text-content-secondary hover:border-edge'}`}>
{active ? 'Selected' : 'Use'}
</button>
) : (
<button onClick={() => pull(m.id)} disabled={!!pulling} className="shrink-0 rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-accent-text disabled:opacity-60">
{isPulling ? 'Pulling…' : 'Pull'}
</button>
)}
</div>
)
})}
</div>
</div>
</div>
)}
</section>
<button onClick={save} disabled={saving} className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-text transition-opacity disabled:opacity-60">
{saving ? 'Saving…' : 'Save'}
</button>
</div>
</div>
)
}
interface AddonRowProps { interface AddonRowProps {
addon: Addon addon: Addon
onToggle: (addon: Addon) => void onToggle: (addon: Addon) => void
+6 -6
View File
@@ -473,10 +473,10 @@ export default function BackupPanel() {
<AlertTriangle size={20} className="text-white" /> <AlertTriangle size={20} className="text-white" />
</div> </div>
<div> <div>
<h3 className="text-white" style={{ margin: 0, fontSize: 'calc(16px * var(--fs-scale-subtitle, 1))', fontWeight: 700 }}> <h3 className="text-white" style={{ margin: 0, fontSize: 16, fontWeight: 700 }}>
{t('backup.restoreConfirmTitle')} {t('backup.restoreConfirmTitle')}
</h3> </h3>
<p className="text-[rgba(255,255,255,0.8)]" style={{ margin: '2px 0 0', fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}> <p className="text-[rgba(255,255,255,0.8)]" style={{ margin: '2px 0 0', fontSize: 12 }}>
{restoreConfirm.filename} {restoreConfirm.filename}
</p> </p>
</div> </div>
@@ -484,11 +484,11 @@ export default function BackupPanel() {
{/* Body */} {/* Body */}
<div style={{ padding: '20px 24px' }}> <div style={{ padding: '20px 24px' }}>
<p className="text-gray-700 dark:text-gray-300" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', lineHeight: 1.6, margin: 0 }}> <p className="text-gray-700 dark:text-gray-300" style={{ fontSize: 13, lineHeight: 1.6, margin: 0 }}>
{t('backup.restoreWarning')} {t('backup.restoreWarning')}
</p> </p>
<div style={{ marginTop: 14, padding: '10px 12px', borderRadius: 10, fontSize: 'calc(12px * var(--fs-scale-body, 1))', lineHeight: 1.5 }} <div style={{ marginTop: 14, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
className="bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-300 border border-red-200 dark:border-red-800" className="bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-300 border border-red-200 dark:border-red-800"
> >
{t('backup.restoreTip')} {t('backup.restoreTip')}
@@ -500,14 +500,14 @@ export default function BackupPanel() {
<button <button
onClick={() => setRestoreConfirm(null)} onClick={() => setRestoreConfirm(null)}
className="text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700" className="text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700"
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }} style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
> >
{t('common.cancel')} {t('common.cancel')}
</button> </button>
<button <button
onClick={executeRestore} onClick={executeRestore}
className="bg-[#dc2626] text-white" className="bg-[#dc2626] text-white"
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }} style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
onMouseEnter={e => e.currentTarget.style.background = '#b91c1c'} onMouseEnter={e => e.currentTarget.style.background = '#b91c1c'}
onMouseLeave={e => e.currentTarget.style.background = '#dc2626'} onMouseLeave={e => e.currentTarget.style.background = '#dc2626'}
> >
@@ -7,16 +7,7 @@ import Section from '../Settings/Section'
import CustomSelect from '../shared/CustomSelect' import CustomSelect from '../shared/CustomSelect'
import { MapView } from '../Map/MapView' import { MapView } from '../Map/MapView'
import { CURRENCIES, SYMBOLS } from '../Budget/BudgetPanel.constants' import { CURRENCIES, SYMBOLS } from '../Budget/BudgetPanel.constants'
import type { DistanceUnit, Place } from '../../types' import type { Place } from '../../types'
import {
MAPBOX_DEFAULT_STYLE,
defaultStyleForProvider,
getStylePresets,
isOpenFreeMapStyle,
normalizeStyleForProvider,
styleSettingKey,
type GlMapProvider,
} from '../Map/glProviders'
const MAP_PRESETS = [ const MAP_PRESETS = [
{ name: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' }, { name: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' },
@@ -28,7 +19,6 @@ const MAP_PRESETS = [
type Defaults = { type Defaults = {
temperature_unit?: string temperature_unit?: string
distance_unit?: DistanceUnit
dark_mode?: string | boolean dark_mode?: string | boolean
time_format?: string time_format?: string
default_currency?: string default_currency?: string
@@ -37,22 +27,18 @@ type Defaults = {
map_provider?: string map_provider?: string
mapbox_access_token?: string mapbox_access_token?: string
mapbox_style?: string mapbox_style?: string
maplibre_style?: string
mapbox_3d_enabled?: boolean mapbox_3d_enabled?: boolean
mapbox_quality_mode?: boolean mapbox_quality_mode?: boolean
} }
type MapProvider = 'leaflet' | GlMapProvider const MAPBOX_STYLE_PRESETS = [
{ name: 'Standard', url: 'mapbox://styles/mapbox/standard' },
function normalizeProvider(value: unknown): MapProvider { { name: 'Streets', url: 'mapbox://styles/mapbox/streets-v12' },
return value === 'mapbox-gl' || value === 'maplibre-gl' ? value : 'leaflet' { name: 'Outdoors', url: 'mapbox://styles/mapbox/outdoors-v12' },
} { name: 'Light', url: 'mapbox://styles/mapbox/light-v11' },
{ name: 'Dark', url: 'mapbox://styles/mapbox/dark-v11' },
function styleForProvider(provider: MapProvider, style?: string | null): string { { name: 'Satellite Streets', url: 'mapbox://styles/mapbox/satellite-streets-v12' },
if (provider === 'leaflet') return style || MAPBOX_DEFAULT_STYLE ]
if (provider === 'mapbox-gl' && isOpenFreeMapStyle(style)) return MAPBOX_DEFAULT_STYLE
return normalizeStyleForProvider(provider, style)
}
function OptionRow({ function OptionRow({
label, label,
@@ -89,7 +75,7 @@ function OptionButton({
style={{ style={{
display: 'flex', alignItems: 'center', gap: 8, display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 20px', borderRadius: 10, cursor: 'pointer', padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
fontFamily: 'inherit', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500, fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
border: active ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)', border: active ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
background: active ? 'var(--bg-hover)' : 'var(--bg-card)', background: active ? 'var(--bg-hover)' : 'var(--bg-card)',
color: 'var(--text-primary)', color: 'var(--text-primary)',
@@ -112,11 +98,10 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
useEffect(() => { useEffect(() => {
adminApi.getDefaultUserSettings().then((data: Defaults) => { adminApi.getDefaultUserSettings().then((data: Defaults) => {
const provider = normalizeProvider(data.map_provider)
setDefaults(data) setDefaults(data)
setMapTileUrl(data.map_tile_url || '') setMapTileUrl(data.map_tile_url || '')
setMapboxToken(data.mapbox_access_token || '') setMapboxToken(data.mapbox_access_token || '')
setMapboxStyle(provider === 'leaflet' ? (data.mapbox_style || '') : styleForProvider(provider, provider === 'maplibre-gl' ? data.maplibre_style : data.mapbox_style)) setMapboxStyle(data.mapbox_style || '')
setLoaded(true) setLoaded(true)
}).catch(() => setLoaded(true)) }).catch(() => setLoaded(true))
}, []) }, [])
@@ -137,10 +122,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
setDefaults(updated) setDefaults(updated)
if (key === 'map_tile_url') setMapTileUrl('') if (key === 'map_tile_url') setMapTileUrl('')
if (key === 'mapbox_access_token') setMapboxToken('') if (key === 'mapbox_access_token') setMapboxToken('')
if (key === 'mapbox_style' || key === 'maplibre_style') { if (key === 'mapbox_style') setMapboxStyle('')
const provider = normalizeProvider(defaults.map_provider)
setMapboxStyle(provider === 'leaflet' ? '' : defaultStyleForProvider(provider))
}
toast.success(t('admin.defaultSettings.reset')) toast.success(t('admin.defaultSettings.reset'))
} catch (err: unknown) { } catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('common.error')) toast.error(err instanceof Error ? err.message : t('common.error'))
@@ -186,24 +168,10 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
}], []) }], [])
if (!loaded) { if (!loaded) {
return <p className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontStyle: 'italic', padding: 16 }}>Loading</p> return <p className="text-content-faint" style={{ fontSize: 12, fontStyle: 'italic', padding: 16 }}>Loading</p>
} }
const darkMode = defaults.dark_mode const darkMode = defaults.dark_mode
const mapProvider = normalizeProvider(defaults.map_provider)
const glStylePresets = mapProvider === 'leaflet' ? [] : getStylePresets(mapProvider)
const styleKey: keyof Defaults = mapProvider === 'maplibre-gl' ? 'maplibre_style' : 'mapbox_style'
const saveMapProvider = (nextProvider: MapProvider) => {
const patch: Partial<Defaults> = { map_provider: nextProvider }
if (nextProvider !== 'leaflet') {
// Load + save the new provider's own style slot so the other provider's style is kept.
const slot = nextProvider === 'maplibre-gl' ? defaults.maplibre_style : defaults.mapbox_style
const nextStyle = styleForProvider(nextProvider, slot)
setMapboxStyle(nextStyle)
patch[styleSettingKey(nextProvider)] = nextStyle
}
save(patch)
}
return ( return (
<Section title={t('admin.defaultSettings.title')} icon={Settings2}> <Section title={t('admin.defaultSettings.title')} icon={Settings2}>
@@ -244,22 +212,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
))} ))}
</OptionRow> </OptionRow>
{/* Distance */}
<OptionRow label={<>{t('settings.distance')} <ResetButton field="distance_unit" /></>}>
{([
{ value: 'metric', label: 'km Metric' },
{ value: 'imperial', label: 'mi Imperial' },
] as const).map(opt => (
<OptionButton
key={opt.value}
active={defaults.distance_unit === opt.value}
onClick={() => save({ distance_unit: opt.value })}
>
{opt.label}
</OptionButton>
))}
</OptionRow>
{/* Time Format */} {/* Time Format */}
<OptionRow label={<>{t('settings.timeFormat')} <ResetButton field="time_format" /></>}> <OptionRow label={<>{t('settings.timeFormat')} <ResetButton field="time_format" /></>}>
{([ {([
@@ -364,21 +316,19 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
{([ {([
{ value: 'leaflet', label: t('admin.defaultSettings.providerLeaflet') }, { value: 'leaflet', label: t('admin.defaultSettings.providerLeaflet') },
{ value: 'mapbox-gl', label: t('admin.defaultSettings.providerMapbox') }, { value: 'mapbox-gl', label: t('admin.defaultSettings.providerMapbox') },
{ value: 'maplibre-gl', label: t('admin.defaultSettings.providerMapLibre') },
] as const).map(opt => ( ] as const).map(opt => (
<OptionButton <OptionButton
key={opt.value} key={opt.value}
active={mapProvider === opt.value} active={(defaults.map_provider || 'leaflet') === opt.value}
onClick={() => saveMapProvider(opt.value)} onClick={() => save({ map_provider: opt.value })}
> >
{opt.label} {opt.label}
</OptionButton> </OptionButton>
))} ))}
</OptionRow> </OptionRow>
{mapProvider !== 'leaflet' && ( {defaults.map_provider === 'mapbox-gl' && (
<div style={{ marginTop: 16, display: 'flex', flexDirection: 'column', gap: 18 }}> <div style={{ marginTop: 16, display: 'flex', flexDirection: 'column', gap: 18 }}>
{mapProvider === 'mapbox-gl' && (
<div> <div>
<label className="block text-sm font-medium mb-1.5 text-content-secondary"> <label className="block text-sm font-medium mb-1.5 text-content-secondary">
{t('admin.defaultSettings.mapboxToken')} {t('admin.defaultSettings.mapboxToken')}
@@ -396,18 +346,17 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
/> />
<p className="text-xs mt-1 text-content-faint">{t('admin.defaultSettings.mapboxTokenHint')}</p> <p className="text-xs mt-1 text-content-faint">{t('admin.defaultSettings.mapboxTokenHint')}</p>
</div> </div>
)}
<div> <div>
<label className="block text-sm font-medium mb-1.5 text-content-secondary"> <label className="block text-sm font-medium mb-1.5 text-content-secondary">
{t('admin.defaultSettings.mapboxStyle')} {t('admin.defaultSettings.mapboxStyle')}
<ResetButton field={styleKey} /> <ResetButton field="mapbox_style" />
</label> </label>
<CustomSelect <CustomSelect
value={mapboxStyle} value={mapboxStyle}
onChange={(value: string) => { if (value) { setMapboxStyle(value); save({ [styleKey]: value }) } }} onChange={(value: string) => { if (value) { setMapboxStyle(value); save({ mapbox_style: value }) } }}
placeholder={t('admin.defaultSettings.mapboxStylePlaceholder')} placeholder={t('admin.defaultSettings.mapboxStylePlaceholder')}
options={glStylePresets.map(p => ({ value: p.url, label: p.name }))} options={MAPBOX_STYLE_PRESETS.map(p => ({ value: p.url, label: p.name }))}
size="sm" size="sm"
style={{ marginBottom: 8 }} style={{ marginBottom: 8 }}
/> />
@@ -415,18 +364,12 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
type="text" type="text"
value={mapboxStyle} value={mapboxStyle}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapboxStyle(e.target.value)} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapboxStyle(e.target.value)}
onBlur={() => { onBlur={() => save({ mapbox_style: mapboxStyle })}
const nextStyle = normalizeStyleForProvider(mapProvider, mapboxStyle) placeholder="mapbox://styles/mapbox/standard"
setMapboxStyle(nextStyle)
save({ [styleKey]: nextStyle })
}}
placeholder={defaultStyleForProvider(mapProvider)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/> />
</div> </div>
{mapProvider === 'mapbox-gl' && (
<>
<OptionRow label={<>{t('admin.defaultSettings.mapbox3d')} <ResetButton field="mapbox_3d_enabled" /></>}> <OptionRow label={<>{t('admin.defaultSettings.mapbox3d')} <ResetButton field="mapbox_3d_enabled" /></>}>
{([ {([
{ value: true, label: t('settings.on') || 'On' }, { value: true, label: t('settings.on') || 'On' },
@@ -448,8 +391,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
</OptionButton> </OptionButton>
))} ))}
</OptionRow> </OptionRow>
</>
)}
</div> </div>
)} )}
</div> </div>
@@ -1,163 +0,0 @@
import ReactDOM from 'react-dom'
import { useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { Loader2, CheckCircle2, AlertCircle, X } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { addListener, removeListener } from '../../api/websocket'
import { reservationsApi } from '../../api/client'
import { useBackgroundTasksStore, type BackgroundImportTask } from '../../store/backgroundTasksStore'
/**
* Global, route-independent widget (bottom-right) that tracks background booking
* imports. Mounted once at the app root so it survives navigation. It listens to the
* user's WebSocket for import:progress / import:done / import:error and reflects each
* job; a finished job offers a "review" action that takes the user to the trip, where
* the per-item review flow opens. Polls running jobs as a backstop for missed pushes.
*/
export default function BackgroundTasksWidget() {
const { t } = useTranslation()
const navigate = useNavigate()
const tasks = useBackgroundTasksStore((s) => s.tasks)
const setProgress = useBackgroundTasksStore((s) => s.setProgress)
const setDone = useBackgroundTasksStore((s) => s.setDone)
const setError = useBackgroundTasksStore((s) => s.setError)
const requestReview = useBackgroundTasksStore((s) => s.requestReview)
const dismiss = useBackgroundTasksStore((s) => s.dismiss)
// On (re)load, reconcile tasks restored from localStorage with the server: a parse
// that was still running when the page reloaded must keep its widget, so re-fetch each
// job's real status (and its parsed items) once. A job the server has since dropped
// (404, expired) is removed so no stale card lingers.
const didRehydrate = useRef(false)
useEffect(() => {
if (didRehydrate.current) return
didRehydrate.current = true
const restored = useBackgroundTasksStore.getState().tasks
for (const task of restored) {
reservationsApi
.importJobStatus(task.tripId, task.id)
.then((s) => {
if (s.status === 'done') setDone(task.id, task.tripId, (s.result?.items ?? []) as never, s.result?.warnings ?? [])
else if (s.status === 'error') setError(task.id, task.tripId, s.error ?? 'error')
else setProgress(task.id, task.tripId, s.done, s.total)
})
.catch((err: { response?: { status?: number } }) => {
if (err?.response?.status === 404) dismiss(task.id)
})
}
// run once on mount against whatever was rehydrated from storage
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Server pushes import:* to the user on whatever page they're on.
useEffect(() => {
const handler = (e: Record<string, unknown>) => {
const type = typeof e.type === 'string' ? e.type : ''
if (!type.startsWith('import:')) return
const id = String(e.jobId ?? '')
const tripId = String(e.tripId ?? '')
if (!id) return
if (type === 'import:progress') setProgress(id, tripId, Number(e.done ?? 0), Number(e.total ?? 1))
else if (type === 'import:done') {
const result = e.result as { items?: unknown[]; warnings?: string[] } | undefined
setDone(id, tripId, (result?.items ?? []) as never, result?.warnings ?? [])
} else if (type === 'import:error') setError(id, tripId, String(e.message ?? 'error'))
}
addListener(handler)
return () => removeListener(handler)
}, [setProgress, setDone, setError])
// Backstop: poll jobs whose state we still need — running ones (in case a WebSocket push
// was missed) and a restored 'done' task whose items haven't been re-fetched yet (so a
// failed one-shot rehydrate self-heals instead of getting stuck on "preview empty").
useEffect(() => {
const pending = tasks.filter((task) => task.status === 'running' || (task.status === 'done' && task.items === undefined))
if (pending.length === 0) return
const iv = setInterval(() => {
for (const task of pending) {
reservationsApi
.importJobStatus(task.tripId, task.id)
.then((s) => {
if (s.status === 'done') setDone(task.id, task.tripId, (s.result?.items ?? []) as never, s.result?.warnings ?? [])
else if (s.status === 'error') setError(task.id, task.tripId, s.error ?? 'error')
else setProgress(task.id, task.tripId, s.done, s.total)
})
.catch(() => {})
}
}, 5000)
return () => clearInterval(iv)
}, [tasks, setProgress, setDone, setError])
if (tasks.length === 0) return null
const review = (task: BackgroundImportTask) => {
requestReview(task.id)
navigate(`/trips/${task.tripId}`)
}
return ReactDOM.createPortal(
<div
style={{ position: 'fixed', right: 16, bottom: 16, zIndex: 50000, display: 'flex', flexDirection: 'column', gap: 8, width: 380, maxWidth: 'calc(100vw - 32px)', fontFamily: 'var(--font-system)' }}
>
{tasks.map((task) => (
<div
key={task.id}
className="bg-surface-card"
style={{ borderRadius: 12, border: '1px solid var(--border-primary)', boxShadow: '0 8px 24px rgba(0,0,0,0.18)', padding: '11px 13px', backdropFilter: 'blur(8px)', display: 'flex', gap: 10, alignItems: 'flex-start' }}
>
<div style={{ flexShrink: 0, marginTop: 1 }}>
{(task.status === 'running' || (task.status === 'done' && task.items === undefined)) && <Loader2 size={16} className="animate-spin" color="var(--accent)" />}
{task.status === 'done' && task.items !== undefined && <CheckCircle2 size={16} color="#10b981" />}
{task.status === 'error' && <AlertCircle size={16} color="#ef4444" />}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{task.label}
</div>
{task.status === 'running' && (
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginTop: 1 }}>
{t('reservations.import.parsing')}
{task.total > 1 ? ` · ${task.done}/${task.total}` : ''}
</div>
)}
{task.status === 'done' && (
task.items === undefined ? (
// Restored from a reload; items are being re-fetched (see the poll backstop).
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginTop: 1 }}>{t('reservations.import.parsing')}</div>
) : task.items.length > 0 ? (
<button
onClick={() => review(task)}
className="bg-accent text-accent-text"
style={{ marginTop: 4, border: 'none', borderRadius: 8, padding: '4px 12px', fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}
>
{t('common.import')}
</button>
) : (
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginTop: 1 }}>{t('reservations.import.previewEmpty')}</div>
)
)}
{task.status === 'error' && (
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: '#b91c1c', marginTop: 1, whiteSpace: 'pre-wrap' }}>{task.error}</div>
)}
</div>
{task.status !== 'running' && (
<button
onClick={() => dismiss(task.id)}
className="bg-transparent text-content-faint"
style={{ flexShrink: 0, border: 'none', cursor: 'pointer', padding: 2, borderRadius: 6, display: 'flex', alignItems: 'center' }}
aria-label={t('common.close')}
>
<X size={13} />
</button>
)}
</div>
))}
</div>,
document.body
)
}
+7 -7
View File
@@ -38,14 +38,14 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
<div style={{ width: 64, height: 64, borderRadius: 16, background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 20px' }}> <div style={{ width: 64, height: 64, borderRadius: 16, background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 20px' }}>
<Calculator size={28} color="#6b7280" /> <Calculator size={28} color="#6b7280" />
</div> </div>
<h2 style={{ fontSize: 'calc(20px * var(--fs-scale-title, 1))', fontWeight: 700, color: 'var(--text-primary)', margin: '0 0 8px' }}>{t('budget.emptyTitle')}</h2> <h2 style={{ fontSize: 20, fontWeight: 700, color: 'var(--text-primary)', margin: '0 0 8px' }}>{t('budget.emptyTitle')}</h2>
<p style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', color: 'var(--text-muted)', margin: '0 0 24px', lineHeight: 1.5 }}>{t('budget.emptyText')}</p> <p style={{ fontSize: 14, color: 'var(--text-muted)', margin: '0 0 24px', lineHeight: 1.5 }}>{t('budget.emptyText')}</p>
{canEdit && ( {canEdit && (
<div style={{ display: 'flex', gap: 6, justifyContent: 'center', alignItems: 'stretch', maxWidth: 320, margin: '0 auto' }}> <div style={{ display: 'flex', gap: 6, justifyContent: 'center', alignItems: 'stretch', maxWidth: 320, margin: '0 auto' }}>
<input value={newCategoryName} onChange={e => setNewCategoryName(e.target.value)} <input value={newCategoryName} onChange={e => setNewCategoryName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleAddCategory()} onKeyDown={e => e.key === 'Enter' && handleAddCategory()}
placeholder={t('budget.emptyPlaceholder')} placeholder={t('budget.emptyPlaceholder')}
style={{ flex: 1, padding: '9px 14px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontFamily: 'inherit', outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', minWidth: 0 }} /> style={{ flex: 1, padding: '9px 14px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 13, fontFamily: 'inherit', outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', minWidth: 0 }} />
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()} <button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
style={{ background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 10, padding: '0 12px', cursor: 'pointer', display: 'flex', alignItems: 'center', opacity: newCategoryName.trim() ? 1 : 0.5, flexShrink: 0 }}> style={{ background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 10, padding: '0 12px', cursor: 'pointer', display: 'flex', alignItems: 'center', opacity: newCategoryName.trim() ? 1 : 0.5, flexShrink: 0 }}>
<Plus size={16} /> <Plus size={16} />
@@ -65,7 +65,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
padding: '14px 16px 14px 22px', padding: '14px 16px 14px 22px',
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, flexWrap: 'wrap', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, flexWrap: 'wrap',
}}> }}>
<h2 style={{ margin: 0, fontSize: 'calc(18px * var(--fs-scale-subtitle, 1))', fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em', flexShrink: 0 }}> <h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em', flexShrink: 0 }}>
{t('budget.title')} {t('budget.title')}
</h2> </h2>
<div className="flex flex-wrap max-md:!w-full max-md:!mt-2" style={{ alignItems: 'center', gap: 8, marginLeft: 'auto', flexShrink: 0 }}> <div className="flex flex-wrap max-md:!w-full max-md:!mt-2" style={{ alignItems: 'center', gap: 8, marginLeft: 'auto', flexShrink: 0 }}>
@@ -85,14 +85,14 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
onChange={e => setNewCategoryName(e.target.value)} onChange={e => setNewCategoryName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleAddCategory() }} onKeyDown={e => { if (e.key === 'Enter') handleAddCategory() }}
placeholder={t('budget.categoryName')} placeholder={t('budget.categoryName')}
style={{ flex: 1, minWidth: 0, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '9px 14px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', outline: 'none', fontFamily: 'inherit', background: 'var(--bg-card)', color: 'var(--text-primary)' }} style={{ flex: 1, minWidth: 0, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '9px 14px', fontSize: 13, outline: 'none', fontFamily: 'inherit', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
/> />
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()} <button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
title={t('budget.addCategory')} title={t('budget.addCategory')}
style={{ style={{
appearance: 'none', border: 'none', cursor: newCategoryName.trim() ? 'pointer' : 'default', fontFamily: 'inherit', appearance: 'none', border: 'none', cursor: newCategoryName.trim() ? 'pointer' : 'default', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 6, display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '9px 14px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, padding: '9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
background: 'var(--accent)', color: 'var(--accent-text)', flexShrink: 0, background: 'var(--accent)', color: 'var(--accent-text)', flexShrink: 0,
opacity: newCategoryName.trim() ? 1 : 0.4, opacity: newCategoryName.trim() ? 1 : 0.4,
transition: 'opacity 0.15s ease', transition: 'opacity 0.15s ease',
@@ -105,7 +105,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
style={{ style={{
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit', appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 6, display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '9px 14px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, padding: '9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
background: 'var(--accent)', color: 'var(--accent-text)', flexShrink: 0, background: 'var(--accent)', color: 'var(--accent-text)', flexShrink: 0,
transition: 'opacity 0.15s ease', transition: 'opacity 0.15s ease',
}} }}
@@ -23,7 +23,7 @@ export default function AddItemRow({ onAdd, t }: AddItemRowProps) {
setTimeout(() => nameRef.current?.focus(), 50) setTimeout(() => nameRef.current?.focus(), 50)
} }
const inp = { border: '1px solid var(--border-primary)', borderRadius: 4, padding: '4px 6px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', outline: 'none', fontFamily: 'inherit', width: '100%', background: 'var(--bg-input)', color: 'var(--text-primary)' } const inp = { border: '1px solid var(--border-primary)', borderRadius: 4, padding: '4px 6px', fontSize: 13, outline: 'none', fontFamily: 'inherit', width: '100%', background: 'var(--bg-input)', color: 'var(--text-primary)' }
return ( return (
<tr className="bg-surface-secondary"> <tr className="bg-surface-secondary">
@@ -44,9 +44,9 @@ export default function AddItemRow({ onAdd, t }: AddItemRowProps) {
<input value={days} onChange={e => setDays(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} <input value={days} onChange={e => setDays(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 60, margin: '0 auto' }} /> placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 60, margin: '0 auto' }} />
</td> </td>
<td className="hidden md:table-cell text-content-faint" style={{ padding: '4px 6px', fontSize: 'calc(12px * var(--fs-scale-body, 1))', textAlign: 'center' }}>-</td> <td className="hidden md:table-cell text-content-faint" style={{ padding: '4px 6px', fontSize: 12, textAlign: 'center' }}>-</td>
<td className="hidden md:table-cell text-content-faint" style={{ padding: '4px 6px', fontSize: 'calc(12px * var(--fs-scale-body, 1))', textAlign: 'center' }}>-</td> <td className="hidden md:table-cell text-content-faint" style={{ padding: '4px 6px', fontSize: 12, textAlign: 'center' }}>-</td>
<td className="hidden lg:table-cell text-content-faint" style={{ padding: '4px 6px', fontSize: 'calc(12px * var(--fs-scale-body, 1))', textAlign: 'center' }}>-</td> <td className="hidden lg:table-cell text-content-faint" style={{ padding: '4px 6px', fontSize: 12, textAlign: 'center' }}>-</td>
<td className="hidden sm:table-cell" style={{ padding: '4px 6px', textAlign: 'center' }}> <td className="hidden sm:table-cell" style={{ padding: '4px 6px', textAlign: 'center' }}>
<div style={{ maxWidth: 90, margin: '0 auto' }}> <div style={{ maxWidth: 90, margin: '0 auto' }}>
<CustomDatePicker value={expenseDate} onChange={setExpenseDate} placeholder="-" compact /> <CustomDatePicker value={expenseDate} onChange={setExpenseDate} placeholder="-" compact />
@@ -103,11 +103,11 @@ export default function BudgetCategoryTable({ cat, grouped, categoryColor, canEd
onChange={e => setEditingCat({ ...editingCat, value: e.target.value })} onChange={e => setEditingCat({ ...editingCat, value: e.target.value })}
onBlur={() => { handleRenameCategory(cat, editingCat.value); setEditingCat(null) }} onBlur={() => { handleRenameCategory(cat, editingCat.value); setEditingCat(null) }}
onKeyDown={e => { if (e.key === 'Enter') { handleRenameCategory(cat, editingCat.value); setEditingCat(null) } if (e.key === 'Escape') setEditingCat(null) }} onKeyDown={e => { if (e.key === 'Enter') { handleRenameCategory(cat, editingCat.value); setEditingCat(null) } if (e.key === 'Escape') setEditingCat(null) }}
style={{ fontWeight: 600, fontSize: 'calc(13px * var(--fs-scale-body, 1))', background: 'rgba(255,255,255,0.15)', border: 'none', borderRadius: 4, color: '#fff', padding: '1px 6px', outline: 'none', fontFamily: 'inherit', width: '100%' }} style={{ fontWeight: 600, fontSize: 13, background: 'rgba(255,255,255,0.15)', border: 'none', borderRadius: 4, color: '#fff', padding: '1px 6px', outline: 'none', fontFamily: 'inherit', width: '100%' }}
/> />
) : ( ) : (
<> <>
<span style={{ fontWeight: 600, fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>{cat}</span> <span style={{ fontWeight: 600, fontSize: 13 }}>{cat}</span>
{canEdit && ( {canEdit && (
<button onClick={() => setEditingCat({ name: cat, value: cat })} <button onClick={() => setEditingCat({ name: cat, value: cat })}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.4)', display: 'flex', padding: 1 }} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.4)', display: 'flex', padding: 1 }}
@@ -119,7 +119,7 @@ export default function BudgetCategoryTable({ cat, grouped, categoryColor, canEd
)} )}
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, opacity: 0.9 }}>{fmt(subtotal, currency)}</span> <span style={{ fontSize: 13, fontWeight: 500, opacity: 0.9 }}>{fmt(subtotal, currency)}</span>
{canEdit && ( {canEdit && (
<button onClick={() => handleDeleteCategory(cat)} title={t('budget.deleteCategory')} <button onClick={() => handleDeleteCategory(cat)} title={t('budget.deleteCategory')}
style={{ background: 'rgba(255,255,255,0.1)', border: 'none', borderRadius: 4, color: '#fff', cursor: 'pointer', padding: '3px 6px', display: 'flex', alignItems: 'center', opacity: 0.6 }} style={{ background: 'rgba(255,255,255,0.1)', border: 'none', borderRadius: 4, color: '#fff', cursor: 'pointer', padding: '3px 6px', display: 'flex', alignItems: 'center', opacity: 0.6 }}
@@ -233,7 +233,7 @@ export default function BudgetCategoryTable({ cat, grouped, categoryColor, canEd
<CustomDatePicker value={item.expense_date || ''} onChange={v => handleUpdateField(item.id, 'expense_date', v || null)} placeholder="—" compact borderless /> <CustomDatePicker value={item.expense_date || ''} onChange={v => handleUpdateField(item.id, 'expense_date', v || null)} placeholder="—" compact borderless />
</div> </div>
) : ( ) : (
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: item.expense_date ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{item.expense_date || '—'}</span> <span style={{ fontSize: 11, color: item.expense_date ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{item.expense_date || '—'}</span>
)} )}
</td> </td>
<td className="hidden sm:table-cell" style={td}><InlineEditCell value={item.note} onSave={v => handleUpdateField(item.id, 'note', v)} placeholder={t('budget.table.note')} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} /></td> <td className="hidden sm:table-cell" style={td}><InlineEditCell value={item.note} onSave={v => handleUpdateField(item.id, 'note', v)} placeholder={t('budget.table.note')} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} /></td>
@@ -50,7 +50,7 @@ export default function InlineEditCell({ value, onSave, type = 'text', style = {
return <input ref={inputRef} type="text" inputMode={type === 'number' ? 'decimal' : 'text'} value={editValue} return <input ref={inputRef} type="text" inputMode={type === 'number' ? 'decimal' : 'text'} value={editValue}
onChange={e => setEditValue(e.target.value)} onBlur={save} onPaste={handlePaste} onChange={e => setEditValue(e.target.value)} onBlur={save} onPaste={handlePaste}
onKeyDown={e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') { setEditValue(value ?? ''); setEditing(false) } }} onKeyDown={e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') { setEditValue(value ?? ''); setEditing(false) } }}
style={{ width: '100%', border: '1px solid var(--accent)', borderRadius: 4, padding: '4px 6px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', ...style }} style={{ width: '100%', border: '1px solid var(--accent)', borderRadius: 4, padding: '4px 6px', fontSize: 13, outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', ...style }}
placeholder={placeholder} /> placeholder={placeholder} />
} }
@@ -62,7 +62,7 @@ export default function InlineEditCell({ value, onSave, type = 'text', style = {
<div onClick={() => { if (readOnly) return; setEditValue(value ?? ''); setEditing(true) }} title={readOnly ? undefined : editTooltip} <div onClick={() => { if (readOnly) return; setEditValue(value ?? ''); setEditing(true) }} title={readOnly ? undefined : editTooltip}
style={{ cursor: readOnly ? 'default' : 'pointer', padding: '2px 4px', borderRadius: 4, minHeight: 22, display: 'flex', alignItems: 'center', style={{ cursor: readOnly ? 'default' : 'pointer', padding: '2px 4px', borderRadius: 4, minHeight: 22, display: 'flex', alignItems: 'center',
justifyContent: style?.textAlign === 'center' ? 'center' : 'flex-start', transition: 'background 0.15s', justifyContent: style?.textAlign === 'center' ? 'center' : 'flex-start', transition: 'background 0.15s',
color: display ? 'var(--text-primary)' : 'var(--text-faint)', fontSize: 'calc(13px * var(--fs-scale-body, 1))', ...style }} color: display ? 'var(--text-primary)' : 'var(--text-faint)', fontSize: 13, ...style }}
onMouseEnter={e => { if (!readOnly) e.currentTarget.style.background = 'var(--bg-hover)' }} onMouseEnter={e => { if (!readOnly) e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { if (!readOnly) e.currentTarget.style.background = 'transparent' }}> onMouseLeave={e => { if (!readOnly) e.currentTarget.style.background = 'transparent' }}>
{display || placeholder || '-'} {display || placeholder || '-'}
@@ -7,7 +7,6 @@ export interface TripMember {
id: number id: number
username: string username: string
avatar_url?: string | null avatar_url?: string | null
is_guest?: boolean
} }
// ── Chip with custom tooltip ───────────────────────────────────────────────── // ── Chip with custom tooltip ─────────────────────────────────────────────────
@@ -57,13 +56,13 @@ export function ChipWithTooltip({ label, avatarUrl, size = 20, paid, onClick }:
pointerEvents: 'none', zIndex: 10000, whiteSpace: 'nowrap', pointerEvents: 'none', zIndex: 10000, whiteSpace: 'nowrap',
display: 'flex', alignItems: 'center', gap: 5, display: 'flex', alignItems: 'center', gap: 5,
background: 'var(--bg-card, white)', color: 'var(--text-primary, #111827)', background: 'var(--bg-card, white)', color: 'var(--text-primary, #111827)',
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500, padding: '5px 10px', borderRadius: 8, fontSize: 11, fontWeight: 500, padding: '5px 10px', borderRadius: 8,
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint, #e5e7eb)', boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint, #e5e7eb)',
}}> }}>
{label} {label}
{paid && ( {paid && (
<span style={{ <span style={{
fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 700, padding: '1px 5px', borderRadius: 4, fontSize: 9, fontWeight: 700, padding: '1px 5px', borderRadius: 4,
background: 'rgba(34,197,94,0.15)', color: '#16a34a', background: 'rgba(34,197,94,0.15)', color: '#16a34a',
textTransform: 'uppercase', letterSpacing: '0.03em', textTransform: 'uppercase', letterSpacing: '0.03em',
}}>Paid</span> }}>Paid</span>
@@ -152,14 +151,14 @@ export default function BudgetMemberChips({ members = [], tripMembers = [], onSe
<button key={tm.id} onClick={() => toggleMember(tm.id)} style={{ <button key={tm.id} onClick={() => toggleMember(tm.id)} style={{
display: 'flex', alignItems: 'center', gap: 6, width: '100%', padding: '5px 8px', display: 'flex', alignItems: 'center', gap: 6, width: '100%', padding: '5px 8px',
borderRadius: 6, border: 'none', background: isActive ? 'var(--bg-hover)' : 'none', cursor: 'pointer', borderRadius: 6, border: 'none', background: isActive ? 'var(--bg-hover)' : 'none', cursor: 'pointer',
fontFamily: 'inherit', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-primary)', textAlign: 'left', fontFamily: 'inherit', fontSize: 11, color: 'var(--text-primary)', textAlign: 'left',
}} }}
onMouseEnter={e => { if (!isActive) e.currentTarget.style.background = 'var(--bg-hover)' }} onMouseEnter={e => { if (!isActive) e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'none' }} onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'none' }}
> >
<div style={{ <div style={{
width: 18, height: 18, borderRadius: '50%', background: 'var(--bg-tertiary)', width: 18, height: 18, borderRadius: '50%', background: 'var(--bg-tertiary)',
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 'calc(8px * var(--fs-scale-caption, 1))', fontWeight: 700, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 8, fontWeight: 700,
color: 'var(--text-muted)', overflow: 'hidden', flexShrink: 0, color: 'var(--text-muted)', overflow: 'hidden', flexShrink: 0,
}}> }}>
{tm.avatar_url {tm.avatar_url
@@ -51,10 +51,10 @@ export default function PerPersonInline({ tripId, budgetItems, currency, locale,
<div key={p.user_id} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '6px 0' }}> <div key={p.user_id} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '6px 0' }}>
<RingAvatar userId={p.user_id} username={p.username} avatarUrl={p.avatar_url} size={34} innerBg={theme.centerBg} textColor={theme.text} /> <RingAvatar userId={p.user_id} username={p.username} avatarUrl={p.avatar_url} size={34} innerBg={theme.centerBg} textColor={theme.text} />
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 'calc(13.5px * var(--fs-scale-body, 1))', fontWeight: 500, letterSpacing: '-0.01em', color: theme.text }}>{p.username}</div> <div style={{ fontSize: 13.5, fontWeight: 500, letterSpacing: '-0.01em', color: theme.text }}>{p.username}</div>
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: theme.faint, marginTop: 1 }}>{percent}%</div> <div style={{ fontSize: 11, color: theme.faint, marginTop: 1 }}>{percent}%</div>
</div> </div>
<div style={{ fontSize: 'calc(13.5px * var(--fs-scale-body, 1))', fontWeight: 600, color: theme.text, letterSpacing: '-0.01em' }}>{fmt(p.total_assigned)}</div> <div style={{ fontSize: 13.5, fontWeight: 600, color: theme.text, letterSpacing: '-0.01em' }}>{fmt(p.total_assigned)}</div>
</div> </div>
) )
})} })}
@@ -46,7 +46,7 @@ export default function PieChart({ segments, size = 200, totalLabel }: PieChartP
boxShadow: 'inset 0 0 12px rgba(0,0,0,0.04)', boxShadow: 'inset 0 0 12px rgba(0,0,0,0.04)',
}}> }}>
<Wallet size={18} color="var(--text-faint)" style={{ marginBottom: 2 }} /> <Wallet size={18} color="var(--text-faint)" style={{ marginBottom: 2 }} />
<span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', fontWeight: 500 }}>{totalLabel}</span> <span style={{ fontSize: 10, color: 'var(--text-faint)', fontWeight: 500 }}>{totalLabel}</span>
</div> </div>
</div> </div>
) )
@@ -47,7 +47,7 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has
<Wallet size={20} strokeWidth={2} /> <Wallet size={20} strokeWidth={2} />
</div> </div>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: theme.faint, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.09em' }}>{t('budget.totalBudget')}</div> <div style={{ fontSize: 11, color: theme.faint, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.09em' }}>{t('budget.totalBudget')}</div>
</div> </div>
</div> </div>
@@ -58,13 +58,13 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has
const [integerPart, decimalPart] = decimals > 0 ? full.split(sep) : [full, ''] const [integerPart, decimalPart] = decimals > 0 ? full.split(sep) : [full, '']
return ( return (
<div style={{ display: 'flex', alignItems: 'baseline', gap: 4, letterSpacing: '-0.03em', lineHeight: 1 }}> <div style={{ display: 'flex', alignItems: 'baseline', gap: 4, letterSpacing: '-0.03em', lineHeight: 1 }}>
<span style={{ fontSize: 'calc(38px * var(--fs-scale-title, 1))', fontWeight: 700 }}>{integerPart}</span> <span style={{ fontSize: 38, fontWeight: 700 }}>{integerPart}</span>
{decimalPart && <span style={{ fontSize: 'calc(22px * var(--fs-scale-title, 1))', fontWeight: 500, color: theme.sub }}>{sep}{decimalPart}</span>} {decimalPart && <span style={{ fontSize: 22, fontWeight: 500, color: theme.sub }}>{sep}{decimalPart}</span>}
<span style={{ fontSize: 'calc(22px * var(--fs-scale-title, 1))', fontWeight: 500, color: theme.sub, marginLeft: 2 }}>{SYMBOLS[currency] || currency}</span> <span style={{ fontSize: 22, fontWeight: 500, color: theme.sub, marginLeft: 2 }}>{SYMBOLS[currency] || currency}</span>
</div> </div>
) )
})()} })()}
<div style={{ color: theme.faint, fontSize: 'calc(12px * var(--fs-scale-body, 1))', marginTop: 8, fontWeight: 500, letterSpacing: '0.04em', display: 'flex', alignItems: 'center', gap: 6 }}> <div style={{ color: theme.faint, fontSize: 12, marginTop: 8, fontWeight: 500, letterSpacing: '0.04em', display: 'flex', alignItems: 'center', gap: 6 }}>
<span>{currency}</span> <span>{currency}</span>
</div> </div>
@@ -78,7 +78,7 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has
<button onClick={() => setSettlementOpen(v => !v)} style={{ <button onClick={() => setSettlementOpen(v => !v)} style={{
display: 'flex', alignItems: 'center', gap: 6, width: '100%', display: 'flex', alignItems: 'center', gap: 6, width: '100%',
background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit', background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit',
color: theme.sub, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, letterSpacing: 0.5, color: theme.sub, fontSize: 11, fontWeight: 600, letterSpacing: 0.5,
}}> }}>
{settlementOpen ? <ChevronDown size={13} /> : <ChevronRight size={13} />} {settlementOpen ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
{t('budget.settlement')} {t('budget.settlement')}
@@ -95,7 +95,7 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has
marginTop: 6, width: 220, padding: '10px 12px', borderRadius: 10, zIndex: 100, marginTop: 6, width: 220, padding: '10px 12px', borderRadius: 10, zIndex: 100,
background: 'var(--bg-card)', border: '1px solid var(--border-faint)', background: 'var(--bg-card)', border: '1px solid var(--border-faint)',
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', boxShadow: '0 4px 16px rgba(0,0,0,0.12)',
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 400, color: 'var(--text-secondary)', lineHeight: 1.5, textAlign: 'left', fontSize: 11, fontWeight: 400, color: 'var(--text-secondary)', lineHeight: 1.5, textAlign: 'left',
}}> }}>
{t('budget.settlementInfo')} {t('budget.settlementInfo')}
</div> </div>
@@ -117,7 +117,7 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has
> >
<RingAvatar userId={flow.from.user_id} username={flow.from.username} avatarUrl={flow.from.avatar_url} size={32} innerBg={theme.centerBg} textColor={theme.text} /> <RingAvatar userId={flow.from.user_id} username={flow.from.username} avatarUrl={flow.from.avatar_url} size={32} innerBg={theme.centerBg} textColor={theme.text} />
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 5 }}> <div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 5 }}>
<span style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 700, color: '#ef4444', letterSpacing: '-0.01em' }}> <span style={{ fontSize: 13, fontWeight: 700, color: '#ef4444', letterSpacing: '-0.01em' }}>
{fmt(flow.amount, currency)} {fmt(flow.amount, currency)}
</span> </span>
<div style={{ width: '100%', height: 2, borderRadius: 2, background: 'linear-gradient(90deg, rgba(239,68,68,0.1), rgba(239,68,68,0.55), rgba(239,68,68,0.3))', position: 'relative' }}> <div style={{ width: '100%', height: 2, borderRadius: 2, background: 'linear-gradient(90deg, rgba(239,68,68,0.1), rgba(239,68,68,0.55), rgba(239,68,68,0.3))', position: 'relative' }}>
@@ -130,7 +130,7 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has
{settlement.balances.filter(b => Math.abs(b.balance) > 0.01).length > 0 && ( {settlement.balances.filter(b => Math.abs(b.balance) > 0.01).length > 0 && (
<div style={{ marginTop: 8, borderTop: `1px solid ${theme.divider}`, paddingTop: 12 }}> <div style={{ marginTop: 8, borderTop: `1px solid ${theme.divider}`, paddingTop: 12 }}>
<div style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, color: theme.faint, textTransform: 'uppercase', letterSpacing: '0.11em', marginBottom: 10 }}> <div style={{ fontSize: 10, fontWeight: 700, color: theme.faint, textTransform: 'uppercase', letterSpacing: '0.11em', marginBottom: 10 }}>
{t('budget.netBalances')} {t('budget.netBalances')}
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
@@ -140,13 +140,13 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has
return ( return (
<div key={b.user_id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '5px 0' }}> <div key={b.user_id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '5px 0' }}>
<RingAvatar userId={b.user_id} username={b.username} avatarUrl={b.avatar_url} size={26} innerBg={theme.centerBg} textColor={theme.text} /> <RingAvatar userId={b.user_id} username={b.username} avatarUrl={b.avatar_url} size={26} innerBg={theme.centerBg} textColor={theme.text} />
<span style={{ flex: 1, fontSize: 'calc(13px * var(--fs-scale-body, 1))', color: theme.text, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> <span style={{ flex: 1, fontSize: 13, color: theme.text, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{b.username} {b.username}
</span> </span>
<span style={{ <span style={{
display: 'inline-flex', alignItems: 'center', gap: 4, display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '4px 10px', borderRadius: 8, padding: '4px 10px', borderRadius: 8,
fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 700, letterSpacing: '-0.01em', fontSize: 12, fontWeight: 700, letterSpacing: '-0.01em',
background: positive ? 'rgba(16,185,129,0.13)' : 'rgba(239,68,68,0.13)', background: positive ? 'rgba(16,185,129,0.13)' : 'rgba(239,68,68,0.13)',
color: positive ? '#10b981' : '#ef4444', color: positive ? '#10b981' : '#ef4444',
}}> }}>
@@ -192,7 +192,7 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has
<PieChartIcon size={18} strokeWidth={2} /> <PieChartIcon size={18} strokeWidth={2} />
</div> </div>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: theme.faint, textTransform: 'uppercase', letterSpacing: '0.09em', fontWeight: 600 }}>{t('budget.byCategory')}</div> <div style={{ fontSize: 11, color: theme.faint, textTransform: 'uppercase', letterSpacing: '0.09em', fontWeight: 600 }}>{t('budget.byCategory')}</div>
</div> </div>
</div> </div>
@@ -226,12 +226,12 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has
})} })}
</svg> </svg>
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', textAlign: 'center', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2, pointerEvents: 'none' }}> <div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', textAlign: 'center', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2, pointerEvents: 'none' }}>
<div style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', color: theme.faint, textTransform: 'uppercase', letterSpacing: '0.12em', fontWeight: 700 }}>{t('budget.total')}</div> <div style={{ fontSize: 10.5, color: theme.faint, textTransform: 'uppercase', letterSpacing: '0.12em', fontWeight: 700 }}>{t('budget.total')}</div>
<div style={{ fontSize: 'calc(22px * var(--fs-scale-title, 1))', fontWeight: 700, letterSpacing: '-0.03em', lineHeight: 1, display: 'flex', alignItems: 'baseline', gap: 2 }}> <div style={{ fontSize: 22, fontWeight: 700, letterSpacing: '-0.03em', lineHeight: 1, display: 'flex', alignItems: 'baseline', gap: 2 }}>
<span>{totalInt}</span> <span>{totalInt}</span>
{totalDec && <span style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, color: theme.sub }}>{decimalSep}{totalDec}</span>} {totalDec && <span style={{ fontSize: 13, fontWeight: 500, color: theme.sub }}>{decimalSep}{totalDec}</span>}
</div> </div>
<div style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', color: theme.faint, fontWeight: 500, marginTop: 2 }}>{currency}</div> <div style={{ fontSize: 10.5, color: theme.faint, fontWeight: 500, marginTop: 2 }}>{currency}</div>
</div> </div>
</div> </div>
@@ -256,13 +256,13 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has
boxShadow: `0 0 12px ${seg.color}80`, boxShadow: `0 0 12px ${seg.color}80`,
}} /> }} />
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 'calc(13.5px * var(--fs-scale-body, 1))', fontWeight: 500, letterSpacing: '-0.01em', color: theme.text, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{seg.name}</div> <div style={{ fontSize: 13.5, fontWeight: 500, letterSpacing: '-0.01em', color: theme.text, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{seg.name}</div>
<div style={{ fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', color: theme.sub, fontWeight: 500, marginTop: 1 }}>{fmt(seg.value, currency)}</div> <div style={{ fontSize: 11.5, color: theme.sub, fontWeight: 500, marginTop: 1 }}>{fmt(seg.value, currency)}</div>
</div> </div>
<span style={{ <span style={{
flexShrink: 0, flexShrink: 0,
padding: '4px 9px', borderRadius: 7, padding: '4px 9px', borderRadius: 7,
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, letterSpacing: '-0.01em', fontSize: 11, fontWeight: 700, letterSpacing: '-0.01em',
background: `${seg.color}26`, background: `${seg.color}26`,
border: `1px solid ${seg.color}40`, border: `1px solid ${seg.color}40`,
color: chipColor, color: chipColor,
@@ -91,7 +91,7 @@ describe('CostsPanel — settlements in the ledger', () => {
expect(screen.getByText('Dinner')).toBeInTheDocument() expect(screen.getByText('Dinner')).toBeInTheDocument()
}) })
it('supports custom split amounts on save', async () => { it('auto-splits the total across participants and rebalances a pinned amount on save', async () => {
let posted: Record<string, unknown> | null = null let posted: Record<string, unknown> | null = null
server.use( server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })), http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
@@ -107,52 +107,24 @@ describe('CostsPanel — settlements in the ledger', () => {
await user.click(await screen.findByRole('button', { name: 'Add expense' })) await user.click(await screen.findByRole('button', { name: 'Add expense' }))
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Dinner') await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Dinner')
const nums = () => screen.getAllByPlaceholderText('0.00') as HTMLInputElement[] const nums = () => screen.getAllByRole('spinbutton') as HTMLInputElement[]
await user.type(nums()[0], '100') // total = 100 await user.type(nums()[0], '100') // total → auto equal-split across the 2 participants
await waitFor(() => expect(nums()[1].value).toBe('50'))
await user.click(screen.getByRole('button', { name: /Custom/i })) expect(nums()[2].value).toBe('50')
// Pin the first participant to 30 → the other non-pinned field rebalances to 70.
const customInputs = screen.getAllByPlaceholderText('50.00') await user.clear(nums()[1]); await user.type(nums()[1], '30')
await user.type(customInputs[0], '30') await waitFor(() => expect(nums()[2].value).toBe('70'))
await user.type(customInputs[1], '70')
const addBtns = screen.getAllByRole('button', { name: 'Add expense' }) const addBtns = screen.getAllByRole('button', { name: 'Add expense' })
await user.click(addBtns[addBtns.length - 1]) // footer submit await user.click(addBtns[addBtns.length - 1]) // footer submit
await waitFor(() => expect(posted).toBeTruthy()) await waitFor(() => expect(posted).toBeTruthy())
expect(posted!.total_price).toBe(100) expect(posted!.total_price).toBe(100)
expect(posted!.payers).toEqual([ expect(posted!.payers).toEqual(expect.arrayContaining([
expect.objectContaining({ amount: 100 })
])
expect(posted!.members).toEqual(expect.arrayContaining([
expect.objectContaining({ user_id: 1, amount: 30 }), expect.objectContaining({ user_id: 1, amount: 30 }),
expect.objectContaining({ user_id: 2, amount: 70 }), expect.objectContaining({ user_id: 2, amount: 70 }),
])) ]))
}) })
it('accepts a comma as the decimal separator in the total amount (#1256)', async () => {
let posted: Record<string, unknown> | null = null
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
http.post('/api/trips/1/budget', async ({ request }) => {
posted = await request.json() as Record<string, unknown>
return HttpResponse.json({ item: { ...buildBudgetItem({ trip_id: 1, name: 'AirTags' }), id: 6 } })
}),
)
const { default: userEvent } = await import('@testing-library/user-event')
const user = userEvent.setup()
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'AirTags')
await user.type(screen.getAllByPlaceholderText('0.00')[0], '39,99') // comma → normalized to 39.99
const addBtns = screen.getAllByRole('button', { name: 'Add expense' })
await user.click(addBtns[addBtns.length - 1]) // footer submit
await waitFor(() => expect(posted).toBeTruthy())
expect(posted!.total_price).toBe(39.99)
})
it('marks an expense with no payer as Unfinished', async () => { it('marks an expense with no payer as Unfinished', async () => {
const item = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Hotel' }), total_price: 90, payers: [], members: [{ user_id: 1, username: 'alice', paid: 0 }] } const item = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Hotel' }), total_price: 90, payers: [], members: [{ user_id: 1, username: 'alice', paid: 0 }] }
server.use( server.use(
@@ -163,95 +135,4 @@ describe('CostsPanel — settlements in the ledger', () => {
await screen.findByText('Hotel') await screen.findByText('Hotel')
expect(screen.getByText('Unfinished')).toBeInTheDocument() expect(screen.getByText('Unfinished')).toBeInTheDocument()
}) })
it('records a recorded-total expense with nobody to split with (#1286)', async () => {
let posted: Record<string, unknown> | null = null
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
http.post('/api/trips/1/budget', async ({ request }) => {
posted = await request.json() as Record<string, unknown>
return HttpResponse.json({ item: { ...buildBudgetItem({ trip_id: 1, name: 'Hotel' }), id: 9 } })
}),
)
const { default: userEvent } = await import('@testing-library/user-event')
const user = userEvent.setup()
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Hotel')
await user.type(screen.getAllByPlaceholderText('0.00')[0], '120') // total only, paid on-site later
// Deselect everyone — the cost is recorded without a split (the bug: this was blocked).
// The participant toggles are buttons; the same names also appear as plain text in
// the Balances sidebar, so target the buttons specifically.
await user.click(screen.getByRole('button', { name: /alice/i }))
await user.click(screen.getByRole('button', { name: /bob/i }))
const addBtns = screen.getAllByRole('button', { name: 'Add expense' })
const submit = addBtns[addBtns.length - 1] // footer submit
expect(submit).not.toBeDisabled()
await user.click(submit)
await waitFor(() => expect(posted).toBeTruthy())
expect(posted!.total_price).toBe(120)
expect(posted!.member_ids).toEqual([])
expect(posted!.payers).toEqual([])
})
it('supports itemized receipt ticket manual entry and split assignment', async () => {
let posted: Record<string, unknown> | null = null
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
http.post('/api/trips/1/budget', async ({ request }) => {
posted = await request.json() as Record<string, unknown>
return HttpResponse.json({ item: { ...buildBudgetItem({ trip_id: 1, name: 'Dinner' }), id: 10 } })
}),
)
const { default: userEvent } = await import('@testing-library/user-event')
const user = userEvent.setup()
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Dinner')
await user.click(screen.getByRole('button', { name: 'Ticket' }))
const addBtn = screen.getByRole('button', { name: /Add item/i })
await user.click(addBtn)
await user.click(addBtn)
await user.click(addBtn)
const itemNames = screen.getAllByPlaceholderText('Item name')
const itemPrices = screen.getAllByPlaceholderText('0.00')
await user.type(itemNames[0], 'Apples')
await user.type(itemPrices[1], '10')
await user.type(itemNames[1], 'chocolate cake')
await user.type(itemPrices[2], '50')
const bobButtons = screen.getAllByRole('button', { name: /bob/i })
await user.click(bobButtons[1])
await user.type(itemNames[2], 'Milk')
await user.type(itemPrices[3], '40')
expect(screen.getByDisplayValue('100.00')).toBeDisabled()
expect(screen.getByText('Individual Shares Summary')).toBeInTheDocument()
expect(screen.getByText(/75\.00/)).toBeInTheDocument()
expect(screen.getByText(/25\.00/)).toBeInTheDocument()
const addBtns = screen.getAllByRole('button', { name: 'Add expense' })
await user.click(addBtns[addBtns.length - 1])
await waitFor(() => expect(posted).toBeTruthy())
expect(posted!.total_price).toBe(100)
expect(posted!.members).toEqual(expect.arrayContaining([
expect.objectContaining({ user_id: 1, amount: 75 }),
expect.objectContaining({ user_id: 2, amount: 25 }),
]))
expect(posted!.note).toContain('TICKETJSON:')
})
}) })
+138 -443
View File
@@ -18,69 +18,6 @@ import { SYMBOLS, CURRENCIES, SPLIT_COLORS } from './BudgetPanel.constants'
import { COST_CATEGORY_LIST, catMeta } from './costsCategories' import { COST_CATEGORY_LIST, catMeta } from './costsCategories'
import type { BudgetItem } from '../../types' import type { BudgetItem } from '../../types'
import type { TripMember } from './BudgetPanelMemberChips' import type { TripMember } from './BudgetPanelMemberChips'
import GuestBadge from '../shared/GuestBadge'
export function splitEqualShares(total: number, members: { user_id: number }[], itemId: number): Record<number, number> {
const n = members.length
if (n === 0) return {}
const totalCents = Math.round(total * 100)
const baseCents = Math.floor(totalCents / n)
const remainder = totalCents % n
const shares: Record<number, number> = {}
const sortedMembers = [...members].sort((a, b) => a.user_id - b.user_id)
const startIndex = itemId % n
for (let i = 0; i < n; i++) {
const member = sortedMembers[i]
const hasExtraCent = ((i - startIndex + n) % n) < remainder
shares[member.user_id] = (baseCents + (hasExtraCent ? 1 : 0)) / 100
}
return shares
}
export interface TicketItem {
id: string
name: string
price: string
participants: Set<number>
}
export function calculateTicketShares(items: TicketItem[]): { shares: Record<number, number>; total: number } {
const shares: Record<number, number> = {}
let totalCents = 0
for (const item of items) {
const priceNum = parseFloat(item.price) || 0
const priceCents = Math.round(priceNum * 100)
totalCents += priceCents
const partIds = [...item.participants]
const n = partIds.length
if (n === 0) continue
const baseCents = Math.floor(priceCents / n)
const remainder = priceCents % n
const sortedPartIds = [...partIds].sort((a, b) => a - b)
for (let i = 0; i < n; i++) {
const id = sortedPartIds[i]
const hasExtraCent = i < remainder
const shareCents = baseCents + (hasExtraCent ? 1 : 0)
shares[id] = (shares[id] || 0) + shareCents
}
}
const finalShares: Record<number, number> = {}
for (const id of Object.keys(shares)) {
finalShares[Number(id)] = shares[Number(id)] / 100
}
return { shares: finalShares, total: totalCents / 100 }
}
interface CostsPanelProps { interface CostsPanelProps {
tripId: number tripId: number
@@ -168,14 +105,9 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
const baseTotal = (e: BudgetItem) => convert(e.total_price || 0, curOf(e)) const baseTotal = (e: BudgetItem) => convert(e.total_price || 0, curOf(e))
const myPaidOf = (e: BudgetItem) => (e.payers || []).filter(p => p.user_id === me).reduce((a, p) => a + convert(p.amount, curOf(e)), 0) const myPaidOf = (e: BudgetItem) => (e.payers || []).filter(p => p.user_id === me).reduce((a, p) => a + convert(p.amount, curOf(e)), 0)
const myShareOf = (e: BudgetItem) => { const myShareOf = (e: BudgetItem) => {
const myMember = (e.members || []).find(m => m.user_id === me) const n = (e.members || []).length
if (!myMember) return 0 if (!n || !(e.members || []).some(m => m.user_id === me)) return 0
if (myMember.amount !== null && myMember.amount !== undefined) { return baseTotal(e) / n
return convert(myMember.amount, curOf(e))
}
const shares = splitEqualShares(e.total_price || 0, e.members || [], e.id)
const myShare = shares[me] || 0
return convert(myShare, curOf(e))
} }
const totals = useMemo(() => { const totals = useMemo(() => {
@@ -291,17 +223,17 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: 24, marginBottom: 28, flexWrap: 'wrap' }}> <div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: 24, marginBottom: 28, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
{dateMeta && ( {dateMeta && (
<span className="bg-surface-card border border-edge text-content-muted" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: '8px 14px', borderRadius: 999, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, whiteSpace: 'nowrap' }}> <span className="bg-surface-card border border-edge text-content-muted" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: '8px 14px', borderRadius: 999, fontSize: 13, fontWeight: 500, whiteSpace: 'nowrap' }}>
{dateMeta.range} · <b className="text-content">{t('costs.daysCount', { count: dateMeta.days })}</b> {dateMeta.range} · <b className="text-content">{t('costs.daysCount', { count: dateMeta.days })}</b>
</span> </span>
)} )}
<span className="bg-surface-card border border-edge text-content-muted" style={{ display: 'inline-flex', alignItems: 'center', gap: 8, padding: '8px 14px 8px 10px', borderRadius: 999, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500 }}> <span className="bg-surface-card border border-edge text-content-muted" style={{ display: 'inline-flex', alignItems: 'center', gap: 8, padding: '8px 14px 8px 10px', borderRadius: 999, fontSize: 13, fontWeight: 500 }}>
<span style={{ display: 'inline-flex' }}> <span style={{ display: 'inline-flex' }}>
{people.slice(0, 4).map((p, i) => { {people.slice(0, 4).map((p, i) => {
const common = { width: 22, height: 22, borderRadius: '50%', border: '2px solid var(--bg-card)', marginLeft: i ? -8 : 0, flexShrink: 0 } as const const common = { width: 22, height: 22, borderRadius: '50%', border: '2px solid var(--bg-card)', marginLeft: i ? -8 : 0, flexShrink: 0 } as const
return p.avatar_url return p.avatar_url
? <img key={p.id} src={p.avatar_url} alt="" style={{ ...common, objectFit: 'cover', display: 'block' }} /> ? <img key={p.id} src={p.avatar_url} alt="" style={{ ...common, objectFit: 'cover', display: 'block' }} />
: <span key={p.id} style={{ ...common, background: colorFor(p.id), color: '#fff', display: 'grid', placeItems: 'center', fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 700 }}>{(p.id === me ? t('costs.youShort') : p.username.charAt(0)).toUpperCase()}</span> : <span key={p.id} style={{ ...common, background: colorFor(p.id), color: '#fff', display: 'grid', placeItems: 'center', fontSize: 9, fontWeight: 700 }}>{(p.id === me ? t('costs.youShort') : p.username.charAt(0)).toUpperCase()}</span>
})} })}
</span> </span>
<b className="text-content">{t('costs.travelers', { count: people.length })}</b> <b className="text-content">{t('costs.travelers', { count: people.length })}</b>
@@ -311,12 +243,12 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<div style={{ display: 'flex', gap: 10 }}> <div style={{ display: 'flex', gap: 10 }}>
<button onClick={settleAll} disabled={!(settlement?.flows || []).length} <button onClick={settleAll} disabled={!(settlement?.flows || []).length}
className="bg-surface-card border border-edge text-content disabled:opacity-40" className="bg-surface-card border border-edge text-content disabled:opacity-40"
style={{ display: 'inline-flex', alignItems: 'center', gap: 7, padding: '10px 16px', borderRadius: 12, fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit' }}> style={{ display: 'inline-flex', alignItems: 'center', gap: 7, padding: '10px 16px', borderRadius: 12, fontSize: 14, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit' }}>
<Check size={16} /> {t('costs.settleUp')} <Check size={16} /> {t('costs.settleUp')}
</button> </button>
<button onClick={() => { setEditing(null); setModalOpen(true) }} <button onClick={() => { setEditing(null); setModalOpen(true) }}
className="bg-[var(--text-primary)] text-[var(--bg-primary)]" className="bg-[var(--text-primary)] text-[var(--bg-primary)]"
style={{ display: 'inline-flex', alignItems: 'center', gap: 7, padding: '10px 18px', borderRadius: 12, fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}> style={{ display: 'inline-flex', alignItems: 'center', gap: 7, padding: '10px 18px', borderRadius: 12, fontSize: 14, fontWeight: 600, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>
<Plus size={16} /> {t('costs.addExpense')} <Plus size={16} /> {t('costs.addExpense')}
</button> </button>
</div> </div>
@@ -345,20 +277,20 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
{/* expenses */} {/* expenses */}
<div> <div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16, gap: 12, flexWrap: 'wrap' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16, gap: 12, flexWrap: 'wrap' }}>
<h3 className="text-content" style={{ fontSize: 'calc(24px * var(--fs-scale-title, 1))', fontWeight: 600, letterSpacing: '-0.025em', margin: 0 }}> <h3 className="text-content" style={{ fontSize: 24, fontWeight: 600, letterSpacing: '-0.025em', margin: 0 }}>
{t('costs.expenses')} {t('costs.expenses')}
</h3> </h3>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}> <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 6, borderRadius: 10, padding: '0 10px', height: 34 }}> <div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 6, borderRadius: 10, padding: '0 10px', height: 34 }}>
<Search size={15} className="text-content-faint" /> <Search size={15} className="text-content-faint" />
<input value={search} onChange={e => setSearch(e.target.value)} placeholder={t('costs.searchPlaceholder')} <input value={search} onChange={e => setSearch(e.target.value)} placeholder={t('costs.searchPlaceholder')}
className="text-content" style={{ border: 0, background: 'none', outline: 'none', fontSize: 'calc(13px * var(--fs-scale-body, 1))', width: 150, fontFamily: 'inherit' }} /> className="text-content" style={{ border: 0, background: 'none', outline: 'none', fontSize: 13, width: 150, fontFamily: 'inherit' }} />
</div> </div>
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 9, padding: 3 }}> <div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 9, padding: 3 }}>
{(['all', 'mine', 'owed'] as const).map(f => ( {(['all', 'mine', 'owed'] as const).map(f => (
<button key={f} onClick={() => setFilter(f)} <button key={f} onClick={() => setFilter(f)}
className={filter === f ? 'bg-surface-card text-content' : 'text-content-muted'} className={filter === f ? 'bg-surface-card text-content' : 'text-content-muted'}
style={{ padding: '6px 11px', fontSize: 'calc(12px * var(--fs-scale-body, 1))', borderRadius: 7, fontWeight: 500, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}> style={{ padding: '6px 11px', fontSize: 12, borderRadius: 7, fontWeight: 500, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>
{t('costs.filter.' + f)} {t('costs.filter.' + f)}
</button> </button>
))} ))}
@@ -375,7 +307,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
return ( return (
<div key={g.day} style={{ marginBottom: 22 }}> <div key={g.day} style={{ marginBottom: 22 }}>
<div className={labelCls} style={{ display: 'flex', alignItems: 'center', margin: '0 0 10px 4px' }}> <div className={labelCls} style={{ display: 'flex', alignItems: 'center', margin: '0 0 10px 4px' }}>
{g.day}<span className="text-content-muted" style={{ marginLeft: 'auto', textTransform: 'none', letterSpacing: 0, fontWeight: 500, fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>{t('costs.spent', { amount: fmt(dtot) })}</span> {g.day}<span className="text-content-muted" style={{ marginLeft: 'auto', textTransform: 'none', letterSpacing: 0, fontWeight: 500, fontSize: 12 }}>{t('costs.spent', { amount: fmt(dtot) })}</span>
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{g.entries.map(en => en.kind === 'expense' {g.entries.map(en => en.kind === 'expense'
@@ -396,7 +328,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
{canEdit && ( {canEdit && (
<button onClick={() => setAddingPayment(true)} <button onClick={() => setAddingPayment(true)}
className="text-content-muted bg-surface-secondary border border-edge" className="text-content-muted bg-surface-secondary border border-edge"
style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '5px 9px', borderRadius: 8, fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}> style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '5px 9px', borderRadius: 8, fontSize: 11.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
<Plus size={13} /> {t('costs.addPayment')} <Plus size={13} /> {t('costs.addPayment')}
</button> </button>
)} )}
@@ -475,8 +407,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
if (flows.length === 0) return ( if (flows.length === 0) return (
<div style={{ textAlign: 'center', padding: '14px 8px' }}> <div style={{ textAlign: 'center', padding: '14px 8px' }}>
<div style={{ width: 46, height: 46, borderRadius: '50%', margin: '0 auto 10px', display: 'grid', placeItems: 'center', background: 'rgba(22,163,74,0.12)', color: '#16a34a' }}><Check size={22} /></div> <div style={{ width: 46, height: 46, borderRadius: '50%', margin: '0 auto 10px', display: 'grid', placeItems: 'center', background: 'rgba(22,163,74,0.12)', color: '#16a34a' }}><Check size={22} /></div>
<div className="text-content" style={{ fontSize: 'calc(14.5px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{t('costs.everyoneSquare')}</div> <div className="text-content" style={{ fontSize: 14.5, fontWeight: 600 }}>{t('costs.everyoneSquare')}</div>
<div className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', marginTop: 2 }}>{t('costs.nothingOutstanding')}</div> <div className="text-content-faint" style={{ fontSize: 12, marginTop: 2 }}>{t('costs.nothingOutstanding')}</div>
</div> </div>
) )
return ( return (
@@ -487,8 +419,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<Avatar id={f.from.user_id} size={32} /><ArrowRight size={15} className="text-content-faint" /><Avatar id={f.to.user_id} size={32} /> <Avatar id={f.from.user_id} size={32} /><ArrowRight size={15} className="text-content-faint" /><Avatar id={f.to.user_id} size={32} />
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
<span className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700 }}>{fmt(f.amount)}</span> <span className="text-content" style={{ fontSize: 14, fontWeight: 700 }}>{fmt(f.amount)}</span>
{canEdit && <button onClick={() => settleFlow(f.from.user_id, f.to.user_id, f.amount)} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '7px 12px', borderRadius: 9, fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>{t('costs.settle')}</button>} {canEdit && <button onClick={() => settleFlow(f.from.user_id, f.to.user_id, f.amount)} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '7px 12px', borderRadius: 9, fontSize: 12, fontWeight: 600, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>{t('costs.settle')}</button>}
</div> </div>
</div> </div>
))} ))}
@@ -502,14 +434,14 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, paddingTop: 8 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 16, paddingTop: 8 }}>
{/* Total card */} {/* Total card */}
<section style={{ background: 'linear-gradient(135deg,#1f2937,#111827)', color: '#fff', borderRadius: 22, padding: '20px 20px 16px', boxShadow: '0 8px 24px -8px rgba(0,0,0,0.28)' }}> <section style={{ background: 'linear-gradient(135deg,#1f2937,#111827)', color: '#fff', borderRadius: 22, padding: '20px 20px 16px', boxShadow: '0 8px 24px -8px rgba(0,0,0,0.28)' }}>
<div style={{ fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', textTransform: 'uppercase', letterSpacing: '0.12em', color: 'rgba(255,255,255,0.6)', fontWeight: 600 }}>{t('costs.totalSpend')}</div> <div style={{ fontSize: 11.5, textTransform: 'uppercase', letterSpacing: '0.12em', color: 'rgba(255,255,255,0.6)', fontWeight: 600 }}>{t('costs.totalSpend')}</div>
<div style={{ fontSize: 'calc(44px * var(--fs-scale-title, 1))', fontWeight: 700, letterSpacing: '-0.04em', lineHeight: 1, marginTop: 8, display: 'flex', alignItems: 'baseline' }}>{bigMoney(totals.totalSpend, 24, 'rgba(255,255,255,0.6)')}</div> <div style={{ fontSize: 44, fontWeight: 700, letterSpacing: '-0.04em', lineHeight: 1, marginTop: 8, display: 'flex', alignItems: 'baseline' }}>{bigMoney(totals.totalSpend, 24, 'rgba(255,255,255,0.6)')}</div>
<div style={{ display: 'flex', gap: 18, marginTop: 12, fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'rgba(255,255,255,0.6)', flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 18, marginTop: 12, fontSize: 12, color: 'rgba(255,255,255,0.6)', flexWrap: 'wrap' }}>
<span>{t('costs.yourShare')} · <b style={{ color: '#fff', fontWeight: 600 }}>{fmt0(totals.myShare)}</b></span> <span>{t('costs.yourShare')} · <b style={{ color: '#fff', fontWeight: 600 }}>{fmt0(totals.myShare)}</b></span>
<span>{t('costs.youPaid')} · <b style={{ color: '#fff', fontWeight: 600 }}>{fmt0(totals.myPaid)}</b></span> <span>{t('costs.youPaid')} · <b style={{ color: '#fff', fontWeight: 600 }}>{fmt0(totals.myPaid)}</b></span>
</div> </div>
{canEdit && ( {canEdit && (
<button onClick={() => { setEditing(null); setModalOpen(true) }} style={{ marginTop: 16, width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, background: 'rgba(255,255,255,0.14)', border: '1px solid rgba(255,255,255,0.16)', color: '#fff', padding: 13, borderRadius: 14, fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}> <button onClick={() => { setEditing(null); setModalOpen(true) }} style={{ marginTop: 16, width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, background: 'rgba(255,255,255,0.14)', border: '1px solid rgba(255,255,255,0.16)', color: '#fff', padding: 13, borderRadius: 14, fontSize: 14, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
<Plus size={17} /> {t('costs.addExpense')} <Plus size={17} /> {t('costs.addExpense')}
</button> </button>
)} )}
@@ -519,24 +451,24 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}> <div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
<div style={{ width: 34, height: 34, borderRadius: 10, display: 'grid', placeItems: 'center', marginBottom: 10, background: '#dc262622', color: '#dc2626' }}><ArrowDown size={17} /></div> <div style={{ width: 34, height: 34, borderRadius: 10, display: 'grid', placeItems: 'center', marginBottom: 10, background: '#dc262622', color: '#dc2626' }}><ArrowDown size={17} /></div>
<div className="text-content" style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{t('costs.youOwe')}</div> <div className="text-content" style={{ fontSize: 12.5, fontWeight: 600 }}>{t('costs.youOwe')}</div>
<div className="text-content-faint" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))' }}>{t('costs.youOweSub')}</div> <div className="text-content-faint" style={{ fontSize: 10.5 }}>{t('costs.youOweSub')}</div>
<div style={{ fontSize: 'calc(27px * var(--fs-scale-title, 1))', fontWeight: 700, letterSpacing: '-0.03em', lineHeight: 1, marginTop: 12, display: 'flex', alignItems: 'baseline', color: '#dc2626' }}>{bigMoney(totals.owe, 16, 'var(--c-ink3)')}</div> <div style={{ fontSize: 27, fontWeight: 700, letterSpacing: '-0.03em', lineHeight: 1, marginTop: 12, display: 'flex', alignItems: 'baseline', color: '#dc2626' }}>{bigMoney(totals.owe, 16, 'var(--c-ink3)')}</div>
</div> </div>
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}> <div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
<div style={{ width: 34, height: 34, borderRadius: 10, display: 'grid', placeItems: 'center', marginBottom: 10, background: '#16a34a22', color: '#16a34a' }}><ArrowUp size={17} /></div> <div style={{ width: 34, height: 34, borderRadius: 10, display: 'grid', placeItems: 'center', marginBottom: 10, background: '#16a34a22', color: '#16a34a' }}><ArrowUp size={17} /></div>
<div className="text-content" style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{t('costs.youreOwed')}</div> <div className="text-content" style={{ fontSize: 12.5, fontWeight: 600 }}>{t('costs.youreOwed')}</div>
<div className="text-content-faint" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))' }}>{t('costs.youreOwedSub')}</div> <div className="text-content-faint" style={{ fontSize: 10.5 }}>{t('costs.youreOwedSub')}</div>
<div style={{ fontSize: 'calc(27px * var(--fs-scale-title, 1))', fontWeight: 700, letterSpacing: '-0.03em', lineHeight: 1, marginTop: 12, display: 'flex', alignItems: 'baseline', color: '#16a34a' }}>{bigMoney(totals.owed, 16, 'var(--c-ink3)')}</div> <div style={{ fontSize: 27, fontWeight: 700, letterSpacing: '-0.03em', lineHeight: 1, marginTop: 12, display: 'flex', alignItems: 'baseline', color: '#16a34a' }}>{bigMoney(totals.owed, 16, 'var(--c-ink3)')}</div>
</div> </div>
</div> </div>
{/* Settle up */} {/* Settle up */}
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}> <div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14, gap: 8 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14, gap: 8 }}>
<div className="text-content" style={{ fontSize: 'calc(19px * var(--fs-scale-subtitle, 1))', fontWeight: 700, letterSpacing: '-0.02em', display: 'flex', alignItems: 'baseline', gap: 8 }}>{t('costs.settleUp')} <span className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 500 }}>{(settlement?.flows || []).length}</span></div> <div className="text-content" style={{ fontSize: 19, fontWeight: 700, letterSpacing: '-0.02em', display: 'flex', alignItems: 'baseline', gap: 8 }}>{t('costs.settleUp')} <span className="text-content-faint" style={{ fontSize: 12, fontWeight: 500 }}>{(settlement?.flows || []).length}</span></div>
{canEdit && ( {canEdit && (
<button onClick={() => setAddingPayment(true)} className="text-content-muted bg-surface-card border border-edge" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '6px 10px', borderRadius: 9, fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}><Plus size={13} /> {t('costs.addPayment')}</button> <button onClick={() => setAddingPayment(true)} className="text-content-muted bg-surface-card border border-edge" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '6px 10px', borderRadius: 9, fontSize: 11.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}><Plus size={13} /> {t('costs.addPayment')}</button>
)} )}
</div> </div>
<SettleFlows /> <SettleFlows />
@@ -544,23 +476,23 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
{/* Expenses */} {/* Expenses */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div className="text-content" style={{ fontSize: 'calc(19px * var(--fs-scale-subtitle, 1))', fontWeight: 700, letterSpacing: '-0.02em' }}>{t('costs.expenses')}</div> <div className="text-content" style={{ fontSize: 19, fontWeight: 700, letterSpacing: '-0.02em' }}>{t('costs.expenses')}</div>
<div className="bg-surface-card border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 8, borderRadius: 12, padding: '0 12px', height: 42 }}> <div className="bg-surface-card border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 8, borderRadius: 12, padding: '0 12px', height: 42 }}>
<Search size={16} className="text-content-faint" /> <Search size={16} className="text-content-faint" />
<input value={search} onChange={e => setSearch(e.target.value)} placeholder={t('costs.searchPlaceholder')} className="text-content" style={{ border: 0, background: 'none', outline: 'none', fontSize: 'calc(14px * var(--fs-scale-body, 1))', width: '100%', fontFamily: 'inherit' }} /> <input value={search} onChange={e => setSearch(e.target.value)} placeholder={t('costs.searchPlaceholder')} className="text-content" style={{ border: 0, background: 'none', outline: 'none', fontSize: 14, width: '100%', fontFamily: 'inherit' }} />
</div> </div>
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 11, padding: 3, gap: 2 }}> <div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 11, padding: 3, gap: 2 }}>
{(['all', 'mine', 'owed'] as const).map(f => ( {(['all', 'mine', 'owed'] as const).map(f => (
<button key={f} onClick={() => setFilter(f)} className={filter === f ? 'bg-surface-card text-content' : 'text-content-muted'} style={{ flex: 1, padding: '8px 6px', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 500, borderRadius: 8, border: 0, cursor: 'pointer', fontFamily: 'inherit', whiteSpace: 'nowrap' }}>{t('costs.filter.' + f)}</button> <button key={f} onClick={() => setFilter(f)} className={filter === f ? 'bg-surface-card text-content' : 'text-content-muted'} style={{ flex: 1, padding: '8px 6px', fontSize: 12.5, fontWeight: 500, borderRadius: 8, border: 0, cursor: 'pointer', fontFamily: 'inherit', whiteSpace: 'nowrap' }}>{t('costs.filter.' + f)}</button>
))} ))}
</div> </div>
{dayGroups.length === 0 {dayGroups.length === 0
? <div className="text-content-faint" style={{ textAlign: 'center', padding: '36px 16px', fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>{search ? t('costs.noMatch') : t('costs.emptyText')}</div> ? <div className="text-content-faint" style={{ textAlign: 'center', padding: '36px 16px', fontSize: 13 }}>{search ? t('costs.noMatch') : t('costs.emptyText')}</div>
: dayGroups.map(g => { : dayGroups.map(g => {
const dtot = g.entries.reduce((a, en) => en.kind === 'expense' ? a + baseTotal(en.e) : a, 0) const dtot = g.entries.reduce((a, en) => en.kind === 'expense' ? a + baseTotal(en.e) : a, 0)
return ( return (
<div key={g.day} style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> <div key={g.day} style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div className={labelCls} style={{ display: 'flex', alignItems: 'center', padding: '0 2px' }}>{g.day}<span className="text-content-muted" style={{ marginLeft: 'auto', textTransform: 'none', letterSpacing: 0, fontWeight: 500, fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))' }}>{t('costs.spent', { amount: fmt(dtot) })}</span></div> <div className={labelCls} style={{ display: 'flex', alignItems: 'center', padding: '0 2px' }}>{g.day}<span className="text-content-muted" style={{ marginLeft: 'auto', textTransform: 'none', letterSpacing: 0, fontWeight: 500, fontSize: 11.5 }}>{t('costs.spent', { amount: fmt(dtot) })}</span></div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>{g.entries.map(en => en.kind === 'expense' <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>{g.entries.map(en => en.kind === 'expense'
? <ExpenseRow key={'e' + en.e.id} e={en.e} /> ? <ExpenseRow key={'e' + en.e.id} e={en.e} />
: <SettlementRow key={'s' + en.s.id} s={en.s} />)}</div> : <SettlementRow key={'s' + en.s.id} s={en.s} />)}</div>
@@ -596,18 +528,13 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
const isUnfinished = baseTotal(e) > 0 && payers.length === 0 const isUnfinished = baseTotal(e) > 0 && payers.length === 0
return ( 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' }}> <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 }}> <span style={{ width: 46, height: 46, borderRadius: 13, display: 'grid', placeItems: 'center', background: c.color + '22', color: c.color }}><Icon size={21} /></span>
<Icon size={21} />
{isMobile && isUnfinished && (
<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={{ minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 7, marginBottom: 6 }}> <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> <span className="text-content" style={{ fontSize: 15, fontWeight: 600 }}>{e.name}</span>
{isUnfinished && !isMobile && ( {isUnfinished && (
<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 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: 11, 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> <span style={{ width: 14, height: 14, borderRadius: '50%', background: '#d97706', color: '#fff', display: 'grid', placeItems: 'center', fontSize: 10, fontWeight: 800 }}>!</span>
{t('costs.unfinished')} {t('costs.unfinished')}
</span> </span>
)} )}
@@ -615,7 +542,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
{payers.length > 0 && ( {payers.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, marginBottom: 5 }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, marginBottom: 5 }}>
{payers.map(p => ( {payers.map(p => (
<span key={p.user_id} className="bg-surface-secondary border border-edge" title={personName(p.user_id)} style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: '3px 10px 3px 3px', borderRadius: 999, fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))' }}> <span key={p.user_id} className="bg-surface-secondary border border-edge" title={personName(p.user_id)} style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: '3px 10px 3px 3px', borderRadius: 999, fontSize: 11.5 }}>
<Avatar id={p.user_id} size={18} /> <Avatar id={p.user_id} size={18} />
<span className="text-content" style={{ fontWeight: 700 }}>{fmt(convert(p.amount, cur))}</span> <span className="text-content" style={{ fontWeight: 700 }}>{fmt(convert(p.amount, cur))}</span>
</span> </span>
@@ -623,16 +550,16 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
</div> </div>
)} )}
{!isMobile && ( {!isMobile && (
<div className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}> <div className="text-content-faint" style={{ fontSize: 12, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{t(c.labelKey)}{cur !== base ? ` · ${fmt(e.total_price, cur)}${fmt(baseTotal(e))}` : ''} {t(c.labelKey)}{cur !== base ? ` · ${fmt(e.total_price, cur)}${fmt(baseTotal(e))}` : ''}
</div> </div>
)} )}
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, alignSelf: 'center' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 10, alignSelf: 'center' }}>
<div style={{ textAlign: 'right', whiteSpace: 'nowrap' }}> <div style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
<div className="text-content" style={{ fontSize: 'calc(18px * var(--fs-scale-subtitle, 1))', fontWeight: 600 }}>{fmt(baseTotal(e))}</div> <div className="text-content" style={{ fontSize: 18, fontWeight: 600 }}>{fmt(baseTotal(e))}</div>
{!isUnfinished && (e.members || []).length > 0 && Math.abs(net) > 0.01 && ( {!isUnfinished && (e.members || []).length > 0 && Math.abs(net) > 0.01 && (
<div style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', marginTop: 2, fontWeight: 500, whiteSpace: 'nowrap', color: net > 0 ? '#16a34a' : '#dc2626' }}> <div style={{ fontSize: 12, marginTop: 2, fontWeight: 500, whiteSpace: 'nowrap', color: net > 0 ? '#16a34a' : '#dc2626' }}>
{net > 0 ? t('costs.youLent', { amount: fmt(net) }) : t('costs.youBorrowed', { amount: fmt(-net) })} {net > 0 ? t('costs.youLent', { amount: fmt(net) }) : t('costs.youBorrowed', { amount: fmt(-net) })}
</div> </div>
)} )}
@@ -655,14 +582,14 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<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' }}> <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={{ width: 46, height: 46, borderRadius: 13, display: 'grid', placeItems: 'center', background: 'rgba(22,163,74,0.12)', color: '#16a34a' }}><ArrowLeftRight size={21} /></span> <span style={{ width: 46, height: 46, borderRadius: 13, display: 'grid', placeItems: 'center', background: 'rgba(22,163,74,0.12)', color: '#16a34a' }}><ArrowLeftRight size={21} /></span>
<div style={{ minWidth: 0 }}> <div style={{ minWidth: 0 }}>
<div className="text-content" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 600, marginBottom: 6 }}>{t('costs.payment')}</div> <div className="text-content" style={{ fontSize: 15, fontWeight: 600, marginBottom: 6 }}>{t('costs.payment')}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 7, minWidth: 0 }} title={`${personName(s.from_user_id)}${personName(s.to_user_id)}`}> <div style={{ display: 'flex', alignItems: 'center', gap: 7, minWidth: 0 }} title={`${personName(s.from_user_id)}${personName(s.to_user_id)}`}>
<Avatar id={s.from_user_id} size={20} /><ArrowRight size={13} className="text-content-faint" /><Avatar id={s.to_user_id} size={20} /> <Avatar id={s.from_user_id} size={20} /><ArrowRight size={13} className="text-content-faint" /><Avatar id={s.to_user_id} size={20} />
<span className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{personName(s.from_user_id)} {personName(s.to_user_id)}</span> <span className="text-content-faint" style={{ fontSize: 12, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{personName(s.from_user_id)} {personName(s.to_user_id)}</span>
</div> </div>
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, alignSelf: 'center' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 10, alignSelf: 'center' }}>
<div className="text-content" style={{ fontSize: 'calc(18px * var(--fs-scale-subtitle, 1))', fontWeight: 600, whiteSpace: 'nowrap' }}>{fmt(s.amount)}</div> <div className="text-content" style={{ fontSize: 18, fontWeight: 600, whiteSpace: 'nowrap' }}>{fmt(s.amount)}</div>
{canEdit && ( {canEdit && (
<div className="exp-actions" style={{ display: 'flex', flexDirection: 'column', gap: 6, flexShrink: 0 }}> <div className="exp-actions" style={{ display: 'flex', flexDirection: 'column', gap: 6, flexShrink: 0 }}>
<button title={t('common.edit')} onClick={() => setEditingSettlement(s)} className="bg-surface-secondary border border-edge text-content-muted" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 999, cursor: 'pointer' }}><Pencil size={13} /></button> <button title={t('common.edit')} onClick={() => setEditingSettlement(s)} className="bg-surface-secondary border border-edge text-content-muted" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 999, cursor: 'pointer' }}><Pencil size={13} /></button>
@@ -686,14 +613,14 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<div key={r.user_id} style={{ display: 'grid', gridTemplateColumns: '28px 1fr auto', gap: 10, alignItems: 'center' }}> <div key={r.user_id} style={{ display: 'grid', gridTemplateColumns: '28px 1fr auto', gap: 10, alignItems: 'center' }}>
<Avatar id={r.user_id} size={28} /> <Avatar id={r.user_id} size={28} />
<div> <div>
<div className="text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{personName(r.user_id)}</div> <div className="text-content" style={{ fontSize: 13, fontWeight: 600 }}>{personName(r.user_id)}</div>
<div className="bg-surface-secondary" style={{ height: 5, borderRadius: 3, marginTop: 5, position: 'relative', overflow: 'hidden' }}> <div className="bg-surface-secondary" style={{ height: 5, borderRadius: 3, marginTop: 5, position: 'relative', overflow: 'hidden' }}>
<span style={{ position: 'absolute', left: '50%', top: -1, bottom: -1, width: 1, background: 'var(--border-primary)' }} /> <span style={{ position: 'absolute', left: '50%', top: -1, bottom: -1, width: 1, background: 'var(--border-primary)' }} />
{pos && <span style={{ position: 'absolute', left: '50%', top: 0, bottom: 0, width: pct / 2 + '%', background: '#16a34a', borderRadius: 3 }} />} {pos && <span style={{ position: 'absolute', left: '50%', top: 0, bottom: 0, width: pct / 2 + '%', background: '#16a34a', borderRadius: 3 }} />}
{neg && <span style={{ position: 'absolute', right: '50%', top: 0, bottom: 0, width: pct / 2 + '%', background: '#dc2626', borderRadius: 3 }} />} {neg && <span style={{ position: 'absolute', right: '50%', top: 0, bottom: 0, width: pct / 2 + '%', background: '#dc2626', borderRadius: 3 }} />}
</div> </div>
</div> </div>
<div style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, textAlign: 'right', color: pos ? '#16a34a' : neg ? '#dc2626' : 'var(--text-faint)' }}> <div style={{ fontSize: 13, fontWeight: 600, textAlign: 'right', color: pos ? '#16a34a' : neg ? '#dc2626' : 'var(--text-faint)' }}>
{pos ? '+' + fmt(r.balance) : neg ? '' + fmt(-r.balance) : fmt(0)} {pos ? '+' + fmt(r.balance) : neg ? '' + fmt(-r.balance) : fmt(0)}
</div> </div>
</div> </div>
@@ -705,21 +632,19 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
function CategoryBreakdown() { function CategoryBreakdown() {
const tot: Record<string, number> = {} const tot: Record<string, number> = {}
for (const e of budgetItems) { const k = catMeta(e.category).key; tot[k] = (tot[k] || 0) + baseTotal(e) } let grand = 0
for (const e of budgetItems) { const k = catMeta(e.category).key; tot[k] = (tot[k] || 0) + baseTotal(e); grand += baseTotal(e) }
const rows = COST_CATEGORY_LIST.filter(c => (tot[c.key] || 0) > 0).sort((a, b) => (tot[b.key] || 0) - (tot[a.key] || 0)) const rows = COST_CATEGORY_LIST.filter(c => (tot[c.key] || 0) > 0).sort((a, b) => (tot[b.key] || 0) - (tot[a.key] || 0))
if (rows.length === 0) return <div className="text-content-faint" style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))' }}>{t('costs.noCategories')}</div> if (rows.length === 0) return <div className="text-content-faint" style={{ fontSize: 12.5 }}>{t('costs.noCategories')}</div>
// Bars are scaled relative to the most expensive category (the top row fills the
// bar), not to the trip grand total — makes the relative ranking readable.
const maxCat = Math.max(0, ...rows.map(c => tot[c.key] || 0))
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{rows.map(c => { {rows.map(c => {
const v = tot[c.key]; const pct = maxCat ? v / maxCat * 100 : 0 const v = tot[c.key]; const pct = grand ? v / grand * 100 : 0
return ( return (
<div key={c.key} style={{ display: 'grid', gridTemplateColumns: 'auto 1fr auto', gap: 10, alignItems: 'center' }}> <div key={c.key} style={{ display: 'grid', gridTemplateColumns: 'auto 1fr auto', gap: 10, alignItems: 'center' }}>
<span style={{ width: 10, height: 10, borderRadius: 3, background: c.color }} /> <span style={{ width: 10, height: 10, borderRadius: 3, background: c.color }} />
<span className="text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500 }}>{t(c.labelKey)}</span> <span className="text-content" style={{ fontSize: 13, fontWeight: 500 }}>{t(c.labelKey)}</span>
<span className="text-content-muted" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{fmt0(v)}</span> <span className="text-content-muted" style={{ fontSize: 13, fontWeight: 600 }}>{fmt0(v)}</span>
<div className="bg-surface-secondary" style={{ gridColumn: '1 / -1', height: 5, borderRadius: 3, overflow: 'hidden', marginTop: -2 }}> <div className="bg-surface-secondary" style={{ gridColumn: '1 / -1', height: 5, borderRadius: 3, overflow: 'hidden', marginTop: -2 }}>
<span style={{ display: 'block', height: '100%', width: pct + '%', background: c.color, borderRadius: 3 }} /> <span style={{ display: 'block', height: '100%', width: pct + '%', background: c.color, borderRadius: 3 }} />
</div> </div>
@@ -750,16 +675,16 @@ function SummaryCard({ label, sub, amount, currency, locale, icon, foot, tone }:
<div style={{ display: 'flex', alignItems: 'center', gap: 11 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 11 }}>
<span style={{ width: 36, height: 36, borderRadius: 11, display: 'grid', placeItems: 'center', background: total ? 'rgba(255,255,255,0.12)' : (accent + '22'), color: total ? '#fff' : accent }}>{icon}</span> <span style={{ width: 36, height: 36, borderRadius: 11, display: 'grid', placeItems: 'center', background: total ? 'rgba(255,255,255,0.12)' : (accent + '22'), color: total ? '#fff' : accent }}>{icon}</span>
<div> <div>
<div style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600 }} className={total ? '' : 'text-content'}>{label}</div> <div style={{ fontSize: 13, fontWeight: 600 }} className={total ? '' : 'text-content'}>{label}</div>
<div style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', opacity: total ? 0.6 : 1 }} className={total ? '' : 'text-content-faint'}>{sub}</div> <div style={{ fontSize: 12, opacity: total ? 0.6 : 1 }} className={total ? '' : 'text-content-faint'}>{sub}</div>
</div> </div>
</div> </div>
<div style={{ fontSize: 'calc(46px * var(--fs-scale-title, 1))', fontWeight: 600, letterSpacing: '-0.035em', lineHeight: 1, marginTop: 20, display: 'flex', alignItems: 'baseline', color: total ? '#fff' : accent }}> <div style={{ fontSize: 46, fontWeight: 600, letterSpacing: '-0.035em', lineHeight: 1, marginTop: 20, display: 'flex', alignItems: 'baseline', color: total ? '#fff' : accent }}>
{parts {parts
? parts.map((p, i) => <span key={i} style={big(p) ? undefined : { fontSize: 'calc(26px * var(--fs-scale-title, 1))', fontWeight: 500, color: muted }}>{p.value}</span>) ? parts.map((p, i) => <span key={i} style={big(p) ? undefined : { fontSize: 26, fontWeight: 500, color: muted }}>{p.value}</span>)
: <span>{formatMoney(amount, currency, locale)}</span>} : <span>{formatMoney(amount, currency, locale)}</span>}
</div> </div>
<div style={{ marginTop: 16, fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap', opacity: total ? 0.85 : 1 }}>{foot}</div> <div style={{ marginTop: 16, fontSize: 12.5, display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap', opacity: total ? 0.85 : 1 }}>{foot}</div>
</div> </div>
) )
} }
@@ -770,7 +695,7 @@ function FlowPills({ ids, lead, Avatar, name }: { ids: number[]; lead: string; A
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}> <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<span className="text-content-faint">{lead}</span> <span className="text-content-faint">{lead}</span>
{uniq.map(id => ( {uniq.map(id => (
<span key={id} className="bg-surface-secondary border border-edge text-content" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '3px 10px 3px 3px', borderRadius: 999, fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600 }}> <span key={id} className="bg-surface-secondary border border-edge text-content" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '3px 10px 3px 3px', borderRadius: 999, fontSize: 12, fontWeight: 600 }}>
<Avatar id={id} size={18} />{name(id)} <Avatar id={id} size={18} />{name(id)}
</span> </span>
))} ))}
@@ -814,8 +739,8 @@ function SettlementModal({ tripId, people, me, editing, onClose, onSaved }: {
<Modal isOpen onClose={onClose} title={editing ? t('costs.editPayment') : t('costs.addPayment')} size="md" <Modal isOpen onClose={onClose} title={editing ? t('costs.editPayment') : t('costs.addPayment')} size="md"
footer={ footer={
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}> <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button onClick={onClose} className="text-content-muted border border-edge" style={{ padding: '8px 16px', borderRadius: 10, background: 'none', fontSize: 'calc(13px * var(--fs-scale-body, 1))', cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button> <button onClick={onClose} className="text-content-muted border border-edge" style={{ padding: '8px 16px', borderRadius: 10, background: 'none', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
<button onClick={save} disabled={!valid || saving} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '8px 20px', borderRadius: 10, border: 0, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: !valid || saving ? 0.5 : 1 }}>{editing ? t('common.save') : t('costs.addPayment')}</button> <button onClick={save} disabled={!valid || saving} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '8px 20px', borderRadius: 10, border: 0, fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: !valid || saving ? 0.5 : 1 }}>{editing ? t('common.save') : t('costs.addPayment')}</button>
</div> </div>
}> }>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
@@ -829,8 +754,8 @@ function SettlementModal({ tripId, people, me, editing, onClose, onSaved }: {
</div> </div>
<div> <div>
<label className={labelCls}>{t('costs.amount')}</label> <label className={labelCls}>{t('costs.amount')}</label>
<input type="text" inputMode="decimal" placeholder="0.00" value={amount} <input type="number" inputMode="decimal" min="0" step="0.01" placeholder="0.00" value={amount}
onChange={e => setAmount(e.target.value.replace(',', '.'))} className={inputCls} style={{ borderRadius: 10, padding: '11px 13px', fontSize: 'calc(14px * var(--fs-scale-body, 1))', outline: 'none', fontWeight: 600 }} /> onChange={e => setAmount(e.target.value)} className={inputCls} style={{ borderRadius: 10, padding: '11px 13px', fontSize: 14, outline: 'none', fontWeight: 600 }} />
</div> </div>
</div> </div>
</Modal> </Modal>
@@ -858,6 +783,11 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
const [cat, setCat] = useState<string>(editing ? catMeta(editing.category).key : (prefill?.category || 'food')) const [cat, setCat] = useState<string>(editing ? catMeta(editing.category).key : (prefill?.category || 'food'))
const [currency, setCurrency] = useState((editing?.currency || base).toUpperCase()) const [currency, setCurrency] = useState((editing?.currency || base).toUpperCase())
const [day, setDay] = useState(editing?.expense_date || new Date().toISOString().slice(0, 10)) const [day, setDay] = useState(editing?.expense_date || new Date().toISOString().slice(0, 10))
// One participant list: a person is "in" the split and may have paid an amount.
// Entering the total auto-distributes it equally across the non-pinned participants;
// touching an amount pins it and the rest rebalance so the paid amounts always sum
// back to the total. Leaving every amount blank = an unfinished expense (counts
// toward the trip total only, never settlements, until who-paid is filled in).
const [total, setTotal] = useState<string>(() => { const [total, setTotal] = useState<string>(() => {
if (editing) return editing.total_price ? String(editing.total_price) : '' if (editing) return editing.total_price ? String(editing.total_price) : ''
if (prefill?.amount != null) return String(prefill.amount) if (prefill?.amount != null) return String(prefill.amount)
@@ -865,192 +795,84 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
}) })
const [participants, setParticipants] = useState<Set<number>>(() => const [participants, setParticipants] = useState<Set<number>>(() =>
editing ? new Set((editing.members || []).map(m => m.user_id)) : new Set(people.map(p => p.id))) editing ? new Set((editing.members || []).map(m => m.user_id)) : new Set(people.map(p => p.id)))
const [paid, setPaid] = useState<Record<number, string>>(() => {
// Payer state: 0 represents "Nobody (planning entry)"
const [payerId, setPayerId] = useState<number>(() => {
const existingPayer = (editing?.payers || []).find(p => p.amount > 0)
return existingPayer ? existingPayer.user_id : me
})
const [splitMode, setSplitMode] = useState<'equally' | 'custom' | 'ticket'>(() => {
if (editing?.note && editing.note.startsWith('TICKETJSON:')) {
return 'ticket'
}
if (editing && editing.members && editing.members.length > 0) {
const hasCustom = editing.members.some(m => m.amount !== null && m.amount !== undefined)
return hasCustom ? 'custom' : 'equally'
}
return 'equally'
})
const [ticketItems, setTicketItems] = useState<TicketItem[]>(() => {
if (editing?.note && editing.note.startsWith('TICKETJSON:')) {
try {
const parsed = JSON.parse(editing.note.slice(11))
return (parsed.items || []).map((item: any) => ({
id: String(Math.random()),
name: item.name,
price: String(item.price),
participants: new Set(item.parts || [])
}))
} catch {
return []
}
}
return []
})
const [customAmounts, setCustomAmounts] = useState<Record<number, string>>(() => {
const m: Record<number, string> = {} const m: Record<number, string> = {}
if (editing && editing.members) { for (const p of editing?.payers || []) if (p.amount > 0) m[p.user_id] = String(p.amount)
for (const member of editing.members) {
if (member.amount !== null && member.amount !== undefined) {
m[member.user_id] = String(member.amount)
}
}
}
return m return m
}) })
// Amounts the user pinned by typing — kept out of the auto-rebalance. Existing
// payer amounts load as pinned so opening an expense never reshuffles them.
const [dirty, setDirty] = useState<Set<number>>(() =>
new Set((editing?.payers || []).filter(p => p.amount > 0).map(p => p.user_id)))
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const isTicketMode = splitMode === 'ticket' const totalNum = parseFloat(total) || 0
const paidSum = round2([...participants].reduce((a, id) => a + (parseFloat(paid[id]) || 0), 0))
const ticketInfo = useMemo(() => { const paidEntered = paidSum > 0
return calculateTicketShares(ticketItems) const balanced = Math.abs(paidSum - totalNum) < 0.01
}, [ticketItems])
const totalNum = isTicketMode ? ticketInfo.total : (parseFloat(total) || 0)
const splitSum = [...participants].reduce((sum, id) => sum + (parseFloat(customAmounts[id]) || 0), 0)
const customBalanced = Math.round(splitSum * 100) === Math.round(totalNum * 100)
const each = participants.size > 0 ? totalNum / participants.size : 0 const each = participants.size > 0 ? totalNum / participants.size : 0
const equalShares = useMemo(() => { const valid = name.trim().length > 0 && totalNum > 0 && participants.size > 0 && (!paidEntered || balanced)
return splitEqualShares(totalNum, [...participants].map(id => ({ user_id: id })), editing?.id || 0)
}, [totalNum, participants, editing])
const placeholderShares = useMemo(() => { // Spread `amount` across `n` people in whole cents so the parts sum back exactly.
const emptyParts = [...participants].filter(id => !customAmounts[id]) const splitCents = (amount: number, n: number): number[] => {
if (emptyParts.length === 0) return {} if (n <= 0) return []
const cents = Math.max(0, Math.round(amount * 100))
const enteredSum = [...participants] const base = Math.floor(cents / n), rem = cents - base * n
.filter(id => customAmounts[id]) return Array.from({ length: n }, (_, i) => (base + (i < rem ? 1 : 0)) / 100)
.reduce((sum, id) => sum + (parseFloat(customAmounts[id]) || 0), 0) }
const remaining = Math.max(0, totalNum - enteredSum) // Recompute the non-pinned participants so every paid amount sums to the total.
const rebalance = (paidMap: Record<number, string>, dirtySet: Set<number>, parts: Set<number>, totalVal: number): Record<number, string> => {
return splitEqualShares(remaining, emptyParts.map(id => ({ user_id: id })), editing?.id || 0) const ids = [...parts]
}, [totalNum, participants, customAmounts, editing]) const free = ids.filter(id => !dirtySet.has(id))
if (free.length === 0) return paidMap
const ticketValid = ticketItems.length > 0 && ticketItems.every(item => item.name.trim().length > 0 && (parseFloat(item.price) || 0) > 0 && item.participants.size > 0) const pinnedSum = ids.filter(id => dirtySet.has(id)).reduce((a, id) => a + (parseFloat(paidMap[id]) || 0), 0)
const valid = name.trim().length > 0 && ( const shares = splitCents(totalVal - pinnedSum, free.length)
isTicketMode const next = { ...paidMap }
? ticketValid free.forEach((id, i) => { next[id] = shares[i] ? String(shares[i]) : '' })
: totalNum > 0 && (participants.size === 0 || splitMode === 'equally' || customBalanced) return next
) }
const onTotalChange = (v: string) => { const onTotalChange = (v: string) => {
setTotal(v.replace(',', '.')) setTotal(v)
setPaid(prev => rebalance(prev, dirty, participants, parseFloat(v) || 0))
} }
const onPaidChange = (id: number, v: string) => {
const handleCustomAmountChange = (id: number, val: string) => { const nextDirty = new Set(dirty); nextDirty.add(id)
val = val.replace(',', '.') setDirty(nextDirty)
if (/^\d*\.?\d{0,2}$/.test(val) || val === '') { setPaid(prev => rebalance({ ...prev, [id]: v }, nextDirty, participants, totalNum))
setCustomAmounts(prev => ({ ...prev, [id]: val }))
} }
}
const handleAddEmptyItem = () => {
setTicketItems(prev => [
...prev,
{
id: String(Date.now() + Math.random()),
name: '',
price: '',
participants: new Set(people.map(p => p.id))
}
])
}
const handleUpdateItemName = (id: string, name: string) => {
setTicketItems(prev => prev.map(item => item.id === id ? { ...item, name } : item))
}
const handleUpdateItemPrice = (id: string, price: string) => {
price = price.replace(',', '.')
if (/^\d*\.?\d{0,2}$/.test(price) || price === '') {
setTicketItems(prev => prev.map(item => item.id === id ? { ...item, price } : item))
}
}
const handleRemoveItem = (id: string) => {
setTicketItems(prev => prev.filter(item => item.id !== id))
}
const handleToggleItemParticipant = (itemId: string, userId: number) => {
setTicketItems(prev => prev.map(item => {
if (item.id === itemId) {
const nextParts = new Set(item.participants)
if (nextParts.has(userId)) nextParts.delete(userId)
else nextParts.add(userId)
return { ...item, participants: nextParts }
}
return item
}))
}
const toggleParticipant = (id: number) => { const toggleParticipant = (id: number) => {
const nextParts = new Set(participants) const nextParts = new Set(participants), nextDirty = new Set(dirty), nextPaid = { ...paid }
if (nextParts.has(id)) { if (nextParts.has(id)) { nextParts.delete(id); nextDirty.delete(id); delete nextPaid[id] }
nextParts.delete(id) else nextParts.add(id)
setCustomAmounts(prev => { setParticipants(nextParts); setDirty(nextDirty)
const copy = { ...prev } setPaid(rebalance(nextPaid, nextDirty, nextParts, totalNum))
delete copy[id]
return copy
})
} else {
nextParts.add(id)
}
setParticipants(nextParts)
} }
const save = async () => { const save = async () => {
if (!valid) return if (!valid) return
setSaving(true) setSaving(true)
const payerList = (payerId > 0 && participants.size > 0) ? [{ user_id: payerId, amount: totalNum }] : [] const payerList = [...participants]
const memberList = [...participants].map(id => ({ .map(id => ({ user_id: id, amount: parseFloat(paid[id]) || 0 }))
user_id: id, .filter(p => p.amount > 0)
amount: splitMode === 'custom'
? (parseFloat(customAmounts[id]) || 0)
: splitMode === 'ticket'
? (ticketInfo.shares[id] || 0)
: null
}))
const data = { const data = {
name: name.trim(), name: name.trim(), category: cat,
category: cat, // Store the actual currency the amounts were entered in; conversion to the
// viewer's display currency happens live (real rates), no manual rate.
currency, currency,
payers: payerList, payers: payerList, member_ids: [...participants],
members: memberList,
member_ids: [...participants],
expense_date: day || null, expense_date: day || null,
// Always record the entered total: the server keeps it as-is for an unfinished
// expense (no payers) and otherwise re-derives it from the payer sum (== total).
total_price: totalNum, total_price: totalNum,
note: splitMode === 'ticket' ? 'TICKETJSON:' + JSON.stringify({ // Link a freshly-created expense to its booking (create-from-booking flow).
items: ticketItems.map(item => ({
name: item.name,
price: item.price,
parts: [...item.participants]
}))
}) : null,
...(!editing && prefill?.reservationId ? { reservation_id: prefill.reservationId } : {}), ...(!editing && prefill?.reservationId ? { reservation_id: prefill.reservationId } : {}),
} }
try { try {
if (editing) await updateBudgetItem(tripId, editing.id, data) if (editing) await updateBudgetItem(tripId, editing.id, data)
else await addBudgetItem(tripId, data) else await addBudgetItem(tripId, data)
onSaved() onSaved()
} catch { } catch { toast.error(t('common.unknownError')) } finally { setSaving(false) }
toast.error(t('common.unknownError'))
} finally {
setSaving(false)
}
} }
const inputCls = 'w-full bg-surface-input border border-edge text-content' const inputCls = 'w-full bg-surface-input border border-edge text-content'
@@ -1060,24 +882,23 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
<Modal isOpen onClose={onClose} title={editing ? t('costs.editExpense') : t('costs.addExpense')} size="2xl" <Modal isOpen onClose={onClose} title={editing ? t('costs.editExpense') : t('costs.addExpense')} size="2xl"
footer={ footer={
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}> <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button onClick={onClose} className="text-content-muted border border-edge" style={{ padding: '8px 16px', borderRadius: 10, background: 'none', fontSize: 'calc(13px * var(--fs-scale-body, 1))', cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button> <button onClick={onClose} className="text-content-muted border border-edge" style={{ padding: '8px 16px', borderRadius: 10, background: 'none', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
<button onClick={save} disabled={!valid || saving} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '8px 20px', borderRadius: 10, border: 0, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: !valid || saving ? 0.5 : 1 }}>{editing ? t('common.save') : t('costs.addExpense')}</button> <button onClick={save} disabled={!valid || saving} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '8px 20px', borderRadius: 10, border: 0, fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: !valid || saving ? 0.5 : 1 }}>{editing ? t('common.save') : t('costs.addExpense')}</button>
</div> </div>
}> }>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div> <div>
<label className={labelCls}>{t('costs.whatFor')}</label> <label className={labelCls}>{t('costs.whatFor')}</label>
<input value={name} onChange={e => setName(e.target.value)} placeholder={t('costs.namePlaceholder')} className={inputCls} style={{ borderRadius: 10, padding: '11px 13px', fontSize: 'calc(14px * var(--fs-scale-body, 1))', outline: 'none' }} /> <input value={name} onChange={e => setName(e.target.value)} placeholder={t('costs.namePlaceholder')} className={inputCls} style={{ borderRadius: 10, padding: '11px 13px', fontSize: 14, outline: 'none' }} />
</div> </div>
<div> <div>
<label className={labelCls}>{t('costs.totalAmount')}</label> <label className={labelCls}>{t('costs.totalAmount')}</label>
<div className="bg-surface-input border border-edge" style={{ height: FIELD_H, boxSizing: 'border-box', display: 'flex', alignItems: 'center', borderRadius: 10, padding: '0 12px', opacity: isTicketMode ? 0.6 : 1 }}> <div className="bg-surface-input border border-edge" style={{ height: FIELD_H, boxSizing: 'border-box', display: 'flex', alignItems: 'center', borderRadius: 10, padding: '0 12px' }}>
<span className="text-content-faint" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))' }}>{sym(currency)}</span> <span className="text-content-faint" style={{ fontSize: 15 }}>{sym(currency)}</span>
<input type="text" inputMode="decimal" placeholder="0.00" value={isTicketMode ? ticketInfo.total.toFixed(2) : total} <input type="number" inputMode="decimal" min="0" step="0.01" placeholder="0.00" value={total}
onChange={e => onTotalChange(e.target.value)} onChange={e => onTotalChange(e.target.value)}
disabled={isTicketMode} className="text-content" style={{ flex: 1, border: 0, background: 'none', outline: 'none', fontSize: 15, fontWeight: 600, paddingLeft: 6, width: '100%' }} />
className="text-content" style={{ flex: 1, border: 0, background: 'none', outline: 'none', fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 600, paddingLeft: 6, width: '100%' }} />
</div> </div>
</div> </div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
@@ -1094,7 +915,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
</div> </div>
{currency !== base && totalNum > 0 && ( {currency !== base && totalNum > 0 && (
<div className="bg-surface-secondary border border-edge text-content-muted" style={{ borderRadius: 10, padding: '10px 12px', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}> <div className="bg-surface-secondary border border-edge text-content-muted" style={{ borderRadius: 10, padding: '10px 12px', fontSize: 12.5, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span>{formatMoney(totalNum, currency, locale)}</span> <span>{formatMoney(totalNum, currency, locale)}</span>
<span className="text-content-faint"></span> <span className="text-content-faint"></span>
<span className="text-content" style={{ fontWeight: 600 }}>{formatMoney(convert(totalNum, currency), base, locale)}</span> <span className="text-content" style={{ fontWeight: 600 }}>{formatMoney(convert(totalNum, currency), base, locale)}</span>
@@ -1110,7 +931,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
return ( return (
<button key={c.key} onClick={() => setCat(c.key)} <button key={c.key} onClick={() => setCat(c.key)}
className={on ? 'bg-surface-card text-content border' : 'bg-surface-secondary text-content-muted border border-edge'} className={on ? 'bg-surface-card text-content border' : 'bg-surface-secondary text-content-muted border border-edge'}
style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: '6px 11px 6px 7px', borderRadius: 999, fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', borderColor: on ? 'var(--text-primary)' : undefined }}> style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: '6px 11px 6px 7px', borderRadius: 999, fontSize: 12.5, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', borderColor: on ? 'var(--text-primary)' : undefined }}>
<span style={{ width: 20, height: 20, borderRadius: 6, display: 'grid', placeItems: 'center', background: c.color + '22', color: c.color }}><Icon size={12} /></span> <span style={{ width: 20, height: 20, borderRadius: 6, display: 'grid', placeItems: 'center', background: c.color + '22', color: c.color }}><Icon size={12} /></span>
{t(c.labelKey)} {t(c.labelKey)}
</button> </button>
@@ -1121,165 +942,39 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
<div> <div>
<label className={labelCls}>{t('costs.whoPaid')}</label> <label className={labelCls}>{t('costs.whoPaid')}</label>
<CustomSelect value={String(payerId)} onChange={v => setPayerId(Number(v))}
options={[
{ value: '0', label: t('costs.noOnePaid') || 'Nobody (planning entry)' },
...people.map(p => ({ value: String(p.id), label: p.id === me ? t('costs.you') : p.username }))
]}
style={{ width: '100%' }} />
</div>
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<label className={labelCls}>{t('costs.split') || 'Split'}</label>
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 8, padding: 2 }}>
<button type="button" onClick={() => setSplitMode('equally')}
className={splitMode === 'equally' ? 'bg-surface-card text-content' : 'text-content-muted'}
style={{ padding: '4px 10px', fontSize: 11.5, borderRadius: 6, fontWeight: 600, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>
{t('costs.splitEqually') || 'Equally'}
</button>
<button type="button" onClick={() => setSplitMode('custom')}
className={splitMode === 'custom' ? 'bg-surface-card text-content' : 'text-content-muted'}
style={{ padding: '4px 10px', fontSize: 11.5, borderRadius: 6, fontWeight: 600, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>
{t('costs.splitCustom') || 'Custom'}
</button>
<button type="button" onClick={() => setSplitMode('ticket')}
className={splitMode === 'ticket' ? 'bg-surface-card text-content' : 'text-content-muted'}
style={{ padding: '4px 10px', fontSize: 11.5, borderRadius: 6, fontWeight: 600, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>
{t('costs.splitTicket') || 'Ticket'}
</button>
</div>
</div>
{splitMode === 'ticket' ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{ticketItems.map((item, itemIdx) => (
<div key={item.id} className="bg-surface-secondary border border-edge" style={{ padding: 10, borderRadius: 10, display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input
type="text"
placeholder="Item name"
value={item.name}
onChange={e => handleUpdateItemName(item.id, e.target.value)}
className="bg-surface-input border border-edge text-content"
style={{ flex: 2, padding: '6px 10px', borderRadius: 8, fontSize: 13, border: '1px solid var(--border-color)', outline: 'none' }}
/>
<div className="bg-surface-input border border-edge" style={{ flex: 1, display: 'flex', alignItems: 'center', padding: '0 8px', borderRadius: 8 }}>
<span className="text-content-faint" style={{ fontSize: 12 }}>{sym(currency)}</span>
<input
type="text"
inputMode="decimal"
placeholder="0.00"
value={item.price}
onChange={e => handleUpdateItemPrice(item.id, e.target.value)}
className="text-content"
style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 13, fontWeight: 600, textAlign: 'right', padding: '6px 0' }}
/>
</div>
<button type="button" onClick={() => handleRemoveItem(item.id)} className="text-content-muted" style={{ background: 'none', border: 0, cursor: 'pointer', padding: 4 }}>
<Trash2 size={15} />
</button>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, alignItems: 'center' }}>
<span className="text-content-faint" style={{ fontSize: 10.5, fontWeight: 600, textTransform: 'uppercase', marginRight: 4 }}>Splitting:</span>
{people.map((p, pIdx) => {
const active = item.participants.has(p.id)
return (
<button
type="button"
key={p.id}
onClick={() => handleToggleItemParticipant(item.id, p.id)}
className={active ? 'bg-surface-card text-content border' : 'bg-surface-secondary text-content-muted border border-edge'}
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '3px 8px', borderRadius: 999, fontSize: 11, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', border: active ? '1px solid var(--text-primary)' : undefined }}
>
{p.avatar_url
? <img src={p.avatar_url} alt="" style={{ width: 14, height: 14, borderRadius: '50%', objectFit: 'cover' }} />
: <span style={{ width: 14, height: 14, borderRadius: '50%', background: SPLIT_COLORS[pIdx % SPLIT_COLORS.length].gradient, color: '#fff', display: 'grid', placeItems: 'center', fontSize: 7, fontWeight: 700 }}>{(p.id === me ? t('costs.youShort') : p.username.charAt(0)).toUpperCase()}</span>}
<span>{p.id === me ? t('costs.you') : p.username}</span>
</button>
)
})}
</div>
</div>
))}
</div>
<button type="button" onClick={handleAddEmptyItem} className="border border-dashed border-edge text-content-muted" style={{ padding: '8px 12px', borderRadius: 10, background: 'none', fontSize: 13, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}>
<Plus size={14} /> Add item
</button>
{ticketItems.length > 0 && (
<div className="bg-surface-secondary border border-edge" style={{ padding: 12, borderRadius: 10 }}>
<div className="text-content" style={{ fontSize: 12, fontWeight: 600, marginBottom: 8 }}>Individual Shares Summary</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{people.map(p => {
const share = ticketInfo.shares[p.id] || 0
return (
<div key={p.id} style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13 }}>
<span className="text-content-muted">{p.id === me ? t('costs.you') : p.username}</span>
<span className="text-content" style={{ fontWeight: 600 }}>{sym(currency)}{share.toFixed(2)}</span>
</div>
)
})}
</div>
</div>
)}
</div>
) : (
<>
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
{people.map((p, idx) => { {people.map((p, idx) => {
const on = participants.has(p.id) const on = participants.has(p.id)
return ( return (
<div key={p.id} className="bg-surface-secondary border border-edge" style={{ display: 'grid', gridTemplateColumns: '1fr 130px', gap: 10, alignItems: 'center', padding: '8px 11px', borderRadius: 10, opacity: on ? 1 : 0.5 }}> <div key={p.id} className="bg-surface-secondary border border-edge" style={{ display: 'grid', gridTemplateColumns: '1fr 130px', gap: 10, alignItems: 'center', padding: '8px 11px', borderRadius: 10, opacity: on ? 1 : 0.5 }}>
<button type="button" onClick={() => toggleParticipant(p.id)} style={{ display: 'inline-flex', alignItems: 'center', gap: 8, background: 'none', border: 0, cursor: 'pointer', fontFamily: 'inherit', padding: 0, minWidth: 0, textAlign: 'left' }}> <button onClick={() => toggleParticipant(p.id)} style={{ display: 'inline-flex', alignItems: 'center', gap: 8, background: 'none', border: 0, cursor: 'pointer', fontFamily: 'inherit', padding: 0, minWidth: 0, textAlign: 'left' }}>
{p.avatar_url {p.avatar_url
? <img src={p.avatar_url} alt="" style={{ width: 22, height: 22, borderRadius: '50%', objectFit: 'cover', display: 'block', flexShrink: 0, opacity: on ? 1 : 0.45 }} /> ? <img src={p.avatar_url} alt="" style={{ width: 22, height: 22, borderRadius: '50%', objectFit: 'cover', display: 'block', flexShrink: 0, opacity: on ? 1 : 0.45 }} />
: <span style={{ width: 22, height: 22, borderRadius: '50%', background: SPLIT_COLORS[idx % SPLIT_COLORS.length].gradient, color: '#fff', display: 'grid', placeItems: 'center', fontSize: 9, fontWeight: 700, flexShrink: 0, opacity: on ? 1 : 0.45 }}>{(p.id === me ? t('costs.youShort') : p.username.charAt(0)).toUpperCase()}</span>} : <span style={{ width: 22, height: 22, borderRadius: '50%', background: SPLIT_COLORS[idx % SPLIT_COLORS.length].gradient, color: '#fff', display: 'grid', placeItems: 'center', fontSize: 9, fontWeight: 700, flexShrink: 0, opacity: on ? 1 : 0.45 }}>{(p.id === me ? t('costs.youShort') : p.username.charAt(0)).toUpperCase()}</span>}
<span className="text-content" style={{ fontSize: 14, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.id === me ? t('costs.you') : p.username}</span> <span className="text-content" style={{ fontSize: 14, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.id === me ? t('costs.you') : p.username}</span>
{p.is_guest && <GuestBadge size="xs" />}
</button> </button>
{splitMode === 'equally' ? ( {on ? (
on ? (
<span className="text-content" style={{ fontSize: 14, fontWeight: 600, textAlign: 'right', paddingRight: 10 }}>
{sym(currency)}{(equalShares[p.id] || 0).toFixed(2)}
</span>
) : (
<span className="text-content-faint" style={{ fontSize: 12, textAlign: 'right', paddingRight: 10 }}>Excluded</span>
)
) : (
on ? (
<div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 4, borderRadius: 8, padding: '0 10px' }}> <div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 4, borderRadius: 8, padding: '0 10px' }}>
<span className="text-content-faint" style={{ fontSize: 13 }}>{sym(currency)}</span> <span className="text-content-faint" style={{ fontSize: 13 }}>{sym(currency)}</span>
<input type="text" inputMode="decimal" placeholder={(placeholderShares[p.id] || 0).toFixed(2)} value={customAmounts[p.id] || ''} <input type="number" inputMode="decimal" min="0" step="0.01" placeholder="0.00" value={paid[p.id] || ''}
onChange={e => handleCustomAmountChange(p.id, e.target.value)} onChange={e => onPaidChange(p.id, e.target.value)}
className="text-content" style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 14, fontWeight: 600, padding: '8px 0', textAlign: 'right' }} /> className="text-content" style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 14, fontWeight: 600, padding: '8px 0', textAlign: 'right' }} />
</div> </div>
) : ( ) : (
<button type="button" onClick={() => toggleParticipant(p.id)} className="text-content-faint" style={{ background: 'none', border: 0, cursor: 'pointer', fontFamily: 'inherit', fontSize: 12, textAlign: 'right' }}>{t('costs.tapToInclude')}</button> <button onClick={() => toggleParticipant(p.id)} className="text-content-faint" style={{ background: 'none', border: 0, cursor: 'pointer', fontFamily: 'inherit', fontSize: 12, textAlign: 'right' }}>{t('costs.tapToInclude')}</button>
)
)} )}
</div> </div>
) )
})} })}
</div> </div>
<div style={{ marginTop: 10, fontSize: 12.5, display: 'flex', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap' }}> <div style={{ marginTop: 10, fontSize: 12.5, display: 'flex', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap' }}>
{splitMode === 'equally' ? (
<span className="text-content-faint"> <span className="text-content-faint">
{participants.size > 0 && t('costs.splitSummary', { count: participants.size, amount: sym(currency) + each.toFixed(2) })} {participants.size === 0 ? t('costs.pickSomeone') : t('costs.splitSummary', { count: participants.size, amount: sym(currency) + each.toFixed(2) })}
</span> </span>
) : ( {paidEntered
<span style={{ fontWeight: 600, color: customBalanced ? '#16a34a' : '#dc2626' }}> ? <span style={{ fontWeight: 600, color: balanced ? '#16a34a' : '#dc2626' }}>{sym(currency)}{paidSum.toFixed(2)} / {sym(currency)}{totalNum.toFixed(2)}</span>
{customBalanced : (totalNum > 0 && <span style={{ color: '#d97706', fontWeight: 600 }}>{t('costs.unfinishedHint')}</span>)}
? 'Split matches total'
: `Sum of splits: ${sym(currency)}${splitSum.toFixed(2)} of ${sym(currency)}${totalNum.toFixed(2)} (${(totalNum - splitSum) > 0 ? 'under by' : 'over by'} ${sym(currency)}${Math.abs(totalNum - splitSum).toFixed(2)})`}
</span>
)}
</div> </div>
</>
)}
</div> </div>
</div> </div>
</Modal> </Modal>
@@ -647,7 +647,7 @@ describe('CollabChat', () => {
let foundBigEmoji = false; let foundBigEmoji = false;
while (el) { while (el) {
const styleAttr = el.getAttribute('style'); const styleAttr = el.getAttribute('style');
if (styleAttr && styleAttr.includes('font-size: calc(40px')) { if (styleAttr && styleAttr.includes('font-size: 40px')) {
foundBigEmoji = true; foundBigEmoji = true;
break; break;
} }
+2 -2
View File
@@ -33,7 +33,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
<div style={{ <div style={{
display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8, display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8,
padding: '6px 10px', borderRadius: 10, background: 'var(--bg-secondary)', padding: '6px 10px', borderRadius: 10, background: 'var(--bg-secondary)',
borderLeft: '3px solid #007AFF', fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-muted)', borderLeft: '3px solid #007AFF', fontSize: 12, color: 'var(--text-muted)',
}}> }}>
<Reply size={12} style={{ flexShrink: 0, opacity: 0.5 }} /> <Reply size={12} style={{ flexShrink: 0, opacity: 0.5 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}> <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>
@@ -67,7 +67,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
disabled={!canEdit} disabled={!canEdit}
style={{ style={{
flex: 1, resize: 'none', border: '1px solid var(--border-primary)', borderRadius: 20, flex: 1, resize: 'none', border: '1px solid var(--border-primary)', borderRadius: 20,
padding: '8px 14px', fontSize: 'calc(14px * var(--fs-scale-body, 1))', lineHeight: 1.4, fontFamily: 'inherit', padding: '8px 14px', fontSize: 14, lineHeight: 1.4, fontFamily: 'inherit',
background: 'var(--bg-input)', color: 'var(--text-primary)', outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', outline: 'none',
maxHeight: 100, overflowY: 'hidden', maxHeight: 100, overflowY: 'hidden',
opacity: canEdit ? 1 : 0.5, opacity: canEdit ? 1 : 0.5,
@@ -49,7 +49,7 @@ export function EmojiPicker({ onSelect, onClose, anchorRef, containerRef }: Emoj
<button key={c} onClick={() => setCat(c)} style={{ <button key={c} onClick={() => setCat(c)} style={{
flex: 1, padding: '4px 0', borderRadius: 6, border: 'none', cursor: 'pointer', flex: 1, padding: '4px 0', borderRadius: 6, border: 'none', cursor: 'pointer',
background: cat === c ? 'var(--bg-hover)' : 'transparent', background: cat === c ? 'var(--bg-hover)' : 'transparent',
color: 'var(--text-primary)', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, fontFamily: 'inherit', color: 'var(--text-primary)', fontSize: 10, fontWeight: 600, fontFamily: 'inherit',
}}> }}>
{c} {c}
</button> </button>
@@ -45,17 +45,17 @@ export function LinkPreview({ url, tripId, own, onLoad }: LinkPreviewProps) {
)} )}
<div style={{ padding: '8px 10px' }}> <div style={{ padding: '8px 10px' }}>
{domain && ( {domain && (
<div style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: own ? 'rgba(255,255,255,0.5)' : 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3, marginBottom: 2 }}> <div style={{ fontSize: 10, fontWeight: 600, color: own ? 'rgba(255,255,255,0.5)' : 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3, marginBottom: 2 }}>
{data.site_name || domain} {data.site_name || domain}
</div> </div>
)} )}
{data.title && ( {data.title && (
<div style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: own ? '#fff' : 'var(--text-primary)', lineHeight: 1.3, marginBottom: 2, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}> <div style={{ fontSize: 12, fontWeight: 600, color: own ? '#fff' : 'var(--text-primary)', lineHeight: 1.3, marginBottom: 2, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
{data.title} {data.title}
</div> </div>
)} )}
{data.description && ( {data.description && (
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: own ? 'rgba(255,255,255,0.7)' : 'var(--text-muted)', lineHeight: 1.3, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}> <div style={{ fontSize: 11, color: own ? 'rgba(255,255,255,0.7)' : 'var(--text-muted)', lineHeight: 1.3, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
{data.description} {data.description}
</div> </div>
)} )}
@@ -14,8 +14,8 @@ export function ChatMessages(props: any) {
{messages.length === 0 ? ( {messages.length === 0 ? (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 8, color: 'var(--text-faint)', padding: 32, textAlign: 'center' }}> <div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 8, color: 'var(--text-faint)', padding: 32, textAlign: 'center' }}>
<MessageCircle size={40} strokeWidth={1.2} style={{ opacity: 0.4 }} /> <MessageCircle size={40} strokeWidth={1.2} style={{ opacity: 0.4 }} />
<span style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{t('collab.chat.empty')}</span> <span style={{ fontSize: 14, fontWeight: 600 }}>{t('collab.chat.empty')}</span>
<span style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', opacity: 0.6, fontFamily: 'var(--font-subtext)' }}>{t('collab.chat.emptyDesc') || ''}</span> <span style={{ fontSize: 12, opacity: 0.6, fontFamily: 'var(--font-subtext)' }}>{t('collab.chat.emptyDesc') || ''}</span>
</div> </div>
) : ( ) : (
<div ref={scrollRef} onScroll={checkAtBottom} className="chat-scroll" style={{ <div ref={scrollRef} onScroll={checkAtBottom} className="chat-scroll" style={{
@@ -25,7 +25,7 @@ export function ChatMessages(props: any) {
{hasMore && ( {hasMore && (
<div style={{ display: 'flex', justifyContent: 'center', padding: '4px 0 10px' }}> <div style={{ display: 'flex', justifyContent: 'center', padding: '4px 0 10px' }}>
<button onClick={handleLoadMore} disabled={loadingMore} style={{ <button onClick={handleLoadMore} disabled={loadingMore} style={{
display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, fontWeight: 600,
color: 'var(--text-muted)', background: 'var(--bg-secondary)', border: '1px solid var(--border-faint)', color: 'var(--text-muted)', background: 'var(--bg-secondary)', border: '1px solid var(--border-faint)',
borderRadius: 99, padding: '5px 14px', cursor: 'pointer', fontFamily: 'inherit', borderRadius: 99, padding: '5px 14px', cursor: 'pointer', fontFamily: 'inherit',
}}> }}>
@@ -51,13 +51,13 @@ export function ChatMessages(props: any) {
<React.Fragment key={msg.id}> <React.Fragment key={msg.id}>
{showDate && ( {showDate && (
<div style={{ display: 'flex', justifyContent: 'center', padding: '14px 0 6px' }}> <div style={{ display: 'flex', justifyContent: 'center', padding: '14px 0 6px' }}>
<span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', background: 'var(--bg-secondary)', padding: '3px 12px', borderRadius: 99, letterSpacing: 0.3, textTransform: 'uppercase' }}> <span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', background: 'var(--bg-secondary)', padding: '3px 12px', borderRadius: 99, letterSpacing: 0.3, textTransform: 'uppercase' }}>
{formatDateSeparator(msg.created_at, t)} {formatDateSeparator(msg.created_at, t)}
</span> </span>
</div> </div>
)} )}
<div style={{ display: 'flex', justifyContent: 'center', padding: '4px 0' }}> <div style={{ display: 'flex', justifyContent: 'center', padding: '4px 0' }}>
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', fontStyle: 'italic' }}> <span style={{ fontSize: 11, color: 'var(--text-faint)', fontStyle: 'italic' }}>
{msg.username} {t('collab.chat.deletedMessage') || 'deleted a message'} · {formatTime(msg.created_at, is12h)} {msg.username} {t('collab.chat.deletedMessage') || 'deleted a message'} · {formatTime(msg.created_at, is12h)}
</span> </span>
</div> </div>
@@ -76,7 +76,7 @@ export function ChatMessages(props: any) {
{showDate && ( {showDate && (
<div style={{ display: 'flex', justifyContent: 'center', padding: '14px 0 6px' }}> <div style={{ display: 'flex', justifyContent: 'center', padding: '14px 0 6px' }}>
<span style={{ <span style={{
fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', fontSize: 10, fontWeight: 600, color: 'var(--text-faint)',
background: 'var(--bg-secondary)', padding: '3px 12px', borderRadius: 99, background: 'var(--bg-secondary)', padding: '3px 12px', borderRadius: 99,
letterSpacing: 0.3, textTransform: 'uppercase', letterSpacing: 0.3, textTransform: 'uppercase',
}}> }}>
@@ -103,7 +103,7 @@ export function ChatMessages(props: any) {
<div style={{ <div style={{
width: 28, height: 28, borderRadius: '50%', background: 'var(--bg-tertiary)', width: 28, height: 28, borderRadius: '50%', background: 'var(--bg-tertiary)',
display: 'flex', alignItems: 'center', justifyContent: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-muted)', fontSize: 11, fontWeight: 700, color: 'var(--text-muted)',
}}> }}>
{(msg.username || '?')[0].toUpperCase()} {(msg.username || '?')[0].toUpperCase()}
</div> </div>
@@ -115,7 +115,7 @@ export function ChatMessages(props: any) {
<div style={{ display: 'flex', flexDirection: 'column', alignItems: own ? 'flex-end' : 'flex-start', maxWidth: '78%', minWidth: 0 }}> <div style={{ display: 'flex', flexDirection: 'column', alignItems: own ? 'flex-end' : 'flex-start', maxWidth: '78%', minWidth: 0 }}>
{/* Username for others at group start */} {/* Username for others at group start */}
{!own && isNewGroup && ( {!own && isNewGroup && (
<span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', marginBottom: 2, paddingLeft: 4 }}> <span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', marginBottom: 2, paddingLeft: 4 }}>
{msg.username} {msg.username}
</span> </span>
)} )}
@@ -138,7 +138,7 @@ export function ChatMessages(props: any) {
}} }}
> >
{bigEmoji ? ( {bigEmoji ? (
<div style={{ fontSize: 'calc(40px * var(--fs-scale-title, 1))', lineHeight: 1.2, padding: '2px 0' }}> <div style={{ fontSize: 40, lineHeight: 1.2, padding: '2px 0' }}>
{msg.text} {msg.text}
</div> </div>
) : ( ) : (
@@ -146,16 +146,16 @@ export function ChatMessages(props: any) {
background: own ? '#007AFF' : 'var(--bg-secondary)', background: own ? '#007AFF' : 'var(--bg-secondary)',
color: own ? '#fff' : 'var(--text-primary)', color: own ? '#fff' : 'var(--text-primary)',
borderRadius: br, padding: hasReply ? '4px 4px 8px 4px' : '8px 14px', borderRadius: br, padding: hasReply ? '4px 4px 8px 4px' : '8px 14px',
fontSize: 'calc(14px * var(--fs-scale-body, 1))', lineHeight: 1.4, wordBreak: 'break-word', whiteSpace: 'pre-wrap', fontSize: 14, lineHeight: 1.4, wordBreak: 'break-word', whiteSpace: 'pre-wrap',
}}> }}>
{/* Inline reply quote */} {/* Inline reply quote */}
{hasReply && ( {hasReply && (
<div style={{ <div style={{
padding: '5px 10px', marginBottom: 4, borderRadius: 12, padding: '5px 10px', marginBottom: 4, borderRadius: 12,
background: own ? 'rgba(255,255,255,0.15)' : 'var(--bg-tertiary)', background: own ? 'rgba(255,255,255,0.15)' : 'var(--bg-tertiary)',
fontSize: 'calc(12px * var(--fs-scale-body, 1))', lineHeight: 1.3, fontSize: 12, lineHeight: 1.3,
}}> }}>
<div style={{ fontWeight: 600, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', opacity: 0.7, marginBottom: 1 }}> <div style={{ fontWeight: 600, fontSize: 11, opacity: 0.7, marginBottom: 1 }}>
{msg.reply_username || ''} {msg.reply_username || ''}
</div> </div>
<div style={{ opacity: 0.8, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> <div style={{ opacity: 0.8, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
@@ -233,7 +233,7 @@ export function ChatMessages(props: any) {
{/* Timestamp — only on last message of group */} {/* Timestamp — only on last message of group */}
{isLastInGroup && ( {isLastInGroup && (
<span style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginTop: 2, padding: '0 4px' }}> <span style={{ fontSize: 9, color: 'var(--text-faint)', marginTop: 2, padding: '0 4px' }}>
{formatTime(msg.created_at, is12h)} {formatTime(msg.created_at, is12h)}
</span> </span>
)} )}
@@ -34,14 +34,14 @@ export function ReactionBadge({ reaction, currentUserId, onReact }: ReactionBadg
}} }}
> >
<TwemojiImg emoji={reaction.emoji} size={16} /> <TwemojiImg emoji={reaction.emoji} size={16} />
{reaction.count > 1 && <span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-muted)', minWidth: 8 }}>{reaction.count}</span>} {reaction.count > 1 && <span style={{ fontSize: 10, fontWeight: 700, color: 'var(--text-muted)', minWidth: 8 }}>{reaction.count}</span>}
</button> </button>
{hover && names && ReactDOM.createPortal( {hover && names && ReactDOM.createPortal(
<div style={{ <div style={{
position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)', position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)',
pointerEvents: 'none', zIndex: 10000, whiteSpace: 'nowrap', pointerEvents: 'none', zIndex: 10000, whiteSpace: 'nowrap',
background: 'var(--bg-card, white)', color: 'var(--text-primary, #111827)', background: 'var(--bg-card, white)', color: 'var(--text-primary, #111827)',
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500, padding: '5px 10px', borderRadius: 8, fontSize: 11, fontWeight: 500, padding: '5px 10px', borderRadius: 8,
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint, #e5e7eb)', boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint, #e5e7eb)',
}}> }}>
{names} {names}
+13 -13
View File
@@ -243,7 +243,7 @@ function CollabNotesLoading({ t }: NotesState) {
return ( return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', fontFamily: FONT }}> <div style={{ height: '100%', display: 'flex', flexDirection: 'column', fontFamily: FONT }}>
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border-faint)' }}> <div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border-faint)' }}>
<h3 style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)', margin: 0, fontFamily: FONT }}> <h3 style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-primary)', margin: 0, fontFamily: FONT }}>
{t('collab.notes.title')} {t('collab.notes.title')}
</h3> </h3>
</div> </div>
@@ -263,7 +263,7 @@ function CollabNotesHeader({ t, canEdit, setShowSettings, setShowNewModal }: Not
return ( return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', flexShrink: 0 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', flexShrink: 0 }}>
<h3 style={{ <h3 style={{
fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-muted)', margin: 0, fontFamily: FONT, fontSize: 12, fontWeight: 600, color: 'var(--text-muted)', margin: 0, fontFamily: FONT,
letterSpacing: 0.3, textTransform: 'uppercase', display: 'flex', alignItems: 'center', gap: 7, letterSpacing: 0.3, textTransform: 'uppercase', display: 'flex', alignItems: 'center', gap: 7,
}}> }}>
<StickyNote size={14} color="var(--text-faint)" /> <StickyNote size={14} color="var(--text-faint)" />
@@ -277,7 +277,7 @@ function CollabNotesHeader({ t, canEdit, setShowSettings, setShowNewModal }: Not
<Settings size={14} /> <Settings size={14} />
</button>} </button>}
{canEdit && <button onClick={() => setShowNewModal(true)} {canEdit && <button onClick={() => setShowNewModal(true)}
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px', background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, fontFamily: FONT, border: 'none', cursor: 'pointer', whiteSpace: 'nowrap' }}> style={{ display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px', background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 11, fontWeight: 600, fontFamily: FONT, border: 'none', cursor: 'pointer', whiteSpace: 'nowrap' }}>
<Plus size={12} /> <Plus size={12} />
{t('collab.notes.new')} {t('collab.notes.new')}
</button>} </button>}
@@ -292,7 +292,7 @@ function CollabCategoryPills({ categories, activeCategory, setActiveCategory, t
<button <button
onClick={() => setActiveCategory(null)} onClick={() => setActiveCategory(null)}
style={{ style={{
flexShrink: 0, borderRadius: 99, padding: '3px 10px', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, fontFamily: FONT, flexShrink: 0, borderRadius: 99, padding: '3px 10px', fontSize: 10, fontWeight: 600, fontFamily: FONT,
border: activeCategory === null ? '1px solid var(--accent)' : '1px solid var(--border-faint)', border: activeCategory === null ? '1px solid var(--accent)' : '1px solid var(--border-faint)',
background: activeCategory === null ? 'var(--accent)' : 'transparent', background: activeCategory === null ? 'var(--accent)' : 'transparent',
color: activeCategory === null ? 'var(--accent-text)' : 'var(--text-secondary)', color: activeCategory === null ? 'var(--accent-text)' : 'var(--text-secondary)',
@@ -306,7 +306,7 @@ function CollabCategoryPills({ categories, activeCategory, setActiveCategory, t
key={cat} key={cat}
onClick={() => setActiveCategory(prev => prev === cat ? null : cat)} onClick={() => setActiveCategory(prev => prev === cat ? null : cat)}
style={{ style={{
flexShrink: 0, borderRadius: 99, padding: '3px 10px', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, fontFamily: FONT, flexShrink: 0, borderRadius: 99, padding: '3px 10px', fontSize: 10, fontWeight: 600, fontFamily: FONT,
border: activeCategory === cat ? '1px solid var(--accent)' : '1px solid var(--border-faint)', border: activeCategory === cat ? '1px solid var(--accent)' : '1px solid var(--border-faint)',
background: activeCategory === cat ? 'var(--accent)' : 'transparent', background: activeCategory === cat ? 'var(--accent)' : 'transparent',
color: activeCategory === cat ? 'var(--accent-text)' : 'var(--text-secondary)', color: activeCategory === cat ? 'var(--accent-text)' : 'var(--text-secondary)',
@@ -334,10 +334,10 @@ function CollabNotesGrid(S: NotesState) {
padding: '48px 20px', textAlign: 'center', height: '100%', padding: '48px 20px', textAlign: 'center', height: '100%',
}}> }}>
<Pencil size={36} color="var(--text-faint)" style={{ marginBottom: 12 }} /> <Pencil size={36} color="var(--text-faint)" style={{ marginBottom: 12 }} />
<div style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4, fontFamily: FONT }}> <div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4, fontFamily: FONT }}>
{t('collab.notes.empty')} {t('collab.notes.empty')}
</div> </div>
<div style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-faint)', fontFamily: FONT }}> <div style={{ fontSize: 12, color: 'var(--text-faint)', fontFamily: FONT }}>
{t('collab.notes.emptyDesc') || 'Create a note to get started'} {t('collab.notes.emptyDesc') || 'Create a note to get started'}
</div> </div>
</div> </div>
@@ -397,10 +397,10 @@ function ViewNoteModal(S: NotesState) {
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
}}> }}>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 'calc(17px * var(--fs-scale-subtitle, 1))', fontWeight: 600, color: 'var(--text-primary)' }}>{viewingNote.title}</div> <div style={{ fontSize: 17, fontWeight: 600, color: 'var(--text-primary)' }}>{viewingNote.title}</div>
{viewingNote.category && ( {viewingNote.category && (
<span style={{ <span style={{
display: 'inline-block', marginTop: 4, fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, display: 'inline-block', marginTop: 4, fontSize: 10, fontWeight: 600,
color: getCategoryColor(viewingNote.category), color: getCategoryColor(viewingNote.category),
background: `${getCategoryColor(viewingNote.category)}18`, background: `${getCategoryColor(viewingNote.category)}18`,
padding: '2px 8px', borderRadius: 6, padding: '2px 8px', borderRadius: 6,
@@ -422,11 +422,11 @@ function ViewNoteModal(S: NotesState) {
</button> </button>
</div> </div>
</div> </div>
<div className="collab-note-md-full" style={{ padding: '16px 20px', overflowY: 'auto', fontSize: 'calc(14px * var(--fs-scale-body, 1))', color: 'var(--text-primary)', lineHeight: 1.7 }}> <div className="collab-note-md-full" style={{ padding: '16px 20px', overflowY: 'auto', fontSize: 14, color: 'var(--text-primary)', lineHeight: 1.7 }}>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{viewingNote.content || ''}</Markdown> <Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{viewingNote.content || ''}</Markdown>
{(viewingNote.attachments || []).length > 0 && ( {(viewingNote.attachments || []).length > 0 && (
<div style={{ marginTop: 16, paddingTop: 16, borderTop: '1px solid var(--border-primary)' }}> <div style={{ marginTop: 16, paddingTop: 16, borderTop: '1px solid var(--border-primary)' }}>
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 10 }}>{t('files.title')}</div> <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 10 }}>{t('files.title')}</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{(viewingNote.attachments || []).map(a => { {(viewingNote.attachments || []).map(a => {
const isImage = a.mime_type?.startsWith('image/') const isImage = a.mime_type?.startsWith('image/')
@@ -449,10 +449,10 @@ function ViewNoteModal(S: NotesState) {
}} }}
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.06)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }} onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.06)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }}
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }}> onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }}>
<span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, color: a.mime_type === 'application/pdf' ? '#ef4444' : 'var(--text-muted)', letterSpacing: 0.3 }}>{ext}</span> <span style={{ fontSize: 10, fontWeight: 700, color: a.mime_type === 'application/pdf' ? '#ef4444' : 'var(--text-muted)', letterSpacing: 0.3 }}>{ext}</span>
</div> </div>
)} )}
<span style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', textAlign: 'center', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', width: '100%' }}>{a.original_name}</span> <span style={{ fontSize: 9, color: 'var(--text-faint)', textAlign: 'center', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', width: '100%' }}>{a.original_name}</span>
</div> </div>
) )
})} })}
@@ -63,11 +63,11 @@ export function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdi
}}> }}>
{!!note.pinned && <Pin size={9} color={color} style={{ flexShrink: 0 }} />} {!!note.pinned && <Pin size={9} color={color} style={{ flexShrink: 0 }} />}
<span style={{ display: 'flex', alignItems: 'center', gap: 5, overflow: 'hidden', flex: 1, minWidth: 0 }}> <span style={{ display: 'flex', alignItems: 'center', gap: 5, overflow: 'hidden', flex: 1, minWidth: 0 }}>
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> <span style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{note.title} {note.title}
</span> </span>
{note.category && ( {note.category && (
<span style={{ fontSize: 'calc(8px * var(--fs-scale-caption, 1))', fontWeight: 600, color, background: `${color}18`, padding: '2px 6px', borderRadius: 99, flexShrink: 0, letterSpacing: '0.02em', textTransform: 'uppercase' }}> <span style={{ fontSize: 8, fontWeight: 600, color, background: `${color}18`, padding: '2px 6px', borderRadius: 99, flexShrink: 0, letterSpacing: '0.02em', textTransform: 'uppercase' }}>
{note.category} {note.category}
</span> </span>
)} )}
@@ -115,7 +115,7 @@ export function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdi
marginBottom: 6, pointerEvents: 'none', opacity: 0, transition: 'opacity 0.12s', marginBottom: 6, pointerEvents: 'none', opacity: 0, transition: 'opacity 0.12s',
whiteSpace: 'nowrap', zIndex: 10, whiteSpace: 'nowrap', zIndex: 10,
background: 'var(--bg-card)', color: 'var(--text-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)',
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500, padding: '5px 10px', borderRadius: 8, fontSize: 11, fontWeight: 500, padding: '5px 10px', borderRadius: 8,
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint)', boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint)',
}}> }}>
{author.username} {author.username}
@@ -137,7 +137,7 @@ export function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdi
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
{note.content && ( {note.content && (
<div className="collab-note-md" style={{ <div className="collab-note-md" style={{
fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', color: 'var(--text-muted)', lineHeight: 1.5, margin: 0, fontSize: 11.5, color: 'var(--text-muted)', lineHeight: 1.5, margin: 0,
maxHeight: '4.5em', overflow: 'hidden', maxHeight: '4.5em', overflow: 'hidden',
wordBreak: 'break-word', fontFamily: FONT, wordBreak: 'break-word', fontFamily: FONT,
}}> }}>
@@ -151,14 +151,14 @@ export function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdi
{/* Website */} {/* Website */}
{note.website && ( {note.website && (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}> <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
<span style={{ fontSize: 'calc(7px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3 }}>Link</span> <span style={{ fontSize: 7, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3 }}>Link</span>
<WebsiteThumbnail url={note.website} tripId={tripId} color={color} /> <WebsiteThumbnail url={note.website} tripId={tripId} color={color} />
</div> </div>
)} )}
{/* Files */} {/* Files */}
{(note.attachments || []).length > 0 && ( {(note.attachments || []).length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}> <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
<span style={{ fontSize: 'calc(7px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3 }}>{t('files.title')}</span> <span style={{ fontSize: 7, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3 }}>{t('files.title')}</span>
<div style={{ display: 'flex', gap: 4 }}> <div style={{ display: 'flex', gap: 4 }}>
{(note.attachments || []).slice(0, note.website ? 1 : 2).map(a => { {(note.attachments || []).slice(0, note.website ? 1 : 2).map(a => {
const isImage = a.mime_type?.startsWith('image/') const isImage = a.mime_type?.startsWith('image/')
@@ -179,12 +179,12 @@ export function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdi
}} }}
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.08)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }} onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.08)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }}
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }}> onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }}>
<span style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 700, color: a.mime_type === 'application/pdf' ? '#ef4444' : 'var(--text-muted)', letterSpacing: 0.3 }}>{ext}</span> <span style={{ fontSize: 9, fontWeight: 700, color: a.mime_type === 'application/pdf' ? '#ef4444' : 'var(--text-muted)', letterSpacing: 0.3 }}>{ext}</span>
</div> </div>
) )
})} })}
{(note.attachments?.length || 0) > (note.website ? 1 : 2) && ( {(note.attachments?.length || 0) > (note.website ? 1 : 2) && (
<span style={{ fontSize: 'calc(8px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', textAlign: 'center' }}>+{(note.attachments?.length || 0) - (note.website ? 1 : 2)}</span> <span style={{ fontSize: 8, color: 'var(--text-faint)', textAlign: 'center' }}>+{(note.attachments?.length || 0) - (note.website ? 1 : 2)}</span>
)} )}
</div> </div>
</div> </div>
@@ -71,7 +71,7 @@ export function CategorySettingsModal({ onClose, categories, categoryColors, onS
}} onClick={e => e.stopPropagation()}> }} onClick={e => e.stopPropagation()}>
{/* Header */} {/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px 12px', borderBottom: '1px solid var(--border-faint)' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px 12px', borderBottom: '1px solid var(--border-faint)' }}>
<h3 style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}> <h3 style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>
{t('collab.notes.categorySettings') || 'Category Settings'} {t('collab.notes.categorySettings') || 'Category Settings'}
</h3> </h3>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 2, display: 'flex' }}> <button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 2, display: 'flex' }}>
@@ -82,7 +82,7 @@ export function CategorySettingsModal({ onClose, categories, categoryColors, onS
{/* Categories list */} {/* Categories list */}
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 10 }}> <div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 10 }}>
{allCats.length === 0 && ( {allCats.length === 0 && (
<p style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-faint)', textAlign: 'center', padding: 16 }}> <p style={{ fontSize: 12, color: 'var(--text-faint)', textAlign: 'center', padding: 16 }}>
{t('collab.notes.noCategoriesYet') || 'No categories yet'} {t('collab.notes.noCategoriesYet') || 'No categories yet'}
</p> </p>
)} )}
@@ -119,7 +119,7 @@ export function CategorySettingsModal({ onClose, categories, categoryColors, onS
placeholder={t('collab.notes.newCategory')} placeholder={t('collab.notes.newCategory')}
style={{ style={{
flex: 1, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', flex: 1, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px',
fontSize: 'calc(13px * var(--fs-scale-body, 1))', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none', fontSize: 13, background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none',
}} /> }} />
<button onClick={handleAddCategory} disabled={!newCatName.trim()} style={{ <button onClick={handleAddCategory} disabled={!newCatName.trim()} style={{
background: newCatName.trim() ? 'var(--accent)' : 'var(--border-primary)', color: 'var(--accent-text)', background: newCatName.trim() ? 'var(--accent)' : 'var(--border-primary)', color: 'var(--accent-text)',
@@ -133,7 +133,7 @@ export function CategorySettingsModal({ onClose, categories, categoryColors, onS
{/* Save */} {/* Save */}
<button onClick={handleSave} style={{ <button onClick={handleSave} style={{
width: '100%', borderRadius: 99, padding: '9px 14px', background: 'var(--accent)', color: 'var(--accent-text)', width: '100%', borderRadius: 99, padding: '9px 14px', background: 'var(--accent)', color: 'var(--accent-text)',
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, border: 'none', cursor: 'pointer', marginTop: 8, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', marginTop: 8,
}}> }}>
{t('collab.notes.save')} {t('collab.notes.save')}
</button> </button>
@@ -21,12 +21,12 @@ export function EditableCatName({ name, onRename }: EditableCatNameProps) {
if (editing) { if (editing) {
return <input ref={inputRef} value={value} onChange={e => setValue(e.target.value)} return <input ref={inputRef} value={value} onChange={e => setValue(e.target.value)}
onBlur={save} onKeyDown={e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') { setValue(name); setEditing(false) } }} onBlur={save} onKeyDown={e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') { setValue(name); setEditing(false) } }}
style={{ flex: 1, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)', border: '1px solid var(--border-primary)', borderRadius: 6, padding: '2px 8px', background: 'var(--bg-input)', fontFamily: 'inherit', outline: 'none' }} /> style={{ flex: 1, fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', border: '1px solid var(--border-primary)', borderRadius: 6, padding: '2px 8px', background: 'var(--bg-input)', fontFamily: 'inherit', outline: 'none' }} />
} }
return ( return (
<span onClick={() => { setValue(name); setEditing(true) }} <span onClick={() => { setValue(name); setEditing(true) }}
style={{ flex: 1, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)', cursor: 'pointer', padding: '2px 0' }} style={{ flex: 1, fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', cursor: 'pointer', padding: '2px 0' }}
title="Click to rename"> title="Click to rename">
{name} {name}
</span> </span>
@@ -37,7 +37,7 @@ export function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
: <Loader2 size={32} className="animate-spin text-[rgba(255,255,255,0.5)]" /> : <Loader2 size={32} className="animate-spin text-[rgba(255,255,255,0.5)]" />
} }
<div style={{ position: 'absolute', top: -36, left: 0, right: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 4px' }}> <div style={{ position: 'absolute', top: -36, left: 0, right: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 4px' }}>
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'rgba(255,255,255,0.7)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '70%' }}>{file.original_name}</span> <span style={{ fontSize: 11, color: 'rgba(255,255,255,0.7)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '70%' }}>{file.original_name}</span>
<div style={{ display: 'flex', gap: 8 }}> <div style={{ display: 'flex', gap: 8 }}>
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}><ExternalLink size={15} /></button> <button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}><ExternalLink size={15} /></button>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}><X size={17} /></button> <button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}><X size={17} /></button>
@@ -48,21 +48,21 @@ export function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
/* Document viewer — card with header */ /* Document viewer — card with header */
<div style={{ width: '100%', maxWidth: 950, height: '94vh', display: 'flex', flexDirection: 'column', background: 'var(--bg-card)', borderRadius: 12, overflow: 'hidden', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }} onClick={e => e.stopPropagation()}> <div style={{ width: '100%', maxWidth: 950, height: '94vh', display: 'flex', flexDirection: 'column', background: 'var(--bg-card)', borderRadius: 12, overflow: 'hidden', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}>
<span style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{file.original_name}</span> <span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{file.original_name}</span>
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}> <div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 3, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-muted)', padding: 0 }}><ExternalLink size={13} /></button> <button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 3, fontSize: 11, color: 'var(--text-muted)', padding: 0 }}><ExternalLink size={13} /></button>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 2 }}><X size={18} /></button> <button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 2 }}><X size={18} /></button>
</div> </div>
</div> </div>
{(isPdf || isTxt) ? ( {(isPdf || isTxt) ? (
<object data={authUrl ? `${authUrl}#view=FitH` : ''} type={file.mime_type} style={{ flex: 1, width: '100%', border: 'none', background: '#fff' }} title={file.original_name}> <object data={authUrl ? `${authUrl}#view=FitH` : ''} type={file.mime_type} style={{ flex: 1, width: '100%', border: 'none', background: '#fff' }} title={file.original_name}>
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}> <p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-primary)', textDecoration: 'underline', fontSize: 'calc(14px * var(--fs-scale-body, 1))', padding: 0 }}>Download</button> <button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-primary)', textDecoration: 'underline', fontSize: 14, padding: 0 }}>Download</button>
</p> </p>
</object> </object>
) : ( ) : (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 40 }}> <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 40 }}>
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-primary)', textDecoration: 'underline', fontSize: 'calc(14px * var(--fs-scale-body, 1))', padding: 0 }}>Download {file.original_name}</button> <button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-primary)', textDecoration: 'underline', fontSize: 14, padding: 0 }}>Download {file.original_name}</button>
</div> </div>
)} )}
</div> </div>
@@ -118,7 +118,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
borderBottom: '1px solid var(--border-faint)', borderBottom: '1px solid var(--border-faint)',
}}> }}>
<h3 style={{ <h3 style={{
fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontSize: 14,
fontWeight: 700, fontWeight: 700,
color: 'var(--text-primary)', color: 'var(--text-primary)',
margin: 0, margin: 0,
@@ -153,7 +153,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
{/* Title */} {/* Title */}
<div> <div>
<div style={{ <div style={{
fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontSize: 9,
fontWeight: 600, fontWeight: 600,
color: 'var(--text-faint)', color: 'var(--text-faint)',
textTransform: 'uppercase', textTransform: 'uppercase',
@@ -173,7 +173,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
border: '1px solid var(--border-primary)', border: '1px solid var(--border-primary)',
borderRadius: 10, borderRadius: 10,
padding: '8px 12px', padding: '8px 12px',
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontSize: 13,
background: 'var(--bg-input)', background: 'var(--bg-input)',
color: 'var(--text-primary)', color: 'var(--text-primary)',
fontFamily: 'inherit', fontFamily: 'inherit',
@@ -186,7 +186,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
{/* Content */} {/* Content */}
<div> <div>
<div style={{ <div style={{
fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontSize: 9,
fontWeight: 600, fontWeight: 600,
color: 'var(--text-faint)', color: 'var(--text-faint)',
textTransform: 'uppercase', textTransform: 'uppercase',
@@ -205,7 +205,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
border: '1px solid var(--border-primary)', border: '1px solid var(--border-primary)',
borderRadius: 10, borderRadius: 10,
padding: '8px 12px', padding: '8px 12px',
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontSize: 13,
background: 'var(--bg-input)', background: 'var(--bg-input)',
color: 'var(--text-primary)', color: 'var(--text-primary)',
fontFamily: 'inherit', fontFamily: 'inherit',
@@ -220,7 +220,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
{/* Category pills */} {/* Category pills */}
<div> <div>
<div style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 6, fontFamily: FONT }}> <div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 6, fontFamily: FONT }}>
{t('collab.notes.category')} {t('collab.notes.category')}
</div> </div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
@@ -229,7 +229,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
const active = category === cat const active = category === cat
return ( return (
<button key={cat} type="button" onClick={() => setCategory(cat)} <button key={cat} type="button" onClick={() => setCategory(cat)}
style={{ padding: '4px 12px', borderRadius: 99, border: active ? `1.5px solid ${c}` : '1px solid var(--border-faint)', background: active ? `${c}18` : 'transparent', color: active ? c : 'var(--text-muted)', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: FONT }}> style={{ padding: '4px 12px', borderRadius: 99, border: active ? `1.5px solid ${c}` : '1px solid var(--border-faint)', background: active ? `${c}18` : 'transparent', color: active ? c : 'var(--text-muted)', fontSize: 11, fontWeight: 600, cursor: 'pointer', fontFamily: FONT }}>
{cat} {cat}
</button> </button>
) )
@@ -239,17 +239,17 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
{/* Website */} {/* Website */}
<div> <div>
<div style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4, fontFamily: FONT }}> <div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4, fontFamily: FONT }}>
{t('collab.notes.website')} {t('collab.notes.website')}
</div> </div>
<input value={website} onChange={e => setWebsite(e.target.value)} <input value={website} onChange={e => setWebsite(e.target.value)}
placeholder={t('collab.notes.websitePlaceholder')} placeholder={t('collab.notes.websitePlaceholder')}
style={{ width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box' }} /> style={{ width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 13, background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box' }} />
</div> </div>
{/* File attachments */} {/* File attachments */}
{canUploadFiles && <div> {canUploadFiles && <div>
<div style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4, fontFamily: FONT }}> <div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4, fontFamily: FONT }}>
{t('collab.notes.attachFiles')} {t('collab.notes.attachFiles')}
</div> </div>
<input ref={fileRef} type="file" multiple style={{ display: 'none' }} onChange={e => { const files = e.target.files; if (files?.length) setPendingFiles(prev => [...prev, ...Array.from(files)]); e.target.value = '' }} /> <input ref={fileRef} type="file" multiple style={{ display: 'none' }} onChange={e => { const files = e.target.files; if (files?.length) setPendingFiles(prev => [...prev, ...Array.from(files)]); e.target.value = '' }} />
@@ -258,7 +258,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
{existingAttachments.map(a => { {existingAttachments.map(a => {
const isImage = a.mime_type?.startsWith('image/') const isImage = a.mime_type?.startsWith('image/')
return ( return (
<div key={a.id} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '3px 8px', borderRadius: 8, background: 'var(--bg-secondary)', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-muted)' }}> <div key={a.id} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '3px 8px', borderRadius: 8, background: 'var(--bg-secondary)', fontSize: 11, color: 'var(--text-muted)' }}>
{isImage && <AuthedImg src={a.url} style={{ width: 18, height: 18, objectFit: 'cover', borderRadius: 3 }} />} {isImage && <AuthedImg src={a.url} style={{ width: 18, height: 18, objectFit: 'cover', borderRadius: 3 }} />}
{(a.original_name || '').length > 20 ? a.original_name.slice(0, 17) + '...' : a.original_name} {(a.original_name || '').length > 20 ? a.original_name.slice(0, 17) + '...' : a.original_name}
<button type="button" onClick={() => handleDeleteAttachment(a.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#ef4444', padding: 0, display: 'flex' }}> <button type="button" onClick={() => handleDeleteAttachment(a.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#ef4444', padding: 0, display: 'flex' }}>
@@ -269,7 +269,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
})} })}
{/* New pending files */} {/* New pending files */}
{pendingFiles.map((f, i) => ( {pendingFiles.map((f, i) => (
<div key={`new-${i}`} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '3px 8px', borderRadius: 8, background: 'var(--bg-secondary)', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-muted)' }}> <div key={`new-${i}`} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '3px 8px', borderRadius: 8, background: 'var(--bg-secondary)', fontSize: 11, color: 'var(--text-muted)' }}>
{f.name.length > 20 ? f.name.slice(0, 17) + '...' : f.name} {f.name.length > 20 ? f.name.slice(0, 17) + '...' : f.name}
<button type="button" onClick={() => setPendingFiles(prev => prev.filter((_, j) => j !== i))} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 0, display: 'flex' }}> <button type="button" onClick={() => setPendingFiles(prev => prev.filter((_, j) => j !== i))} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 0, display: 'flex' }}>
<X size={10} /> <X size={10} />
@@ -277,7 +277,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
</div> </div>
))} ))}
<button type="button" onClick={() => fileRef.current?.click()} <button type="button" onClick={() => fileRef.current?.click()}
style={{ padding: '4px 10px', borderRadius: 8, border: '1px dashed var(--border-faint)', background: 'transparent', cursor: 'pointer', color: 'var(--text-faint)', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontFamily: FONT, display: 'inline-flex', alignItems: 'center', gap: 4 }}> style={{ padding: '4px 10px', borderRadius: 8, border: '1px dashed var(--border-faint)', background: 'transparent', cursor: 'pointer', color: 'var(--text-faint)', fontSize: 11, fontFamily: FONT, display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<Plus size={11} /> {t('files.attach') || 'Add'} <Plus size={11} /> {t('files.attach') || 'Add'}
</button> </button>
</div> </div>
@@ -293,7 +293,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
padding: '7px 14px', padding: '7px 14px',
background: canSubmit ? 'var(--accent)' : 'var(--border-primary)', background: canSubmit ? 'var(--accent)' : 'var(--border-primary)',
color: canSubmit ? 'var(--accent-text)' : 'var(--text-faint)', color: canSubmit ? 'var(--accent-text)' : 'var(--text-faint)',
fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontSize: 12,
fontWeight: 600, fontWeight: 600,
fontFamily: FONT, fontFamily: FONT,
border: 'none', border: 'none',
@@ -37,7 +37,7 @@ export function WebsiteThumbnail({ url, tripId, color }: WebsiteThumbnailProps)
) : ( ) : (
<> <>
<ExternalLink size={14} color="var(--text-muted)" /> <ExternalLink size={14} color="var(--text-muted)" />
<span style={{ fontSize: 'calc(7px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-muted)', maxWidth: 42, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textAlign: 'center' }}> <span style={{ fontSize: 7, fontWeight: 600, color: 'var(--text-muted)', maxWidth: 42, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textAlign: 'center' }}>
{domain} {domain}
</span> </span>
</> </>
+1 -1
View File
@@ -175,7 +175,7 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
padding: '8px 0', borderRadius: 10, border: 'none', cursor: 'pointer', padding: '8px 0', borderRadius: 10, border: 'none', cursor: 'pointer',
background: active ? 'var(--accent)' : 'transparent', background: active ? 'var(--accent)' : 'transparent',
color: active ? 'var(--accent-text)' : 'var(--text-muted)', color: active ? 'var(--accent-text)' : 'var(--text-muted)',
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, fontFamily: 'inherit', fontSize: 11, fontWeight: 600, fontFamily: 'inherit',
transition: 'all 0.15s', transition: 'all 0.15s',
}}> }}>
{tab.label} {tab.label}
+22 -22
View File
@@ -88,30 +88,30 @@ function CreatePollModal({ onClose, onCreate, t }: CreatePollModalProps) {
<div style={{ position: 'fixed', inset: 0, background: 'var(--overlay-bg, rgba(0,0,0,0.35))', backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999, padding: 16, fontFamily: FONT }} onClick={onClose}> <div style={{ position: 'fixed', inset: 0, background: 'var(--overlay-bg, rgba(0,0,0,0.35))', backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999, padding: 16, fontFamily: FONT }} onClick={onClose}>
<form style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 400, maxHeight: '90vh', overflow: 'auto', border: '1px solid var(--border-faint)' }} onClick={e => e.stopPropagation()} onSubmit={handleSubmit}> <form style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 400, maxHeight: '90vh', overflow: 'auto', border: '1px solid var(--border-faint)' }} onClick={e => e.stopPropagation()} onSubmit={handleSubmit}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px 12px', borderBottom: '1px solid var(--border-faint)' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px 12px', borderBottom: '1px solid var(--border-faint)' }}>
<h3 style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>{t('collab.polls.new')}</h3> <h3 style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>{t('collab.polls.new')}</h3>
<button type="button" onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 2, display: 'flex' }}><X size={16} /></button> <button type="button" onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 2, display: 'flex' }}><X size={16} /></button>
</div> </div>
<div style={{ padding: '14px 16px 16px', display: 'flex', flexDirection: 'column', gap: 12 }}> <div style={{ padding: '14px 16px 16px', display: 'flex', flexDirection: 'column', gap: 12 }}>
{/* Question */} {/* Question */}
<div> <div>
<div style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4 }}>{t('collab.polls.question')}</div> <div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4 }}>{t('collab.polls.question')}</div>
<input autoFocus value={question} onChange={e => setQuestion(e.target.value)} placeholder={t('collab.polls.questionPlaceholder') || 'Ask a question...'} style={{ width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box' }} /> <input autoFocus value={question} onChange={e => setQuestion(e.target.value)} placeholder={t('collab.polls.questionPlaceholder') || 'Ask a question...'} style={{ width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 13, background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box' }} />
</div> </div>
{/* Options */} {/* Options */}
<div> <div>
<div style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4 }}>{t('collab.polls.options')}</div> <div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4 }}>{t('collab.polls.options')}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{options.map((opt, i) => ( {options.map((opt, i) => (
<div key={i} style={{ display: 'flex', gap: 6, alignItems: 'center' }}> <div key={i} style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<input value={opt} onChange={e => updateOption(i, e.target.value)} placeholder={`${t('collab.polls.option')} ${i + 1}`} <input value={opt} onChange={e => updateOption(i, e.target.value)} placeholder={`${t('collab.polls.option')} ${i + 1}`}
style={{ flex: 1, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none' }} /> style={{ flex: 1, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 13, background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none' }} />
{options.length > 2 && ( {options.length > 2 && (
<button type="button" onClick={() => removeOption(i)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 2 }}><X size={14} /></button> <button type="button" onClick={() => removeOption(i)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 2 }}><X size={14} /></button>
)} )}
</div> </div>
))} ))}
<button type="button" onClick={addOption} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '6px 12px', borderRadius: 10, border: '1px dashed var(--border-faint)', background: 'transparent', cursor: 'pointer', color: 'var(--text-faint)', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontFamily: FONT }}> <button type="button" onClick={addOption} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '6px 12px', borderRadius: 10, border: '1px dashed var(--border-faint)', background: 'transparent', cursor: 'pointer', color: 'var(--text-faint)', fontSize: 12, fontFamily: FONT }}>
<Plus size={12} /> {t('collab.polls.addOption')} <Plus size={12} /> {t('collab.polls.addOption')}
</button> </button>
</div> </div>
@@ -126,13 +126,13 @@ function CreatePollModal({ onClose, onCreate, t }: CreatePollModalProps) {
}}> }}>
<div style={{ width: 16, height: 16, borderRadius: '50%', background: '#fff', transition: 'transform 0.2s', transform: multiChoice ? 'translateX(16px)' : 'translateX(0)' }} /> <div style={{ width: 16, height: 16, borderRadius: '50%', background: '#fff', transition: 'transform 0.2s', transform: multiChoice ? 'translateX(16px)' : 'translateX(0)' }} />
</div> </div>
<span style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-muted)', fontFamily: FONT }}>{t('collab.polls.multiChoice')}</span> <span style={{ fontSize: 12, color: 'var(--text-muted)', fontFamily: FONT }}>{t('collab.polls.multiChoice')}</span>
</label> </label>
{/* Submit */} {/* Submit */}
<button type="submit" disabled={!canSubmit} style={{ <button type="submit" disabled={!canSubmit} style={{
width: '100%', borderRadius: 99, padding: '9px 14px', background: canSubmit ? 'var(--accent)' : 'var(--border-primary)', width: '100%', borderRadius: 99, padding: '9px 14px', background: canSubmit ? 'var(--accent)' : 'var(--border-primary)',
color: canSubmit ? 'var(--accent-text)' : 'var(--text-faint)', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, border: 'none', cursor: canSubmit ? 'pointer' : 'default', fontFamily: FONT, color: canSubmit ? 'var(--accent-text)' : 'var(--text-faint)', fontSize: 13, fontWeight: 600, border: 'none', cursor: canSubmit ? 'pointer' : 'default', fontFamily: FONT,
}}> }}>
{submitting ? '...' : t('collab.polls.create')} {submitting ? '...' : t('collab.polls.create')}
</button> </button>
@@ -168,7 +168,7 @@ function VoterChip({ voter, offset }: VoterChipProps) {
style={{ style={{
width: 18, height: 18, borderRadius: '50%', background: 'var(--bg-tertiary)', width: 18, height: 18, borderRadius: '50%', background: 'var(--bg-tertiary)',
display: 'flex', alignItems: 'center', justifyContent: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 'calc(7px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-muted)', overflow: 'hidden', fontSize: 7, fontWeight: 700, color: 'var(--text-muted)', overflow: 'hidden',
border: '1.5px solid var(--bg-card)', marginLeft: offset ? -5 : 0, flexShrink: 0, border: '1.5px solid var(--bg-card)', marginLeft: offset ? -5 : 0, flexShrink: 0,
}}> }}>
{voter.avatar_url ? <img src={voter.avatar_url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : (voter.username || '?')[0].toUpperCase()} {voter.avatar_url ? <img src={voter.avatar_url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : (voter.username || '?')[0].toUpperCase()}
@@ -178,7 +178,7 @@ function VoterChip({ voter, offset }: VoterChipProps) {
position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)', position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)',
pointerEvents: 'none', zIndex: 10000, whiteSpace: 'nowrap', pointerEvents: 'none', zIndex: 10000, whiteSpace: 'nowrap',
background: 'var(--bg-card)', color: 'var(--text-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)',
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500, padding: '5px 10px', borderRadius: 8, fontSize: 11, fontWeight: 500, padding: '5px 10px', borderRadius: 8,
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint)', boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint)',
}}> }}>
{voter.username} {voter.username}
@@ -217,26 +217,26 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
background: isClosed ? 'var(--bg-secondary)' : 'transparent', background: isClosed ? 'var(--bg-secondary)' : 'transparent',
}}> }}>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.35, wordBreak: 'break-word' }}> <div style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.35, wordBreak: 'break-word' }}>
{poll.question} {poll.question}
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 4, flexWrap: 'wrap' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 4, flexWrap: 'wrap' }}>
{isClosed && ( {isClosed && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', background: 'var(--bg-tertiary)', padding: '2px 7px', borderRadius: 99 }}> <span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', background: 'var(--bg-tertiary)', padding: '2px 7px', borderRadius: 99 }}>
<Lock size={8} /> {t('collab.polls.closed')} <Lock size={8} /> {t('collab.polls.closed')}
</span> </span>
)} )}
{remaining && !isClosed && ( {remaining && !isClosed && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, color: '#f59e0b', background: '#f59e0b18', padding: '2px 7px', borderRadius: 99 }}> <span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 9, fontWeight: 600, color: '#f59e0b', background: '#f59e0b18', padding: '2px 7px', borderRadius: 99 }}>
<Clock size={8} /> {remaining} <Clock size={8} /> {remaining}
</span> </span>
)} )}
{poll.multi_choice && ( {poll.multi_choice && (
<span style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', background: 'var(--bg-tertiary)', padding: '2px 7px', borderRadius: 99 }}> <span style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', background: 'var(--bg-tertiary)', padding: '2px 7px', borderRadius: 99 }}>
{t('collab.polls.multiChoice')} {t('collab.polls.multiChoice')}
</span> </span>
)} )}
<span style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}> <span style={{ fontSize: 9, color: 'var(--text-faint)' }}>
{total} {total === 1 ? 'vote' : 'votes'} {total} {total === 1 ? 'vote' : 'votes'}
</span> </span>
</div> </div>
@@ -303,7 +303,7 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
{/* Label */} {/* Label */}
<span style={{ <span style={{
flex: 1, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: myVote || isWinner ? 600 : 400, flex: 1, fontSize: 13, fontWeight: myVote || isWinner ? 600 : 400,
color: 'var(--text-primary)', position: 'relative', zIndex: 1, color: 'var(--text-primary)', position: 'relative', zIndex: 1,
}}> }}>
{typeof opt === 'string' ? opt : opt.text} {typeof opt === 'string' ? opt : opt.text}
@@ -321,7 +321,7 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
{/* Percentage */} {/* Percentage */}
{(hasVoted || isClosed) && ( {(hasVoted || isClosed) && (
<span style={{ <span style={{
fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 700, color: myVote ? '#007AFF' : 'var(--text-muted)', fontSize: 12, fontWeight: 700, color: myVote ? '#007AFF' : 'var(--text-muted)',
position: 'relative', zIndex: 1, minWidth: 32, textAlign: 'right', position: 'relative', zIndex: 1, minWidth: 32, textAlign: 'right',
}}> }}>
{pct}% {pct}%
@@ -443,14 +443,14 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', fontFamily: FONT }}> <div style={{ height: '100%', display: 'flex', flexDirection: 'column', fontFamily: FONT }}>
{/* Header */} {/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', flexShrink: 0 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', flexShrink: 0 }}>
<h3 style={{ margin: 0, fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 7, letterSpacing: 0.3, textTransform: 'uppercase' }}> <h3 style={{ margin: 0, fontSize: 12, fontWeight: 600, color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 7, letterSpacing: 0.3, textTransform: 'uppercase' }}>
<BarChart3 size={14} color="var(--text-faint)" /> <BarChart3 size={14} color="var(--text-faint)" />
{t('collab.polls.title')} {t('collab.polls.title')}
</h3> </h3>
{canEdit && ( {canEdit && (
<button onClick={() => setShowForm(true)} style={{ <button onClick={() => setShowForm(true)} style={{
display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px', display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px',
background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 11, fontWeight: 600,
fontFamily: FONT, border: 'none', cursor: 'pointer', fontFamily: FONT, border: 'none', cursor: 'pointer',
}}> }}>
<Plus size={12} /> {t('collab.polls.new')} <Plus size={12} /> {t('collab.polls.new')}
@@ -463,8 +463,8 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
{polls.length === 0 ? ( {polls.length === 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '48px 20px', textAlign: 'center', height: '100%' }}> <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '48px 20px', textAlign: 'center', height: '100%' }}>
<BarChart3 size={36} color="var(--text-faint)" strokeWidth={1.3} style={{ marginBottom: 12 }} /> <BarChart3 size={36} color="var(--text-faint)" strokeWidth={1.3} style={{ marginBottom: 12 }} />
<div style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>{t('collab.polls.empty')}</div> <div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>{t('collab.polls.empty')}</div>
<div style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-faint)' }}>{t('collab.polls.emptyHint')}</div> <div style={{ fontSize: 12, color: 'var(--text-faint)' }}>{t('collab.polls.emptyHint')}</div>
</div> </div>
) : ( ) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
@@ -474,7 +474,7 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
{closedPolls.length > 0 && ( {closedPolls.length > 0 && (
<> <>
{activePolls.length > 0 && ( {activePolls.length > 0 && (
<div style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3, padding: '8px 0 2px' }}> <div style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3, padding: '8px 0 2px' }}>
{t('collab.polls.closedSection') || 'Closed'} {t('collab.polls.closedSection') || 'Closed'}
</div> </div>
)} )}
@@ -91,7 +91,7 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
padding: '10px 14px', display: 'flex', alignItems: 'center', gap: 7, flexShrink: 0, padding: '10px 14px', display: 'flex', alignItems: 'center', gap: 7, flexShrink: 0,
}}> }}>
<Sparkles size={14} color="var(--text-faint)" /> <Sparkles size={14} color="var(--text-faint)" />
<span style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-muted)', letterSpacing: 0.3, textTransform: 'uppercase' }}> <span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-muted)', letterSpacing: 0.3, textTransform: 'uppercase' }}>
{t('collab.whatsNext.title') || "What's Next"} {t('collab.whatsNext.title') || "What's Next"}
</span> </span>
</div> </div>
@@ -101,8 +101,8 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
{upcoming.length === 0 ? ( {upcoming.length === 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', padding: '48px 20px', textAlign: 'center' }}> <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', padding: '48px 20px', textAlign: 'center' }}>
<Calendar size={36} color="var(--text-faint)" strokeWidth={1.3} style={{ marginBottom: 12 }} /> <Calendar size={36} color="var(--text-faint)" strokeWidth={1.3} style={{ marginBottom: 12 }} />
<div style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>{t('collab.whatsNext.empty')}</div> <div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>{t('collab.whatsNext.empty')}</div>
<div style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-faint)' }}>{t('collab.whatsNext.emptyHint')}</div> <div style={{ fontSize: 12, color: 'var(--text-faint)' }}>{t('collab.whatsNext.emptyHint')}</div>
</div> </div>
) : ( ) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
@@ -114,7 +114,7 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
<React.Fragment key={item.id}> <React.Fragment key={item.id}>
{showDayHeader && ( {showDayHeader && (
<div style={{ <div style={{
fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 500, color: 'var(--text-faint)', fontSize: 10, fontWeight: 500, color: 'var(--text-faint)',
textTransform: 'uppercase', letterSpacing: 0.5, textTransform: 'uppercase', letterSpacing: 0.5,
padding: idx === 0 ? '0 4px 4px' : '8px 4px 4px', padding: idx === 0 ? '0 4px 4px' : '8px 4px 4px',
}}> }}>
@@ -132,15 +132,15 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
> >
{/* Time column */} {/* Time column */}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minWidth: 44, flexShrink: 0 }}> <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minWidth: 44, flexShrink: 0 }}>
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-primary)', whiteSpace: 'nowrap', lineHeight: 1 }}> <span style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-primary)', whiteSpace: 'nowrap', lineHeight: 1 }}>
{item.time ? formatTime(item.time, is12h) : 'TBD'} {item.time ? formatTime(item.time, is12h) : 'TBD'}
</span> </span>
{item.endTime && ( {item.endTime && (
<> <>
<span style={{ fontSize: 'calc(7px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', fontWeight: 600, letterSpacing: 0.3, margin: '2px 0', textTransform: 'uppercase' }}> <span style={{ fontSize: 7, color: 'var(--text-faint)', fontWeight: 600, letterSpacing: 0.3, margin: '2px 0', textTransform: 'uppercase' }}>
{t('collab.whatsNext.until') || 'bis'} {t('collab.whatsNext.until') || 'bis'}
</span> </span>
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-primary)', whiteSpace: 'nowrap', lineHeight: 1 }}> <span style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-primary)', whiteSpace: 'nowrap', lineHeight: 1 }}>
{formatTime(item.endTime, is12h)} {formatTime(item.endTime, is12h)}
</span> </span>
</> </>
@@ -152,13 +152,13 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
{/* Details */} {/* Details */}
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)', lineHeight: 1.3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> <div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-primary)', lineHeight: 1.3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{item.name} {item.name}
</div> </div>
{item.address && ( {item.address && (
<div style={{ display: 'flex', alignItems: 'center', gap: 3, marginTop: 2 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 3, marginTop: 2 }}>
<MapPin size={9} color="var(--text-faint)" style={{ flexShrink: 0 }} /> <MapPin size={9} color="var(--text-faint)" style={{ flexShrink: 0 }} />
<span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> <span style={{ fontSize: 10, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{item.address} {item.address}
</span> </span>
</div> </div>
@@ -175,7 +175,7 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
<div style={{ <div style={{
width: 16, height: 16, borderRadius: '50%', background: 'var(--bg-secondary)', width: 16, height: 16, borderRadius: '50%', background: 'var(--bg-secondary)',
display: 'flex', alignItems: 'center', justifyContent: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 'calc(7px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-muted)', fontSize: 7, fontWeight: 700, color: 'var(--text-muted)',
overflow: 'hidden', flexShrink: 0, overflow: 'hidden', flexShrink: 0,
}}> }}>
{p.avatar {p.avatar
@@ -183,7 +183,7 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
: p.username?.[0]?.toUpperCase() : p.username?.[0]?.toUpperCase()
} }
</div> </div>
<span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 500, color: 'var(--text-muted)' }}>{p.username}</span> <span style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)' }}>{p.username}</span>
</div> </div>
))} ))}
</div> </div>
@@ -1,4 +1,4 @@
import { FileText, FileImage, File, FileVideo, Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route } from 'lucide-react' import { FileText, FileImage, File, Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route } from 'lucide-react'
import { downloadFile } from '../../utils/fileDownload' import { downloadFile } from '../../utils/fileDownload'
export function isImage(mimeType?: string | null) { export function isImage(mimeType?: string | null) {
@@ -6,30 +6,9 @@ export function isImage(mimeType?: string | null) {
return mimeType.startsWith('image/') return mimeType.startsWith('image/')
} }
export function isVideo(mimeType?: string | null) {
return !!mimeType && mimeType.startsWith('video/')
}
/** Image or video — the file types that open in the media lightbox (#823). */
export function isMedia(mimeType?: string | null) {
return isImage(mimeType) || isVideo(mimeType)
}
/**
* Markdown file (#1345). Detected by EXTENSION first browsers often send an
* empty / octet-stream / text/plain MIME for .md falling back to the markdown
* MIME types.
*/
export function isMarkdown(mimeType?: string | null, name?: string | null) {
const ext = (name || '').toLowerCase().split('.').pop()
if (ext === 'md' || ext === 'markdown') return true
return !!mimeType && (mimeType === 'text/markdown' || mimeType === 'text/x-markdown')
}
export function getFileIcon(mimeType?: string | null) { export function getFileIcon(mimeType?: string | null) {
if (!mimeType) return File if (!mimeType) return File
if (mimeType === 'application/pdf') return FileText if (mimeType === 'application/pdf') return FileText
if (isVideo(mimeType)) return FileVideo
if (isImage(mimeType)) return FileImage if (isImage(mimeType)) return FileImage
return File return File
} }
@@ -15,15 +15,6 @@ vi.mock('../../api/authUrl', () => ({
getAuthUrl: vi.fn().mockResolvedValue('http://localhost/signed-url'), getAuthUrl: vi.fn().mockResolvedValue('http://localhost/signed-url'),
})); }));
// 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', () => ({
default: ({ children }: { children: string }) => <span data-testid="md">{children}</span>,
}));
vi.mock('remark-gfm', () => ({ default: () => ({}) }));
vi.mock('remark-breaks', () => ({ default: () => ({}) }));
vi.mock('rehype-sanitize', () => ({ default: () => ({}) }));
// Mock filesApi // Mock filesApi
vi.mock('../../api/client', async (importOriginal) => { vi.mock('../../api/client', async (importOriginal) => {
const original = (await importOriginal()) as any; const original = (await importOriginal()) as any;
@@ -298,21 +289,6 @@ describe('FileManager', () => {
}); });
}); });
it('FE-COMP-FILEMANAGER-034: markdown file click opens an inline rendered preview (#1345)', async () => {
server.use(http.get('http://localhost/signed-url', () => HttpResponse.text('# Hello heading\n\nworld body')));
const files = [buildFile({ id: 1, mime_type: 'text/markdown', original_name: 'notes.md' })];
render(<FileManager {...defaultProps} files={files} />);
const user = userEvent.setup();
await user.click(screen.getByText('notes.md'));
await waitFor(() => {
const md = screen.getByTestId('md');
expect(md).toBeInTheDocument();
expect(md.textContent).toContain('Hello heading');
});
});
it('FE-COMP-FILEMANAGER-015: file with uploader name shows avatar chip initials', () => { it('FE-COMP-FILEMANAGER-015: file with uploader name shows avatar chip initials', () => {
const files = [buildFile({ uploaded_by_name: 'Alice Smith' })]; const files = [buildFile({ uploaded_by_name: 'Alice Smith' })];
render(<FileManager {...defaultProps} files={files} />); render(<FileManager {...defaultProps} files={files} />);
+4 -8
View File
@@ -2,27 +2,23 @@ import { useFileManager, type FileManagerProps } from './useFileManager'
import { ImageLightbox } from './FileManagerImageLightbox' import { ImageLightbox } from './FileManagerImageLightbox'
import { AssignModal } from './FileManagerAssignModal' import { AssignModal } from './FileManagerAssignModal'
import { PdfPreviewModal } from './FileManagerPdfPreviewModal' import { PdfPreviewModal } from './FileManagerPdfPreviewModal'
import { MarkdownPreviewModal } from './FileManagerMarkdownPreviewModal'
import { isMarkdown } from './FileManager.helpers'
import { FileManagerToolbar } from './FileManagerToolbar' import { FileManagerToolbar } from './FileManagerToolbar'
import { TrashView } from './FileManagerTrashView' import { TrashView } from './FileManagerTrashView'
import { FilesView } from './FileManagerFilesView' import { FilesView } from './FileManagerFilesView'
export default function FileManager(props: FileManagerProps) { export default function FileManager(props: FileManagerProps) {
const S = useFileManager(props) const S = useFileManager(props)
const { lightboxIndex, setLightboxIndex, mediaFiles, assignFileId, previewFile, handlePaste, showTrash } = S const { lightboxIndex, setLightboxIndex, imageFiles, assignFileId, previewFile, handlePaste, showTrash } = S
return ( return (
<div className="flex flex-col h-full" style={{ fontFamily: "var(--font-system)" }} onPaste={handlePaste} tabIndex={-1}> <div className="flex flex-col h-full" style={{ fontFamily: "var(--font-system)" }} onPaste={handlePaste} tabIndex={-1}>
{/* Lightbox */} {/* Lightbox */}
{lightboxIndex !== null && <ImageLightbox files={mediaFiles} initialIndex={lightboxIndex} onClose={() => setLightboxIndex(null)} />} {lightboxIndex !== null && <ImageLightbox files={imageFiles} initialIndex={lightboxIndex} onClose={() => setLightboxIndex(null)} />}
{/* Assign modal */} {/* Assign modal */}
{assignFileId && <AssignModal {...S} />} {assignFileId && <AssignModal {...S} />}
{/* Document preview modal (markdown is rendered inline; everything else PDF/object) */} {/* PDF preview modal */}
{previewFile && (isMarkdown(previewFile.mime_type, previewFile.original_name) {previewFile && <PdfPreviewModal {...S} />}
? <MarkdownPreviewModal {...S} />
: <PdfPreviewModal {...S} />)}
{/* Toolbar */} {/* Toolbar */}
<FileManagerToolbar {...S} /> <FileManagerToolbar {...S} />
@@ -17,8 +17,8 @@ export function AssignModal(S: FileManagerState) {
}} onClick={e => e.stopPropagation()}> }} onClick={e => e.stopPropagation()}>
<div style={{ padding: '16px 20px 12px', borderBottom: '1px solid var(--border-primary)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}> <div style={{ padding: '16px 20px 12px', borderBottom: '1px solid var(--border-primary)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 600, color: 'var(--text-primary)' }}>{t('files.assignTitle')}</div> <div style={{ fontSize: 15, fontWeight: 600, color: 'var(--text-primary)' }}>{t('files.assignTitle')}</div>
<div style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-faint)', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> <div style={{ fontSize: 12, color: 'var(--text-faint)', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{files.find(f => f.id === assignFileId)?.original_name || ''} {files.find(f => f.id === assignFileId)?.original_name || ''}
</div> </div>
</div> </div>
@@ -27,7 +27,7 @@ export function AssignModal(S: FileManagerState) {
</button> </button>
</div> </div>
<div style={{ padding: '8px 12px 0' }}> <div style={{ padding: '8px 12px 0' }}>
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', padding: '0 2px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}> <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '0 2px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}>
{t('files.noteLabel') || 'Note'} {t('files.noteLabel') || 'Note'}
</div> </div>
<input <input
@@ -43,7 +43,7 @@ export function AssignModal(S: FileManagerState) {
}} }}
onKeyDown={e => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }} onKeyDown={e => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }}
style={{ style={{
width: '100%', padding: '7px 10px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', borderRadius: 8, width: '100%', padding: '7px 10px', fontSize: 13, borderRadius: 8,
border: '1px solid var(--border-primary)', background: 'var(--bg-secondary)', border: '1px solid var(--border-primary)', background: 'var(--bg-secondary)',
color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none',
}} }}
@@ -91,7 +91,7 @@ export function AssignModal(S: FileManagerState) {
} }
}} style={{ }} style={{
width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: isLinked ? 'var(--bg-hover)' : 'none', width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: isLinked ? 'var(--bg-hover)' : 'none',
border: 'none', cursor: 'pointer', fontSize: 'calc(13px * var(--fs-scale-body, 1))', color: 'var(--text-primary)', border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)',
borderRadius: 8, fontFamily: 'inherit', fontWeight: isLinked ? 600 : 400, borderRadius: 8, fontFamily: 'inherit', fontWeight: isLinked ? 600 : 400,
display: 'flex', alignItems: 'center', gap: 6, display: 'flex', alignItems: 'center', gap: 6,
}} }}
@@ -106,18 +106,18 @@ export function AssignModal(S: FileManagerState) {
const placesSection = places.length > 0 && ( const placesSection = places.length > 0 && (
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}> <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}>
{t('files.assignPlace')} {t('files.assignPlace')}
</div> </div>
{dayGroups.map(({ day, dayPlaces }) => ( {dayGroups.map(({ day, dayPlaces }) => (
<div key={day.id}> <div key={day.id}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-muted)', padding: '8px 10px 2px' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', padding: '8px 10px 2px' }}>
<span>{day.title || t('dayplan.dayN', { n: day.day_number })}</span> <span>{day.title || t('dayplan.dayN', { n: day.day_number })}</span>
{(() => { {(() => {
const badge = day.date || (day.title ? t('dayplan.dayN', { n: day.day_number }) : null) const badge = day.date || (day.title ? t('dayplan.dayN', { n: day.day_number }) : null)
return badge ? ( return badge ? (
<span style={{ <span style={{
fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', fontSize: 10, fontWeight: 600, color: 'var(--text-faint)',
background: 'var(--bg-tertiary)', padding: '1px 6px', borderRadius: 999, background: 'var(--bg-tertiary)', padding: '1px 6px', borderRadius: 999,
}}>{badge}</span> }}>{badge}</span>
) : null ) : null
@@ -128,7 +128,7 @@ export function AssignModal(S: FileManagerState) {
))} ))}
{unassigned.length > 0 && ( {unassigned.length > 0 && (
<div> <div>
{dayGroups.length > 0 && <div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-muted)', padding: '8px 10px 2px' }}>{t('files.unassigned')}</div>} {dayGroups.length > 0 && <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', padding: '8px 10px 2px' }}>{t('files.unassigned')}</div>}
{unassigned.map(placeBtn)} {unassigned.map(placeBtn)}
</div> </div>
)} )}
@@ -166,7 +166,7 @@ export function AssignModal(S: FileManagerState) {
} }
}} style={{ }} style={{
width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: isLinked ? 'var(--bg-hover)' : 'none', width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: isLinked ? 'var(--bg-hover)' : 'none',
border: 'none', cursor: 'pointer', fontSize: 'calc(13px * var(--fs-scale-body, 1))', color: 'var(--text-primary)', border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)',
borderRadius: 8, fontFamily: 'inherit', fontWeight: isLinked ? 600 : 400, borderRadius: 8, fontFamily: 'inherit', fontWeight: isLinked ? 600 : 400,
display: 'flex', alignItems: 'center', gap: 6, display: 'flex', alignItems: 'center', gap: 6,
}} }}
@@ -183,7 +183,7 @@ export function AssignModal(S: FileManagerState) {
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
{bookingReservations.length > 0 && ( {bookingReservations.length > 0 && (
<> <>
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}> <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}>
{t('files.assignBooking')} {t('files.assignBooking')}
</div> </div>
{bookingReservations.map(reservationBtn)} {bookingReservations.map(reservationBtn)}
@@ -191,7 +191,7 @@ export function AssignModal(S: FileManagerState) {
)} )}
{transportReservations.length > 0 && ( {transportReservations.length > 0 && (
<> <>
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5, marginTop: bookingReservations.length > 0 ? 4 : 0 }}> <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5, marginTop: bookingReservations.length > 0 ? 4 : 0 }}>
{t('files.assignTransport')} {t('files.assignTransport')}
</div> </div>
{transportReservations.map(reservationBtn)} {transportReservations.map(reservationBtn)}
@@ -32,7 +32,7 @@ export function AvatarChip({ name, avatarUrl, size = 20 }: { name: string; avata
<div style={{ <div style={{
position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)', position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)',
background: 'var(--bg-elevated)', color: 'var(--text-primary)', background: 'var(--bg-elevated)', color: 'var(--text-primary)',
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500, padding: '3px 8px', borderRadius: 6, fontSize: 11, fontWeight: 500, padding: '3px 8px', borderRadius: 6,
boxShadow: '0 2px 8px rgba(0,0,0,0.15)', whiteSpace: 'nowrap', zIndex: 9999, boxShadow: '0 2px 8px rgba(0,0,0,0.15)', whiteSpace: 'nowrap', zIndex: 9999,
pointerEvents: 'none', pointerEvents: 'none',
}}> }}>
@@ -22,15 +22,15 @@ export function FilesView(S: FileManagerState) {
<input {...getInputProps()} /> <input {...getInputProps()} />
<Upload size={24} style={{ margin: '0 auto 8px', color: isDragActive ? 'var(--text-secondary)' : 'var(--text-faint)', display: 'block' }} /> <Upload size={24} style={{ margin: '0 auto 8px', color: isDragActive ? 'var(--text-secondary)' : 'var(--text-faint)', display: 'block' }} />
{uploading ? ( {uploading ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, fontSize: 'calc(13px * var(--fs-scale-body, 1))', color: 'var(--text-secondary)' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, fontSize: 13, color: 'var(--text-secondary)' }}>
<div style={{ width: 14, height: 14, border: '2px solid var(--text-secondary)', borderTopColor: 'transparent', borderRadius: '50%', animation: 'spin 0.8s linear infinite' }} /> <div style={{ width: 14, height: 14, border: '2px solid var(--text-secondary)', borderTopColor: 'transparent', borderRadius: '50%', animation: 'spin 0.8s linear infinite' }} />
{t('files.uploading')} {t('files.uploading')}
</div> </div>
) : ( ) : (
<> <>
<p style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', color: 'var(--text-secondary)', fontWeight: 500, margin: 0 }}>{t('files.dropzone')}</p> <p style={{ fontSize: 13, color: 'var(--text-secondary)', fontWeight: 500, margin: 0 }}>{t('files.dropzone')}</p>
<p style={{ fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginTop: 3 }}>{t('files.dropzoneHint')}</p> <p style={{ fontSize: 11.5, color: 'var(--text-faint)', marginTop: 3 }}>{t('files.dropzoneHint')}</p>
<p style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginTop: 6, opacity: 0.7 }}> <p style={{ fontSize: 10, color: 'var(--text-faint)', marginTop: 6, opacity: 0.7 }}>
{(allowedFileTypes || 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv').toUpperCase().split(',').join(', ')} · Max 50 MB {(allowedFileTypes || 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv').toUpperCase().split(',').join(', ')} · Max 50 MB
</p> </p>
</> </>
@@ -48,14 +48,14 @@ export function FilesView(S: FileManagerState) {
...(files.some(f => f.note_id) ? [{ id: 'collab', label: t('files.filterCollab') || 'Collab' }] : []), ...(files.some(f => f.note_id) ? [{ id: 'collab', label: t('files.filterCollab') || 'Collab' }] : []),
].map(tab => ( ].map(tab => (
<button key={tab.id} onClick={() => setFilterType(tab.id)} style={{ <button key={tab.id} onClick={() => setFilterType(tab.id)} style={{
padding: '4px 12px', borderRadius: 99, border: 'none', cursor: 'pointer', fontSize: 'calc(12px * var(--fs-scale-body, 1))', padding: '4px 12px', borderRadius: 99, border: 'none', cursor: 'pointer', fontSize: 12,
fontFamily: 'inherit', transition: 'all 0.12s', fontFamily: 'inherit', transition: 'all 0.12s',
background: filterType === tab.id ? 'var(--accent)' : 'transparent', background: filterType === tab.id ? 'var(--accent)' : 'transparent',
color: filterType === tab.id ? 'var(--accent-text)' : 'var(--text-muted)', color: filterType === tab.id ? 'var(--accent-text)' : 'var(--text-muted)',
fontWeight: filterType === tab.id ? 600 : 400, fontWeight: filterType === tab.id ? 600 : 400,
}}>{tab.icon ? <tab.icon size={13} fill={filterType === tab.id ? '#facc15' : 'none'} color={filterType === tab.id ? '#facc15' : 'currentColor'} /> : tab.label}</button> }}>{tab.icon ? <tab.icon size={13} fill={filterType === tab.id ? '#facc15' : 'none'} color={filterType === tab.id ? '#facc15' : 'currentColor'} /> : tab.label}</button>
))} ))}
<span style={{ marginLeft: 'auto', fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', alignSelf: 'center' }}> <span style={{ marginLeft: 'auto', fontSize: 11.5, color: 'var(--text-faint)', alignSelf: 'center' }}>
{filteredFiles.length === 1 ? t('files.countSingular') : t('files.count', { count: filteredFiles.length })} {filteredFiles.length === 1 ? t('files.countSingular') : t('files.count', { count: filteredFiles.length })}
</span> </span>
</div> </div>
@@ -65,8 +65,8 @@ export function FilesView(S: FileManagerState) {
{filteredFiles.length === 0 ? ( {filteredFiles.length === 0 ? (
<div style={{ textAlign: 'center', padding: '60px 20px', color: 'var(--text-faint)' }}> <div style={{ textAlign: 'center', padding: '60px 20px', color: 'var(--text-faint)' }}>
<FileText size={40} style={{ color: 'var(--text-faint)', display: 'block', margin: '0 auto 12px' }} /> <FileText size={40} style={{ color: 'var(--text-faint)', display: 'block', margin: '0 auto 12px' }} />
<p style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-secondary)', margin: '0 0 4px' }}>{t('files.empty')}</p> <p style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', margin: '0 0 4px' }}>{t('files.empty')}</p>
<p style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', color: 'var(--text-faint)', margin: 0 }}>{t('files.emptyHint')}</p> <p style={{ fontSize: 13, color: 'var(--text-faint)', margin: 0 }}>{t('files.emptyHint')}</p>
</div> </div>
) : ( ) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
@@ -1,11 +1,10 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { ExternalLink, Download, X, ChevronLeft, ChevronRight, Play } from 'lucide-react' import { ExternalLink, Download, X, ChevronLeft, ChevronRight } from 'lucide-react'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import type { TripFile } from '../../types' import type { TripFile } from '../../types'
import { getAuthUrl } from '../../api/authUrl' import { getAuthUrl } from '../../api/authUrl'
import { openFile as openFileUrl } from '../../utils/fileDownload' import { openFile as openFileUrl } from '../../utils/fileDownload'
import { triggerDownload, isVideo } from './FileManager.helpers' import { triggerDownload } from './FileManager.helpers'
import VideoPlayer from '../Journey/VideoPlayer'
// Image lightbox with gallery navigation // Image lightbox with gallery navigation
interface ImageLightboxProps { interface ImageLightboxProps {
@@ -21,14 +20,10 @@ export function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxPro
const [touchStart, setTouchStart] = useState<number | null>(null) const [touchStart, setTouchStart] = useState<number | null>(null)
const file = files[index] const file = files[index]
const fileIsVideo = isVideo(file?.mime_type)
useEffect(() => { useEffect(() => {
setImgSrc('') setImgSrc('')
// Images use a one-shot signed URL; a video must use the plain same-origin if (file) getAuthUrl(file.url, 'download').then(setImgSrc)
// URL (cookie auth) so its many Range requests all authenticate (#823). }, [file?.url])
if (file && !isVideo(file.mime_type)) getAuthUrl(file.url, 'download').then(setImgSrc)
}, [file?.url, file?.mime_type])
const goPrev = () => setIndex(i => Math.max(0, i - 1)) const goPrev = () => setIndex(i => Math.max(0, i - 1))
const goNext = () => setIndex(i => Math.min(files.length - 1, i + 1)) const goNext = () => setIndex(i => Math.min(files.length - 1, i + 1))
@@ -76,7 +71,7 @@ export function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxPro
> >
{/* Header */} {/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', flexShrink: 0 }} onClick={e => e.stopPropagation()}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', flexShrink: 0 }} onClick={e => e.stopPropagation()}>
<span style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'rgba(255,255,255,0.7)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}> <span style={{ fontSize: 12, color: 'rgba(255,255,255,0.7)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>
{file.original_name} {file.original_name}
<span style={{ marginLeft: 8, color: 'rgba(255,255,255,0.4)' }}>{index + 1} / {files.length}</span> <span style={{ marginLeft: 8, color: 'rgba(255,255,255,0.4)' }}>{index + 1} / {files.length}</span>
</span> </span>
@@ -103,13 +98,7 @@ export function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxPro
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative', minHeight: 0 }} <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative', minHeight: 0 }}
onClick={e => { if (e.target === e.currentTarget) onClose() }}> onClick={e => { if (e.target === e.currentTarget) onClose() }}>
{navBtn('left', goPrev, hasPrev)} {navBtn('left', goPrev, hasPrev)}
{fileIsVideo ? ( {imgSrc && <img src={imgSrc} alt={file.original_name} style={{ maxWidth: '85vw', maxHeight: '80vh', objectFit: 'contain', borderRadius: 8, display: 'block' }} onClick={e => e.stopPropagation()} />}
<div onClick={e => e.stopPropagation()}>
<VideoPlayer src={file.url} style={{ maxWidth: '85vw', maxHeight: '80vh', borderRadius: 8 }} />
</div>
) : (
imgSrc && <img src={imgSrc} alt={file.original_name} style={{ maxWidth: '85vw', maxHeight: '80vh', objectFit: 'contain', borderRadius: 8, display: 'block' }} onClick={e => e.stopPropagation()} />
)}
{navBtn('right', goNext, hasNext)} {navBtn('right', goNext, hasNext)}
</div> </div>
@@ -126,20 +115,14 @@ export function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxPro
} }
function ThumbImg({ file, active, onClick }: { file: TripFile & { url: string }; active: boolean; onClick: () => void }) { function ThumbImg({ file, active, onClick }: { file: TripFile & { url: string }; active: boolean; onClick: () => void }) {
const fileIsVideo = isVideo(file.mime_type)
const [src, setSrc] = useState('') const [src, setSrc] = useState('')
// Videos have no stored thumbnail and can't render as an <img>; show a play useEffect(() => { getAuthUrl(file.url, 'download').then(setSrc) }, [file.url])
// placeholder and don't mint a download token for them (#823).
useEffect(() => { if (!fileIsVideo) getAuthUrl(file.url, 'download').then(setSrc) }, [file.url, fileIsVideo])
return ( return (
<button onClick={onClick} style={{ <button onClick={onClick} style={{
width: 48, height: 48, borderRadius: 6, overflow: 'hidden', border: active ? '2px solid #fff' : '2px solid transparent', width: 48, height: 48, borderRadius: 6, overflow: 'hidden', border: active ? '2px solid #fff' : '2px solid transparent',
opacity: active ? 1 : 0.5, cursor: 'pointer', padding: 0, background: '#111', flexShrink: 0, transition: 'opacity 0.15s', opacity: active ? 1 : 0.5, cursor: 'pointer', padding: 0, background: '#111', flexShrink: 0, transition: 'opacity 0.15s',
display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'rgba(255,255,255,0.7)',
}}> }}>
{fileIsVideo {src && <img src={src} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />}
? <Play size={16} fill="currentColor" />
: (src && <img src={src} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />)}
</button> </button>
) )
} }
@@ -1,72 +0,0 @@
import { useEffect, useState } from 'react'
import ReactDOM from 'react-dom'
import { ExternalLink, Download, X } from 'lucide-react'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import rehypeSanitize from 'rehype-sanitize'
import { openFile as openFileUrl } from '../../utils/fileDownload'
import type { FileManagerState } from './useFileManager'
import { triggerDownload } from './FileManager.helpers'
/**
* Inline preview for uploaded Markdown files (#1345). Fetches the file's text via
* the signed preview URL and renders it with react-markdown. Output is sanitized
* with rehype-sanitize these are UNTRUSTED uploads, unlike collab notes and
* react-markdown v10 already drops raw HTML, so no script can execute.
*/
export function MarkdownPreviewModal(S: FileManagerState) {
const { previewFile, setPreviewFile, previewFileUrl, toast, t } = S
const [text, setText] = useState('')
const [err, setErr] = useState(false)
useEffect(() => {
if (!previewFileUrl) return
let cancelled = false
setErr(false)
setText('')
fetch(previewFileUrl, { credentials: 'include' })
.then(r => (r.ok ? r.text() : Promise.reject(new Error('load failed'))))
.then(body => { if (!cancelled) setText(body) })
.catch(() => { if (!cancelled) setErr(true) })
return () => { cancelled = true }
}, [previewFileUrl])
return ReactDOM.createPortal(
<div
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.85)', zIndex: 10000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
onClick={() => setPreviewFile(null)}
>
<div
style={{ width: '100%', maxWidth: 820, height: '94vh', background: 'var(--bg-card)', borderRadius: 12, overflow: 'hidden', display: 'flex', flexDirection: 'column', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}
onClick={e => e.stopPropagation()}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}>
<span style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{previewFile.original_name}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
<button
onClick={() => openFileUrl(previewFile.url, previewFile.original_name).catch(() => toast.error(t('files.openError')))}
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer', padding: '4px 8px', borderRadius: 6 }}>
<ExternalLink size={13} /> {t('files.openTab')}
</button>
<button
onClick={() => triggerDownload(previewFile.url, previewFile.original_name)}
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer', padding: '4px 8px', borderRadius: 6 }}>
<Download size={13} /> {t('files.download') || 'Download'}
</button>
<button onClick={() => setPreviewFile(null)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 4, borderRadius: 6 }}>
<X size={18} />
</button>
</div>
</div>
<div className="collab-note-md" style={{ flex: 1, overflowY: 'auto', padding: '20px 28px', color: 'var(--text-primary)', lineHeight: 1.6, wordBreak: 'break-word' }}>
{err
? <p style={{ color: 'var(--text-muted)' }}>{t('files.openError')}</p>
: <Markdown remarkPlugins={[remarkGfm, remarkBreaks]} rehypePlugins={[rehypeSanitize]}>{text}</Markdown>}
</div>
</div>
</div>,
document.body
)
}
@@ -16,18 +16,18 @@ export function PdfPreviewModal(S: FileManagerState) {
onClick={e => e.stopPropagation()} onClick={e => e.stopPropagation()}
> >
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}>
<span style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{previewFile.original_name}</span> <span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{previewFile.original_name}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
<button <button
onClick={() => openFileUrl(previewFile.url, previewFile.original_name).catch(() => toast.error(t('files.openError')))} onClick={() => openFileUrl(previewFile.url, previewFile.original_name).catch(() => toast.error(t('files.openError')))}
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'none', padding: '4px 8px', borderRadius: 6, transition: 'color 0.15s' }} style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'none', padding: '4px 8px', borderRadius: 6, transition: 'color 0.15s' }}
onMouseEnter={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'} onMouseEnter={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'}
onMouseLeave={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-muted)'}> onMouseLeave={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-muted)'}>
<ExternalLink size={13} /> {t('files.openTab')} <ExternalLink size={13} /> {t('files.openTab')}
</button> </button>
<button <button
onClick={() => triggerDownload(previewFile.url, previewFile.original_name)} onClick={() => triggerDownload(previewFile.url, previewFile.original_name)}
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'none', padding: '4px 8px', borderRadius: 6, transition: 'color 0.15s' }} style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'none', padding: '4px 8px', borderRadius: 6, transition: 'color 0.15s' }}
onMouseEnter={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'} onMouseEnter={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'}
onMouseLeave={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-muted)'}> onMouseLeave={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-muted)'}>
<Download size={13} /> {t('files.download') || 'Download'} <Download size={13} /> {t('files.download') || 'Download'}
@@ -49,7 +49,7 @@ export function FileRow(p: FileManagerState & { file: TripFile; isTrash?: boolea
const isPdf = file.mime_type === 'application/pdf' const isPdf = file.mime_type === 'application/pdf'
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%', background: isPdf ? '#ef44441a' : 'var(--bg-tertiary)' }}> <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%', background: isPdf ? '#ef44441a' : 'var(--bg-tertiary)' }}>
<span style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 700, color: isPdf ? '#ef4444' : 'var(--text-muted)', letterSpacing: 0.3 }}>{ext}</span> <span style={{ fontSize: 9, fontWeight: 700, color: isPdf ? '#ef4444' : 'var(--text-muted)', letterSpacing: 0.3 }}>{ext}</span>
</div> </div>
) )
})() })()
@@ -65,19 +65,19 @@ export function FileRow(p: FileManagerState & { file: TripFile; isTrash?: boolea
{!isTrash && file.starred ? <Star size={12} fill="#facc15" color="#facc15" style={{ flexShrink: 0 }} /> : null} {!isTrash && file.starred ? <Star size={12} fill="#facc15" color="#facc15" style={{ flexShrink: 0 }} /> : null}
<span <span
onClick={() => !isTrash && openFile(file)} onClick={() => !isTrash && openFile(file)}
style={{ fontWeight: 500, fontSize: 'calc(13px * var(--fs-scale-body, 1))', color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: isTrash ? 'default' : 'pointer' }} style={{ fontWeight: 500, fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: isTrash ? 'default' : 'pointer' }}
> >
{file.original_name} {file.original_name}
</span> </span>
</div> </div>
{file.description && ( {file.description && (
<p style={{ fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', margin: '2px 0 0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{file.description}</p> <p style={{ fontSize: 11.5, color: 'var(--text-faint)', margin: '2px 0 0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{file.description}</p>
)} )}
<div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 6, marginTop: 4 }}> <div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 6, marginTop: 4 }}>
{file.file_size && <span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}>{formatSize(file.file_size)}</span>} {file.file_size && <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formatSize(file.file_size)}</span>}
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}>{formatDateWithLocale(file.created_at, locale)}</span> <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formatDateWithLocale(file.created_at, locale)}</span>
{linkedPlaces.map(p => ( {linkedPlaces.map(p => (
<SourceBadge key={p.id} icon={MapPin} label={`${t('files.sourcePlan')} · ${p.name}`} /> <SourceBadge key={p.id} icon={MapPin} label={`${t('files.sourcePlan')} · ${p.name}`} />
@@ -8,7 +8,7 @@ export function SourceBadge({ icon: Icon, label }: SourceBadgeProps) {
return ( return (
<span style={{ <span style={{
display: 'inline-flex', alignItems: 'center', gap: 4, display: 'inline-flex', alignItems: 'center', gap: 4,
fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', color: '#4b5563', fontSize: 10.5, color: '#4b5563',
background: 'var(--bg-tertiary)', border: '1px solid var(--border-primary)', background: 'var(--bg-tertiary)', border: '1px solid var(--border-primary)',
borderRadius: 6, padding: '2px 7px', borderRadius: 6, padding: '2px 7px',
fontWeight: 500, maxWidth: '100%', overflow: 'hidden', fontWeight: 500, maxWidth: '100%', overflow: 'hidden',
@@ -10,7 +10,7 @@ export function FileManagerToolbar(S: FileManagerState) {
padding: '14px 16px 14px 22px', padding: '14px 16px 14px 22px',
display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap', display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap',
}}> }}>
<h2 style={{ margin: 0, fontSize: 'calc(18px * var(--fs-scale-subtitle, 1))', fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em', flexShrink: 0 }}> <h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em', flexShrink: 0 }}>
{showTrash ? (t('files.trash') || 'Trash') : t('files.title')} {showTrash ? (t('files.trash') || 'Trash') : t('files.title')}
</h2> </h2>
@@ -40,7 +40,7 @@ export function FileManagerToolbar(S: FileManagerState) {
style={{ style={{
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit', appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 6, display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '6px 12px', borderRadius: 99, fontSize: 'calc(13px * var(--fs-scale-body, 1))', whiteSpace: 'nowrap', padding: '6px 12px', borderRadius: 99, fontSize: 13, whiteSpace: 'nowrap',
background: active ? 'var(--bg-card)' : 'transparent', background: active ? 'var(--bg-card)' : 'transparent',
color: active ? 'var(--text-primary)' : 'var(--text-muted)', color: active ? 'var(--text-primary)' : 'var(--text-muted)',
fontWeight: active ? 500 : 400, fontWeight: active ? 500 : 400,
@@ -51,7 +51,7 @@ export function FileManagerToolbar(S: FileManagerState) {
{TabIcon ? <TabIcon size={13} fill={active ? '#facc15' : 'none'} color={active ? '#facc15' : 'currentColor'} /> : null} {TabIcon ? <TabIcon size={13} fill={active ? '#facc15' : 'none'} color={active ? '#facc15' : 'currentColor'} /> : null}
{'label' in tab && tab.label} {'label' in tab && tab.label}
<span style={{ <span style={{
fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, fontSize: 10, fontWeight: 600,
background: active ? 'var(--bg-tertiary)' : 'rgba(0,0,0,0.06)', background: active ? 'var(--bg-tertiary)' : 'rgba(0,0,0,0.06)',
color: 'var(--text-faint)', color: 'var(--text-faint)',
padding: '1px 6px', borderRadius: 99, minWidth: 16, textAlign: 'center', padding: '1px 6px', borderRadius: 99, minWidth: 16, textAlign: 'center',
@@ -66,7 +66,7 @@ export function FileManagerToolbar(S: FileManagerState) {
<button onClick={toggleTrash} style={{ <button onClick={toggleTrash} style={{
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit', appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 6, display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '9px 14px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, padding: '9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
background: 'var(--accent)', color: 'var(--accent-text)', background: 'var(--accent)', color: 'var(--accent-text)',
flexShrink: 0, marginLeft: 'auto', flexShrink: 0, marginLeft: 'auto',
opacity: showTrash ? 1 : 0.88, opacity: showTrash ? 1 : 0.88,
@@ -10,7 +10,7 @@ export function TrashView(S: FileManagerState) {
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 12 }}> <div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 12 }}>
<button onClick={handleEmptyTrash} style={{ <button onClick={handleEmptyTrash} style={{
padding: '5px 12px', borderRadius: 8, border: '1px solid #fecaca', padding: '5px 12px', borderRadius: 8, border: '1px solid #fecaca',
background: '#fef2f2', color: '#dc2626', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 500, background: '#fef2f2', color: '#dc2626', fontSize: 12, fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit', cursor: 'pointer', fontFamily: 'inherit',
}}> }}>
{t('files.emptyTrash') || 'Empty Trash'} {t('files.emptyTrash') || 'Empty Trash'}
@@ -24,7 +24,7 @@ export function TrashView(S: FileManagerState) {
) : trashFiles.length === 0 ? ( ) : trashFiles.length === 0 ? (
<div style={{ textAlign: 'center', padding: '60px 20px', color: 'var(--text-faint)' }}> <div style={{ textAlign: 'center', padding: '60px 20px', color: 'var(--text-faint)' }}>
<Trash2 size={40} style={{ color: 'var(--text-faint)', display: 'block', margin: '0 auto 12px' }} /> <Trash2 size={40} style={{ color: 'var(--text-faint)', display: 'block', margin: '0 auto 12px' }} />
<p style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-secondary)', margin: '0 0 4px' }}>{t('files.trashEmpty') || 'Trash is empty'}</p> <p style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', margin: '0 0 4px' }}>{t('files.trashEmpty') || 'Trash is empty'}</p>
</div> </div>
) : ( ) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
@@ -7,7 +7,7 @@ import type { Place, Reservation, TripFile, Day, AssignmentsMap } from '../../ty
import { useCanDo } from '../../store/permissionsStore' import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore' import { useTripStore } from '../../store/tripStore'
import { getAuthUrl } from '../../api/authUrl' import { getAuthUrl } from '../../api/authUrl'
import { isImage, isMedia } from './FileManager.helpers' import { isImage } from './FileManager.helpers'
export interface FileManagerProps { export interface FileManagerProps {
files?: TripFile[] files?: TripFile[]
@@ -184,12 +184,11 @@ export function useFileManager({ files = [], onUpload, onDelete, onUpdate, place
} }
} }
// Image OR video — both open in the lightbox; videos play there (#823). const imageFiles = filteredFiles.filter(f => isImage(f.mime_type))
const mediaFiles = filteredFiles.filter(f => isMedia(f.mime_type))
const openFile = (file) => { const openFile = (file) => {
if (isMedia(file.mime_type)) { if (isImage(file.mime_type)) {
const idx = mediaFiles.findIndex(f => f.id === file.id) const idx = imageFiles.findIndex(f => f.id === file.id)
setLightboxIndex(idx >= 0 ? idx : 0) setLightboxIndex(idx >= 0 ? idx : 0)
} else { } else {
setPreviewFile(file) setPreviewFile(file)
@@ -203,7 +202,7 @@ export function useFileManager({ files = [], onUpload, onDelete, onUpdate, place
toggleTrash, refreshFiles, handleStar, handleRestore, handlePermanentDelete, handleEmptyTrash, toggleTrash, refreshFiles, handleStar, handleRestore, handlePermanentDelete, handleEmptyTrash,
previewFile, setPreviewFile, previewFileUrl, assignFileId, setAssignFileId, previewFile, setPreviewFile, previewFileUrl, assignFileId, setAssignFileId,
getRootProps, getInputProps, isDragActive, handlePaste, filteredFiles, handleDelete, getRootProps, getInputProps, isDragActive, handlePaste, filteredFiles, handleDelete,
handleAssign, mediaFiles, openFile, handleAssign, imageFiles, openFile,
} }
} }
@@ -48,7 +48,7 @@ export default function JournalBody({ text, dark }: Props) {
<pre style={{ <pre style={{
background: dark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.04)', background: dark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.04)',
borderRadius: 8, padding: 14, overflowX: 'auto', borderRadius: 8, padding: 14, overflowX: 'auto',
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontFamily: 'monospace', margin: '12px 0', fontSize: 13, fontFamily: 'monospace', margin: '12px 0',
}}> }}>
<code>{children}</code> <code>{children}</code>
</pre> </pre>
@@ -1,7 +1,6 @@
import { useEffect, useState, useRef } from 'react' import { useEffect, useState, useRef } from 'react'
import { RefreshCw, Camera, Image, Plus, X, Play } from 'lucide-react' import { RefreshCw, Camera, Image, Plus, X } from 'lucide-react'
import { normalizeImageFiles } from '../../utils/convertHeic' import { normalizeImageFiles } from '../../utils/convertHeic'
import { isVideoFile } from '../../utils/videoPoster'
import { useJourneyStore } from '../../store/journeyStore' import { useJourneyStore } from '../../store/journeyStore'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { journeyApi, addonsApi } from '../../api/client' import { journeyApi, addonsApi } from '../../api/client'
@@ -67,11 +66,7 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
if (!files?.length) return if (!files?.length) return
setGalleryProgress({ done: 0, total: files.length }) setGalleryProgress({ done: 0, total: files.length })
try { try {
// Videos skip HEIC normalization; only images are converted (#823). const normalized = await normalizeImageFiles(files)
const all = Array.from(files)
const videos = all.filter(isVideoFile)
const images = all.filter(f => !isVideoFile(f))
const normalized = [...(images.length ? await normalizeImageFiles(images) : []), ...videos]
const { failed } = await useJourneyStore.getState().uploadGalleryPhotos(journeyId, normalized, { const { failed } = await useJourneyStore.getState().uploadGalleryPhotos(journeyId, normalized, {
onProgress: p => setGalleryProgress({ done: p.done, total: p.total }), onProgress: p => setGalleryProgress({ done: p.done, total: p.total }),
}) })
@@ -115,7 +110,7 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
return ( return (
<div> <div>
<input ref={galleryFileRef} type="file" accept="image/*,video/*" multiple onChange={handleGalleryUpload} className="hidden" /> <input ref={galleryFileRef} type="file" accept="image/*" multiple onChange={handleGalleryUpload} className="hidden" />
{/* Header */} {/* Header */}
<div className="flex items-center justify-between mb-4 flex-wrap gap-2"> <div className="flex items-center justify-between mb-4 flex-wrap gap-2">
@@ -163,26 +158,13 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
className="relative aspect-square rounded-lg overflow-hidden cursor-pointer group" className="relative aspect-square rounded-lg overflow-hidden cursor-pointer group"
onClick={() => onPhotoClick(allPhotos, i)} onClick={() => onPhotoClick(allPhotos, i)}
> >
{photo.media_type === 'video' && !photo.thumbnail_path ? (
// Poster-less video (capture failed / unsupported codec): show a
// neutral tile rather than a broken 404 thumbnail (#823).
<div className="w-full h-full bg-zinc-200 dark:bg-zinc-800" />
) : (
<img <img
src={photoUrl(photo, 'thumbnail')} src={photoUrl(photo, 'thumbnail')}
alt={photo.caption || ''} alt={photo.caption || ''}
className="w-full h-full object-cover transition-transform group-hover:scale-105" className="w-full h-full object-cover transition-transform group-hover:scale-105"
loading="lazy" loading="lazy"
/> />
)}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" /> <div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
{photo.media_type === 'video' && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<span className="w-9 h-9 rounded-full bg-black/55 backdrop-blur flex items-center justify-center text-white">
<Play size={16} className="ml-0.5" fill="currentColor" />
</span>
</div>
)}
{/* Delete button */} {/* Delete button */}
<button <button
onClick={(e) => { e.stopPropagation(); handleDeletePhoto(photo.id) }} onClick={(e) => { e.stopPropagation(); handleDeletePhoto(photo.id) }}
@@ -223,10 +205,10 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
for (const group of groups) { for (const group of groups) {
try { try {
if (entryId) { if (entryId) {
const result = await journeyApi.addProviderPhotos(entryId, pickerProvider!, group.assetIds, undefined, group.passphrase, group.mediaTypes) const result = await journeyApi.addProviderPhotos(entryId, pickerProvider!, group.assetIds, undefined, group.passphrase)
added += result.added || 0 added += result.added || 0
} else { } else {
const result = await journeyApi.addProviderPhotosToGallery(journeyId, pickerProvider!, group.assetIds, group.passphrase, group.mediaTypes) const result = await journeyApi.addProviderPhotosToGallery(journeyId, pickerProvider!, group.assetIds, group.passphrase)
added += result.added || 0 added += result.added || 0
} }
} catch { } catch {
@@ -13,7 +13,7 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
trips: JourneyTrip[] trips: JourneyTrip[]
existingAssetIds: Set<string> existingAssetIds: Set<string>
onClose: () => void onClose: () => void
onAdd: (groups: Array<{ assetIds: string[]; passphrase?: string; mediaTypes?: string[] }>, entryId: number | null) => Promise<void> onAdd: (groups: Array<{ assetIds: string[]; passphrase?: string }>, entryId: number | null) => Promise<void>
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const [filter, setFilter] = useState<'trip' | 'custom' | 'all' | 'album'>('trip') const [filter, setFilter] = useState<'trip' | 'custom' | 'all' | 'album'>('trip')
@@ -27,7 +27,7 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
const [searchPage, setSearchPage] = useState(1) const [searchPage, setSearchPage] = useState(1)
const [searchFrom, setSearchFrom] = useState('') const [searchFrom, setSearchFrom] = useState('')
const [searchTo, setSearchTo] = useState('') const [searchTo, setSearchTo] = useState('')
const [selected, setSelected] = useState<Map<string, { albumId?: string; passphrase?: string; mediaType?: string }>>(new Map()) const [selected, setSelected] = useState<Map<string, { albumId?: string; passphrase?: string }>>(new Map())
const [customFrom, setCustomFrom] = useState('') const [customFrom, setCustomFrom] = useState('')
const [customTo, setCustomTo] = useState('') const [customTo, setCustomTo] = useState('')
const [targetEntryId, setTargetEntryId] = useState<number | null>(null) const [targetEntryId, setTargetEntryId] = useState<number | null>(null)
@@ -123,8 +123,7 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
if (next.has(id)) { if (next.has(id)) {
next.delete(id) next.delete(id)
} else { } else {
const mediaType = (photos as any[]).find(p => p.id === id)?.mediaType next.set(id, { albumId: selectedAlbum ?? undefined, passphrase: selectedAlbumPassphrase })
next.set(id, { albumId: selectedAlbum ?? undefined, passphrase: selectedAlbumPassphrase, mediaType })
} }
return next return next
}) })
@@ -294,7 +293,7 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
if (allSelected) { if (allSelected) {
setSelected(new Map()) setSelected(new Map())
} else { } else {
setSelected(new Map(selectable.map((a: any) => [a.id, { albumId: selectedAlbum ?? undefined, passphrase: selectedAlbumPassphrase, mediaType: a.mediaType }]))) setSelected(new Map(selectable.map((a: any) => [a.id, { albumId: selectedAlbum ?? undefined, passphrase: selectedAlbumPassphrase }])))
} }
}} }}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-[11px] font-medium border border-zinc-200 dark:border-zinc-700 text-zinc-500 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800" className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-[11px] font-medium border border-zinc-200 dark:border-zinc-700 text-zinc-500 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800"
@@ -397,14 +396,13 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
</button> </button>
<button <button
onClick={() => { onClick={() => {
const groupMap = new Map<string | undefined, { assetIds: string[]; mediaTypes: string[] }>() const groupMap = new Map<string | undefined, string[]>()
for (const [assetId, { passphrase, mediaType }] of selected.entries()) { for (const [assetId, { passphrase }] of selected.entries()) {
const g = groupMap.get(passphrase) || { assetIds: [], mediaTypes: [] } const list = groupMap.get(passphrase) || []
g.assetIds.push(assetId) list.push(assetId)
g.mediaTypes.push(mediaType === 'video' ? 'video' : 'image') groupMap.set(passphrase, list)
groupMap.set(passphrase, g)
} }
const groups = [...groupMap.entries()].map(([passphrase, g]) => ({ assetIds: g.assetIds, mediaTypes: g.mediaTypes, passphrase })) const groups = [...groupMap.entries()].map(([passphrase, assetIds]) => ({ assetIds, passphrase }))
onAdd(groups, targetEntryId) onAdd(groups, targetEntryId)
}} }}
disabled={selected.size === 0} disabled={selected.size === 0}
+2 -2
View File
@@ -300,7 +300,7 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
border: `1px solid ${dark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.1)'}`, border: `1px solid ${dark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.1)'}`,
color: dark ? '#fff' : '#18181B', color: dark ? '#fff' : '#18181B',
display: 'flex', alignItems: 'center', justifyContent: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', fontSize: 'calc(16px * var(--fs-scale-subtitle, 1))', fontWeight: 700, lineHeight: 1, cursor: 'pointer', fontSize: 16, fontWeight: 700, lineHeight: 1,
}} }}
>+</button> >+</button>
<button <button
@@ -312,7 +312,7 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
border: `1px solid ${dark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.1)'}`, border: `1px solid ${dark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.1)'}`,
color: dark ? '#fff' : '#18181B', color: dark ? '#fff' : '#18181B',
display: 'flex', alignItems: 'center', justifyContent: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', fontSize: 'calc(16px * var(--fs-scale-subtitle, 1))', fontWeight: 700, lineHeight: 1, cursor: 'pointer', fontSize: 16, fontWeight: 700, lineHeight: 1,
}} }}
></button> ></button>
</div> </div>
@@ -1,11 +1,7 @@
import { forwardRef, lazy, Suspense, useImperativeHandle, useRef } from 'react' import { forwardRef, useImperativeHandle, useRef } from 'react'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
import JourneyMap, { type JourneyMapHandle } from './JourneyMap' import JourneyMap, { type JourneyMapHandle } from './JourneyMap'
import type { JourneyMapGLHandle } from './JourneyMapGL' import JourneyMapGL, { type JourneyMapGLHandle } from './JourneyMapGL'
// Lazy-load the GL renderer (and its ~230 KB gzip engine) so Leaflet-only
// installs never download it — it ships only once a GL provider is picked.
const JourneyMapGL = lazy(() => import('./JourneyMapGL'))
// Unified handle — both providers expose the same three methods. // Unified handle — both providers expose the same three methods.
export type JourneyMapAutoHandle = JourneyMapHandle export type JourneyMapAutoHandle = JourneyMapHandle
@@ -41,9 +37,8 @@ const JourneyMapAuto = forwardRef<JourneyMapAutoHandle, Props>(function JourneyM
const glRef = useRef<JourneyMapGLHandle>(null) const glRef = useRef<JourneyMapGLHandle>(null)
// Fall back to Leaflet when the user selected Mapbox GL but hasn't // Fall back to Leaflet when the user selected Mapbox GL but hasn't
// supplied a token yet. MapLibre/OpenFreeMap is tokenless. // supplied a token yet — otherwise the map would just show a stub.
const useGL = provider === 'maplibre-gl' || (provider === 'mapbox-gl' && !!token) const useGL = provider === 'mapbox-gl' && !!token
const glProvider = provider === 'maplibre-gl' ? 'maplibre-gl' : 'mapbox-gl'
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
highlightMarker: (id) => (useGL ? glRef.current : leafletRef.current)?.highlightMarker(id), highlightMarker: (id) => (useGL ? glRef.current : leafletRef.current)?.highlightMarker(id),
@@ -52,12 +47,8 @@ const JourneyMapAuto = forwardRef<JourneyMapAutoHandle, Props>(function JourneyM
}), [useGL]) }), [useGL])
if (useGL) { if (useGL) {
return ( // eslint-disable-next-line @typescript-eslint/no-explicit-any
<Suspense fallback={null}> return <JourneyMapGL ref={glRef} {...(props as any)} />
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<JourneyMapGL ref={glRef} {...(props as any)} glProvider={glProvider} />
</Suspense>
)
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
return <JourneyMap ref={leafletRef} {...(props as any)} /> return <JourneyMap ref={leafletRef} {...(props as any)} />
+35 -63
View File
@@ -1,11 +1,8 @@
import { useEffect, useRef, useImperativeHandle, forwardRef, useCallback } from 'react' import { useEffect, useRef, useImperativeHandle, forwardRef, useCallback } from 'react'
import mapboxgl from 'mapbox-gl' import mapboxgl from 'mapbox-gl'
import maplibregl from 'maplibre-gl'
import 'mapbox-gl/dist/mapbox-gl.css' import 'mapbox-gl/dist/mapbox-gl.css'
import 'maplibre-gl/dist/maplibre-gl.css'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup' import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup'
import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from '../Map/glProviders'
export interface JourneyMapGLHandle { export interface JourneyMapGLHandle {
highlightMarker: (id: string | null) => void highlightMarker: (id: string | null) => void
@@ -35,7 +32,6 @@ interface Props {
onMarkerClick?: (id: string, type?: string) => void onMarkerClick?: (id: string, type?: string) => void
fullScreen?: boolean fullScreen?: boolean
paddingBottom?: number paddingBottom?: number
glProvider?: GlMapProvider
} }
interface Item { interface Item {
@@ -99,10 +95,8 @@ function ensureJourneyPopupStyle() {
const s = document.createElement('style') const s = document.createElement('style')
s.id = 'trek-journey-popup-style' s.id = 'trek-journey-popup-style'
s.textContent = ` s.textContent = `
.mapboxgl-popup.trek-journey-popup, .mapboxgl-popup.trek-journey-popup { pointer-events: none; animation: trek-journey-popup-in 180ms ease-out; }
.maplibregl-popup.trek-journey-popup { pointer-events: none; animation: trek-journey-popup-in 180ms ease-out; } .mapboxgl-popup.trek-journey-popup .mapboxgl-popup-content {
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-content,
.maplibregl-popup.trek-journey-popup .maplibregl-popup-content {
padding: 9px 14px 10px; padding: 9px 14px 10px;
border-radius: 14px; border-radius: 14px;
background: rgba(255, 255, 255, 0.94); background: rgba(255, 255, 255, 0.94);
@@ -114,24 +108,20 @@ function ensureJourneyPopupStyle() {
min-width: 160px; min-width: 160px;
max-width: 280px; max-width: 280px;
} }
.mapboxgl-popup.trek-journey-popup.trek-dark .mapboxgl-popup-content, .mapboxgl-popup.trek-journey-popup.trek-dark .mapboxgl-popup-content {
.maplibregl-popup.trek-journey-popup.trek-dark .maplibregl-popup-content {
background: rgba(24, 24, 27, 0.88); background: rgba(24, 24, 27, 0.88);
border-color: rgba(255, 255, 255, 0.08); border-color: rgba(255, 255, 255, 0.08);
color: #FAFAFA; color: #FAFAFA;
} }
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-tip, .mapboxgl-popup.trek-journey-popup .mapboxgl-popup-tip {
.maplibregl-popup.trek-journey-popup .maplibregl-popup-tip {
border-top-color: rgba(255, 255, 255, 0.94); border-top-color: rgba(255, 255, 255, 0.94);
border-bottom-color: rgba(255, 255, 255, 0.94); border-bottom-color: rgba(255, 255, 255, 0.94);
} }
.mapboxgl-popup.trek-journey-popup.trek-dark .mapboxgl-popup-tip, .mapboxgl-popup.trek-journey-popup.trek-dark .mapboxgl-popup-tip {
.maplibregl-popup.trek-journey-popup.trek-dark .maplibregl-popup-tip {
border-top-color: rgba(24, 24, 27, 0.88); border-top-color: rgba(24, 24, 27, 0.88);
border-bottom-color: rgba(24, 24, 27, 0.88); border-bottom-color: rgba(24, 24, 27, 0.88);
} }
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-close-button, .mapboxgl-popup.trek-journey-popup .mapboxgl-popup-close-button { display: none; }
.maplibregl-popup.trek-journey-popup .maplibregl-popup-close-button { display: none; }
.trek-journey-popup-title { .trek-journey-popup-title {
font-size: 13.5px; font-size: 13.5px;
font-weight: 600; font-weight: 600;
@@ -142,8 +132,7 @@ function ensureJourneyPopupStyle() {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-title, .mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-title { color: #FAFAFA; }
.maplibregl-popup.trek-journey-popup.trek-dark .trek-journey-popup-title { color: #FAFAFA; }
.trek-journey-popup-sub { .trek-journey-popup-sub {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
@@ -154,8 +143,7 @@ function ensureJourneyPopupStyle() {
line-height: 1.35; line-height: 1.35;
white-space: nowrap; white-space: nowrap;
} }
.mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-sub, .mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-sub { color: #A1A1AA; }
.maplibregl-popup.trek-journey-popup.trek-dark .trek-journey-popup-sub { color: #A1A1AA; }
.trek-journey-popup-place { .trek-journey-popup-place {
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
@@ -206,29 +194,20 @@ function markerHtml(dayColor: string, dayLabel: number, highlighted: boolean): H
const EMPTY_TRAIL: { lat: number; lng: number }[] = [] const EMPTY_TRAIL: { lat: number; lng: number }[] = []
const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL( const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL(
{ entries, trail, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, glProvider = 'mapbox-gl' }, { entries, trail, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom },
ref ref
) { ) {
const stableTrail = trail || EMPTY_TRAIL const stableTrail = trail || EMPTY_TRAIL
const rawMapboxStyle = useSettingsStore(s => s.settings.mapbox_style || MAPBOX_DEFAULT_STYLE) const mapboxStyle = useSettingsStore(s => s.settings.mapbox_style || 'mapbox://styles/mapbox/standard')
const rawMaplibreStyle = useSettingsStore(s => s.settings.maplibre_style || '')
const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '') const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '')
const mapbox3d = useSettingsStore(s => s.settings.mapbox_3d_enabled !== false) const mapbox3d = useSettingsStore(s => s.settings.mapbox_3d_enabled !== false)
const mapboxQuality = useSettingsStore(s => s.settings.mapbox_quality_mode === true) const mapboxQuality = useSettingsStore(s => s.settings.mapbox_quality_mode === true)
const mapLang = useSettingsStore(s => s.settings.language)
const isMapLibre = glProvider === 'maplibre-gl'
const gl = (isMapLibre ? maplibregl : mapboxgl) as any
const glStyle = styleForActiveProvider(glProvider, rawMapboxStyle, rawMaplibreStyle)
const enableMapbox3d = !isMapLibre && mapbox3d
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
// eslint-disable-next-line @typescript-eslint/no-explicit-any const mapRef = useRef<mapboxgl.Map | null>(null)
const mapRef = useRef<any | null>(null) const markersRef = useRef<Map<string, mapboxgl.Marker>>(new Map())
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const markersRef = useRef<Map<string, any>>(new Map())
const itemsRef = useRef<Item[]>([]) const itemsRef = useRef<Item[]>([])
const highlightedRef = useRef<string | null>(null) const highlightedRef = useRef<string | null>(null)
// eslint-disable-next-line @typescript-eslint/no-explicit-any const popupRef = useRef<mapboxgl.Popup | null>(null)
const popupRef = useRef<any | null>(null)
const onMarkerClickRef = useRef(onMarkerClick) const onMarkerClickRef = useRef(onMarkerClick)
onMarkerClickRef.current = onMarkerClick onMarkerClickRef.current = onMarkerClick
const darkRef = useRef(dark) const darkRef = useRef(dark)
@@ -268,7 +247,7 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
const el = popupRef.current.getElement() const el = popupRef.current.getElement()
if (el) el.classList.toggle('trek-dark', !!darkRef.current) if (el) el.classList.toggle('trek-dark', !!darkRef.current)
} else { } else {
popupRef.current = new gl.Popup({ popupRef.current = new mapboxgl.Popup({
closeButton: false, closeButton: false,
closeOnClick: false, closeOnClick: false,
closeOnMove: false, closeOnMove: false,
@@ -281,7 +260,7 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
.setHTML(html) .setHTML(html)
.addTo(mapRef.current) .addTo(mapRef.current)
} }
}, [gl]) }, [])
const hidePopup = useCallback(() => { const hidePopup = useCallback(() => {
if (popupRef.current) { if (popupRef.current) {
@@ -326,11 +305,11 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
mapRef.current.flyTo({ mapRef.current.flyTo({
center: marker.getLngLat(), center: marker.getLngLat(),
zoom: Math.max(mapRef.current.getZoom(), 14), zoom: Math.max(mapRef.current.getZoom(), 14),
pitch: enableMapbox3d ? 45 : 0, pitch: mapbox3d ? 45 : 0,
duration: 600, duration: 600,
}) })
} catch { /* map not yet ready */ } } catch { /* map not yet ready */ }
}, [highlightMarker, enableMapbox3d]) }, [highlightMarker, mapbox3d])
const invalidateSize = useCallback(() => { const invalidateSize = useCallback(() => {
try { mapRef.current?.resize() } catch { /* map not yet ready */ } try { mapRef.current?.resize() } catch { /* map not yet ready */ }
@@ -341,46 +320,39 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
// Build map once per style/token change. Markers and layers are rebuilt // Build map once per style/token change. Markers and layers are rebuilt
// inside the same effect so they stay in sync with the active style. // inside the same effect so they stay in sync with the active style.
useEffect(() => { useEffect(() => {
if (!containerRef.current || (!isMapLibre && !mapboxToken)) return if (!containerRef.current || !mapboxToken) return
if (!isMapLibre) mapboxgl.accessToken = mapboxToken mapboxgl.accessToken = mapboxToken
const items = buildItems(entries) const items = buildItems(entries)
itemsRef.current = items itemsRef.current = items
const bounds = new gl.LngLatBounds() const bounds = new mapboxgl.LngLatBounds()
items.forEach(i => bounds.extend([i.lng, i.lat])) items.forEach(i => bounds.extend([i.lng, i.lat]))
stableTrail.forEach(p => bounds.extend([p.lng, p.lat])) stableTrail.forEach(p => bounds.extend([p.lng, p.lat]))
const hasPoints = items.length > 0 || stableTrail.length > 0 const hasPoints = items.length > 0 || stableTrail.length > 0
const mapOptions: Record<string, unknown> = { const map = new mapboxgl.Map({
container: containerRef.current, container: containerRef.current,
style: glStyle, style: mapboxStyle,
center: hasPoints ? bounds.getCenter() : [0, 30], center: hasPoints ? bounds.getCenter() : [0, 30],
zoom: hasPoints ? 2 : 1, zoom: hasPoints ? 2 : 1,
pitch: enableMapbox3d && fullScreen ? 45 : 0, pitch: mapbox3d && fullScreen ? 45 : 0,
attributionControl: true, attributionControl: true,
antialias: mapboxQuality, antialias: mapboxQuality,
} projection: mapboxQuality ? 'globe' : 'mercator',
if (!isMapLibre) mapOptions.projection = mapboxQuality ? 'globe' : 'mercator' })
const map = new gl.Map(mapOptions as any)
mapRef.current = map mapRef.current = map
map.on('load', () => { map.on('load', () => {
if (enableMapbox3d) { if (mapbox3d) {
if (!isStandardFamily(glStyle) && wantsTerrain(glStyle)) addTerrainAndSky(map) if (!isStandardFamily(mapboxStyle) && wantsTerrain(mapboxStyle)) addTerrainAndSky(map)
if (supportsCustom3d(glStyle)) addCustom3dBuildings(map, !!darkRef.current) if (supportsCustom3d(mapboxStyle)) addCustom3dBuildings(map, !!darkRef.current)
} }
// Flatten Mapbox Standard's built-in DEM so HTML markers (at Z=0) // Flatten Mapbox Standard's built-in DEM so HTML markers (at Z=0)
// stay pinned to their coordinates at every zoom and pitch. // stay pinned to their coordinates at every zoom and pitch.
if (glStyle === MAPBOX_DEFAULT_STYLE) { if (mapboxStyle === 'mapbox://styles/mapbox/standard') {
try { map.setTerrain(null) } catch { /* noop */ } try { map.setTerrain(null) } catch { /* noop */ }
} }
// Pin the basemap label language to the UI language so labels don't fall back to the
// browser/OS locale and stack multiple scripts per place (#1299).
if (!isMapLibre && isStandardFamily(glStyle)) {
try { map.setConfigProperty('basemap', 'language', basemapLanguage(mapLang)) } catch { /* style/SDK may not support it */ }
}
// route trail — dashed line connecting entries in time order // route trail — dashed line connecting entries in time order
if (items.length > 1) { if (items.length > 1) {
@@ -411,7 +383,7 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
// markers // markers
items.forEach((item) => { items.forEach((item) => {
const el = markerHtml(item.dayColor, item.dayLabel, false) const el = markerHtml(item.dayColor, item.dayLabel, false)
const marker = new gl.Marker({ element: el, anchor: 'bottom' }) const marker = new mapboxgl.Marker({ element: el, anchor: 'bottom' })
.setLngLat([item.lng, item.lat]) .setLngLat([item.lng, item.lat])
.addTo(map) .addTo(map)
el.addEventListener('click', (ev) => { el.addEventListener('click', (ev) => {
@@ -428,7 +400,7 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
map.fitBounds(bounds, { map.fitBounds(bounds, {
padding: { top: 50, bottom: pb, left: 50, right: 50 }, padding: { top: 50, bottom: pb, left: 50, right: 50 },
maxZoom: 16, maxZoom: 16,
pitch: enableMapbox3d && fullScreen ? 45 : 0, pitch: mapbox3d && fullScreen ? 45 : 0,
duration: 0, duration: 0,
}) })
} catch { /* empty bounds */ } } catch { /* empty bounds */ }
@@ -446,7 +418,7 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
try { map.remove() } catch { /* noop */ } try { map.remove() } catch { /* noop */ }
mapRef.current = null mapRef.current = null
} }
}, [entries, stableTrail, glProvider, glStyle, mapboxToken, enableMapbox3d, mapboxQuality, fullScreen, paddingBottom]) }, [entries, stableTrail, mapboxStyle, mapboxToken, mapbox3d, mapboxQuality, fullScreen, paddingBottom])
// external activeMarkerId → highlight + flyTo // external activeMarkerId → highlight + flyTo
useEffect(() => { useEffect(() => {
@@ -459,15 +431,15 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
mapRef.current.flyTo({ mapRef.current.flyTo({
center: marker.getLngLat(), center: marker.getLngLat(),
zoom: Math.max(mapRef.current.getZoom(), 12), zoom: Math.max(mapRef.current.getZoom(), 12),
pitch: enableMapbox3d && fullScreen ? 45 : 0, pitch: mapbox3d && fullScreen ? 45 : 0,
duration: 500, duration: 500,
}) })
} catch { /* map not ready */ } } catch { /* map not ready */ }
}, 50) }, 50)
return () => clearTimeout(t) return () => clearTimeout(t)
}, [activeMarkerId, highlightMarker, enableMapbox3d, fullScreen]) }, [activeMarkerId, highlightMarker, mapbox3d, fullScreen])
if (!isMapLibre && !mapboxToken) { if (!mapboxToken) {
return ( return (
<div <div
style={{ position: 'relative', height: height === 9999 ? '100%' : height, width: '100%', borderRadius: 'inherit', overflow: 'hidden' }} style={{ position: 'relative', height: height === 9999 ? '100%' : height, width: '100%', borderRadius: 'inherit', overflow: 'hidden' }}
@@ -1,6 +1,5 @@
import { useState, useEffect, useCallback, useRef } from 'react' import { useState, useEffect, useCallback, useRef } from 'react'
import { ChevronLeft, ChevronRight, X } from 'lucide-react' import { ChevronLeft, ChevronRight, X } from 'lucide-react'
import VideoPlayer from './VideoPlayer'
interface LightboxPhoto { interface LightboxPhoto {
id: string id: string
@@ -9,7 +8,6 @@ interface LightboxPhoto {
provider?: string provider?: string
asset_id?: string | null asset_id?: string | null
owner_id?: number | null owner_id?: number | null
mediaType?: string | null
} }
interface Props { interface Props {
@@ -83,7 +81,7 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose }: Props
> >
{/* Top bar */} {/* Top bar */}
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, zIndex: 10, display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '16px 20px' }}> <div style={{ position: 'absolute', top: 0, left: 0, right: 0, zIndex: 10, display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '16px 20px' }}>
<span style={{ color: 'rgba(255,255,255,0.5)', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500 }}> <span style={{ color: 'rgba(255,255,255,0.5)', fontSize: 13, fontWeight: 500 }}>
{idx + 1} / {photos.length} {idx + 1} / {photos.length}
</span> </span>
<button onClick={onClose} style={{ <button onClick={onClose} style={{
@@ -109,10 +107,7 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose }: Props
</button> </button>
)} )}
{/* Photo or video */} {/* Photo */}
{photo.mediaType === 'video' ? (
<VideoPlayer key={photo.id} src={photo.src} />
) : (
<img <img
key={photo.id} key={photo.id}
src={photo.src} src={photo.src}
@@ -123,7 +118,6 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose }: Props
animation: 'fadeIn 0.15s ease', animation: 'fadeIn 0.15s ease',
}} }}
/> />
)}
{/* Next button */} {/* Next button */}
{hasNext && ( {hasNext && (
@@ -143,7 +137,7 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose }: Props
{photo.caption && ( {photo.caption && (
<div style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', zIndex: 5, maxWidth: '70%', textAlign: 'center' }}> <div style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', zIndex: 5, maxWidth: '70%', textAlign: 'center' }}>
<p style={{ <p style={{
fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontStyle: 'italic', fontSize: 14, fontStyle: 'italic',
color: 'rgba(255,255,255,0.75)', margin: 0, lineHeight: 1.5, color: 'rgba(255,255,255,0.75)', margin: 0, lineHeight: 1.5,
background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(8px)', background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(8px)',
padding: '6px 14px', borderRadius: 10, padding: '6px 14px', borderRadius: 10,
@@ -1,51 +0,0 @@
import React, { useEffect, useRef } from 'react'
import Plyr from 'plyr'
import 'plyr/dist/plyr.css'
/**
* Video player for gallery/lightbox playback (#823), built on Plyr over a native
* <video>. Local videos stream with HTTP Range (seeking works out of the box) and
* the source carries the correct video MIME from the server. The Plyr instance is
* created once per mounted source and destroyed on unmount, so navigating away in
* the lightbox stops playback.
*/
export default function VideoPlayer({
src,
poster,
autoPlay = true,
style,
}: {
src: string
poster?: string
autoPlay?: boolean
style?: React.CSSProperties
}): React.ReactElement {
const videoRef = useRef<HTMLVideoElement>(null)
useEffect(() => {
const el = videoRef.current
if (!el) return
const player = new Plyr(el, {
controls: ['play-large', 'play', 'progress', 'current-time', 'mute', 'volume', 'fullscreen'],
autoplay: autoPlay,
// Keep playback inline so the lightbox stays in control on mobile.
clickToPlay: true,
})
return () => { try { player.destroy() } catch { /* already torn down */ } }
}, [src, autoPlay])
return (
<div
style={{
width: 'min(92vw, 1100px)',
maxHeight: '92vh',
borderRadius: 4,
overflow: 'hidden',
animation: 'fadeIn 0.15s ease',
...style,
}}
>
<video ref={videoRef} src={src} poster={poster} playsInline controls preload="metadata" />
</div>
)
}
@@ -1,4 +1,4 @@
// FE-COMP-BOTTOMNAV-001 to FE-COMP-BOTTOMNAV-010 // FE-COMP-BOTTOMNAV-001 to FE-COMP-BOTTOMNAV-006
vi.mock('../../api/websocket', () => ({ vi.mock('../../api/websocket', () => ({
connect: vi.fn(), connect: vi.fn(),
@@ -30,7 +30,6 @@ const currentUser = buildUser({ id: 1, username: 'testuser', email: 'test@exampl
beforeEach(() => { beforeEach(() => {
resetAllStores(); resetAllStores();
mockNavigate.mockClear(); mockNavigate.mockClear();
sessionStorage.clear();
seedStore(useAuthStore, { user: currentUser, isAuthenticated: true }); seedStore(useAuthStore, { user: currentUser, isAuthenticated: true });
}); });
@@ -80,37 +79,4 @@ describe('BottomNav', () => {
render(<BottomNav />); render(<BottomNav />);
expect(screen.queryByText('Foo Addon')).not.toBeInTheDocument(); expect(screen.queryByText('Foo Addon')).not.toBeInTheDocument();
}); });
// Context-aware "+" inside a trip — #1349
it('FE-COMP-BOTTOMNAV-007: in a trip, the "+" adds a place by default (plan tab)', async () => {
const user = userEvent.setup();
sessionStorage.setItem('trip-tab-42', 'plan');
render(<BottomNav />, { initialEntries: ['/trips/42'] });
await user.click(screen.getByRole('button', { name: 'Add Place/Activity' }));
expect(mockNavigate).toHaveBeenCalledWith('/trips/42?create=place');
});
it('FE-COMP-BOTTOMNAV-008: Bookings tab → "+" creates a reservation', async () => {
const user = userEvent.setup();
sessionStorage.setItem('trip-tab-42', 'buchungen');
render(<BottomNav />, { initialEntries: ['/trips/42'] });
await user.click(screen.getByRole('button', { name: 'Manual Booking' }));
expect(mockNavigate).toHaveBeenCalledWith('/trips/42?create=reservation');
});
it('FE-COMP-BOTTOMNAV-009: Transports tab → "+" creates a transport', async () => {
const user = userEvent.setup();
sessionStorage.setItem('trip-tab-42', 'transports');
render(<BottomNav />, { initialEntries: ['/trips/42'] });
await user.click(screen.getByRole('button', { name: 'Manual Transport' }));
expect(mockNavigate).toHaveBeenCalledWith('/trips/42?create=transport');
});
it('FE-COMP-BOTTOMNAV-010: Costs tab → "+" creates an expense', async () => {
const user = userEvent.setup();
sessionStorage.setItem('trip-tab-42', 'finanzplan');
render(<BottomNav />, { initialEntries: ['/trips/42'] });
await user.click(screen.getByRole('button', { name: 'Add expense' }));
expect(mockNavigate).toHaveBeenCalledWith('/trips/42?create=expense');
});
}); });
+6 -9
View File
@@ -25,15 +25,12 @@ function useCreateAction(): { label: string; run: () => void } {
const onJourneyList = useMatch('/journey') const onJourneyList = useMatch('/journey')
if (inTrip) { if (inTrip) {
// The "+" is context-aware per active tab: Bookings → reservation, // On the Costs tab the "+" adds an expense; otherwise it adds a place.
// Transports → transport, Costs → expense. Tabs without a create modal const tripTab = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem(`trip-tab-${inTrip.params.id}`) : null
// (lists / files / collab) fall through to adding a place. #1349 if (tripTab === 'finanzplan') {
const id = inTrip.params.id return { label: t('costs.addExpense'), run: () => navigate(`/trips/${inTrip.params.id}?create=expense`) }
const tripTab = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem(`trip-tab-${id}`) : null }
if (tripTab === 'finanzplan') return { label: t('costs.addExpense'), run: () => navigate(`/trips/${id}?create=expense`) } return { label: t('places.addPlace'), run: () => navigate(`/trips/${inTrip.params.id}?create=place`) }
if (tripTab === 'buchungen') return { label: t('reservations.addManual'), run: () => navigate(`/trips/${id}?create=reservation`) }
if (tripTab === 'transports') return { label: t('transport.addManual'), run: () => navigate(`/trips/${id}?create=transport`) }
return { label: t('places.addPlace'), run: () => navigate(`/trips/${id}?create=place`) }
} }
if (inJourney) { if (inJourney) {
return { label: t('journey.detail.addEntry'), run: () => navigate(`/journey/${inJourney.params.id}?create=entry`) } return { label: t('journey.detail.addEntry'), run: () => navigate(`/journey/${inJourney.params.id}?create=entry`) }
+13 -13
View File
@@ -287,12 +287,12 @@ export default function DemoBanner(): React.ReactElement | null {
{/* Header */} {/* Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
<img src="/icons/icon-dark.svg" alt="" style={{ width: 36, height: 36, borderRadius: 10 }} /> <img src="/icons/icon-dark.svg" alt="" style={{ width: 36, height: 36, borderRadius: 10 }} />
<h2 style={{ margin: 0, fontSize: 'calc(17px * var(--fs-scale-subtitle, 1))', fontWeight: 700, color: '#111827', display: 'flex', alignItems: 'center', gap: 5 }}> <h2 style={{ margin: 0, fontSize: 17, fontWeight: 700, color: '#111827', display: 'flex', alignItems: 'center', gap: 5 }}>
{t.titleBefore}<img src="/text-dark.svg" alt="TREK" style={{ height: 18 }} />{t.titleAfter} {t.titleBefore}<img src="/text-dark.svg" alt="TREK" style={{ height: 18 }} />{t.titleAfter}
</h2> </h2>
</div> </div>
<p style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', color: '#6b7280', lineHeight: 1.6, margin: '0 0 12px' }}> <p style={{ fontSize: 13, color: '#6b7280', lineHeight: 1.6, margin: '0 0 12px' }}>
{t.description} {t.description}
</p> </p>
@@ -303,7 +303,7 @@ export default function DemoBanner(): React.ReactElement | null {
background: '#f0f9ff', border: '1px solid #bae6fd', borderRadius: 10, padding: '8px 10px', background: '#f0f9ff', border: '1px solid #bae6fd', borderRadius: 10, padding: '8px 10px',
}}> }}>
<Clock size={13} style={{ flexShrink: 0, color: '#0284c7' }} /> <Clock size={13} style={{ flexShrink: 0, color: '#0284c7' }} />
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: '#0369a1', fontWeight: 600 }}> <span style={{ fontSize: 11, color: '#0369a1', fontWeight: 600 }}>
{t.resetIn} {minutesLeft} {t.minutes} {t.resetIn} {minutesLeft} {t.minutes}
</span> </span>
</div> </div>
@@ -312,7 +312,7 @@ export default function DemoBanner(): React.ReactElement | null {
background: '#fffbeb', border: '1px solid #fde68a', borderRadius: 10, padding: '8px 10px', background: '#fffbeb', border: '1px solid #fde68a', borderRadius: 10, padding: '8px 10px',
}}> }}>
<Upload size={13} style={{ flexShrink: 0, color: '#b45309' }} /> <Upload size={13} style={{ flexShrink: 0, color: '#b45309' }} />
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: '#b45309' }}>{t.uploadNote}</span> <span style={{ fontSize: 11, color: '#b45309' }}>{t.uploadNote}</span>
</div> </div>
</div> </div>
@@ -323,15 +323,15 @@ export default function DemoBanner(): React.ReactElement | null {
}}> }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<Map size={14} style={{ color: '#111827' }} /> <Map size={14} style={{ color: '#111827' }} />
<span style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 700, color: '#111827', display: 'flex', alignItems: 'center', gap: 4 }}> <span style={{ fontSize: 12, fontWeight: 700, color: '#111827', display: 'flex', alignItems: 'center', gap: 4 }}>
{t.whatIs} {t.whatIs}
</span> </span>
</div> </div>
<p style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: '#64748b', lineHeight: 1.5, margin: 0 }}>{t.whatIsDesc}</p> <p style={{ fontSize: 12, color: '#64748b', lineHeight: 1.5, margin: 0 }}>{t.whatIsDesc}</p>
</div> </div>
{/* Addons */} {/* Addons */}
<p style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, color: '#374151', margin: '0 0 8px', textTransform: 'uppercase', letterSpacing: '0.08em', display: 'flex', alignItems: 'center', gap: 6 }}> <p style={{ fontSize: 10, fontWeight: 700, color: '#374151', margin: '0 0 8px', textTransform: 'uppercase', letterSpacing: '0.08em', display: 'flex', alignItems: 'center', gap: 6 }}>
<Puzzle size={12} /> <Puzzle size={12} />
{t.addonsTitle} {t.addonsTitle}
</p> </p>
@@ -345,16 +345,16 @@ export default function DemoBanner(): React.ReactElement | null {
}}> }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 2 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 2 }}>
<Icon size={12} style={{ flexShrink: 0, color: '#111827' }} /> <Icon size={12} style={{ flexShrink: 0, color: '#111827' }} />
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, color: '#111827' }}>{name}</span> <span style={{ fontSize: 11, fontWeight: 700, color: '#111827' }}>{name}</span>
</div> </div>
<p style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', color: '#94a3b8', margin: 0, lineHeight: 1.3, paddingLeft: 18 }}>{desc}</p> <p style={{ fontSize: 10, color: '#94a3b8', margin: 0, lineHeight: 1.3, paddingLeft: 18 }}>{desc}</p>
</div> </div>
) )
})} })}
</div> </div>
{/* Full version features */} {/* Full version features */}
<p style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, color: '#374151', margin: '0 0 8px', textTransform: 'uppercase', letterSpacing: '0.08em', display: 'flex', alignItems: 'center', gap: 6 }}> <p style={{ fontSize: 10, fontWeight: 700, color: '#374151', margin: '0 0 8px', textTransform: 'uppercase', letterSpacing: '0.08em', display: 'flex', alignItems: 'center', gap: 6 }}>
<Shield size={12} /> <Shield size={12} />
{t.fullVersionTitle} {t.fullVersionTitle}
</p> </p>
@@ -362,7 +362,7 @@ export default function DemoBanner(): React.ReactElement | null {
{t.features.map((text, i) => { {t.features.map((text, i) => {
const Icon = featureIcons[i] const Icon = featureIcons[i]
return ( return (
<div key={text} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: '#4b5563', padding: '4px 0' }}> <div key={text} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 11, color: '#4b5563', padding: '4px 0' }}>
<Icon size={13} style={{ flexShrink: 0, color: '#9ca3af' }} /> <Icon size={13} style={{ flexShrink: 0, color: '#9ca3af' }} />
<span>{text}</span> <span>{text}</span>
</div> </div>
@@ -377,7 +377,7 @@ export default function DemoBanner(): React.ReactElement | null {
position: 'sticky', bottom: 0, background: 'white', position: 'sticky', bottom: 0, background: 'white',
marginTop: 'auto', marginTop: 'auto',
}}> }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: '#9ca3af' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#9ca3af' }}>
<Github size={13} /> <Github size={13} />
<span>{t.selfHost}</span> <span>{t.selfHost}</span>
<a href="https://github.com/mauriceboe/TREK" target="_blank" rel="noopener noreferrer" <a href="https://github.com/mauriceboe/TREK" target="_blank" rel="noopener noreferrer"
@@ -387,7 +387,7 @@ export default function DemoBanner(): React.ReactElement | null {
</div> </div>
<button onClick={() => setDismissed(true)} style={{ <button onClick={() => setDismissed(true)} style={{
background: '#111827', color: 'white', border: 'none', background: '#111827', color: 'white', border: 'none',
borderRadius: 10, padding: '8px 20px', fontSize: 'calc(12px * var(--fs-scale-body, 1))', borderRadius: 10, padding: '8px 20px', fontSize: 12,
fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
}}> }}>
{t.close} {t.close}
@@ -55,7 +55,7 @@ export default function InAppNotificationBell(): React.ReactElement {
className="absolute -top-0.5 -right-0.5 flex items-center justify-center rounded-full text-white font-bold" className="absolute -top-0.5 -right-0.5 flex items-center justify-center rounded-full text-white font-bold"
style={{ style={{
background: '#ef4444', background: '#ef4444',
fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontSize: 9,
minWidth: 14, minWidth: 14,
height: 14, height: 14,
padding: '0 3px', padding: '0 3px',
+3 -11
View File
@@ -5,7 +5,7 @@ import { useAuthStore } from '../../store/authStore'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
import { useAddonStore } from '../../store/addonStore' import { useAddonStore } from '../../store/addonStore'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass, BookOpen } from 'lucide-react' import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass } from 'lucide-react'
import type { LucideIcon } from 'lucide-react' import type { LucideIcon } from 'lucide-react'
import InAppNotificationBell from './InAppNotificationBell.tsx' import InAppNotificationBell from './InAppNotificationBell.tsx'
@@ -154,7 +154,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
<Link key={tab.id} to={tab.path} <Link key={tab.id} to={tab.path}
className="flex items-center gap-1.5 transition-colors" className="flex items-center gap-1.5 transition-colors"
style={{ style={{
padding: '5px 16px', borderRadius: 9, fontSize: 'calc(13.5px * var(--fs-scale-body, 1))', fontWeight: 500, padding: '5px 16px', borderRadius: 9, fontSize: 13.5, fontWeight: 500,
color: isActive ? 'var(--text-primary)' : 'var(--text-muted)', color: isActive ? 'var(--text-primary)' : 'var(--text-muted)',
background: isActive ? 'var(--bg-card)' : 'transparent', background: isActive ? 'var(--bg-card)' : 'transparent',
boxShadow: isActive ? '0 1px 2px rgba(0,0,0,0.06), 0 2px 6px rgba(0,0,0,0.05)' : 'none', boxShadow: isActive ? '0 1px 2px rgba(0,0,0,0.06), 0 2px 6px rgba(0,0,0,0.05)' : 'none',
@@ -252,14 +252,6 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
{t('nav.settings')} {t('nav.settings')}
</Link> </Link>
<Link to="/help" onClick={() => setUserMenuOpen(false)}
className="flex items-center gap-2 px-4 py-2 text-sm transition-colors text-content-secondary"
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
<BookOpen className="w-4 h-4" />
{t('nav.help')}
</Link>
{user.role === 'admin' && ( {user.role === 'admin' && (
<Link to="/admin" onClick={() => setUserMenuOpen(false)} <Link to="/admin" onClick={() => setUserMenuOpen(false)}
className="flex items-center gap-2 px-4 py-2 text-sm transition-colors text-content-secondary" className="flex items-center gap-2 px-4 py-2 text-sm transition-colors text-content-secondary"
@@ -282,7 +274,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 5, background: 'var(--bg-tertiary)', borderRadius: 99, padding: '4px 12px' }}> <div style={{ display: 'inline-flex', alignItems: 'center', gap: 5, background: 'var(--bg-tertiary)', borderRadius: 99, padding: '4px 12px' }}>
<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="TREK" style={{ height: 10, opacity: 0.5 }} /> <img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="TREK" style={{ height: 10, opacity: 0.5 }} />
<span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)' }}>v{appVersion}</span> <span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)' }}>v{appVersion}</span>
</div> </div>
<a href="https://discord.gg/NhZBDSd4qW" target="_blank" rel="noopener noreferrer" <a href="https://discord.gg/NhZBDSd4qW" target="_blank" rel="noopener noreferrer"
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 24, height: 24, borderRadius: 99, background: 'var(--bg-tertiary)', transition: 'background 0.15s' }} style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 24, height: 24, borderRadius: 99, background: 'var(--bg-tertiary)', transition: 'background 0.15s' }}
@@ -7,20 +7,16 @@ vi.mock('../../sync/mutationQueue', () => ({
mutationQueue: { mutationQueue: {
pendingCount: vi.fn(), pendingCount: vi.fn(),
failedCount: vi.fn(), failedCount: vi.fn(),
conflictCount: vi.fn(),
}, },
})) }))
import { mutationQueue } from '../../sync/mutationQueue' import { mutationQueue } from '../../sync/mutationQueue'
import { _resetNetworkMode } from '../../sync/networkMode'
const pendingCount = mutationQueue.pendingCount as ReturnType<typeof vi.fn> const pendingCount = mutationQueue.pendingCount as ReturnType<typeof vi.fn>
const failedCount = mutationQueue.failedCount as ReturnType<typeof vi.fn> const failedCount = mutationQueue.failedCount as ReturnType<typeof vi.fn>
const conflictCount = mutationQueue.conflictCount as ReturnType<typeof vi.fn>
afterEach(() => { afterEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
_resetNetworkMode()
Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true }) Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true })
}) })
@@ -28,27 +24,15 @@ describe('OfflineBanner (B3 surface)', () => {
it('shows the failed pill when failedCount > 0 while online', async () => { it('shows the failed pill when failedCount > 0 while online', async () => {
pendingCount.mockResolvedValue(0) pendingCount.mockResolvedValue(0)
failedCount.mockResolvedValue(2) failedCount.mockResolvedValue(2)
conflictCount.mockResolvedValue(0)
render(<OfflineBanner />) render(<OfflineBanner />)
expect(await screen.findByText(/failed to sync: 2/i)).toBeInTheDocument() expect(await screen.findByText(/2 changes failed to sync/i)).toBeInTheDocument()
}) })
it('shows the conflict pill when conflicts exist while online', async () => { it('stays hidden when online with nothing pending or failed', async () => {
pendingCount.mockResolvedValue(0) pendingCount.mockResolvedValue(0)
failedCount.mockResolvedValue(0) failedCount.mockResolvedValue(0)
conflictCount.mockResolvedValue(3)
render(<OfflineBanner />)
expect(await screen.findByText(/conflicts: 3/i)).toBeInTheDocument()
})
it('stays hidden when online with nothing pending, failed or conflicting', async () => {
pendingCount.mockResolvedValue(0)
failedCount.mockResolvedValue(0)
conflictCount.mockResolvedValue(0)
const { container } = render(<OfflineBanner />) const { container } = render(<OfflineBanner />)
// Give the async poll a tick to resolve. // Give the async poll a tick to resolve.
+39 -41
View File
@@ -1,44 +1,49 @@
/** /**
* OfflineBanner connectivity + sync state indicator. * OfflineBanner connectivity + sync state indicator.
* *
* Priority (highest first): * States:
* N failed red pill "Failed to sync: N" (changes were dropped) * N failed red pill "N changes failed to sync" (takes priority)
* N conflicts purple pill "Conflicts: N" (need resolving) * offline + N queued amber pill "Offline · N queued"
* offline amber pill "Offline" / "Offline mode" / "Offline · N queued" * offline + 0 queued amber pill "Offline"
* online + N blue pill "Syncing N…" * online + N pending blue pill "Syncing N…"
* online + 0 hidden * online + 0 pending hidden
* *
* Rendered as a small floating pill anchored to the bottom-center of the * Rendered as a small floating pill anchored to the bottom-center of the
* viewport so it never competes with top navigation or sticky modal * viewport so it never competes with top navigation or sticky modal
* headers. On mobile it hovers just above the bottom tab bar. * headers. On mobile it hovers just above the bottom tab bar.
*/ */
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { WifiOff, RefreshCw, AlertTriangle, GitMerge } from 'lucide-react' import { WifiOff, RefreshCw, AlertTriangle } from 'lucide-react'
import { mutationQueue } from '../../sync/mutationQueue' import { mutationQueue } from '../../sync/mutationQueue'
import { useNetworkMode } from '../../hooks/useNetworkMode'
import { useTranslation } from '../../i18n'
const POLL_MS = 3_000 const POLL_MS = 3_000
export default function OfflineBanner(): React.ReactElement | null { export default function OfflineBanner(): React.ReactElement | null {
const { t } = useTranslation() const [isOnline, setIsOnline] = useState(navigator.onLine)
const { offline, forced } = useNetworkMode()
const [pendingCount, setPendingCount] = useState(0) const [pendingCount, setPendingCount] = useState(0)
const [failedCount, setFailedCount] = useState(0) const [failedCount, setFailedCount] = useState(0)
const [conflictCount, setConflictCount] = useState(0)
useEffect(() => {
const onOnline = () => setIsOnline(true)
const onOffline = () => setIsOnline(false)
window.addEventListener('online', onOnline)
window.addEventListener('offline', onOffline)
return () => {
window.removeEventListener('online', onOnline)
window.removeEventListener('offline', onOffline)
}
}, [])
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
async function poll() { async function poll() {
const [n, failed, conflicts] = await Promise.all([ const [n, failed] = await Promise.all([
mutationQueue.pendingCount(), mutationQueue.pendingCount(),
mutationQueue.failedCount(), mutationQueue.failedCount(),
mutationQueue.conflictCount(),
]) ])
if (!cancelled) { if (!cancelled) {
setPendingCount(n) setPendingCount(n)
setFailedCount(failed) setFailedCount(failed)
setConflictCount(conflicts)
} }
} }
poll() poll()
@@ -46,34 +51,22 @@ export default function OfflineBanner(): React.ReactElement | null {
return () => { cancelled = true; clearInterval(id) } return () => { cancelled = true; clearInterval(id) }
}, []) }, [])
const hidden = !offline && pendingCount === 0 && failedCount === 0 && conflictCount === 0 const hidden = isOnline && pendingCount === 0 && failedCount === 0
if (hidden) return null if (hidden) return null
const offline = !isOnline
// Failed mutations are the most important signal — they mean data was dropped. // Failed mutations are the most important signal — they mean data was dropped.
// Conflicts come next (they still need a decision), then plain offline status.
const failed = failedCount > 0 const failed = failedCount > 0
const conflict = !failed && conflictCount > 0 const bg = failed ? '#b91c1c' : offline ? '#92400e' : '#1e40af'
const bg = failed ? '#b91c1c' : conflict ? '#6d28d9' : offline ? '#92400e' : '#1e40af' const text = '#fff'
let label: string const label = failed
let icon: React.ReactElement ? `${failedCount} change${failedCount !== 1 ? 's' : ''} failed to sync`
if (failed) { : offline
label = t('settings.offline.banner.failed', { count: failedCount }) ? pendingCount > 0
icon = <AlertTriangle size={12} /> ? `Offline · ${pendingCount} queued`
} else if (conflict) { : 'Offline'
label = t('settings.offline.banner.conflicts', { count: conflictCount }) : `Syncing ${pendingCount}`
icon = <GitMerge size={12} />
} else if (offline) {
label = pendingCount > 0
? t('settings.offline.banner.queued', { count: pendingCount })
: forced
? t('settings.offline.banner.forced')
: t('settings.offline.banner.offline')
icon = <WifiOff size={12} />
} else {
label = t('settings.offline.banner.syncing', { count: pendingCount })
icon = <RefreshCw size={12} style={{ animation: 'spin 1s linear infinite' }} />
}
return ( return (
<div <div
@@ -88,20 +81,25 @@ export default function OfflineBanner(): React.ReactElement | null {
transform: 'translateX(-50%)', transform: 'translateX(-50%)',
zIndex: 9999, zIndex: 9999,
background: bg, background: bg,
color: '#fff', color: text,
display: 'inline-flex', display: 'inline-flex',
alignItems: 'center', alignItems: 'center',
gap: 6, gap: 6,
padding: '6px 14px', padding: '6px 14px',
borderRadius: 999, borderRadius: 999,
boxShadow: '0 4px 16px rgba(0,0,0,0.18), 0 0 0 1px rgba(255,255,255,0.08)', boxShadow: '0 4px 16px rgba(0,0,0,0.18), 0 0 0 1px rgba(255,255,255,0.08)',
fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontSize: 12,
fontWeight: 600, fontWeight: 600,
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
pointerEvents: 'none', pointerEvents: 'none',
}} }}
> >
{icon} {failed
? <AlertTriangle size={12} />
: offline
? <WifiOff size={12} />
: <RefreshCw size={12} style={{ animation: 'spin 1s linear infinite' }} />
}
{label} {label}
</div> </div>
) )
+3 -17
View File
@@ -5,9 +5,6 @@ export interface PageSidebarTab {
id: string id: string
label: string label: string
icon: LucideIcon icon: LucideIcon
/** Optional group heading shown above the first tab of each group. Tabs that
* share a group must be contiguous in the array. */
group?: string
} }
interface PageSidebarProps { interface PageSidebarProps {
@@ -163,21 +160,12 @@ function SidebarInner({
</div> </div>
)} )}
<nav className="flex flex-col gap-1 flex-1"> <nav className="flex flex-col gap-1 flex-1">
{(() => { {tabs.map((tab) => {
let lastGroup: string | undefined
return tabs.map((tab) => {
const Icon = tab.icon const Icon = tab.icon
const active = tab.id === activeTab const active = tab.id === activeTab
const showHeader = !!tab.group && tab.group !== lastGroup
lastGroup = tab.group
return ( return (
<React.Fragment key={tab.id}>
{showHeader && (
<div className="text-[10px] font-bold tracking-widest uppercase text-content-faint px-3 mt-3 mb-0.5 first:mt-0">
{tab.group}
</div>
)}
<button <button
key={tab.id}
onClick={() => onTabChange(tab.id)} onClick={() => onTabChange(tab.id)}
className={`flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm text-left transition-colors ${active ? 'text-content font-semibold' : 'text-content-secondary font-medium'}`} className={`flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm text-left transition-colors ${active ? 'text-content font-semibold' : 'text-content-secondary font-medium'}`}
style={{ style={{
@@ -193,10 +181,8 @@ function SidebarInner({
<Icon size={16} className="shrink-0" /> <Icon size={16} className="shrink-0" />
<span className="truncate">{tab.label}</span> <span className="truncate">{tab.label}</span>
</button> </button>
</React.Fragment>
) )
}) })}
})()}
</nav> </nav>
{footer && ( {footer && (
<div <div
+4 -10
View File
@@ -1,21 +1,15 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Navigation } from 'lucide-react' import { Navigation } from 'lucide-react'
import type mapboxgl from 'mapbox-gl'
export interface CompassMap {
getBearing: () => number
on: (type: 'rotate', listener: () => void) => unknown
off: (type: 'rotate', listener: () => void) => unknown
easeTo: (options: { bearing: number; pitch: number; duration: number }) => unknown
}
/** /**
* Round compass pill for the GL planner map. The map can be rotated and * Round compass pill for the Mapbox planner map. The Mapbox map can be rotated and
* pitched, so this shows the current bearing (the arrow points to north) and snaps * pitched, so this shows the current bearing (the arrow points to north) and snaps
* the camera back to north + flat on click. Rendered next to the POI "explore" pill * the camera back to north + flat on click. Rendered next to the POI "explore" pill
* (GL only) and built as the SAME frosted shell (padding 4 around a 34px button) * (Mapbox only) and built as the SAME frosted shell (padding 4 around a 34px button)
* so its height and transparency match the POI pill exactly. * so its height and transparency match the POI pill exactly.
*/ */
export function MapCompassPill({ map }: { map: CompassMap }) { export function MapCompassPill({ map }: { map: mapboxgl.Map }) {
const [bearing, setBearing] = useState(() => map.getBearing()) const [bearing, setBearing] = useState(() => map.getBearing())
useEffect(() => { useEffect(() => {
@@ -244,22 +244,4 @@ describe('MapView', () => {
rerender(<MapView places={places} fitKey={2} />) rerender(<MapView places={places} fitKey={2} />)
expect(mapMock.fitBounds.mock.calls.length).toBeGreaterThan(afterFirst) expect(mapMock.fitBounds.mock.calls.length).toBeGreaterThan(afterFirst)
}) })
it('FE-COMP-MAPVIEW-020: a day fit expands to include the route once it arrives (#1128)', async () => {
const L = ((await import('leaflet')).default) as unknown as { latLngBounds: ReturnType<typeof vi.fn> }
const dayPlaces = [
buildMapPlace({ id: 1, lat: 48.0, lng: 2.0 }),
buildMapPlace({ id: 2, lat: 48.1, lng: 2.1 }),
]
// Day selected, route not computed yet → first fit is the two destinations.
const { rerender } = render(<MapView places={dayPlaces} dayPlaces={dayPlaces} route={[]} fitKey={5} />)
const lastBounds = () => { const c = L.latLngBounds.mock.calls; return c[c.length - 1][0] }
expect(lastBounds()).toHaveLength(2)
// The day's route arrives → one-shot re-fit including the 3 route points.
L.latLngBounds.mockClear()
rerender(<MapView places={dayPlaces} dayPlaces={dayPlaces} route={[[[47.9, 1.9], [48.05, 2.05], [48.2, 2.2]]]} fitKey={5} />)
expect(L.latLngBounds).toHaveBeenCalled()
expect(lastBounds()).toHaveLength(5) // 2 destinations + 3 route points
})
}) })
+9 -39
View File
@@ -212,27 +212,24 @@ function MapController({ center, zoom }: MapControllerProps) {
return null return null
} }
// Fit bounds when places change (fitKey triggers re-fit). On a day selection we // Fit bounds when places change (fitKey triggers re-fit)
// fit to that day's destinations immediately, then — once the day's route has
// finished computing asynchronously — re-fit once more to include the full route
// polyline, so a route that bulges past its stops stays in view (#1128).
interface BoundsControllerProps { interface BoundsControllerProps {
hasDayDetail?: boolean hasDayDetail?: boolean
places: Place[] places: Place[]
routeCoords: [number, number][]
fitKey: number fitKey: number
paddingOpts: L.FitBoundsOptions paddingOpts: L.FitBoundsOptions
} }
function BoundsController({ places, routeCoords, fitKey, paddingOpts, hasDayDetail }: BoundsControllerProps) { function BoundsController({ places, fitKey, paddingOpts, hasDayDetail }: BoundsControllerProps) {
const map = useMap() const map = useMap()
const prevFitKey = useRef(-1) const prevFitKey = useRef(-1)
const awaitingRoute = useRef(false)
const fitTo = useCallback((coords: [number, number][]) => { useEffect(() => {
if (coords.length === 0) return if (fitKey === prevFitKey.current) return
prevFitKey.current = fitKey
if (places.length === 0) return
try { try {
const bounds = L.latLngBounds(coords) const bounds = L.latLngBounds(places.map(p => [p.lat, p.lng]))
if (bounds.isValid()) { if (bounds.isValid()) {
map.fitBounds(bounds, { ...paddingOpts, maxZoom: 16, animate: true }) map.fitBounds(bounds, { ...paddingOpts, maxZoom: 16, animate: true })
if (hasDayDetail) { if (hasDayDetail) {
@@ -240,27 +237,8 @@ function BoundsController({ places, routeCoords, fitKey, paddingOpts, hasDayDeta
} }
} }
} catch {} } catch {}
}, [map, paddingOpts, hasDayDetail])
// New fitKey (initial trip fit or a day selection): fit to the destinations now
// and arm a one-shot re-fit for when the route arrives.
useEffect(() => {
if (fitKey === prevFitKey.current) return
prevFitKey.current = fitKey
awaitingRoute.current = false
if (places.length === 0) return
fitTo(places.map(p => [p.lat, p.lng] as [number, number]))
awaitingRoute.current = true
}, [fitKey]) // eslint-disable-line react-hooks/exhaustive-deps }, [fitKey]) // eslint-disable-line react-hooks/exhaustive-deps
// Once the just-selected day's route is ready, expand the fit to include it.
// One-shot per day-fit, so later route-profile toggles don't re-zoom the map.
useEffect(() => {
if (!awaitingRoute.current || routeCoords.length === 0) return
awaitingRoute.current = false
fitTo([...places.map(p => [p.lat, p.lng] as [number, number]), ...routeCoords])
}, [routeCoords]) // eslint-disable-line react-hooks/exhaustive-deps
return null return null
} }
@@ -485,9 +463,6 @@ export const MapView = memo(function MapView({
const thumbRafRef = useRef<number | null>(null) const thumbRafRef = useRef<number | null>(null)
const placeIds = useMemo(() => places.map(p => p.id).join(','), [places]) const placeIds = useMemo(() => places.map(p => p.id).join(','), [places])
// Flattened [lat,lng] points of the selected day's route, so the bounds fit can
// include the full polyline once it has been computed.
const routeCoords = useMemo<[number, number][]>(() => (route || []).flat() as [number, number][], [route])
useEffect(() => { useEffect(() => {
if (!places || places.length === 0 || !placesPhotosEnabled) return if (!places || places.length === 0 || !placesPhotosEnabled) return
const cleanups: (() => void)[] = [] const cleanups: (() => void)[] = []
@@ -594,12 +569,7 @@ export const MapView = memo(function MapView({
// Desktop browsers only get IP-based geolocation (city-level accuracy), // Desktop browsers only get IP-based geolocation (city-level accuracy),
// so the button would be misleading. Mobile, where real GPS lives, keeps it. // so the button would be misleading. Mobile, where real GPS lives, keeps it.
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768 const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
// When the day-detail panel is open it slides up over the map (bottom: navh+20, const locationButtonBottom = 'calc(var(--bottom-nav-h, 84px) + 12px)'
// height var(--day-panel-h)) and covers the button's band, so lift the button
// above it; otherwise keep the plain bottom-nav offset. #1348
const locationButtonBottom = hasDayDetail
? 'calc(var(--bottom-nav-h, 84px) + 20px + var(--day-panel-h, 0px) + 12px)'
: 'calc(var(--bottom-nav-h, 84px) + 12px)'
return ( return (
<> <>
@@ -622,7 +592,7 @@ export const MapView = memo(function MapView({
/> />
<MapController center={center} zoom={zoom} /> <MapController center={center} zoom={zoom} />
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} routeCoords={dayPlaces.length > 0 ? routeCoords : []} fitKey={fitKey} paddingOpts={paddingOpts} hasDayDetail={hasDayDetail} /> <BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} fitKey={fitKey} paddingOpts={paddingOpts} hasDayDetail={hasDayDetail} />
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} /> <SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} />
<MapClickHandler onClick={onMapClick} /> <MapClickHandler onClick={onMapClick} />
<MapContextMenuHandler onContextMenu={onMapContextMenu} /> <MapContextMenuHandler onContextMenu={onMapContextMenu} />
+4 -19
View File
@@ -1,36 +1,21 @@
import { lazy, Suspense } from 'react'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
import { MapView } from './MapView' import { MapView } from './MapView'
import { MapViewGL } from './MapViewGL'
// MapLibre/Mapbox pull in a ~230 KB (gzip) GL engine. Lazy-load the GL renderer so
// Leaflet-only installs never download it — it ships only once a GL provider is picked.
const MapViewGL = lazy(() => import('./MapViewGL').then(m => ({ default: m.MapViewGL })))
// Auto-selects the map renderer based on user settings. Keeps the existing // Auto-selects the map renderer based on user settings. Keeps the existing
// Leaflet MapView untouched so the Mapbox GL variant can mature iteratively // Leaflet MapView untouched so the Mapbox GL variant can mature iteratively
// behind a toggle. Atlas is not affected — it imports Leaflet directly. // behind a toggle. Atlas is not affected — it imports Leaflet directly.
// //
// Offline maps: only the Leaflet renderer supports full pre-download (raster // Offline maps: only the Leaflet renderer supports full pre-download (raster
// tiles via sync/tilePrefetcher.ts). GL maps are best-effort offline — their // tiles via sync/tilePrefetcher.ts). Mapbox GL is best-effort offline — its
// vector tiles are cached opportunistically by the Service Worker as you view // vector tiles are cached opportunistically by the Service Worker as you view
// them online (see the GL tile rules in vite.config.js), not prefetched. // them online (see the mapbox-tiles rule in vite.config.js), not prefetched.
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
export function MapViewAuto(props: any) { export function MapViewAuto(props: any) {
const provider = useSettingsStore(s => s.settings.map_provider) const provider = useSettingsStore(s => s.settings.map_provider)
const token = useSettingsStore(s => s.settings.mapbox_access_token) const token = useSettingsStore(s => s.settings.mapbox_access_token)
// Fall back to Leaflet when Mapbox is selected but no token is set, // Fall back to Leaflet when Mapbox is selected but no token is set,
// so trip planner never shows an empty map due to a missing token. // so trip planner never shows an empty map due to a missing token.
const glProvider = provider === 'maplibre-gl' ? 'maplibre-gl' if (provider === 'mapbox-gl' && token) return <MapViewGL {...props} />
: provider === 'mapbox-gl' && token ? 'mapbox-gl'
: null
if (glProvider) {
// Render the previous Leaflet map as the fallback so there's no blank flash
// while the GL chunk loads on first use.
return (
<Suspense fallback={<MapView {...props} />}>
<MapViewGL {...props} glProvider={glProvider} />
</Suspense>
)
}
return <MapView {...props} /> return <MapView {...props} />
} }
+1 -89
View File
@@ -26,10 +26,6 @@ const glMap = vi.hoisted(() => ({
getStyle: vi.fn().mockReturnValue({ layers: [] }), getStyle: vi.fn().mockReturnValue({ layers: [] }),
isStyleLoaded: vi.fn().mockReturnValue(true), isStyleLoaded: vi.fn().mockReturnValue(true),
getCanvasContainer: vi.fn(() => document.createElement('div')), getCanvasContainer: vi.fn(() => document.createElement('div')),
getLayer: vi.fn().mockReturnValue(null),
queryRenderedFeatures: vi.fn().mockReturnValue([]),
querySourceFeatures: vi.fn().mockReturnValue([]),
easeTo: vi.fn(),
})) }))
vi.mock('mapbox-gl', () => ({ vi.mock('mapbox-gl', () => ({
@@ -62,35 +58,6 @@ vi.mock('mapbox-gl', () => ({
})) }))
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({})) vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}))
vi.mock('maplibre-gl', () => ({
default: {
Map: vi.fn(function () {
return glMap
}),
Marker: vi.fn(function () {
return {
setLngLat: vi.fn().mockReturnThis(),
addTo: vi.fn().mockReturnThis(),
remove: vi.fn(),
getElement: vi.fn(() => document.createElement('div')),
}
}),
LngLatBounds: vi.fn(function () {
return { extend: vi.fn().mockReturnThis() }
}),
NavigationControl: vi.fn(),
Popup: vi.fn(function () {
return {
setLngLat: vi.fn().mockReturnThis(),
setHTML: vi.fn().mockReturnThis(),
addTo: vi.fn().mockReturnThis(),
remove: vi.fn(),
}
}),
},
}))
vi.mock('maplibre-gl/dist/maplibre-gl.css', () => ({}))
vi.mock('./mapboxSetup', () => ({ vi.mock('./mapboxSetup', () => ({
isStandardFamily: vi.fn(() => false), isStandardFamily: vi.fn(() => false),
supportsCustom3d: vi.fn(() => false), supportsCustom3d: vi.fn(() => false),
@@ -105,7 +72,7 @@ vi.mock('./locationMarkerMapbox', () => ({
vi.mock('./reservationsMapbox', () => ({ vi.mock('./reservationsMapbox', () => ({
ReservationMapboxOverlay: vi.fn(function () { ReservationMapboxOverlay: vi.fn(function () {
return { update: vi.fn(), destroy: vi.fn() } return { update: vi.fn() }
}), }),
})) }))
@@ -140,13 +107,6 @@ function buildMapPlace(overrides: Record<string, any> = {}) {
} }
beforeEach(() => { beforeEach(() => {
glMap.on.mockImplementation(() => glMap)
glMap.off.mockImplementation(() => glMap)
glMap.once.mockImplementation(() => glMap)
glMap.getSource.mockReturnValue(null)
glMap.getLayer.mockReturnValue(null)
glMap.queryRenderedFeatures.mockReturnValue([])
glMap.querySourceFeatures.mockReturnValue([])
useSettingsStore.setState({ useSettingsStore.setState({
settings: { settings: {
...useSettingsStore.getState().settings, ...useSettingsStore.getState().settings,
@@ -217,52 +177,4 @@ describe('MapViewGL', () => {
await act(async () => {}) await act(async () => {})
expect(glMap.fitBounds.mock.calls.length).toBeGreaterThan(after_first) expect(glMap.fitBounds.mock.calls.length).toBeGreaterThan(after_first)
}) })
it('FE-COMP-MAPVIEWGL-004: renders with the MapLibre provider and no token', async () => {
const mapboxgl = (await import('mapbox-gl')).default
const maplibregl = (await import('maplibre-gl')).default
useSettingsStore.setState({
settings: {
...useSettingsStore.getState().settings,
map_provider: 'maplibre-gl',
mapbox_access_token: '', // MapLibre/OpenFreeMap is tokenless — must not short-circuit
maplibre_style: 'https://tiles.openfreemap.org/styles/liberty',
},
} as any)
const places = [buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 })]
render(<MapViewGL places={places} fitKey={1} glProvider="maplibre-gl" />)
await act(async () => {})
// The MapLibre engine builds the map even without a token; Mapbox is not used.
expect(maplibregl.Map).toHaveBeenCalled()
expect(mapboxgl.Map).not.toHaveBeenCalled()
})
it('FE-COMP-MAPVIEWGL-005: adds the clustered place source + layers so markers group on zoom-out (#1385)', async () => {
glMap.on.mockImplementation((event: string, handlerOrLayer: unknown) => {
if (event === 'load' && typeof handlerOrLayer === 'function') (handlerOrLayer as () => void)()
return glMap
})
useSettingsStore.setState({
settings: {
...useSettingsStore.getState().settings,
map_provider: 'maplibre-gl',
mapbox_access_token: '',
maplibre_style: 'https://tiles.openfreemap.org/styles/liberty',
},
} as any)
render(<MapViewGL places={[buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 })]} fitKey={1} glProvider="maplibre-gl" />)
await act(async () => {})
expect(glMap.addSource).toHaveBeenCalledWith('trip-place-clusters', expect.objectContaining({
type: 'geojson',
cluster: true,
clusterRadius: 30,
clusterMaxZoom: 10,
}))
expect(glMap.addLayer).toHaveBeenCalledWith(expect.objectContaining({ id: 'trip-place-clusters-circle' }))
expect(glMap.addLayer).toHaveBeenCalledWith(expect.objectContaining({ id: 'trip-place-clusters-count' }))
})
}) })
+53 -310
View File
@@ -1,9 +1,7 @@
import { useEffect, useRef, useMemo, useState, createElement } from 'react' import { useEffect, useRef, useMemo, useState, createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server' import { renderToStaticMarkup } from 'react-dom/server'
import mapboxgl from 'mapbox-gl' import mapboxgl from 'mapbox-gl'
import maplibregl from 'maplibre-gl'
import 'mapbox-gl/dist/mapbox-gl.css' import 'mapbox-gl/dist/mapbox-gl.css'
import 'maplibre-gl/dist/maplibre-gl.css'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
import { useAuthStore } from '../../store/authStore' import { useAuthStore } from '../../store/authStore'
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService' import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
@@ -11,12 +9,11 @@ import { CATEGORY_ICON_MAP } from '../shared/categoryIcons'
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from './mapboxSetup' import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from './mapboxSetup'
import { attachLocationMarker, type LocationMarkerHandle } from './locationMarkerMapbox' import { attachLocationMarker, type LocationMarkerHandle } from './locationMarkerMapbox'
import { ReservationMapboxOverlay } from './reservationsMapbox' import { ReservationMapboxOverlay } from './reservationsMapbox'
import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from './glProviders'
import LocationButton from './LocationButton' import LocationButton from './LocationButton'
import { useGeolocation } from '../../hooks/useGeolocation' import { useGeolocation } from '../../hooks/useGeolocation'
import type { Place, Reservation } from '../../types' import type { Place, Reservation } from '../../types'
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories' import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
import { buildPoiPopupHtml } from './placePopup' import { buildPlacePopupHtml, buildPoiPopupHtml } from './placePopup'
function categoryIconSvg(iconName: string | null | undefined, size: number): string { function categoryIconSvg(iconName: string | null | undefined, size: number): string {
const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin'] const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
@@ -25,34 +22,6 @@ function categoryIconSvg(iconName: string | null | undefined, size: number): str
} catch { return '' } } catch { return '' }
} }
// Marker grouping for the GL map (#1385): MapLibre/Mapbox can't show the rich
// HTML photo markers *and* cluster them natively, so we feed the place points
// into a clustered GeoJSON source. The cluster bubbles render as GL circles +
// a count label; the individual rich HTML markers are then only drawn for the
// points the source reports as currently unclustered. Grouping is always on,
// matching the Leaflet map's MarkerClusterGroup.
const PLACE_CLUSTER_SOURCE_ID = 'trip-place-clusters'
const PLACE_CLUSTER_CIRCLE_LAYER_ID = 'trip-place-clusters-circle'
const PLACE_CLUSTER_COUNT_LAYER_ID = 'trip-place-clusters-count'
const PLACE_UNCLUSTERED_LAYER_ID = 'trip-place-unclustered-hit'
type PlaceWithCoords = Place & { lat: number; lng: number }
function hasValidCoords(place: Place): place is PlaceWithCoords {
return place.lat != null && place.lng != null && Number.isFinite(place.lat) && Number.isFinite(place.lng)
}
function buildPlaceClusterData(places: Place[]) {
return {
type: 'FeatureCollection' as const,
features: places.filter(hasValidCoords).map(place => ({
type: 'Feature' as const,
properties: { placeId: place.id },
geometry: { type: 'Point' as const, coordinates: [place.lng, place.lat] },
})),
}
}
interface RouteSegment { interface RouteSegment {
mid: [number, number] mid: [number, number]
from: [number, number] from: [number, number]
@@ -85,9 +54,7 @@ interface Props {
pois?: Poi[] pois?: Poi[]
onPoiClick?: (poi: Poi) => void onPoiClick?: (poi: Poi) => void
onViewportChange?: (bbox: { south: number; west: number; north: number; east: number }) => void onViewportChange?: (bbox: { south: number; west: number; north: number; east: number }) => void
glProvider?: GlMapProvider onMapReady?: (map: mapboxgl.Map | null) => void
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onMapReady?: (map: any | null) => void
} }
function createMarkerElement(place: Place & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement { function createMarkerElement(place: Place & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement {
@@ -124,8 +91,8 @@ function createMarkerElement(place: Place & { category_color?: string; category_
} }
const wrap = document.createElement('div') const wrap = document.createElement('div')
// Do NOT set `position: relative` here — GL map libraries ship // Do NOT set `position: relative` here — mapbox-gl ships
// marker classes with `position: absolute` and rely on it. An inline // `.mapboxgl-marker { position: absolute }` and relies on it. An inline
// `position: relative` here overrides the class, turns every marker into // `position: relative` here overrides the class, turns every marker into
// a static block element, and stacks them in document order inside the // a static block element, and stacks them in document order inside the
// canvas container. The result looks exactly like "markers drift as the // canvas container. The result looks exactly like "markers drift as the
@@ -202,45 +169,29 @@ export function MapViewGL({
pois = [], pois = [],
onPoiClick, onPoiClick,
onViewportChange, onViewportChange,
glProvider = 'mapbox-gl',
onMapReady, onMapReady,
}: Props) { }: Props) {
const rawMapboxStyle = useSettingsStore(s => s.settings.mapbox_style || MAPBOX_DEFAULT_STYLE) const mapboxStyle = useSettingsStore(s => s.settings.mapbox_style || 'mapbox://styles/mapbox/standard')
const rawMaplibreStyle = useSettingsStore(s => s.settings.maplibre_style || '')
const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '') const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '')
const mapbox3d = useSettingsStore(s => s.settings.mapbox_3d_enabled !== false) const mapbox3d = useSettingsStore(s => s.settings.mapbox_3d_enabled !== false)
const mapboxQuality = useSettingsStore(s => s.settings.mapbox_quality_mode === true) const mapboxQuality = useSettingsStore(s => s.settings.mapbox_quality_mode === true)
const showEndpointLabels = useSettingsStore(s => s.settings.map_booking_labels) !== false const showEndpointLabels = useSettingsStore(s => s.settings.map_booking_labels) !== false
const mapLang = useSettingsStore(s => s.settings.language)
const isMapLibre = glProvider === 'maplibre-gl'
const gl = (isMapLibre ? maplibregl : mapboxgl) as any
const glStyle = styleForActiveProvider(glProvider, rawMapboxStyle, rawMaplibreStyle)
const enableMapbox3d = !isMapLibre && mapbox3d
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled) const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
const [photoUrls, setPhotoUrls] = useState<Record<string, string>>(getAllThumbs) const [photoUrls, setPhotoUrls] = useState<Record<string, string>>(getAllThumbs)
const [mapReady, setMapReady] = useState(false) const [mapReady, setMapReady] = useState(false)
// Hover tooltip — a cursor-following name/category/address card, matching the
// Leaflet map's overlay exactly (no anchored popup, no photo thumbnail).
const [hoverPlace, setHoverPlace] = useState<(Place & { category_color?: string | null; category_icon?: string | null; category_name?: string | null }) | null>(null)
const [hoverPos, setHoverPos] = useState<{ x: number; y: number } | null>(null)
const hoverIdRef = useRef<number | null>(null)
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
// eslint-disable-next-line @typescript-eslint/no-explicit-any const mapRef = useRef<mapboxgl.Map | null>(null)
const mapRef = useRef<any | null>(null) const markersRef = useRef<Map<number, mapboxgl.Marker>>(new Map())
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const markersRef = useRef<Map<number, any>>(new Map())
const locationMarkerRef = useRef<LocationMarkerHandle | null>(null) const locationMarkerRef = useRef<LocationMarkerHandle | null>(null)
const reservationOverlayRef = useRef<ReservationMapboxOverlay | null>(null) const reservationOverlayRef = useRef<ReservationMapboxOverlay | null>(null)
// Refs so the reservation overlay always sees the latest callback / // Refs so the reservation overlay always sees the latest callback /
// options without forcing a full overlay rebuild on every prop change. // options without forcing a full overlay rebuild on every prop change.
const onReservationClickRef = useRef(onReservationClick) const onReservationClickRef = useRef(onReservationClick)
onReservationClickRef.current = onReservationClick onReservationClickRef.current = onReservationClick
// eslint-disable-next-line @typescript-eslint/no-explicit-any const poiMarkersRef = useRef<mapboxgl.Marker[]>([])
const poiMarkersRef = useRef<any[]>([])
// Single reusable hover popup (name/category/address card) shared by planned // Single reusable hover popup (name/category/address card) shared by planned
// places and POI markers — mirrors the Leaflet map's hover tooltip. // places and POI markers — mirrors the Leaflet map's hover tooltip.
// eslint-disable-next-line @typescript-eslint/no-explicit-any const popupRef = useRef<mapboxgl.Popup | null>(null)
const popupRef = useRef<any | null>(null)
const onPoiClickRef = useRef(onPoiClick) const onPoiClickRef = useRef(onPoiClick)
onPoiClickRef.current = onPoiClick onPoiClickRef.current = onPoiClick
const onViewportChangeRef = useRef(onViewportChange) const onViewportChangeRef = useRef(onViewportChange)
@@ -253,25 +204,23 @@ export function MapViewGL({
onClickRefs.current.map = onMapClick onClickRefs.current.map = onMapClick
onClickRefs.current.context = onMapContextMenu onClickRefs.current.context = onMapContextMenu
// Build/rebuild the map on provider/style/token/3d change // Build/rebuild the map on style/token/3d change
useEffect(() => { useEffect(() => {
if (!containerRef.current || (!isMapLibre && !mapboxToken)) return if (!containerRef.current || !mapboxToken) return
if (!isMapLibre) mapboxgl.accessToken = mapboxToken mapboxgl.accessToken = mapboxToken
const mapOptions: Record<string, unknown> = { const map = new mapboxgl.Map({
container: containerRef.current, container: containerRef.current,
style: glStyle, style: mapboxStyle,
center: [center[1], center[0]], center: [center[1], center[0]],
zoom, zoom,
pitch: enableMapbox3d ? 45 : 0, pitch: mapbox3d ? 45 : 0,
attributionControl: true, attributionControl: true,
antialias: mapboxQuality, antialias: mapboxQuality,
} projection: mapboxQuality ? 'globe' : 'mercator',
if (!isMapLibre) mapOptions.projection = mapboxQuality ? 'globe' : 'mercator' })
const map = new gl.Map(mapOptions as any)
mapRef.current = map mapRef.current = map
popupRef.current = new gl.Popup({ popupRef.current = new mapboxgl.Popup({
closeButton: false, closeButton: false,
closeOnClick: false, closeOnClick: false,
offset: 18, offset: 18,
@@ -285,12 +234,12 @@ export function MapViewGL({
;(window as any).__trek_map = map ;(window as any).__trek_map = map
map.on('load', () => { map.on('load', () => {
if (enableMapbox3d) { if (mapbox3d) {
// Terrain is only valuable on satellite styles — on clean vector // Terrain is only valuable on satellite styles — on clean vector
// styles it makes route lines drift off the HTML markers because // styles it makes route lines drift off the HTML markers because
// the lines snap to DEM height while markers stay at sea level. // the lines snap to DEM height while markers stay at sea level.
if (!isStandardFamily(glStyle) && wantsTerrain(glStyle)) addTerrainAndSky(map) if (!isStandardFamily(mapboxStyle) && wantsTerrain(mapboxStyle)) addTerrainAndSky(map)
if (supportsCustom3d(glStyle)) { if (supportsCustom3d(mapboxStyle)) {
const dark = document.documentElement.classList.contains('dark') const dark = document.documentElement.classList.contains('dark')
addCustom3dBuildings(map, dark) addCustom3dBuildings(map, dark)
} }
@@ -303,7 +252,7 @@ export function MapViewGL({
// non-satellite Standard style still looks great without terrain, // non-satellite Standard style still looks great without terrain,
// so flatten it out to keep markers pinned. (Satellite variants // so flatten it out to keep markers pinned. (Satellite variants
// are left alone — the DEM is what gives them their character.) // are left alone — the DEM is what gives them their character.)
if (glStyle === MAPBOX_DEFAULT_STYLE) { if (mapboxStyle === 'mapbox://styles/mapbox/standard') {
try { map.setTerrain(null) } catch { /* noop */ } try { map.setTerrain(null) } catch { /* noop */ }
} }
// initial route source — kept around so updates can setData() cheaply // initial route source — kept around so updates can setData() cheaply
@@ -341,89 +290,6 @@ export function MapViewGL({
layout: { 'line-cap': 'round', 'line-join': 'round' }, layout: { 'line-cap': 'round', 'line-join': 'round' },
}) })
} }
if (!map.getSource(PLACE_CLUSTER_SOURCE_ID)) {
map.addSource(PLACE_CLUSTER_SOURCE_ID, {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
cluster: true,
clusterRadius: 30,
clusterMaxZoom: 10,
})
map.addLayer({
id: PLACE_CLUSTER_CIRCLE_LAYER_ID,
type: 'circle',
source: PLACE_CLUSTER_SOURCE_ID,
filter: ['has', 'point_count'],
paint: {
'circle-color': '#111827',
'circle-opacity': 0.97,
'circle-radius': ['step', ['get', 'point_count'], 18, 10, 21, 50, 24],
'circle-stroke-width': 2.5,
'circle-stroke-color': 'rgba(255,255,255,0.9)',
},
})
map.addLayer({
id: PLACE_CLUSTER_COUNT_LAYER_ID,
type: 'symbol',
source: PLACE_CLUSTER_SOURCE_ID,
filter: ['has', 'point_count'],
layout: {
'text-field': ['get', 'point_count_abbreviated'],
'text-size': 12,
'text-allow-overlap': true,
},
paint: {
'text-color': '#ffffff',
'text-halo-color': 'rgba(17,24,39,0.35)',
'text-halo-width': 1,
},
})
map.addLayer({
id: PLACE_UNCLUSTERED_LAYER_ID,
type: 'circle',
source: PLACE_CLUSTER_SOURCE_ID,
filter: ['!', ['has', 'point_count']],
paint: {
'circle-radius': 24,
'circle-opacity': 0,
'circle-stroke-opacity': 0,
},
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const zoomToCluster = (e: any) => {
const features = typeof map.queryRenderedFeatures === 'function'
? map.queryRenderedFeatures(e.point, { layers: [PLACE_CLUSTER_CIRCLE_LAYER_ID, PLACE_CLUSTER_COUNT_LAYER_ID] })
: []
const feature = features?.[0]
const clusterId = feature?.properties?.cluster_id
const coordinates = feature?.geometry?.coordinates
if (clusterId == null || !Array.isArray(coordinates)) return
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const source = map.getSource(PLACE_CLUSTER_SOURCE_ID) as any
const easeToZoom = (nextZoom: number) => {
try { map.easeTo({ center: coordinates, zoom: nextZoom, duration: 350 }) } catch { /* noop */ }
}
try {
const maybeZoom = source?.getClusterExpansionZoom?.(clusterId, (err: Error | null, nextZoom: number) => {
if (!err && typeof nextZoom === 'number') easeToZoom(nextZoom)
})
if (typeof maybeZoom === 'number') easeToZoom(maybeZoom)
else if (maybeZoom && typeof maybeZoom.then === 'function') maybeZoom.then(easeToZoom).catch(() => {})
} catch { /* noop */ }
}
const setClusterCursor = () => {
const canvas = typeof map.getCanvas === 'function' ? map.getCanvas() : null
if (canvas) canvas.style.cursor = 'pointer'
}
const clearClusterCursor = () => {
const canvas = typeof map.getCanvas === 'function' ? map.getCanvas() : null
if (canvas) canvas.style.cursor = ''
}
map.on('click', PLACE_CLUSTER_CIRCLE_LAYER_ID, zoomToCluster)
map.on('click', PLACE_CLUSTER_COUNT_LAYER_ID, zoomToCluster)
map.on('mouseenter', PLACE_CLUSTER_CIRCLE_LAYER_ID, setClusterCursor)
map.on('mouseleave', PLACE_CLUSTER_CIRCLE_LAYER_ID, clearClusterCursor)
}
// Signal that sources/layers are attached so overlay effects can // Signal that sources/layers are attached so overlay effects can
// safely add their own sources. Style rebuilds reset this via the // safely add their own sources. Style rebuilds reset this via the
// cleanup below. // cleanup below.
@@ -432,15 +298,7 @@ export function MapViewGL({
map.on('click', (e) => { map.on('click', (e) => {
const t = e.originalEvent.target as HTMLElement const t = e.originalEvent.target as HTMLElement
if (t.closest('.mapboxgl-marker, .maplibregl-marker')) return // markers handle their own click if (t.closest('.mapboxgl-marker')) return // markers handle their own click
// A click that lands on a cluster bubble is the cluster's to handle
// (zoom-to-expand), not an "add place here" map click.
if (
typeof map.getLayer === 'function'
&& map.getLayer(PLACE_CLUSTER_CIRCLE_LAYER_ID)
&& typeof map.queryRenderedFeatures === 'function'
&& map.queryRenderedFeatures(e.point, { layers: [PLACE_CLUSTER_CIRCLE_LAYER_ID, PLACE_CLUSTER_COUNT_LAYER_ID] }).length > 0
) return
onClickRefs.current.map?.({ latlng: { lat: e.lngLat.lat, lng: e.lngLat.lng } }) onClickRefs.current.map?.({ latlng: { lat: e.lngLat.lat, lng: e.lngLat.lng } })
}) })
// Emit the viewport bbox (pan/zoom + once on first idle) so the POI-explore // Emit the viewport bbox (pan/zoom + once on first idle) so the POI-explore
@@ -451,7 +309,7 @@ export function MapViewGL({
} }
map.on('moveend', emitViewport) map.on('moveend', emitViewport)
map.once('idle', emitViewport) map.once('idle', emitViewport)
// In the GL map the right mouse button is reserved for the // In the mapbox-gl map the right mouse button is reserved for the
// built-in rotate/pitch gesture, so we bind the "add place" action // built-in rotate/pitch gesture, so we bind the "add place" action
// to the middle mouse button (button === 1) instead. // to the middle mouse button (button === 1) instead.
const canvas = map.getCanvasContainer() const canvas = map.getCanvasContainer()
@@ -498,9 +356,7 @@ export function MapViewGL({
const ll = marker.getLngLat() const ll = marker.getLngLat()
let alt = 0 let alt = 0
try { try {
const e = typeof map.queryTerrainElevation === 'function' const e = map.queryTerrainElevation([ll.lng, ll.lat])
? map.queryTerrainElevation([ll.lng, ll.lat])
: null
if (typeof e === 'number' && Number.isFinite(e)) alt = e if (typeof e === 'number' && Number.isFinite(e)) alt = e
} catch { /* terrain not ready */ } } catch { /* terrain not ready */ }
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -512,9 +368,7 @@ export function MapViewGL({
} }
}) })
} }
// Terrain altitude sync only matters with mapbox 3D/terrain on; skip the per-frame map.on('render', syncMarkerAltitudes)
// listener entirely for MapLibre and flat mapbox styles.
if (enableMapbox3d) map.on('render', syncMarkerAltitudes)
return () => { return () => {
canvas.removeEventListener('mousedown', onAuxDown) canvas.removeEventListener('mousedown', onAuxDown)
@@ -535,17 +389,7 @@ export function MapViewGL({
mapRef.current = null mapRef.current = null
setMapReady(false) setMapReady(false)
} }
}, [glProvider, glStyle, mapboxToken, enableMapbox3d, mapboxQuality]) // rebuild on provider/style changes only }, [mapboxStyle, mapboxToken, mapbox3d]) // rebuild on style changes only
// Pin the basemap label language to the UI language so labels don't fall back to the
// browser/OS locale and stack multiple scripts per place (e.g. "India/भारत/India", #1299).
// Mapbox Standard exposes this via a basemap config property; classic and MapLibre styles
// are left as-is. Runs on load (mapReady) and whenever the UI language changes.
useEffect(() => {
const map = mapRef.current
if (!map || !mapReady || isMapLibre || !isStandardFamily(glStyle)) return
try { map.setConfigProperty('basemap', 'language', basemapLanguage(mapLang)) } catch { /* style/SDK may not support the basemap language property */ }
}, [mapLang, mapReady, isMapLibre, glStyle])
// Photo loading — mirrors the Leaflet MapView. Updates via RAF to batch // Photo loading — mirrors the Leaflet MapView. Updates via RAF to batch
// simultaneous thumb arrivals into one re-render. // simultaneous thumb arrivals into one re-render.
@@ -600,33 +444,27 @@ export function MapViewGL({
} }
}, [placeIds, placesPhotosEnabled]) // eslint-disable-line react-hooks/exhaustive-deps }, [placeIds, placesPhotosEnabled]) // eslint-disable-line react-hooks/exhaustive-deps
// Reconcile markers with places + photos. The clustered GeoJSON source decides // Reconcile markers with places + photos. Rebuilds the DOM node when any
// which points are currently unclustered, and we render the existing rich HTML // visual input changes so photos, selection state and order badges stay
// marker DOM only for those visible leaves — clustered points show up as the GL // in sync.
// cluster bubble + count instead.
useEffect(() => { useEffect(() => {
const map = mapRef.current const map = mapRef.current
if (!map || !mapReady) return if (!map) return
// Markers are about to be rebuilt; drop any open hover popup first. A marker // Markers are about to be rebuilt; drop any open hover popup first. A marker
// recreated under the pointer (e.g. when its photo streams in) never fires // recreated under the pointer (e.g. when its photo streams in) never fires
// mouseleave, which would otherwise leave the popup orphaned on the map. // mouseleave, which would otherwise leave the popup orphaned on the map.
popupRef.current?.remove() popupRef.current?.remove()
const validPlaces = places.filter(hasValidCoords) const ids = new Set(places.map(p => p.id))
const reconcileMarkers = (visiblePlaces: PlaceWithCoords[]) => {
const ids = new Set(visiblePlaces.map(p => p.id))
markersRef.current.forEach((marker, id) => { markersRef.current.forEach((marker, id) => {
if (!ids.has(id)) { if (!ids.has(id)) {
marker.remove() marker.remove()
markersRef.current.delete(id) markersRef.current.delete(id)
// Removing a marker under the cursor (e.g. it just got clustered) never
// fires mouseleave, so drop its tooltip here to avoid orphaning it.
if (hoverIdRef.current === id) { hoverIdRef.current = null; setHoverPlace(null); setHoverPos(null) }
} }
}) })
visiblePlaces.forEach(place => { places.forEach(place => {
if (!place.lat || !place.lng) return
const orderNumbers = dayOrderMap[place.id] ?? null const orderNumbers = dayOrderMap[place.id] ?? null
const pck = place.google_place_id || place.osm_id || `${place.lat},${place.lng}` const pck = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
const photoUrl = (pck && photoUrls[pck]) || place.image_url || null const photoUrl = (pck && photoUrls[pck]) || place.image_url || null
@@ -636,19 +474,12 @@ export function MapViewGL({
ev.stopPropagation() ev.stopPropagation()
onClickRefs.current.marker?.(place.id) onClickRefs.current.marker?.(place.id)
}) })
el.addEventListener('mouseenter', (ev) => { el.addEventListener('mouseenter', () => {
hoverIdRef.current = place.id popupRef.current?.setLngLat([place.lng, place.lat])
setHoverPlace(place as Place & { category_color?: string; category_icon?: string; category_name?: string }) .setHTML(buildPlacePopupHtml(place as Place & { category_color?: string; category_icon?: string; category_name?: string }, photoUrl))
setHoverPos({ x: (ev as MouseEvent).clientX, y: (ev as MouseEvent).clientY }) .addTo(map)
})
el.addEventListener('mousemove', (ev) => {
setHoverPos({ x: (ev as MouseEvent).clientX, y: (ev as MouseEvent).clientY })
})
el.addEventListener('mouseleave', () => {
hoverIdRef.current = null
setHoverPlace(null)
setHoverPos(null)
}) })
el.addEventListener('mouseleave', () => { popupRef.current?.remove() })
// Recreate marker each time rather than patching internal state — // Recreate marker each time rather than patching internal state —
// mapbox-gl's internal _element bookkeeping breaks under DOM swaps. // mapbox-gl's internal _element bookkeeping breaks under DOM swaps.
const existing = markersRef.current.get(place.id) const existing = markersRef.current.get(place.id)
@@ -658,60 +489,12 @@ export function MapViewGL({
// pitch. Tried `pitchAlignment: 'map'` to snap markers onto terrain, // pitch. Tried `pitchAlignment: 'map'` to snap markers onto terrain,
// but it rotates the element by the pitch angle and visually offsets // but it rotates the element by the pitch angle and visually offsets
// the anchor by ~100px at 45° tilt, which caused the observed drift. // the anchor by ~100px at 45° tilt, which caused the observed drift.
const m = new gl.Marker({ element: el, anchor: 'center' }) const m = new mapboxgl.Marker({ element: el, anchor: 'center' })
.setLngLat([place.lng, place.lat]) .setLngLat([place.lng, place.lat])
.addTo(map) .addTo(map)
markersRef.current.set(place.id, m) markersRef.current.set(place.id, m)
}) })
} }, [places, selectedPlaceId, dayOrderMap, photoUrls])
const source = map.getSource(PLACE_CLUSTER_SOURCE_ID) as mapboxgl.GeoJSONSource | undefined
if (!source || typeof map.querySourceFeatures !== 'function') {
// No cluster source (e.g. style without it / test env): fall back to the
// original behaviour and draw a marker for every place.
reconcileMarkers(validPlaces)
return
}
source.setData(buildPlaceClusterData(places) as any)
const placesById = new Map<number, PlaceWithCoords>(validPlaces.map(place => [place.id, place]))
let raf: number | null = null
const runReconcile = () => {
raf = null
const features = map.querySourceFeatures(PLACE_CLUSTER_SOURCE_ID, { filter: ['!', ['has', 'point_count']] }) || []
const seen = new Set<number>()
const visiblePlaces: PlaceWithCoords[] = []
for (const feature of features) {
const rawId = feature?.properties?.placeId
const id = typeof rawId === 'string' ? Number(rawId) : rawId
if (typeof id !== 'number' || Number.isNaN(id) || seen.has(id)) continue
const place = placesById.get(id)
if (!place) continue
seen.add(id)
visiblePlaces.push(place)
}
reconcileMarkers(visiblePlaces)
}
const scheduleReconcile = () => {
if (raf !== null) return
raf = requestAnimationFrame(runReconcile)
}
// Cluster membership only settles once the source has (re)indexed and the
// viewport stops moving, so reconcile on the next frame and on every
// idle/move/zoom.
scheduleReconcile()
map.once('idle', scheduleReconcile)
map.on('moveend', scheduleReconcile)
map.on('zoomend', scheduleReconcile)
return () => {
if (raf !== null) cancelAnimationFrame(raf)
map.off('moveend', scheduleReconcile)
map.off('zoomend', scheduleReconcile)
map.off('idle', scheduleReconcile)
}
}, [places, selectedPlaceId, dayOrderMap, photoUrls, mapReady, glProvider])
// Reconcile OSM "explore" POI markers (imperative, kept separate from the // Reconcile OSM "explore" POI markers (imperative, kept separate from the
// planned-place markers so they don't cluster or get confused with them). // planned-place markers so they don't cluster or get confused with them).
@@ -728,10 +511,10 @@ export function MapViewGL({
}) })
el.addEventListener('mouseleave', () => { popupRef.current?.remove() }) el.addEventListener('mouseleave', () => { popupRef.current?.remove() })
el.addEventListener('click', (ev) => { ev.stopPropagation(); onPoiClickRef.current?.(poi) }) el.addEventListener('click', (ev) => { ev.stopPropagation(); onPoiClickRef.current?.(poi) })
const m = new gl.Marker({ element: el, anchor: 'center' }).setLngLat([poi.lng, poi.lat]).addTo(map) const m = new mapboxgl.Marker({ element: el, anchor: 'center' }).setLngLat([poi.lng, poi.lat]).addTo(map)
poiMarkersRef.current.push(m) poiMarkersRef.current.push(m)
} }
}, [pois, mapReady, glProvider]) }, [pois, mapReady])
// Update route geojson // Update route geojson
useEffect(() => { useEffect(() => {
@@ -795,7 +578,7 @@ export function MapViewGL({
showStats: showReservationStats, showStats: showReservationStats,
showEndpointLabels, showEndpointLabels,
onEndpointClick: (id) => onReservationClickRef.current?.(id), onEndpointClick: (id) => onReservationClickRef.current?.(id),
}, gl.Marker as any) })
} }
reservationOverlayRef.current.update(visibleReservations, { reservationOverlayRef.current.update(visibleReservations, {
showConnections: true, showConnections: true,
@@ -803,7 +586,7 @@ export function MapViewGL({
showEndpointLabels, showEndpointLabels,
onEndpointClick: (id) => onReservationClickRef.current?.(id), onEndpointClick: (id) => onReservationClickRef.current?.(id),
}) })
}, [visibleReservations, showReservationStats, showEndpointLabels, mapReady, glProvider]) }, [visibleReservations, showReservationStats, showEndpointLabels, mapReady])
// Fit bounds on fitKey change — matches the Leaflet BoundsController // Fit bounds on fitKey change — matches the Leaflet BoundsController
const paddingOpts = useMemo(() => { const paddingOpts = useMemo(() => {
@@ -823,14 +606,14 @@ export function MapViewGL({
const target = dayPlaces.length > 0 ? dayPlaces : places const target = dayPlaces.length > 0 ? dayPlaces : places
const valid = target.filter(p => p.lat && p.lng) const valid = target.filter(p => p.lat && p.lng)
if (valid.length === 0) return if (valid.length === 0) return
const bounds = new gl.LngLatBounds() const bounds = new mapboxgl.LngLatBounds()
valid.forEach(p => bounds.extend([p.lng, p.lat])) valid.forEach(p => bounds.extend([p.lng, p.lat]))
const run = () => { const run = () => {
try { try {
map.fitBounds(bounds, { map.fitBounds(bounds, {
padding: paddingOpts, padding: paddingOpts,
maxZoom: 15, maxZoom: 15,
pitch: enableMapbox3d ? 45 : 0, pitch: mapbox3d ? 45 : 0,
duration: 400, duration: 400,
}) })
} catch { /* noop */ } } catch { /* noop */ }
@@ -849,7 +632,7 @@ export function MapViewGL({
map.flyTo({ map.flyTo({
center: [target.lng, target.lat], center: [target.lng, target.lat],
zoom: Math.max(map.getZoom(), 14), zoom: Math.max(map.getZoom(), 14),
pitch: enableMapbox3d ? 45 : 0, pitch: mapbox3d ? 45 : 0,
duration: 400, duration: 400,
// Account for the side panels and the bottom inspector / day-detail panel // Account for the side panels and the bottom inspector / day-detail panel
// so the selected pin lands in the centre of the *visible* map area rather // so the selected pin lands in the centre of the *visible* map area rather
@@ -857,7 +640,7 @@ export function MapViewGL({
padding: paddingOpts, padding: paddingOpts,
}) })
} catch { /* noop */ } } catch { /* noop */ }
}, [selectedPlaceId, enableMapbox3d]) // eslint-disable-line react-hooks/exhaustive-deps }, [selectedPlaceId, mapbox3d]) // eslint-disable-line react-hooks/exhaustive-deps
// External center/zoom prop changes — jump without animation // External center/zoom prop changes — jump without animation
useEffect(() => { useEffect(() => {
@@ -880,7 +663,7 @@ export function MapViewGL({
} }
if (!userPosition) return if (!userPosition) return
const apply = () => { const apply = () => {
if (!locationMarkerRef.current) locationMarkerRef.current = attachLocationMarker(map, gl.Marker as any) if (!locationMarkerRef.current) locationMarkerRef.current = attachLocationMarker(map)
locationMarkerRef.current.update(userPosition) locationMarkerRef.current.update(userPosition)
if (trackingMode === 'follow') { if (trackingMode === 'follow') {
// easeTo is gentler than flyTo for continuous updates // easeTo is gentler than flyTo for continuous updates
@@ -896,9 +679,9 @@ export function MapViewGL({
} }
if (map.loaded()) apply() if (map.loaded()) apply()
else map.once('load', apply) else map.once('load', apply)
}, [userPosition, trackingMode, glProvider]) }, [userPosition, trackingMode])
if (!isMapLibre && !mapboxToken) { if (!mapboxToken) {
return ( return (
<div className="w-full h-full flex items-center justify-center bg-zinc-100 dark:bg-zinc-800 text-center px-6"> <div className="w-full h-full flex items-center justify-center bg-zinc-100 dark:bg-zinc-800 text-center px-6">
<div className="text-sm text-zinc-500"> <div className="text-sm text-zinc-500">
@@ -912,14 +695,7 @@ export function MapViewGL({
// Desktop browsers only get IP-based geolocation (city-level accuracy), // Desktop browsers only get IP-based geolocation (city-level accuracy),
// so the button would be misleading. Mobile, where real GPS lives, keeps it. // so the button would be misleading. Mobile, where real GPS lives, keeps it.
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768 const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
// When the day-detail panel is open it slides up over the map (bottom: navh+20, const buttonBottom = 'calc(var(--bottom-nav-h, 84px) + 12px)'
// height var(--day-panel-h)) and covers the button's band, so lift the button
// above it; otherwise keep the plain bottom-nav offset. #1348
const buttonBottom = hasDayDetail
? 'calc(var(--bottom-nav-h, 84px) + 20px + var(--day-panel-h, 0px) + 12px)'
: 'calc(var(--bottom-nav-h, 84px) + 12px)'
const HoverIcon = (hoverPlace?.category_icon && CATEGORY_ICON_MAP[hoverPlace.category_icon]) || CATEGORY_ICON_MAP['MapPin']
return ( return (
<div className="w-full h-full relative"> <div className="w-full h-full relative">
@@ -932,39 +708,6 @@ export function MapViewGL({
bottomOffset={buttonBottom as unknown as number} bottomOffset={buttonBottom as unknown as number}
/> />
)} )}
{/* Hover tooltip cursor-following name/category/address card, identical to
the Leaflet map's overlay (no anchored popup, no photo). */}
{hoverPlace && hoverPos && !isMobile && (
<div data-testid="tooltip" style={{
position: 'fixed',
left: hoverPos.x + 14,
top: hoverPos.y - 10,
zIndex: 9999,
pointerEvents: 'none',
background: 'white',
borderRadius: 8,
boxShadow: '0 2px 10px rgba(0,0,0,0.15)',
padding: '6px 10px',
fontFamily: 'var(--font-system)',
maxWidth: 220,
whiteSpace: 'nowrap',
}}>
<div style={{ fontWeight: 600, fontSize: 12, color: '#111827', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{hoverPlace.name}
</div>
{hoverPlace.category_name && (
<div style={{ display: 'flex', alignItems: 'center', gap: 3, marginTop: 1 }}>
<HoverIcon size={10} style={{ color: hoverPlace.category_color || '#6b7280', flexShrink: 0 }} />
<span style={{ fontSize: 11, color: '#6b7280' }}>{hoverPlace.category_name}</span>
</div>
)}
{hoverPlace.address && (
<div style={{ fontSize: 11, color: '#9ca3af', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis' }}>
{hoverPlace.address}
</div>
)}
</div>
)}
</div> </div>
) )
} }
@@ -6,7 +6,6 @@ import {
calculateSegments, calculateSegments,
optimizeRoute, optimizeRoute,
generateGoogleMapsUrl, generateGoogleMapsUrl,
withHotelBookends,
} from './RouteCalculator' } from './RouteCalculator'
const OSRM_BASE = 'https://router.project-osrm.org/route/v1' const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
@@ -242,46 +241,3 @@ describe('generateGoogleMapsUrl', () => {
expect(result).toContain('48.86,2.36') expect(result).toContain('48.86,2.36')
}) })
}) })
// ── withHotelBookends (#1275: draw the hotel → first / last → hotel legs) ────────
describe('withHotelBookends', () => {
const hotel = { lat: 1, lng: 1 }
const a = { lat: 2, lng: 2 }
const b = { lat: 3, lng: 3 }
const evening = { lat: 4, lng: 4 }
it('FE-COMP-ROUTECALCULATOR-021: leaves runs untouched when there is no hotel', () => {
const runs = [[a, b]]
expect(withHotelBookends(runs, a, b, null, null)).toEqual([[a, b]])
})
it('FE-COMP-ROUTECALCULATOR-022: prepends hotel→first and appends last→hotel around the runs', () => {
const runs = [[a, b]]
expect(withHotelBookends(runs, a, b, hotel, evening)).toEqual([
[hotel, a],
[a, b],
[b, evening],
])
})
it('FE-COMP-ROUTECALCULATOR-023: a single stop with no runs still draws hotel→stop→hotel', () => {
expect(withHotelBookends([], a, a, hotel, evening)).toEqual([
[hotel, a],
[a, evening],
])
})
it('FE-COMP-ROUTECALCULATOR-024: a missing first/last waypoint skips that bookend', () => {
const runs = [[a, b]]
expect(withHotelBookends(runs, undefined, undefined, hotel, evening)).toEqual([[a, b]])
})
it('FE-COMP-ROUTECALCULATOR-025: only the start hotel adds just the opening leg', () => {
const runs = [[a, b]]
expect(withHotelBookends(runs, a, b, hotel, null)).toEqual([
[hotel, a],
[a, b],
])
})
})
+8 -38
View File
@@ -1,6 +1,4 @@
import { useSettingsStore } from '../../store/settingsStore' import type { RouteResult, RouteSegment, RouteWithLegs, Waypoint, RouteAnchors } from '../../types'
import type { DistanceUnit, RouteResult, RouteSegment, RouteWithLegs, Waypoint, RouteAnchors } from '../../types'
import { formatDistance } from '../../utils/units'
const OSRM_BASE = 'https://router.project-osrm.org/route/v1' const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
@@ -62,34 +60,13 @@ export async function calculateRoute(
coordinates, coordinates,
distance, distance,
duration, duration,
distanceText: formatRouteDistance(distance), distanceText: formatDistance(distance),
durationText: formatDuration(duration), durationText: formatDuration(duration),
walkingText: formatDuration(walkingDuration), walkingText: formatDuration(walkingDuration),
drivingText: formatDuration(drivingDuration), drivingText: formatDuration(drivingDuration),
} }
} }
/**
* Prepends a hotelfirst-waypoint run and appends a last-waypointhotel run to the
* day's activity runs, so the drawn route starts and ends at the day's accommodation
* (matching the sidebar's hotel connectors). A bookend is only added when both its
* hotel and the first/last located waypoint exist; passing nulls leaves `runs`
* untouched. The shared first/last waypoint is repeated so the polylines join.
*/
export function withHotelBookends(
runs: Waypoint[][],
firstWay: Waypoint | undefined,
lastWay: Waypoint | undefined,
startHotel: Waypoint | null,
endHotel: Waypoint | null,
): Waypoint[][] {
const out: Waypoint[][] = []
if (startHotel && firstWay) out.push([startHotel, firstWay])
out.push(...runs)
if (endHotel && lastWay) out.push([lastWay, endHotel])
return out
}
export function generateGoogleMapsUrl(places: Waypoint[]): string | null { export function generateGoogleMapsUrl(places: Waypoint[]): string | null {
const valid = places.filter((p) => p.lat && p.lng) const valid = places.filter((p) => p.lat && p.lng)
if (valid.length === 0) return null if (valid.length === 0) return null
@@ -220,7 +197,7 @@ export async function calculateSegments(
duration: leg.duration, duration: leg.duration,
walkingText: formatDuration(walkingDuration), walkingText: formatDuration(walkingDuration),
drivingText: formatDuration(leg.duration), drivingText: formatDuration(leg.duration),
distanceText: formatRouteDistance(leg.distance), distanceText: formatDistance(leg.distance),
} }
}) })
} }
@@ -240,9 +217,7 @@ export async function calculateRouteWithLegs(
} }
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';') const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
// The cached result carries formatted leg distances, so the active distance unit is const cacheKey = `${profile}:${coords}`
// part of the key — otherwise switching km↔mi would return stale text (#1300).
const cacheKey = `${profile}:${getDistanceUnit()}:${coords}`
const cached = routeCache.get(cacheKey) const cached = routeCache.get(cacheKey)
if (cached) return cached if (cached) return cached
@@ -269,7 +244,7 @@ export async function calculateRouteWithLegs(
duration: leg.duration, duration: leg.duration,
walkingText: formatDuration(walkingDuration), walkingText: formatDuration(walkingDuration),
drivingText: formatDuration(leg.duration), drivingText: formatDuration(leg.duration),
distanceText: formatRouteDistance(leg.distance), distanceText: formatDistance(leg.distance),
durationText: formatDuration(leg.duration), durationText: formatDuration(leg.duration),
} }
} }
@@ -284,16 +259,11 @@ export async function calculateRouteWithLegs(
return result return result
} }
function getDistanceUnit(): DistanceUnit { function formatDistance(meters: number): string {
return useSettingsStore.getState().settings.distance_unit === 'imperial' ? 'imperial' : 'metric' if (meters < 1000) {
}
function formatRouteDistance(meters: number): string {
const unit = getDistanceUnit()
if (unit === 'metric' && meters < 1000) {
return `${Math.round(meters)} m` return `${Math.round(meters)} m`
} }
return formatDistance(meters / 1000, unit) return `${(meters / 1000).toFixed(1)} km`
} }
function formatDuration(seconds: number): string { function formatDuration(seconds: number): string {
@@ -1,72 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
MAPBOX_DEFAULT_STYLE,
OPENFREEMAP_DEFAULT_STYLE,
isOpenFreeMapStyle,
normalizeStyleForProvider,
styleForActiveProvider,
basemapLanguage,
} from './glProviders'
describe('glProviders', () => {
it('keeps OpenFreeMap styles for MapLibre', () => {
const style = 'https://tiles.openfreemap.org/styles/bright'
expect(normalizeStyleForProvider('maplibre-gl', style)).toBe(style)
})
it('falls back to OpenFreeMap for MapLibre styles outside the CSP allowlist', () => {
expect(normalizeStyleForProvider('maplibre-gl', 'https://demotiles.maplibre.org/style.json')).toBe(
OPENFREEMAP_DEFAULT_STYLE,
)
expect(normalizeStyleForProvider('maplibre-gl', MAPBOX_DEFAULT_STYLE)).toBe(OPENFREEMAP_DEFAULT_STYLE)
})
it('leaves Mapbox styles unchanged for Mapbox GL', () => {
expect(normalizeStyleForProvider('mapbox-gl', MAPBOX_DEFAULT_STYLE)).toBe(MAPBOX_DEFAULT_STYLE)
})
it('matches the OpenFreeMap CSP host', () => {
expect(isOpenFreeMapStyle('https://tiles.openfreemap.org/styles/liberty')).toBe(true)
expect(isOpenFreeMapStyle('https://demotiles.maplibre.org/style.json')).toBe(false)
})
it('rejects host/userinfo spoofing and http downgrade', () => {
expect(isOpenFreeMapStyle('https://tiles.openfreemap.org.evil.com/styles/x')).toBe(false)
expect(isOpenFreeMapStyle('https://evil.com/@tiles.openfreemap.org/styles/x')).toBe(false)
expect(isOpenFreeMapStyle('http://tiles.openfreemap.org/styles/liberty')).toBe(false)
expect(isOpenFreeMapStyle(' https://tiles.openfreemap.org/styles/liberty ')).toBe(true)
})
it('falls back to provider defaults for empty/whitespace styles', () => {
expect(normalizeStyleForProvider('maplibre-gl', '')).toBe(OPENFREEMAP_DEFAULT_STYLE)
expect(normalizeStyleForProvider('maplibre-gl', ' ')).toBe(OPENFREEMAP_DEFAULT_STYLE)
expect(normalizeStyleForProvider('mapbox-gl', '')).toBe(MAPBOX_DEFAULT_STYLE)
expect(normalizeStyleForProvider('mapbox-gl', null)).toBe(MAPBOX_DEFAULT_STYLE)
})
it('styleForActiveProvider reads each provider\'s own style slot', () => {
const mb = 'mapbox://styles/me/custom'
const ofm = 'https://tiles.openfreemap.org/styles/bright'
expect(styleForActiveProvider('mapbox-gl', mb, ofm)).toBe(mb)
expect(styleForActiveProvider('maplibre-gl', mb, ofm)).toBe(ofm)
// An empty MapLibre slot falls back to the OpenFreeMap default, leaving mapbox untouched.
expect(styleForActiveProvider('maplibre-gl', mb, '')).toBe(OPENFREEMAP_DEFAULT_STYLE)
})
it('basemapLanguage maps TREK UI codes to basemap label codes (#1299)', () => {
// Pass-through for plain ISO 639-1 codes.
expect(basemapLanguage('en')).toBe('en')
expect(basemapLanguage('de')).toBe('de')
expect(basemapLanguage('fr')).toBe('fr')
// TREK-specific overrides.
expect(basemapLanguage('br')).toBe('pt')
expect(basemapLanguage('gr')).toBe('el')
expect(basemapLanguage('zh')).toBe('zh-Hans')
expect(basemapLanguage('zhTw')).toBe('zh-Hant')
expect(basemapLanguage('zh-TW')).toBe('zh-Hant')
// Falls back to English when unset.
expect(basemapLanguage(undefined)).toBe('en')
expect(basemapLanguage('')).toBe('en')
})
})
-87
View File
@@ -1,87 +0,0 @@
export type GlMapProvider = 'mapbox-gl' | 'maplibre-gl'
export interface GlStylePreset {
name: string
url: string
tags?: string[]
}
export const MAPBOX_DEFAULT_STYLE = 'mapbox://styles/mapbox/standard'
export const OPENFREEMAP_DEFAULT_STYLE = 'https://tiles.openfreemap.org/styles/liberty'
export const MAPBOX_STYLE_PRESETS: GlStylePreset[] = [
{ name: 'Mapbox Standard', url: MAPBOX_DEFAULT_STYLE, tags: ['3D', 'Apple-like'] },
{ name: 'Standard Satellite', url: 'mapbox://styles/mapbox/standard-satellite', tags: ['3D', 'Satellite'] },
{ name: 'Streets', url: 'mapbox://styles/mapbox/streets-v12', tags: ['3D', 'Classic'] },
{ name: 'Outdoors', url: 'mapbox://styles/mapbox/outdoors-v12', tags: ['3D', 'Terrain'] },
{ name: 'Light', url: 'mapbox://styles/mapbox/light-v11', tags: ['3D', 'Minimal'] },
{ name: 'Dark', url: 'mapbox://styles/mapbox/dark-v11', tags: ['3D', 'Dark'] },
{ name: 'Satellite', url: 'mapbox://styles/mapbox/satellite-v9', tags: ['3D', 'Satellite'] },
{ name: 'Satellite Streets', url: 'mapbox://styles/mapbox/satellite-streets-v12', tags: ['3D', 'Satellite'] },
{ name: 'Navigation Day', url: 'mapbox://styles/mapbox/navigation-day-v1', tags: ['3D', 'Apple-like'] },
{ name: 'Navigation Night', url: 'mapbox://styles/mapbox/navigation-night-v1', tags: ['3D', 'Dark'] },
]
export const OPENFREEMAP_STYLE_PRESETS: GlStylePreset[] = [
{ name: 'OpenFreeMap Liberty', url: OPENFREEMAP_DEFAULT_STYLE, tags: ['OpenFreeMap', '2D'] },
{ name: 'OpenFreeMap Bright', url: 'https://tiles.openfreemap.org/styles/bright', tags: ['OpenFreeMap', 'Classic'] },
{ name: 'OpenFreeMap Positron', url: 'https://tiles.openfreemap.org/styles/positron', tags: ['OpenFreeMap', 'Minimal'] },
]
export function getStylePresets(provider: GlMapProvider): GlStylePreset[] {
return provider === 'maplibre-gl' ? OPENFREEMAP_STYLE_PRESETS : MAPBOX_STYLE_PRESETS
}
export function defaultStyleForProvider(provider: GlMapProvider): string {
return provider === 'maplibre-gl' ? OPENFREEMAP_DEFAULT_STYLE : MAPBOX_DEFAULT_STYLE
}
export function isOpenFreeMapStyle(style?: string | null): boolean {
return (style || '').trim().startsWith('https://tiles.openfreemap.org/')
}
export function normalizeStyleForProvider(provider: GlMapProvider, style?: string | null): string {
const trimmed = (style || '').trim()
if (!trimmed) return defaultStyleForProvider(provider)
if (provider === 'maplibre-gl') {
return isOpenFreeMapStyle(trimmed) ? trimmed : OPENFREEMAP_DEFAULT_STYLE
}
return trimmed
}
/** The settings key that holds the style for a given GL provider. */
export function styleSettingKey(provider: GlMapProvider): 'mapbox_style' | 'maplibre_style' {
return provider === 'maplibre-gl' ? 'maplibre_style' : 'mapbox_style'
}
/**
* Each GL provider keeps its style in its own slot (mapbox_style / maplibre_style), so
* switching providers never overwrites the other one's custom style. Picks and normalizes
* the style for the active provider.
*/
export function styleForActiveProvider(
provider: GlMapProvider,
mapboxStyle?: string | null,
maplibreStyle?: string | null,
): string {
return normalizeStyleForProvider(provider, provider === 'maplibre-gl' ? maplibreStyle : mapboxStyle)
}
// A few TREK UI language codes differ from what the GL basemap expects for its labels.
const BASEMAP_LANG_OVERRIDES: Record<string, string> = {
br: 'pt', // TREK 'br' = Brazilian Portuguese
gr: 'el', // TREK 'gr' = Greek
zh: 'zh-Hans',
zhTw: 'zh-Hant',
'zh-TW': 'zh-Hant',
}
/**
* Maps a TREK UI language code to the label language the GL basemap expects. Used to pin
* Mapbox Standard's basemap labels to the user's language so they don't fall back to the
* browser/OS locale and stack multiple scripts per place (#1299).
*/
export function basemapLanguage(uiLang: string | undefined): string {
const code = (uiLang || 'en').trim()
return BASEMAP_LANG_OVERRIDES[code] ?? code
}
@@ -1,13 +1,6 @@
import type mapboxgl from 'mapbox-gl' import mapboxgl from 'mapbox-gl'
import type { GeoPosition } from '../../hooks/useGeolocation' import type { GeoPosition } from '../../hooks/useGeolocation'
type MarkerConstructor = new (options?: { element?: HTMLElement; anchor?: string }) => {
setLngLat: (lngLat: mapboxgl.LngLatLike) => { addTo: (map: mapboxgl.Map) => unknown }
addTo: (map: mapboxgl.Map) => unknown
remove: () => void
getElement: () => HTMLElement
}
// Build the DOM element that backs the mapbox Marker. We animate the // Build the DOM element that backs the mapbox Marker. We animate the
// heading cone via a CSS rotation so the DOM stays stable across updates // heading cone via a CSS rotation so the DOM stays stable across updates
// and mapbox doesn't get confused about which element to position. // and mapbox doesn't get confused about which element to position.
@@ -73,10 +66,10 @@ export interface LocationMarkerHandle {
// mapbox map. Returns a handle the caller uses to push position updates // mapbox map. Returns a handle the caller uses to push position updates
// and clean up. Keeps its own DOM element and GeoJSON source so it can // and clean up. Keeps its own DOM element and GeoJSON source so it can
// coexist with the regular trip markers. // coexist with the regular trip markers.
export function attachLocationMarker(map: mapboxgl.Map, MarkerCtor: MarkerConstructor): LocationMarkerHandle { export function attachLocationMarker(map: mapboxgl.Map): LocationMarkerHandle {
ensurePulseStyle() ensurePulseStyle()
const { root, cone } = buildLocationEl() const { root, cone } = buildLocationEl()
const marker = new MarkerCtor({ element: root, anchor: 'center' }) const marker = new mapboxgl.Marker({ element: root, anchor: 'center' })
const ensureAccuracyLayer = () => { const ensureAccuracyLayer = () => {
if (map.getSource('trek-location-accuracy')) return if (map.getSource('trek-location-accuracy')) return
@@ -8,7 +8,7 @@
import { createElement } from 'react' import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server' import { renderToStaticMarkup } from 'react-dom/server'
import type mapboxgl from 'mapbox-gl' import mapboxgl from 'mapbox-gl'
import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route } from 'lucide-react' import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route } from 'lucide-react'
import { escapeHtml } from '@trek/shared' import { escapeHtml } from '@trek/shared'
import type { Reservation, ReservationEndpoint } from '../../types' import type { Reservation, ReservationEndpoint } from '../../types'
@@ -220,29 +220,18 @@ export interface ReservationOverlayOptions {
onEndpointClick?: (reservationId: number) => void onEndpointClick?: (reservationId: number) => void
} }
type GlMarker = {
setLngLat: (lngLat: mapboxgl.LngLatLike) => GlMarker
addTo: (map: mapboxgl.Map) => GlMarker
remove: () => void
getElement: () => HTMLElement
}
type MarkerConstructor = new (options?: { element?: HTMLElement; anchor?: string }) => GlMarker
export class ReservationMapboxOverlay { export class ReservationMapboxOverlay {
private map: mapboxgl.Map private map: mapboxgl.Map
private items: TransportItem[] = [] private items: TransportItem[] = []
private opts: ReservationOverlayOptions private opts: ReservationOverlayOptions
private MarkerCtor: MarkerConstructor private endpointMarkers: mapboxgl.Marker[] = []
private endpointMarkers: GlMarker[] = [] private statsMarkers: { marker: mapboxgl.Marker; arc: [number, number][] }[] = []
private statsMarkers: { marker: GlMarker; arc: [number, number][] }[] = []
private rerender: () => void private rerender: () => void
private destroyed = false private destroyed = false
constructor(map: mapboxgl.Map, opts: ReservationOverlayOptions, MarkerCtor: MarkerConstructor) { constructor(map: mapboxgl.Map, opts: ReservationOverlayOptions) {
this.map = map this.map = map
this.opts = opts this.opts = opts
this.MarkerCtor = MarkerCtor
this.rerender = () => { if (!this.destroyed) this.render() } this.rerender = () => { if (!this.destroyed) this.render() }
this.setupLayer() this.setupLayer()
map.on('zoomend', this.rerender) map.on('zoomend', this.rerender)
@@ -361,7 +350,7 @@ export class ReservationMapboxOverlay {
this.opts.onEndpointClick?.(item.res.id) this.opts.onEndpointClick?.(item.res.id)
}) })
} }
const marker = new this.MarkerCtor({ element: node, anchor: 'center' }) const marker = new mapboxgl.Marker({ element: node, anchor: 'center' })
.setLngLat([ep.lng, ep.lat]) .setLngLat([ep.lng, ep.lat])
.addTo(map) .addTo(map)
this.endpointMarkers.push(marker) this.endpointMarkers.push(marker)
-22
View File
@@ -323,28 +323,6 @@ describe('downloadTripPDF', () => {
expect(photoCalled).toBe(true) expect(photoCalled).toBe(true)
}) })
it('FE-COMP-TRIPPDF-019b: fetches photos for OSM places via osm_id recovered from the places pool (#1130)', async () => {
let fetchedId: string | null = null
server.use(
http.get('/api/maps/place-photo/:placeId', ({ params }) => {
fetchedId = params.placeId as string
return HttpResponse.json({ photoUrl: 'https://example.com/osm.jpg' })
}),
)
// The assignment projection drops osm_id; the full place in `places` carries it.
const osmPlace = { ...placeWithDetails, id: 101, image_url: null, google_place_id: null, osm_id: 'node/240109189', lat: 41.89, lng: 12.49 }
const args = {
...richArgs,
places: [osmPlace],
assignments: {
'10': [{ ...assignmentForDay, id: 201, place_id: 101, place: { ...placeWithDetails, id: 101, image_url: null, google_place_id: null } }],
} as any,
}
await downloadTripPDF(args)
// osm_id is used as the photo key (not the coords fallback), proving the pool lookup works.
expect(fetchedId).toBe('node/240109189')
})
it('FE-COMP-TRIPPDF-020: renders empty day message when no items assigned', async () => { it('FE-COMP-TRIPPDF-020: renders empty day message when no items assigned', async () => {
const args = { const args = {
...minimalArgs, ...minimalArgs,
+10 -18
View File
@@ -97,29 +97,21 @@ function dayCost(assignments, dayId, locale) {
return total > 0 ? `${total.toLocaleString(locale)} EUR` : null return total > 0 ? `${total.toLocaleString(locale)} EUR` : null
} }
// Pre-fetch place photos for all assigned places. // Pre-fetch Google Place photos for all assigned places
// Assignment places are a server-side projection that drops osm_id, so we recover async function fetchPlacePhotos(assignments: AssignmentsMap) {
// the full place from the trip's places pool and key the photo off the same id the
// app UI uses (google_place_id || osm_id || coords) — otherwise OSM/coords-only
// places fell back to category icons in the PDF even though they show photos in-app.
async function fetchPlacePhotos(assignments: AssignmentsMap, places: Place[]) {
const photoMap = {} // placeId → photoUrl const photoMap = {} // placeId → photoUrl
// The assignment projection drops osm_id, so recover it from the full places pool.
const osmById = new Map((places || []).map(p => [p.id, p.osm_id]))
const allPlaces = Object.values(assignments).flatMap(a => a.map(x => x.place)).filter(Boolean) const allPlaces = Object.values(assignments).flatMap(a => a.map(x => x.place)).filter(Boolean)
const unique = [...new Map(allPlaces.map(p => [p.id, p])).values()] const unique = [...new Map(allPlaces.map(p => [p.id, p])).values()]
const toFetch = unique // Assignment places are a server-side projection that omits osm_id, so photo
.map(p => ({ p, osm_id: osmById.get(p.id) })) // pre-fetch keys off the google_place_id that the projection does carry.
.filter(({ p, osm_id }) => !p.image_url && (p.google_place_id || osm_id || (p.lat != null && p.lng != null))) const toFetch = unique.filter(p => !p.image_url && p.google_place_id)
await Promise.allSettled( await Promise.allSettled(
toFetch.map(async ({ p, osm_id }) => { toFetch.map(async (place) => {
// Same key the app UI uses: google_place_id || osm_id || coords.
const photoId = p.google_place_id || osm_id || `coords:${p.lat}:${p.lng}`
try { try {
const data = await mapsApi.placePhoto(photoId, p.lat, p.lng, p.name) const data = await mapsApi.placePhoto(place.google_place_id, place.lat, place.lng, place.name)
if (data.photoUrl) photoMap[p.id] = data.photoUrl if (data.photoUrl) photoMap[place.id] = data.photoUrl
} catch {} } catch {}
}) })
) )
@@ -149,8 +141,8 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
//retrieve accommodations for the trip to display on the day sections and prefetch their photos if needed //retrieve accommodations for the trip to display on the day sections and prefetch their photos if needed
const accommodations = await accommodationsApi.list(trip.id); const accommodations = await accommodationsApi.list(trip.id);
// Pre-fetch place photos (Google, OSM and coords-only places) // Pre-fetch place photos from Google
const photoMap = await fetchPlacePhotos(assignments, places) const photoMap = await fetchPlacePhotos(assignments)
const totalAssigned = new Set( const totalAssigned = new Set(
Object.values(assignments || {}).flatMap(a => a.map(x => x.place?.id)).filter(Boolean) Object.values(assignments || {}).flatMap(a => a.map(x => x.place?.id)).filter(Boolean)
@@ -82,7 +82,7 @@ export default function ApplyTemplateButton({ tripId, style, className }: ApplyT
style={{ style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%', display: 'flex', alignItems: 'center', gap: 8, width: '100%',
padding: '8px 12px', borderRadius: 8, border: 'none', cursor: 'pointer', padding: '8px 12px', borderRadius: 8, border: 'none', cursor: 'pointer',
background: 'transparent', fontFamily: 'inherit', fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-primary)', background: 'transparent', fontFamily: 'inherit', fontSize: 12, color: 'var(--text-primary)',
}} }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'} onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'} onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
@@ -90,7 +90,7 @@ export default function ApplyTemplateButton({ tripId, style, className }: ApplyT
<Package size={13} className="text-content-faint" /> <Package size={13} className="text-content-faint" />
<div style={{ flex: 1, textAlign: 'left' }}> <div style={{ flex: 1, textAlign: 'left' }}>
<div style={{ fontWeight: 600 }}>{tmpl.name}</div> <div style={{ fontWeight: 600 }}>{tmpl.name}</div>
<div style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}> <div style={{ fontSize: 10, color: 'var(--text-faint)' }}>
{tmpl.item_count} {t('admin.packingTemplates.items')} {tmpl.item_count} {t('admin.packingTemplates.items')}
</div> </div>
</div> </div>
@@ -174,9 +174,7 @@ describe('PackingListPanel', () => {
it('FE-COMP-PACKING-016: delete item button exists and triggers API call', async () => { it('FE-COMP-PACKING-016: delete item button exists and triggers API call', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
// Uncategorized item: deleting it is a plain DELETE (a custom category's last const item = buildPackingItem({ id: 99, name: 'To Remove', category: 'Test' });
// item is instead converted to a placeholder — see FE-COMP-PACKING-070).
const item = buildPackingItem({ id: 99, name: 'To Remove', category: null });
let deleteCalled = false; let deleteCalled = false;
server.use( server.use(
http.delete('/api/trips/1/packing/99', () => { http.delete('/api/trips/1/packing/99', () => {
@@ -1417,114 +1415,4 @@ describe('PackingListPanel', () => {
expect(clickSpy).toHaveBeenCalled(); expect(clickSpy).toHaveBeenCalled();
clickSpy.mockRestore(); clickSpy.mockRestore();
}); });
it('FE-COMP-PACKING-070: deleting the last item of a custom category converts the row to a placeholder so the category persists in place (#1289)', async () => {
const user = userEvent.setup();
const item = buildPackingItem({ id: 99, name: 'Tent', category: 'Camping Gear' });
// handleDeleteItem decides "last in category" from the rendered list.
seedStore(useTripStore, { packingItems: [item] });
let deleted = false;
let putBody: Record<string, unknown> | null = null;
server.use(
http.delete('/api/trips/1/packing/99', () => {
deleted = true;
return HttpResponse.json({ success: true });
}),
http.put('/api/trips/1/packing/99', async ({ request }) => {
putBody = await request.json() as Record<string, unknown>;
return HttpResponse.json({ item: buildPackingItem({ id: 99, name: '...', category: 'Camping Gear' }) });
})
);
render(<PackingListPanel tripId={1} items={[item]} />);
await user.click(screen.getByTitle('Delete'));
// The row is updated in place (same id) rather than deleted, so colour/position hold.
await waitFor(() => expect(putBody).toMatchObject({ name: '...' }));
expect(deleted).toBe(false);
});
it('FE-COMP-PACKING-071: deleting the placeholder row deletes it, dismissing the empty category (#1289)', async () => {
const user = userEvent.setup();
const placeholder = buildPackingItem({ id: 5, name: '...', category: 'Camping Gear' });
seedStore(useTripStore, { packingItems: [placeholder] });
let deleted = false;
let converted = false;
server.use(
http.delete('/api/trips/1/packing/5', () => {
deleted = true;
return HttpResponse.json({ success: true });
}),
http.put('/api/trips/1/packing/5', () => {
converted = true;
return HttpResponse.json({ item: placeholder });
})
);
render(<PackingListPanel tripId={1} items={[placeholder]} />);
await user.click(screen.getByTitle('Delete'));
await waitFor(() => expect(deleted).toBe(true));
// It is the placeholder itself — it must be removed, not re-converted.
expect(converted).toBe(false);
});
it('FE-COMP-PACKING-072: adding an item to an empty category reuses the placeholder row instead of appending (#1289)', async () => {
const user = userEvent.setup();
const placeholder = buildPackingItem({ id: 5, name: '...', category: 'Camping Gear' });
seedStore(useTripStore, { packingItems: [placeholder] });
let posted = false;
let putBody: Record<string, unknown> | null = null;
server.use(
http.post('/api/trips/1/packing', () => {
posted = true;
return HttpResponse.json({ item: buildPackingItem({ id: 6 }) });
}),
http.put('/api/trips/1/packing/5', async ({ request }) => {
putBody = await request.json() as Record<string, unknown>;
return HttpResponse.json({ item: buildPackingItem({ id: 5, name: 'Tent', category: 'Camping Gear' }) });
})
);
render(<PackingListPanel tripId={1} items={[placeholder]} />);
// Open the category's inline "Add item" and add a real entry.
await user.click(screen.getByText('Add item'));
const input = await screen.findByPlaceholderText('Item name...');
await user.type(input, 'Tent');
await user.keyboard('{Enter}');
await waitFor(() => expect(putBody).toMatchObject({ name: 'Tent' }));
expect(posted).toBe(false);
});
// ── Three-tier sharing (#858) ──────────────────────────────────────────────
it('FE-COMP-PACKING-080: the view switch separates the Common pool from My list', async () => {
seedStore(useAuthStore, { user: buildUser({ id: 1 }), isAuthenticated: true });
const items = [
buildPackingItem({ name: 'Group tent', is_private: 0 }),
buildPackingItem({ name: 'My diary', is_private: 1, owner_id: 1 }),
];
render(<PackingListPanel tripId={1} items={items} />);
// Default view = Common pool → only the shared item.
expect(await screen.findByText('Group tent')).toBeInTheDocument();
expect(screen.queryByText('My diary')).not.toBeInTheDocument();
// Switch to "My list" → only the personal item.
await userEvent.click(screen.getByText('My list'));
expect(await screen.findByText('My diary')).toBeInTheDocument();
expect(screen.queryByText('Group tent')).not.toBeInTheDocument();
});
it('FE-COMP-PACKING-081: a shared-to-me item shows the "by <bringer>" badge in My list', async () => {
seedStore(useAuthStore, { user: buildUser({ id: 1 }), isAuthenticated: true });
const items = [
buildPackingItem({ name: 'Power bank', is_private: 1, owner_id: 2, owner_username: 'Bob', recipients: [{ user_id: 1, username: 'me' }] }),
];
render(<PackingListPanel tripId={1} items={items} />);
await userEvent.click(screen.getByText('My list'));
await screen.findByText('Power bank');
// "by Bob" — taken care of by the bringer.
expect(screen.getByText('by Bob')).toBeInTheDocument();
});
}); });
@@ -1,7 +1,7 @@
import { usePackingList } from './usePackingListPanel' import { usePackingList } from './usePackingListPanel'
import type { PackingListPanelProps } from './usePackingListPanel' import type { PackingListPanelProps } from './usePackingListPanel'
import { PackingHeader } from './PackingListPanelHeader' import { PackingHeader } from './PackingListPanelHeader'
import { PackingViewTabs } from './PackingListPanelViewTabs' import { PackingFilterTabs } from './PackingListPanelFilterTabs'
import { PackingList } from './PackingListPanelList' import { PackingList } from './PackingListPanelList'
import { BagSidebar } from './PackingListPanelBagSidebar' import { BagSidebar } from './PackingListPanelBagSidebar'
import { BagModal } from './PackingListPanelBagModal' import { BagModal } from './PackingListPanelBagModal'
@@ -18,8 +18,8 @@ export default function PackingListPanel(props: PackingListPanelProps) {
{/* ── Header ── */} {/* ── Header ── */}
<PackingHeader {...S} /> <PackingHeader {...S} />
{/* ── Tabs: Gemeinsam / Meine Liste (#858) + Filter ── */} {/* ── Filter-Tabs ── */}
<PackingViewTabs {...S} /> <PackingFilterTabs {...S} />
{/* ── Liste + Bags Sidebar ── */} {/* ── Liste + Bags Sidebar ── */}
<div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}> <div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
@@ -69,13 +69,13 @@ export function BagCard({ bag, bagItems, totalWeight, pct, tripId, tripMembers,
const isSelected = memberIds.includes(m.id) const isSelected = memberIds.includes(m.id)
return ( return (
<button key={m.id} onClick={() => { toggleMember(m.id); }} <button key={m.id} onClick={() => { toggleMember(m.id); }}
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '6px 10px', borderRadius: 6, border: 'none', background: isSelected ? 'var(--bg-tertiary)' : 'transparent', cursor: 'pointer', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-primary)', fontFamily: 'inherit' }} style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '6px 10px', borderRadius: 6, border: 'none', background: isSelected ? 'var(--bg-tertiary)' : 'transparent', cursor: 'pointer', fontSize: 11, color: 'var(--text-primary)', fontFamily: 'inherit' }}
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = 'var(--bg-secondary)' }} onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = 'var(--bg-secondary)' }}
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = 'transparent' }}> onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = 'transparent' }}>
{m.avatar ? ( {m.avatar ? (
<img src={m.avatar} alt="" style={{ width: 20, height: 20, borderRadius: '50%', objectFit: 'cover' }} /> <img src={m.avatar} alt="" style={{ width: 20, height: 20, borderRadius: '50%', objectFit: 'cover' }} />
) : ( ) : (
<span style={{ width: 20, height: 20, borderRadius: '50%', background: 'var(--bg-tertiary)', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text-faint)' }}> <span style={{ width: 20, height: 20, borderRadius: '50%', background: 'var(--bg-tertiary)', fontSize: 10, fontWeight: 700, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text-faint)' }}>
{m.username[0].toUpperCase()} {m.username[0].toUpperCase()}
</span> </span>
)} )}
@@ -84,9 +84,9 @@ export function BagCard({ bag, bagItems, totalWeight, pct, tripId, tripMembers,
</button> </button>
) )
})} })}
{tripMembers.length === 0 && <div style={{ padding: '8px 10px', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}>{t('packing.noMembers')}</div>} {tripMembers.length === 0 && <div style={{ padding: '8px 10px', fontSize: 11, color: 'var(--text-faint)' }}>{t('packing.noMembers')}</div>}
<div style={{ borderTop: '1px solid var(--border-secondary)', marginTop: 4, paddingTop: 4 }}> <div style={{ borderTop: '1px solid var(--border-secondary)', marginTop: 4, paddingTop: 4 }}>
<button onClick={() => setShowUserPicker(false)} style={{ width: '100%', padding: '6px 10px', borderRadius: 6, border: 'none', background: 'transparent', cursor: 'pointer', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', fontFamily: 'inherit', textAlign: 'center' }}> <button onClick={() => setShowUserPicker(false)} style={{ width: '100%', padding: '6px 10px', borderRadius: 6, border: 'none', background: 'transparent', cursor: 'pointer', fontSize: 11, color: 'var(--text-faint)', fontFamily: 'inherit', textAlign: 'center' }}>
{t('common.close')} {t('common.close')}
</button> </button>
</div> </div>
@@ -14,7 +14,7 @@ export function BagModal(S: PackingState) {
<div style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 360, maxHeight: 'calc(100vh - 80px)', overflow: 'auto', padding: 20, boxShadow: '0 16px 48px rgba(0,0,0,0.15)', flexShrink: 0 }} <div style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 360, maxHeight: 'calc(100vh - 80px)', overflow: 'auto', padding: 20, boxShadow: '0 16px 48px rgba(0,0,0,0.15)', flexShrink: 0 }}
onClick={e => e.stopPropagation()}> onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<h3 style={{ margin: 0, fontSize: 'calc(16px * var(--fs-scale-subtitle, 1))', fontWeight: 700, color: 'var(--text-primary)' }}>{t('packing.bags')}</h3> <h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>{t('packing.bags')}</h3>
<button onClick={() => setShowBagModal(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}><X size={18} /></button> <button onClick={() => setShowBagModal(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}><X size={18} /></button>
</div> </div>
@@ -37,19 +37,19 @@ export function BagModal(S: PackingState) {
<div style={{ marginBottom: 16, opacity: 0.6 }}> <div style={{ marginBottom: 16, opacity: 0.6 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<span style={{ width: 12, height: 12, borderRadius: '50%', border: '2px dashed var(--border-primary)', flexShrink: 0 }} /> <span style={{ width: 12, height: 12, borderRadius: '50%', border: '2px dashed var(--border-primary)', flexShrink: 0 }} />
<span style={{ flex: 1, fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-faint)' }}>{t('packing.noBag')}</span> <span style={{ flex: 1, fontSize: 14, fontWeight: 600, color: 'var(--text-faint)' }}>{t('packing.noBag')}</span>
<span style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', color: 'var(--text-faint)' }}> <span style={{ fontSize: 13, color: 'var(--text-faint)' }}>
{unassignedWeight >= 1000 ? `${(unassignedWeight / 1000).toFixed(1)} kg` : `${unassignedWeight} g`} {unassignedWeight >= 1000 ? `${(unassignedWeight / 1000).toFixed(1)} kg` : `${unassignedWeight} g`}
</span> </span>
</div> </div>
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}>{unassigned.length} {t('admin.packingTemplates.items')}</div> <div style={{ fontSize: 11, color: 'var(--text-faint)' }}>{unassigned.length} {t('admin.packingTemplates.items')}</div>
</div> </div>
) )
})()} })()}
{/* Total */} {/* Total */}
<div style={{ borderTop: '1px solid var(--border-secondary)', paddingTop: 12, marginTop: 8 }}> <div style={{ borderTop: '1px solid var(--border-secondary)', paddingTop: 12, marginTop: 8 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)' }}> <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 14, fontWeight: 700, color: 'var(--text-primary)' }}>
<span>{t('packing.totalWeight')}</span> <span>{t('packing.totalWeight')}</span>
<span>{(() => { const w = items.reduce((s, i) => s + itemWeight(i), 0); return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g` })()}</span> <span>{(() => { const w = items.reduce((s, i) => s + itemWeight(i), 0); return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g` })()}</span>
</div> </div>
@@ -61,7 +61,7 @@ export function BagModal(S: PackingState) {
<input autoFocus value={newBagName} onChange={e => setNewBagName(e.target.value)} <input autoFocus value={newBagName} onChange={e => setNewBagName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleCreateBag(); if (e.key === 'Escape') { setShowAddBag(false); setNewBagName('') } }} onKeyDown={e => { if (e.key === 'Enter') handleCreateBag(); if (e.key === 'Escape') { setShowAddBag(false); setNewBagName('') } }}
placeholder={t('packing.bagName')} placeholder={t('packing.bagName')}
style={{ flex: 1, padding: '8px 12px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontFamily: 'inherit', outline: 'none' }} /> style={{ flex: 1, padding: '8px 12px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 13, fontFamily: 'inherit', outline: 'none' }} />
<button onClick={handleCreateBag} disabled={!newBagName.trim()} <button onClick={handleCreateBag} disabled={!newBagName.trim()}
style={{ padding: '8px 12px', borderRadius: 10, border: 'none', background: newBagName.trim() ? 'var(--text-primary)' : 'var(--border-primary)', color: 'var(--bg-primary)', cursor: newBagName.trim() ? 'pointer' : 'default', display: 'flex', alignItems: 'center' }}> style={{ padding: '8px 12px', borderRadius: 10, border: 'none', background: newBagName.trim() ? 'var(--text-primary)' : 'var(--border-primary)', color: 'var(--bg-primary)', cursor: newBagName.trim() ? 'pointer' : 'default', display: 'flex', alignItems: 'center' }}>
<Plus size={14} /> <Plus size={14} />
@@ -69,7 +69,7 @@ export function BagModal(S: PackingState) {
</div> </div>
) : ( ) : (
<button onClick={() => setShowAddBag(true)} <button onClick={() => setShowAddBag(true)}
style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 14, padding: '9px 14px', borderRadius: 10, border: '1px dashed var(--border-primary)', background: 'none', cursor: 'pointer', fontSize: 'calc(13px * var(--fs-scale-body, 1))', color: 'var(--text-faint)', fontFamily: 'inherit', width: '100%', transition: 'all 0.15s' }} style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 14, padding: '9px 14px', borderRadius: 10, border: '1px dashed var(--border-primary)', background: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-faint)', fontFamily: 'inherit', width: '100%', transition: 'all 0.15s' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--text-muted)'; e.currentTarget.style.color = 'var(--text-secondary)' }} onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--text-muted)'; e.currentTarget.style.color = 'var(--text-secondary)' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.color = 'var(--text-faint)' }}> onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.color = 'var(--text-faint)' }}>
<Plus size={14} /> {t('packing.addBag')} <Plus size={14} /> {t('packing.addBag')}
@@ -9,8 +9,8 @@ export function BagSidebar(S: PackingState) {
showAddBag, setShowAddBag, newBagName, setNewBagName, handleCreateBag, showAddBag, setShowAddBag, newBagName, setNewBagName, handleCreateBag,
} = S } = S
return ( return (
<div className="hidden xl:block" style={{ width: 260, marginLeft: 16, borderLeft: '1px solid var(--border-secondary)', overflowY: 'auto', padding: 16, flexShrink: 0 }}> <div className="hidden xl:block" style={{ width: 260, borderLeft: '1px solid var(--border-secondary)', overflowY: 'auto', padding: 16, flexShrink: 0 }}>
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-faint)', marginBottom: 12 }}> <div style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-faint)', marginBottom: 12 }}>
{t('packing.bags')} {t('packing.bags')}
</div> </div>
@@ -33,19 +33,19 @@ export function BagSidebar(S: PackingState) {
<div style={{ marginBottom: 14, opacity: 0.6 }}> <div style={{ marginBottom: 14, opacity: 0.6 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
<span style={{ width: 10, height: 10, borderRadius: '50%', border: '2px dashed var(--border-primary)', flexShrink: 0 }} /> <span style={{ width: 10, height: 10, borderRadius: '50%', border: '2px dashed var(--border-primary)', flexShrink: 0 }} />
<span style={{ flex: 1, fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-faint)' }}>{t('packing.noBag')}</span> <span style={{ flex: 1, fontSize: 12, fontWeight: 600, color: 'var(--text-faint)' }}>{t('packing.noBag')}</span>
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}> <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>
{unassignedWeight >= 1000 ? `${(unassignedWeight / 1000).toFixed(1)} kg` : `${unassignedWeight} g`} {unassignedWeight >= 1000 ? `${(unassignedWeight / 1000).toFixed(1)} kg` : `${unassignedWeight} g`}
</span> </span>
</div> </div>
<div style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}>{unassigned.length} {t('admin.packingTemplates.items')}</div> <div style={{ fontSize: 10, color: 'var(--text-faint)' }}>{unassigned.length} {t('admin.packingTemplates.items')}</div>
</div> </div>
) )
})()} })()}
{/* Total */} {/* Total */}
<div style={{ borderTop: '1px solid var(--border-secondary)', paddingTop: 10, marginTop: 6 }}> <div style={{ borderTop: '1px solid var(--border-secondary)', paddingTop: 10, marginTop: 6 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)' }}> <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, fontWeight: 700, color: 'var(--text-primary)' }}>
<span>{t('packing.totalWeight')}</span> <span>{t('packing.totalWeight')}</span>
<span>{(() => { const w = items.reduce((s, i) => s + itemWeight(i), 0); return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g` })()}</span> <span>{(() => { const w = items.reduce((s, i) => s + itemWeight(i), 0); return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g` })()}</span>
</div> </div>
@@ -57,14 +57,14 @@ export function BagSidebar(S: PackingState) {
<input autoFocus value={newBagName} onChange={e => setNewBagName(e.target.value)} <input autoFocus value={newBagName} onChange={e => setNewBagName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleCreateBag(); if (e.key === 'Escape') { setShowAddBag(false); setNewBagName('') } }} onKeyDown={e => { if (e.key === 'Enter') handleCreateBag(); if (e.key === 'Escape') { setShowAddBag(false); setNewBagName('') } }}
placeholder={t('packing.bagName')} placeholder={t('packing.bagName')}
style={{ flex: 1, padding: '5px 8px', borderRadius: 8, border: '1px solid var(--border-primary)', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontFamily: 'inherit', outline: 'none' }} /> style={{ flex: 1, padding: '5px 8px', borderRadius: 8, border: '1px solid var(--border-primary)', fontSize: 11, fontFamily: 'inherit', outline: 'none' }} />
<button onClick={handleCreateBag} style={{ padding: '4px 8px', borderRadius: 8, border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)', cursor: 'pointer', display: 'flex', alignItems: 'center' }}> <button onClick={handleCreateBag} style={{ padding: '4px 8px', borderRadius: 8, border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)', cursor: 'pointer', display: 'flex', alignItems: 'center' }}>
<Plus size={12} /> <Plus size={12} />
</button> </button>
</div> </div>
) : ( ) : (
<button onClick={() => setShowAddBag(true)} <button onClick={() => setShowAddBag(true)}
style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 12, padding: '5px 8px', borderRadius: 8, border: '1px dashed var(--border-primary)', background: 'none', cursor: 'pointer', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', fontFamily: 'inherit', width: '100%' }}> style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 12, padding: '5px 8px', borderRadius: 8, border: '1px dashed var(--border-primary)', background: 'none', cursor: 'pointer', fontSize: 11, color: 'var(--text-faint)', fontFamily: 'inherit', width: '100%' }}>
<Plus size={11} /> {t('packing.addBag')} <Plus size={11} /> {t('packing.addBag')}
</button> </button>
))} ))}
@@ -10,7 +10,6 @@ import type { PackingItem, PackingBag } from '../../types'
import { katColor } from './packingListPanel.helpers' import { katColor } from './packingListPanel.helpers'
import type { TripMember, CategoryAssignee } from './usePackingListPanel' import type { TripMember, CategoryAssignee } from './usePackingListPanel'
import { ArtikelZeile } from './PackingListPanelItemRow' import { ArtikelZeile } from './PackingListPanelItemRow'
import GuestBadge from '../shared/GuestBadge'
interface KategorieGruppeProps { interface KategorieGruppeProps {
kategorie: string kategorie: string
@@ -19,7 +18,6 @@ interface KategorieGruppeProps {
allCategories: string[] allCategories: string[]
onRename: (oldName: string, newName: string) => Promise<void> onRename: (oldName: string, newName: string) => Promise<void>
onDeleteAll: (items: PackingItem[]) => Promise<void> onDeleteAll: (items: PackingItem[]) => Promise<void>
onDeleteItem: (item: PackingItem) => Promise<void>
onAddItem: (category: string, name: string) => Promise<void> onAddItem: (category: string, name: string) => Promise<void>
assignees: CategoryAssignee[] assignees: CategoryAssignee[]
tripMembers: TripMember[] tripMembers: TripMember[]
@@ -28,40 +26,10 @@ interface KategorieGruppeProps {
bags?: PackingBag[] bags?: PackingBag[]
onCreateBag: (name: string) => Promise<PackingBag | undefined> onCreateBag: (name: string) => Promise<PackingBag | undefined>
canEdit?: boolean canEdit?: boolean
// Drag-to-reorder (#969): the full ordered item list + a persist callback. The
// order is global, so a within-category drag is mapped back onto the full list.
allItems: PackingItem[]
onReorder: (orderedIds: number[]) => void
// Three-tier sharing (#858) — threaded down to each item's share control.
currentUserId?: number
onSetSharing?: (id: number, visibility: 'common' | 'personal' | 'shared', recipientIds: number[]) => void
onClone?: (id: number) => void
onJoin?: (id: number) => void
onLeave?: (id: number, userId: number) => void
} }
export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onDeleteItem, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag, canEdit = true, allItems, onReorder, currentUserId, onSetSharing, onClone, onJoin, onLeave }: KategorieGruppeProps) { export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag, canEdit = true }: KategorieGruppeProps) {
const [offen, setOffen] = useState(true) const [offen, setOffen] = useState(true)
const [dragId, setDragId] = useState<number | null>(null)
const [overId, setOverId] = useState<number | null>(null)
const handleReorderDrop = (targetId: number) => {
const from = dragId
setDragId(null); setOverId(null)
if (from == null || from === targetId) return
const catOrder = items.map(i => i.id)
const fi = catOrder.indexOf(from)
const ti = catOrder.indexOf(targetId)
if (fi < 0 || ti < 0) return
catOrder.splice(fi, 1)
catOrder.splice(ti, 0, from)
// Slot the reordered category ids back into the positions this category's
// items occupy in the global list, leaving every other category untouched.
const catIds = new Set(items.map(i => i.id))
let ci = 0
const globalIds = allItems.map(i => (catIds.has(i.id) ? catOrder[ci++] : i.id))
onReorder(globalIds)
}
const [editingName, setEditingName] = useState(false) const [editingName, setEditingName] = useState(false)
const [editKatName, setEditKatName] = useState(kategorie) const [editKatName, setEditKatName] = useState(kategorie)
const [showMenu, setShowMenu] = useState(false) const [showMenu, setShowMenu] = useState(false)
@@ -130,10 +98,10 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
onChange={e => setEditKatName(e.target.value)} onChange={e => setEditKatName(e.target.value)}
onBlur={handleSaveKatName} onBlur={handleSaveKatName}
onKeyDown={e => { if (e.key === 'Enter') handleSaveKatName(); if (e.key === 'Escape') { setEditingName(false); setEditKatName(kategorie) } }} onKeyDown={e => { if (e.key === 'Enter') handleSaveKatName(); if (e.key === 'Escape') { setEditingName(false); setEditKatName(kategorie) } }}
style={{ flex: 1, fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 600, border: 'none', borderBottom: '2px solid var(--text-primary)', outline: 'none', background: 'transparent', fontFamily: 'inherit', color: 'var(--text-primary)', padding: '0 2px' }} style={{ flex: 1, fontSize: 12.5, fontWeight: 600, border: 'none', borderBottom: '2px solid var(--text-primary)', outline: 'none', background: 'transparent', fontFamily: 'inherit', color: 'var(--text-primary)', padding: '0 2px' }}
/> />
) : ( ) : (
<span style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.04em' }}> <span style={{ fontSize: 12.5, fontWeight: 700, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.04em' }}>
{kategorie} {kategorie}
</span> </span>
)} )}
@@ -149,7 +117,7 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
width: 22, height: 22, borderRadius: '50%', flexShrink: 0, cursor: canEdit ? 'pointer' : 'default', width: 22, height: 22, borderRadius: '50%', flexShrink: 0, cursor: canEdit ? 'pointer' : 'default',
background: `hsl(${a.username.charCodeAt(0) * 37 % 360}, 55%, 55%)`, background: `hsl(${a.username.charCodeAt(0) * 37 % 360}, 55%, 55%)`,
display: 'flex', alignItems: 'center', justifyContent: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'white', textTransform: 'uppercase', fontSize: 10, fontWeight: 700, color: 'white', textTransform: 'uppercase',
border: '2px solid var(--bg-card)', transition: 'opacity 0.15s', border: '2px solid var(--bg-card)', transition: 'opacity 0.15s',
}} }}
> >
@@ -159,7 +127,7 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
position: 'absolute', top: '100%', left: '50%', transform: 'translateX(-50%)', position: 'absolute', top: '100%', left: '50%', transform: 'translateX(-50%)',
marginTop: 6, padding: '3px 8px', borderRadius: 6, zIndex: 60, marginTop: 6, padding: '3px 8px', borderRadius: 6, zIndex: 60,
background: 'var(--text-primary)', color: 'var(--bg-primary)', background: 'var(--text-primary)', color: 'var(--bg-primary)',
fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, whiteSpace: 'nowrap', fontSize: 10, fontWeight: 600, whiteSpace: 'nowrap',
pointerEvents: 'none', opacity: 0, transition: 'opacity 0.15s', pointerEvents: 'none', opacity: 0, transition: 'opacity 0.15s',
}}> }}>
{a.username} {a.username}
@@ -199,7 +167,7 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
display: 'flex', alignItems: 'center', gap: 8, width: '100%', display: 'flex', alignItems: 'center', gap: 8, width: '100%',
padding: '6px 10px', borderRadius: 8, border: 'none', cursor: 'pointer', padding: '6px 10px', borderRadius: 8, border: 'none', cursor: 'pointer',
background: isAssigned ? 'var(--bg-hover)' : 'transparent', background: isAssigned ? 'var(--bg-hover)' : 'transparent',
fontFamily: 'inherit', fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-primary)', fontFamily: 'inherit', fontSize: 12, color: 'var(--text-primary)',
transition: 'background 0.1s', transition: 'background 0.1s',
}} }}
onMouseEnter={e => { if (!isAssigned) e.currentTarget.style.background = 'var(--bg-tertiary)' }} onMouseEnter={e => { if (!isAssigned) e.currentTarget.style.background = 'var(--bg-tertiary)' }}
@@ -209,20 +177,17 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
width: 20, height: 20, borderRadius: '50%', flexShrink: 0, width: 20, height: 20, borderRadius: '50%', flexShrink: 0,
background: `hsl(${m.username.charCodeAt(0) * 37 % 360}, 55%, 55%)`, background: `hsl(${m.username.charCodeAt(0) * 37 % 360}, 55%, 55%)`,
display: 'flex', alignItems: 'center', justifyContent: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'white', textTransform: 'uppercase', fontSize: 10, fontWeight: 700, color: 'white', textTransform: 'uppercase',
}}> }}>
{m.username[0]} {m.username[0]}
</div> </div>
<span style={{ flex: 1, display: 'flex', alignItems: 'center', gap: 6, minWidth: 0 }}> <span style={{ flex: 1 }}>{m.username}</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{m.username}</span>
{m.is_guest && <GuestBadge size="xs" />}
</span>
{isAssigned && <Check size={12} className="text-content-muted" />} {isAssigned && <Check size={12} className="text-content-muted" />}
</button> </button>
) )
})} })}
{tripMembers.length === 0 && ( {tripMembers.length === 0 && (
<div style={{ padding: '8px 10px', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}>{t('packing.noMembers')}</div> <div style={{ padding: '8px 10px', fontSize: 11, color: 'var(--text-faint)' }}>{t('packing.noMembers')}</div>
)} )}
</div> </div>
)} )}
@@ -231,7 +196,7 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
</div> </div>
<span style={{ <span style={{
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, padding: '1px 8px', borderRadius: 99, fontSize: 11, fontWeight: 600, padding: '1px 8px', borderRadius: 99,
background: alleAbgehakt ? 'rgba(22,163,74,0.12)' : 'var(--bg-tertiary)', background: alleAbgehakt ? 'rgba(22,163,74,0.12)' : 'var(--bg-tertiary)',
color: alleAbgehakt ? '#16a34a' : 'var(--text-muted)', color: alleAbgehakt ? '#16a34a' : 'var(--text-muted)',
}}> }}>
@@ -266,16 +231,7 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
{offen && ( {offen && (
<div style={{ padding: '4px 4px 6px' }}> <div style={{ padding: '4px 4px 6px' }}>
{items.map(item => ( {items.map(item => (
<ArtikelZeile key={item.id} item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} onDelete={onDeleteItem} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} canEdit={canEdit} <ArtikelZeile key={item.id} item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} canEdit={canEdit} />
tripMembers={tripMembers} currentUserId={currentUserId} onSetSharing={onSetSharing} onClone={onClone} onJoin={onJoin} onLeave={onLeave}
drag={canEdit ? {
isDragging: dragId === item.id,
isOver: overId === item.id && dragId !== null && dragId !== item.id,
onStart: (id) => { setDragId(id); setOverId(null) },
onOver: (id) => setOverId(id),
onEnd: () => { setDragId(null); setOverId(null) },
onDrop: handleReorderDrop,
} : undefined} />
))} ))}
{/* Inline add item */} {/* Inline add item */}
{canEdit && (showAddItem ? ( {canEdit && (showAddItem ? (
@@ -294,7 +250,7 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
if (e.key === 'Escape') { setShowAddItem(false); setNewItemName('') } if (e.key === 'Escape') { setShowAddItem(false); setNewItemName('') }
}} }}
placeholder={t('packing.addItemPlaceholder')} placeholder={t('packing.addItemPlaceholder')}
style={{ flex: 1, padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-primary)', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontFamily: 'inherit', outline: 'none', color: 'var(--text-primary)', background: 'var(--bg-input)' }} style={{ flex: 1, padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-primary)', fontSize: 12.5, fontFamily: 'inherit', outline: 'none', color: 'var(--text-primary)', background: 'var(--bg-input)' }}
/> />
<button onClick={() => { if (newItemName.trim()) { onAddItem(kategorie, newItemName.trim()); setNewItemName(''); setTimeout(() => addItemRef.current?.focus(), 30) } }} <button onClick={() => { if (newItemName.trim()) { onAddItem(kategorie, newItemName.trim()); setNewItemName(''); setTimeout(() => addItemRef.current?.focus(), 30) } }}
disabled={!newItemName.trim()} disabled={!newItemName.trim()}
@@ -308,7 +264,7 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
</div> </div>
) : ( ) : (
<button onClick={() => { setShowAddItem(true); setTimeout(() => addItemRef.current?.focus(), 30) }} <button onClick={() => { setShowAddItem(true); setTimeout(() => addItemRef.current?.focus(), 30) }}
style={{ display: 'flex', alignItems: 'center', gap: 5, padding: '5px 10px', margin: '2px 4px', borderRadius: 8, border: 'none', background: 'none', cursor: 'pointer', fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-faint)', fontFamily: 'inherit' }} style={{ display: 'flex', alignItems: 'center', gap: 5, padding: '5px 10px', margin: '2px 4px', borderRadius: 8, border: 'none', background: 'none', cursor: 'pointer', fontSize: 12, color: 'var(--text-faint)', fontFamily: 'inherit' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-secondary)'} onMouseEnter={e => e.currentTarget.style.color = 'var(--text-secondary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}> onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Plus size={12} /> {t('packing.addItem')} <Plus size={12} /> {t('packing.addItem')}
@@ -332,7 +288,7 @@ function MenuItem({ icon, label, onClick, danger = false }: MenuItemProps) {
<button onClick={onClick} style={{ <button onClick={onClick} style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%', display: 'flex', alignItems: 'center', gap: 8, width: '100%',
padding: '7px 10px', background: 'none', border: 'none', cursor: 'pointer', padding: '7px 10px', background: 'none', border: 'none', cursor: 'pointer',
fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontFamily: 'inherit', borderRadius: 7, textAlign: 'left', fontSize: 12.5, fontFamily: 'inherit', borderRadius: 7, textAlign: 'left',
color: danger ? '#ef4444' : 'var(--text-secondary)', color: danger ? '#ef4444' : 'var(--text-secondary)',
}} }}
onMouseEnter={e => e.currentTarget.style.background = danger ? '#fef2f2' : 'var(--bg-tertiary)'} onMouseEnter={e => e.currentTarget.style.background = danger ? '#fef2f2' : 'var(--bg-tertiary)'}
@@ -0,0 +1,17 @@
import type { PackingState } from './usePackingListPanel'
export function PackingFilterTabs({ items, filter, setFilter, t }: PackingState) {
if (items.length === 0) return null
return (
<div style={{ display: 'flex', gap: 4, padding: '10px 0 0', flexShrink: 0 }}>
{[['alle', t('packing.filterAll')], ['offen', t('packing.filterOpen')], ['erledigt', t('packing.filterDone')]].map(([id, label]) => (
<button key={id} onClick={() => setFilter(id)} style={{
padding: '4px 12px', borderRadius: 99, border: 'none', cursor: 'pointer',
fontSize: 12, fontFamily: 'inherit', fontWeight: filter === id ? 600 : 400,
background: filter === id ? 'var(--text-primary)' : 'transparent',
color: filter === id ? 'var(--bg-primary)' : 'var(--text-muted)',
}}>{label}</button>
))}
</div>
)
}
@@ -17,9 +17,9 @@ export function PackingHeader(S: PackingState) {
<div style={{ display: 'flex', alignItems: inlineHeader ? 'flex-start' : 'center', justifyContent: 'space-between', gap: 14 }}> <div style={{ display: 'flex', alignItems: inlineHeader ? 'flex-start' : 'center', justifyContent: 'space-between', gap: 14 }}>
{inlineHeader ? ( {inlineHeader ? (
<div> <div>
<h2 style={{ margin: 0, fontSize: 'calc(18px * var(--fs-scale-subtitle, 1))', fontWeight: 700, color: 'var(--text-primary)' }}>{t('packing.title')}</h2> <h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>{t('packing.title')}</h2>
{items.length > 0 && ( {items.length > 0 && (
<p style={{ margin: '2px 0 0', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', color: 'var(--text-faint)' }}> <p style={{ margin: '2px 0 0', fontSize: 12.5, color: 'var(--text-faint)' }}>
{t('packing.progress', { packed: abgehakt, total: items.length, percent: fortschritt })} {t('packing.progress', { packed: abgehakt, total: items.length, percent: fortschritt })}
</p> </p>
)} )}
@@ -34,7 +34,7 @@ export function PackingHeader(S: PackingState) {
onChange={e => setSaveTemplateName(e.target.value)} onChange={e => setSaveTemplateName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleSaveAsTemplate(); if (e.key === 'Escape') { setShowSaveTemplate(false); setSaveTemplateName('') } }} onKeyDown={e => { if (e.key === 'Enter') handleSaveAsTemplate(); if (e.key === 'Escape') { setShowSaveTemplate(false); setSaveTemplateName('') } }}
placeholder={t('packing.templateName')} placeholder={t('packing.templateName')}
style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', padding: '5px 10px', borderRadius: 99, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit', width: 140, background: 'var(--bg-card)', color: 'var(--text-primary)' }} style={{ fontSize: 12, padding: '5px 10px', borderRadius: 99, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit', width: 140, background: 'var(--bg-card)', color: 'var(--text-primary)' }}
/> />
<button onClick={handleSaveAsTemplate} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: '#10b981' }}><Check size={14} /></button> <button onClick={handleSaveAsTemplate} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: '#10b981' }}><Check size={14} /></button>
<button onClick={() => { setShowSaveTemplate(false); setSaveTemplateName('') }} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)' }}><X size={14} /></button> <button onClick={() => { setShowSaveTemplate(false); setSaveTemplateName('') }} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)' }}><X size={14} /></button>
@@ -43,7 +43,7 @@ export function PackingHeader(S: PackingState) {
{inlineHeader && canEdit && ( {inlineHeader && canEdit && (
<button onClick={() => setShowImportModal(true)} style={{ <button onClick={() => setShowImportModal(true)} style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99, display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
border: '1px solid var(--border-primary)', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 500, cursor: 'pointer', border: '1px solid var(--border-primary)', fontSize: 12, fontWeight: 500, cursor: 'pointer',
fontFamily: 'inherit', background: 'var(--bg-card)', color: 'var(--text-muted)', fontFamily: 'inherit', background: 'var(--bg-card)', color: 'var(--text-muted)',
}}> }}>
<Upload size={12} /> <span className="hidden sm:inline">{t('packing.import')}</span> <Upload size={12} /> <span className="hidden sm:inline">{t('packing.import')}</span>
@@ -51,7 +51,7 @@ export function PackingHeader(S: PackingState) {
)} )}
{inlineHeader && canEdit && abgehakt > 0 && ( {inlineHeader && canEdit && abgehakt > 0 && (
<button onClick={handleClearChecked} style={{ <button onClick={handleClearChecked} style={{
fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', padding: '5px 10px', borderRadius: 99, border: '1px solid rgba(239,68,68,0.3)', fontSize: 11.5, padding: '5px 10px', borderRadius: 99, border: '1px solid rgba(239,68,68,0.3)',
background: 'rgba(239,68,68,0.1)', color: '#ef4444', cursor: 'pointer', fontFamily: 'inherit', background: 'rgba(239,68,68,0.1)', color: '#ef4444', cursor: 'pointer', fontFamily: 'inherit',
}}> }}>
<span className="hidden sm:inline">{t('packing.clearChecked', { count: abgehakt })}</span> <span className="hidden sm:inline">{t('packing.clearChecked', { count: abgehakt })}</span>
@@ -62,7 +62,7 @@ export function PackingHeader(S: PackingState) {
<div ref={templateDropdownRef} style={{ position: 'relative' }}> <div ref={templateDropdownRef} style={{ position: 'relative' }}>
<button onClick={() => setShowTemplateDropdown(v => !v)} disabled={applyingTemplate} style={{ <button onClick={() => setShowTemplateDropdown(v => !v)} disabled={applyingTemplate} style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99, display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
border: '1px solid', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', border: '1px solid', fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
background: showTemplateDropdown ? 'var(--text-primary)' : 'var(--bg-card)', background: showTemplateDropdown ? 'var(--text-primary)' : 'var(--bg-card)',
borderColor: showTemplateDropdown ? 'var(--text-primary)' : 'var(--border-primary)', borderColor: showTemplateDropdown ? 'var(--text-primary)' : 'var(--border-primary)',
color: showTemplateDropdown ? 'var(--bg-primary)' : 'var(--text-muted)', color: showTemplateDropdown ? 'var(--bg-primary)' : 'var(--text-muted)',
@@ -80,7 +80,7 @@ export function PackingHeader(S: PackingState) {
style={{ style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%', display: 'flex', alignItems: 'center', gap: 8, width: '100%',
padding: '8px 12px', borderRadius: 8, border: 'none', cursor: 'pointer', padding: '8px 12px', borderRadius: 8, border: 'none', cursor: 'pointer',
background: 'transparent', fontFamily: 'inherit', fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-primary)', background: 'transparent', fontFamily: 'inherit', fontSize: 12, color: 'var(--text-primary)',
transition: 'background 0.1s', transition: 'background 0.1s',
}} }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'} onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
@@ -89,7 +89,7 @@ export function PackingHeader(S: PackingState) {
<Package size={13} className="text-content-faint" /> <Package size={13} className="text-content-faint" />
<div style={{ flex: 1, textAlign: 'left' }}> <div style={{ flex: 1, textAlign: 'left' }}>
<div style={{ fontWeight: 600 }}>{tmpl.name}</div> <div style={{ fontWeight: 600 }}>{tmpl.name}</div>
<div style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}>{tmpl.item_count} {t('admin.packingTemplates.items')}</div> <div style={{ fontSize: 10, color: 'var(--text-faint)' }}>{tmpl.item_count} {t('admin.packingTemplates.items')}</div>
</div> </div>
</button> </button>
))} ))}
@@ -100,7 +100,7 @@ export function PackingHeader(S: PackingState) {
{inlineHeader && canEdit && isAdmin && items.length > 0 && !showSaveTemplate && ( {inlineHeader && canEdit && isAdmin && items.length > 0 && !showSaveTemplate && (
<button onClick={() => setShowSaveTemplate(true)} style={{ <button onClick={() => setShowSaveTemplate(true)} style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99, display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
border: '1px solid var(--border-primary)', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', border: '1px solid var(--border-primary)', fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
background: 'var(--bg-card)', color: 'var(--text-muted)', background: 'var(--bg-card)', color: 'var(--text-muted)',
}}> }}>
<FolderPlus size={12} /> <span className="hidden sm:inline">{t('packing.saveAsTemplate')}</span> <FolderPlus size={12} /> <span className="hidden sm:inline">{t('packing.saveAsTemplate')}</span>
@@ -110,7 +110,7 @@ export function PackingHeader(S: PackingState) {
<button onClick={() => setShowBagModal(true)} className="xl:!hidden" <button onClick={() => setShowBagModal(true)} className="xl:!hidden"
style={{ style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99, display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
border: '1px solid', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', border: '1px solid', fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
background: showBagModal ? 'var(--text-primary)' : 'var(--bg-card)', background: showBagModal ? 'var(--text-primary)' : 'var(--bg-card)',
borderColor: showBagModal ? 'var(--text-primary)' : 'var(--border-primary)', borderColor: showBagModal ? 'var(--text-primary)' : 'var(--border-primary)',
color: showBagModal ? 'var(--bg-primary)' : 'var(--text-muted)', color: showBagModal ? 'var(--bg-primary)' : 'var(--text-muted)',
@@ -127,7 +127,7 @@ export function PackingHeader(S: PackingState) {
{fortschritt === 100 ? ( {fortschritt === 100 ? (
<div style={{ <div style={{
display: 'flex', alignItems: 'center', gap: 8, display: 'flex', alignItems: 'center', gap: 8,
fontSize: 'calc(16px * var(--fs-scale-subtitle, 1))', fontWeight: 700, color: '#10b981', fontSize: 16, fontWeight: 700, color: '#10b981',
letterSpacing: '-0.01em', flexShrink: 0, letterSpacing: '-0.01em', flexShrink: 0,
}}> }}>
<CheckCheck size={18} strokeWidth={2.5} /> <CheckCheck size={18} strokeWidth={2.5} />
@@ -137,17 +137,17 @@ export function PackingHeader(S: PackingState) {
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, flexShrink: 0 }}> <div style={{ display: 'flex', alignItems: 'baseline', gap: 8, flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'baseline' }}> <div style={{ display: 'flex', alignItems: 'baseline' }}>
<span style={{ <span style={{
fontSize: 'calc(22px * var(--fs-scale-title, 1))', fontWeight: 700, color: 'var(--text-primary)', fontSize: 22, fontWeight: 700, color: 'var(--text-primary)',
fontVariantNumeric: 'tabular-nums', letterSpacing: '-0.02em', fontVariantNumeric: 'tabular-nums', letterSpacing: '-0.02em',
lineHeight: 1, lineHeight: 1,
}}>{abgehakt}</span> }}>{abgehakt}</span>
<span style={{ <span style={{
fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500, color: 'var(--text-faint)', fontSize: 14, fontWeight: 500, color: 'var(--text-faint)',
fontVariantNumeric: 'tabular-nums', lineHeight: 1, marginLeft: 1, fontVariantNumeric: 'tabular-nums', lineHeight: 1, marginLeft: 1,
}}>/{items.length}</span> }}>/{items.length}</span>
</div> </div>
<span style={{ <span style={{
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, padding: '2px 7px', fontSize: 11, fontWeight: 600, padding: '2px 7px',
borderRadius: 99, background: 'var(--bg-tertiary)', borderRadius: 99, background: 'var(--bg-tertiary)',
color: 'var(--text-muted)', color: 'var(--text-muted)',
fontVariantNumeric: 'tabular-nums', fontVariantNumeric: 'tabular-nums',
@@ -195,7 +195,7 @@ export function PackingHeader(S: PackingState) {
type="text" value={newCatName} onChange={e => setNewCatName(e.target.value)} type="text" value={newCatName} onChange={e => setNewCatName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleAddNewCategory(); if (e.key === 'Escape') { setAddingCategory(false); setNewCatName('') } }} onKeyDown={e => { if (e.key === 'Enter') handleAddNewCategory(); if (e.key === 'Escape') { setAddingCategory(false); setNewCatName('') } }}
placeholder={t('packing.newCategoryPlaceholder')} placeholder={t('packing.newCategoryPlaceholder')}
style={{ flex: 1, padding: '8px 12px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 'calc(13.5px * var(--fs-scale-body, 1))', fontFamily: 'inherit', outline: 'none', color: 'var(--text-primary)' }} style={{ flex: 1, padding: '8px 12px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 13.5, fontFamily: 'inherit', outline: 'none', color: 'var(--text-primary)' }}
/> />
<button onClick={handleAddNewCategory} disabled={!newCatName.trim()} <button onClick={handleAddNewCategory} disabled={!newCatName.trim()}
style={{ padding: '8px 12px', borderRadius: 10, border: 'none', background: newCatName.trim() ? 'var(--text-primary)' : 'var(--border-primary)', color: 'var(--bg-primary)', cursor: newCatName.trim() ? 'pointer' : 'default', display: 'flex', alignItems: 'center' }}> style={{ padding: '8px 12px', borderRadius: 10, border: 'none', background: newCatName.trim() ? 'var(--text-primary)' : 'var(--border-primary)', color: 'var(--bg-primary)', cursor: newCatName.trim() ? 'pointer' : 'default', display: 'flex', alignItems: 'center' }}>
@@ -208,7 +208,7 @@ export function PackingHeader(S: PackingState) {
</div> </div>
) : ( ) : (
<button onClick={() => setAddingCategory(true)} <button onClick={() => setAddingCategory(true)}
style={{ display: 'flex', alignItems: 'center', gap: 6, width: '100%', padding: '9px 14px', borderRadius: 10, border: '1px dashed var(--border-primary)', background: 'none', cursor: 'pointer', fontSize: 'calc(13px * var(--fs-scale-body, 1))', color: 'var(--text-faint)', fontFamily: 'inherit', transition: 'all 0.15s' }} style={{ display: 'flex', alignItems: 'center', gap: 6, width: '100%', padding: '9px 14px', borderRadius: 10, border: '1px dashed var(--border-primary)', background: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-faint)', fontFamily: 'inherit', transition: 'all 0.15s' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--text-muted)'; e.currentTarget.style.color = 'var(--text-secondary)' }} onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--text-muted)'; e.currentTarget.style.color = 'var(--text-secondary)' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.color = 'var(--text-faint)' }}> onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.color = 'var(--text-faint)' }}>
<FolderPlus size={14} /> {t('packing.addCategory')} <FolderPlus size={14} /> {t('packing.addCategory')}

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