mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-07-09 15:05:59 +00:00
Compare commits
291 Commits
438d4fc400
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 69537d047d | |||
| 68fae2d0fa | |||
| 04a6bba2f0 | |||
| 0c66834872 | |||
| a5522e99d8 | |||
| 6f20c11dff | |||
| 3ef122c843 | |||
| 094622f06d | |||
| 90a2833011 | |||
| d320007627 | |||
| a2d80e8752 | |||
| 634210f2b1 | |||
| eeba57fa67 | |||
| 91c9909dc4 | |||
| c3b14467d8 | |||
| 1cabbec11a | |||
| 6ae47cbdf5 | |||
| 91217e83fa | |||
| d110f8f0ce | |||
| 8b801af94a | |||
| b16a7d7b77 | |||
| 64136484ee | |||
| 16e27926c5 | |||
| cf9be554ac | |||
| 39f27ef55d | |||
| 3d714ff04c | |||
| c3c70bcf9d | |||
| bc56c2dcc1 | |||
| aec9c4f97c | |||
| a4c0a79956 | |||
| 12745cd03c | |||
| 3c507c2314 | |||
| aad99e1c30 | |||
| 2a88b0c711 | |||
| 73e9ccfd6d | |||
| ef5f8b9d38 | |||
| 332e5c3c42 | |||
| 45ef63cbc1 | |||
| 3fdc61c8fa | |||
| 1127700219 | |||
| 17f3c86967 | |||
| 8c9476e487 | |||
| 447cb31e7f | |||
| 29c2979da4 | |||
| 5d52d089db | |||
| 7dabfa3779 | |||
| 7cd5a5d04c | |||
| a7dc4fbfe7 | |||
| a920c6f72a | |||
| 1cee4e93b1 | |||
| 660e4b9dfc | |||
| af73943be8 | |||
| f61e144e11 | |||
| f7c54d3d90 | |||
| 02c52e63a0 | |||
| de49cde363 | |||
| 5e3f7095a5 | |||
| 380641f4fd | |||
| 452360683c | |||
| 132ddcad16 | |||
| 2b0815be9f | |||
| f4dbe02702 | |||
| 8ffdb9e153 | |||
| 440bb8497f | |||
| fab288a889 | |||
| 5b9723cf8d | |||
| 9a823c0bc2 | |||
| c1eaa91e50 | |||
| c7998e926a | |||
| 04415be3bc | |||
| 06b94c14a5 | |||
| 5a007a1f0e | |||
| 3c87d37cd9 | |||
| f2304e076b | |||
| 216d5e99e1 | |||
| 45f1ff7799 | |||
| 268ed419b2 | |||
| bd2d91845b | |||
| 8eb5e19fea | |||
| 5f77fa5cca | |||
| bfc857d246 | |||
| 05269f0710 | |||
| 5152a5849f | |||
| 1fdc19712f | |||
| a83ecc4ffb | |||
| 60cbd20327 | |||
| 75e3bb3985 | |||
| 819aa793ae | |||
| 3686792ce8 | |||
| 2af292bd64 | |||
| 33fcbba414 | |||
| 3011cf6c2f | |||
| 7d87d87eec | |||
| 8014264b21 | |||
| baf156a36b | |||
| 59134ae38e | |||
| 6cdcc82d9c | |||
| 1e0feb93dd | |||
| f2972481fe | |||
| b9da2494d4 | |||
| fd5258129a | |||
| 56fee19d64 | |||
| 6be8a67c82 | |||
| 3198fd35eb | |||
| ef3f043e00 | |||
| 6a3e6ae083 | |||
| b8ceb081fa | |||
| 0c484959ce | |||
| f97e284592 | |||
| 0910c90e7f | |||
| de4077d461 | |||
| 68e101ed37 | |||
| 25b8d28574 | |||
| 74cace7c8f | |||
| 4e478722c3 | |||
| 5f9da5b72f | |||
| 783332cc0e | |||
| ec8509f2de | |||
| 37ef3a9d18 | |||
| a54503f26d | |||
| 4abb38b517 | |||
| 743b724cbc | |||
| 04f2ec72c6 | |||
| 7673aa52f2 | |||
| e56a901d82 | |||
| 641711322e | |||
| fac2393388 | |||
| 7eac5a5a02 | |||
| d6ed7a60a0 | |||
| ad64df42ed | |||
| 4af35b162e | |||
| 20c1858b23 | |||
| e986c9ab27 | |||
| 61ffdb553e | |||
| 1abc9b2bc7 | |||
| 8713443665 | |||
| c92c02e1b8 | |||
| 993d9bf713 | |||
| c7e4b2781b | |||
| a88cd772cf | |||
| 98d11d4267 | |||
| 6707dac4a9 | |||
| c552472b63 | |||
| 5fd66f4833 | |||
| 50609b078a | |||
| 42b45dcd82 | |||
| 9dd9057b7b | |||
| 23987c76bb | |||
| 7173e82fe8 | |||
| 72dfa2c60c | |||
| d19305bda4 | |||
| 7aa2f6e4f2 | |||
| 3e64cb86a6 | |||
| e4efcf0840 | |||
| e34f40b686 | |||
| 3701ab6cad | |||
| e91f592f22 | |||
| 1cc69fc22a | |||
| 4d131db9af | |||
| f5d03e7213 | |||
| 891171ce6c | |||
| 720edce2ee | |||
| b27793f99a | |||
| 813db0ca6e | |||
| 741639edf0 | |||
| bb8f4d4e5e | |||
| fac043c691 | |||
| a3f395e5ac | |||
| b6a414b79f | |||
| 200108b76a | |||
| a7334a9060 | |||
| 2cda779bc5 | |||
| 4742915389 | |||
| d6bba454e0 | |||
| 6f42e84183 | |||
| cb3f9f0021 | |||
| f24d44b4a3 | |||
| af90ba0911 | |||
| 8c941b52f9 | |||
| c7e8a5614d | |||
| c10b9cc202 | |||
| d1e024277f | |||
| 172cff57a2 | |||
| 0d6737726d | |||
| 6996a67670 | |||
| 84adc28684 | |||
| f206fa6dff | |||
| c3b3c278b8 | |||
| d09a62fcc8 | |||
| f4b2143a59 | |||
| 33f554b1bf | |||
| fc1f29bb29 | |||
| 01e5859564 | |||
| 6a70f4fc41 | |||
| 27fbc241e8 | |||
| 574c54c16c | |||
| 0cb0567d28 | |||
| 76447f4a73 | |||
| 55ff5c03dd | |||
| 3277965426 | |||
| d95d26e493 | |||
| 4abe96fe01 | |||
| 7bac753ff3 | |||
| 743397994e | |||
| 459426ed43 | |||
| b3fa87bdd6 | |||
| 519dc3b0d8 | |||
| c1d61c98f0 | |||
| c7f5694f63 | |||
| d0b4052c5d | |||
| 1c81e8b959 | |||
| 8f1c99a07a | |||
| 5fdd4aa153 | |||
| 22801938b5 | |||
| 8640100312 | |||
| e666313865 | |||
| aa72d527c9 | |||
| 684ac3b442 | |||
| f049229e25 | |||
| 38565c3c6d | |||
| a1cbc11169 | |||
| b859ae8b00 | |||
| ae14a6c860 | |||
| 41c541828f | |||
| 37f1fff367 | |||
| 0c1c534435 | |||
| 0631e34a79 | |||
| 8a013f6fa9 | |||
| 7c3440f139 | |||
| 4ceea09e31 | |||
| 03cdb4d276 | |||
| f0877a2e7d | |||
| aa91f009ad | |||
| 2277f28a57 | |||
| 1ec2d62b1c | |||
| 649735726f | |||
| 4e91fbca48 | |||
| 4cb9b18cc6 | |||
| f3b54166fb | |||
| 8c63235cd2 | |||
| 3554fde8d6 | |||
| eb0ab4001d | |||
| 497d8e854f | |||
| e6fe14cac2 | |||
| 2a8caf6e7d | |||
| 005e0c109d | |||
| e54ea2f17d | |||
| 544a76d2da | |||
| a5ba246cb8 | |||
| 0b2780ead2 | |||
| 91fcaa50f6 | |||
| 9669642c62 | |||
| 7531badbe8 | |||
| 424018fc66 | |||
| 9d8af4b357 | |||
| 5b3f77f11d | |||
| e04cf85bef | |||
| 3d65bb0c12 | |||
| 94dca8cad7 | |||
| b1145e7e0a | |||
| 382ec37142 | |||
| 92e3ebb4d5 | |||
| 49fb2fded2 | |||
| 4cd4c9c8d8 | |||
| 6cc8908f87 | |||
| 68f48bc070 | |||
| 76d8abb44d | |||
| 91c350c946 | |||
| 1e4a9a95c2 | |||
| fe54f45d62 | |||
| b36c9931b3 | |||
| c1fe1d2d6a | |||
| ebbbf91d60 | |||
| 328d1c9468 | |||
| 48ebdff2d5 | |||
| 457a42b229 | |||
| 7df5956920 | |||
| 0d50d5d7c3 | |||
| 4a3aa478c6 | |||
| abee2fc088 | |||
| e40465ba1f | |||
| 8dab26fe7b | |||
| 7459067b2e | |||
| a2c552f04d | |||
| 27762458e6 | |||
| adbe15abc4 | |||
| 982b99f0f6 | |||
| 6a797a39ae | |||
| d2cd317070 | |||
| 6ab6d79494 | |||
| d35972db39 |
+1
-1
@@ -30,8 +30,8 @@ Thumbs.db
|
||||
sonar-project.properties
|
||||
server/tests/
|
||||
server/vitest.config.ts
|
||||
server/reset-admin.js
|
||||
**/*.test.ts
|
||||
**/*.spec.ts
|
||||
wiki/
|
||||
scripts/
|
||||
charts/
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
name: Publish plugin-sdk to npm
|
||||
|
||||
# Publishes trek-plugin-sdk when a tag like `plugin-sdk-v1.2.0` is pushed.
|
||||
# One-time setup: add an npm automation token as the repo secret NPM_TOKEN.
|
||||
# The package's prepublishOnly hook builds + tests before publishing.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'plugin-sdk-v*'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: plugin-sdk
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
# 22 matches the TREK server runtime and has node:sqlite, which the
|
||||
# dev-server tests exercise.
|
||||
node-version: 22
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
- run: npm ci
|
||||
- run: npm publish
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
+3
-1
@@ -65,4 +65,6 @@ coverage
|
||||
test-data
|
||||
|
||||
.run
|
||||
.full-review
|
||||
.full-review
|
||||
# Wiki offline snapshot is baked in at build, not committed (duplicates wiki/)
|
||||
server/assets/wiki/
|
||||
|
||||
+5
-15
@@ -46,23 +46,11 @@ COPY package.json package-lock.json ./
|
||||
COPY shared/package.json ./shared/
|
||||
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 && \
|
||||
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 && \
|
||||
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; \
|
||||
fi && \
|
||||
ln -sf "$(find /usr/lib -name kitinerary-extractor -type f | head -1)" /usr/local/bin/kitinerary-extractor; \
|
||||
apt-get purge -y python3 build-essential && \
|
||||
apt-get autoremove -y && \
|
||||
rm -rf /var/lib/apt/lists/* /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
|
||||
@@ -89,6 +77,8 @@ COPY server/tsconfig.json ./server/
|
||||
# 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.
|
||||
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=client-builder /app/client/dist ./server/public
|
||||
COPY --from=client-builder /app/client/public/fonts ./server/public/fonts
|
||||
|
||||
@@ -49,12 +49,12 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
|
||||
<div align="center">
|
||||
<a href="docs/screenshots/dashboard.png"><img src="docs/screenshots/dashboard.png" alt="Dashboard" width="49%" /></a>
|
||||
<a href="docs/screenshots/trip-planner.png"><img src="docs/screenshots/trip-planner.png" alt="Trip planner with 3D map" width="49%" /></a>
|
||||
<a href="docs/screenshots/trip-planner.png"><img src="docs/screenshots/trip-planner.png" alt="Trip planner · day plan & route" width="49%" /></a>
|
||||
<a href="docs/screenshots/journey.png"><img src="docs/screenshots/journey.png" alt="Journey journal" width="49%" /></a>
|
||||
<a href="docs/screenshots/budget.png"><img src="docs/screenshots/budget.png" alt="Costs · expense splitting" width="49%" /></a>
|
||||
<a href="docs/screenshots/atlas.png"><img src="docs/screenshots/atlas.png" alt="Atlas · visited countries" width="49%" /></a>
|
||||
<a href="docs/screenshots/vacay.png"><img src="docs/screenshots/vacay.png" alt="Vacay planner" width="49%" /></a>
|
||||
<a href="docs/screenshots/trip-iceland.png"><img src="docs/screenshots/trip-iceland.png" alt="Trip planner · day plan and route" width="49%" /></a>
|
||||
<a href="docs/screenshots/collections.png"><img src="docs/screenshots/collections.png" alt="Collections · saved place lists" width="49%" /></a>
|
||||
<a href="docs/screenshots/admin.png"><img src="docs/screenshots/admin.png" alt="Admin panel" width="49%" /></a>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<?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>
|
||||
+3
-1
@@ -39,7 +39,9 @@ See `values.yaml` for more options.
|
||||
|
||||
## Notes
|
||||
- Ingress is off by default. Enable and configure hosts for your domain.
|
||||
- PVCs require a default StorageClass or specify one as needed.
|
||||
- PVCs use the cluster's default StorageClass. Set `persistence.data.storageClassName` and/or `persistence.uploads.storageClassName` to bind a specific class.
|
||||
- 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.
|
||||
- `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.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: trek
|
||||
version: 3.1.1
|
||||
version: 3.1.4
|
||||
description: Minimal Helm chart for TREK app
|
||||
appVersion: "3.1.1"
|
||||
appVersion: "3.1.4"
|
||||
|
||||
@@ -21,3 +21,9 @@
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -70,3 +70,9 @@ data:
|
||||
{{- if .Values.env.MCP_RATE_LIMIT }}
|
||||
MCP_RATE_LIMIT: {{ .Values.env.MCP_RATE_LIMIT | quote }}
|
||||
{{- 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 }}
|
||||
|
||||
@@ -82,8 +82,16 @@ spec:
|
||||
periodSeconds: 10
|
||||
volumes:
|
||||
- name: data
|
||||
{{- if .Values.persistence.enabled }}
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ include "trek.fullname" . }}-data
|
||||
claimName: {{ default (printf "%s-data" (include "trek.fullname" .)) .Values.persistence.data.existingClaim }}
|
||||
{{- else }}
|
||||
emptyDir: {}
|
||||
{{- end }}
|
||||
- name: uploads
|
||||
{{- if .Values.persistence.enabled }}
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ include "trek.fullname" . }}-uploads
|
||||
claimName: {{ default (printf "%s-uploads" (include "trek.fullname" .)) .Values.persistence.uploads.existingClaim }}
|
||||
{{- else }}
|
||||
emptyDir: {}
|
||||
{{- end }}
|
||||
|
||||
@@ -1,26 +1,42 @@
|
||||
{{- if .Values.persistence.enabled }}
|
||||
{{- if and .Values.persistence.enabled (not .Values.persistence.data.existingClaim) }}
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ include "trek.fullname" . }}-data
|
||||
labels:
|
||||
app: {{ include "trek.name" . }}
|
||||
{{- with .Values.persistence.data.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
{{- with .Values.persistence.data.storageClassName }}
|
||||
storageClassName: {{ . | quote }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.persistence.data.size }}
|
||||
{{- end }}
|
||||
---
|
||||
{{- if and .Values.persistence.enabled (not .Values.persistence.uploads.existingClaim) }}
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ include "trek.fullname" . }}-uploads
|
||||
labels:
|
||||
app: {{ include "trek.name" . }}
|
||||
{{- with .Values.persistence.uploads.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
{{- with .Values.persistence.uploads.storageClassName }}
|
||||
storageClassName: {{ . | quote }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.persistence.uploads.size }}
|
||||
|
||||
@@ -67,6 +67,12 @@ env:
|
||||
# Max MCP API requests per user per minute. Defaults to 300.
|
||||
# MCP_MAX_SESSION_PER_USER: "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.
|
||||
@@ -95,11 +101,21 @@ existingSecret: ""
|
||||
existingSecretKey: ENCRYPTION_KEY
|
||||
|
||||
persistence:
|
||||
# When disabled, volumes fall back to an ephemeral emptyDir (data lost on pod restart).
|
||||
enabled: true
|
||||
data:
|
||||
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:
|
||||
size: 1Gi
|
||||
storageClassName: ""
|
||||
# Specify an existing PVC to bind. The other values are then ignored.
|
||||
existingClaim: ""
|
||||
annotations: {}
|
||||
|
||||
resources:
|
||||
requests:
|
||||
|
||||
+5
-1
@@ -5,6 +5,10 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<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 -->
|
||||
<meta name="theme-color" content="#09090b" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
@@ -13,7 +17,7 @@
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon-180x180.png" />
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/svg+xml" href="/icons/icon-dark.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/icons/icon.svg" />
|
||||
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
|
||||
+8
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trek/client",
|
||||
"version": "3.1.1",
|
||||
"version": "3.1.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -17,6 +17,8 @@
|
||||
"lint": "eslint .",
|
||||
"lint:check": "eslint .",
|
||||
"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:report": "playwright show-report",
|
||||
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.css\"",
|
||||
@@ -30,11 +32,14 @@
|
||||
"@trek/shared": "*",
|
||||
"axios": "^1.6.7",
|
||||
"dexie": "^4.4.2",
|
||||
"drag-drop-touch": "^1.3.1",
|
||||
"heic-to": "^1.4.2",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.344.0",
|
||||
"mapbox-gl": "^3.22.0",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"marked": "^18.0.0",
|
||||
"plyr": "^3.8.4",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-dropzone": "^14.4.1",
|
||||
@@ -47,6 +52,7 @@
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"topojson-client": "^3.1.0",
|
||||
"tz-lookup": "^6.1.25",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^4.5.2"
|
||||
},
|
||||
@@ -81,7 +87,7 @@
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^6.0.2",
|
||||
"typescript-eslint": "^8.58.2",
|
||||
"vite": "^8.0.16",
|
||||
"vite": "8.1.0",
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* 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 */
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,73 @@
|
||||
#!/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);
|
||||
}
|
||||
+73
-20
@@ -2,7 +2,10 @@ import React, { useEffect, ReactNode } from 'react'
|
||||
import { Routes, Route, Navigate, useLocation } from 'react-router-dom'
|
||||
import { useAuthStore } from './store/authStore'
|
||||
import { useSettingsStore } from './store/settingsStore'
|
||||
import { applyAppearance } from './theme/applyAppearance'
|
||||
import { useAddonStore } from './store/addonStore'
|
||||
import { usePluginStore } from './store/pluginStore'
|
||||
import PluginPage from './pages/PluginPage'
|
||||
import LoginPage from './pages/LoginPage'
|
||||
import ForgotPasswordPage from './pages/ForgotPasswordPage'
|
||||
import ResetPasswordPage from './pages/ResetPasswordPage'
|
||||
@@ -12,14 +15,19 @@ import FilesPage from './pages/FilesPage'
|
||||
import AdminPage from './pages/AdminPage'
|
||||
import SettingsPage from './pages/SettingsPage'
|
||||
import VacayPage from './pages/VacayPage'
|
||||
import HelpPage from './pages/HelpPage'
|
||||
import AtlasPage from './pages/AtlasPage'
|
||||
import JourneyPage from './pages/JourneyPage'
|
||||
import JourneyDetailPage from './pages/JourneyDetailPage'
|
||||
import CollectionsPage from './pages/CollectionsPage'
|
||||
import JourneyPublicPage from './pages/JourneyPublicPage'
|
||||
import SharedTripPage from './pages/SharedTripPage'
|
||||
import JoinTripPage from './pages/JoinTripPage'
|
||||
import InAppNotificationsPage from './pages/InAppNotificationsPage.tsx'
|
||||
import OAuthAuthorizePage from './pages/OAuthAuthorizePage'
|
||||
import { ToastContainer } from './components/shared/Toast'
|
||||
import SaveToCollectionModal from './components/Collections/SaveToCollectionModal'
|
||||
import BackgroundTasksWidget from './components/BackgroundTasks/BackgroundTasksWidget'
|
||||
import BottomNav from './components/Layout/BottomNav'
|
||||
import { TranslationProvider, useTranslation } from './i18n'
|
||||
import { authApi } from './api/client'
|
||||
@@ -105,6 +113,7 @@ export default function App() {
|
||||
const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled } = useAuthStore()
|
||||
const { loadSettings } = useSettingsStore()
|
||||
const { loadAddons } = useAddonStore()
|
||||
const { loadPlugins } = usePluginStore()
|
||||
|
||||
useEffect(() => {
|
||||
if (!location.pathname.startsWith('/shared/') && !location.pathname.startsWith('/public/') && !location.pathname.startsWith('/login')) {
|
||||
@@ -162,6 +171,7 @@ export default function App() {
|
||||
if (isAuthenticated) {
|
||||
loadSettings()
|
||||
loadAddons()
|
||||
loadPlugins()
|
||||
}
|
||||
}, [isAuthenticated])
|
||||
|
||||
@@ -174,30 +184,21 @@ export default function App() {
|
||||
const isSharedPage = location.pathname.startsWith('/shared/')
|
||||
|
||||
useEffect(() => {
|
||||
// Shared page always forces light mode
|
||||
if (isSharedPage) {
|
||||
document.documentElement.classList.remove('dark')
|
||||
const meta = document.querySelector('meta[name="theme-color"]')
|
||||
if (meta) meta.setAttribute('content', '#ffffff')
|
||||
return
|
||||
}
|
||||
|
||||
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 run = () =>
|
||||
applyAppearance({
|
||||
darkMode: settings.dark_mode,
|
||||
appearance: settings.appearance,
|
||||
isSharedPage,
|
||||
})
|
||||
run()
|
||||
// Re-resolve on OS theme change while in auto mode.
|
||||
if (!isSharedPage && settings.dark_mode === 'auto') {
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
applyDark(mq.matches)
|
||||
const handler = (e: MediaQueryListEvent) => applyDark(e.matches)
|
||||
const handler = () => run()
|
||||
mq.addEventListener('change', handler)
|
||||
return () => mq.removeEventListener('change', handler)
|
||||
}
|
||||
applyDark(mode === true || mode === 'dark')
|
||||
}, [settings.dark_mode, isSharedPage])
|
||||
}, [settings.dark_mode, settings.appearance, isSharedPage])
|
||||
|
||||
const isAuthPage = location.pathname.startsWith('/login')
|
||||
|| location.pathname.startsWith('/register')
|
||||
@@ -208,6 +209,8 @@ export default function App() {
|
||||
<TranslationProvider>
|
||||
{!isAuthPage && <SystemNoticeHost />}
|
||||
<ToastContainer />
|
||||
{!isAuthPage && <BackgroundTasksWidget />}
|
||||
{!isAuthPage && <SaveToCollectionModal />}
|
||||
<OfflineBanner />
|
||||
<Routes>
|
||||
<Route path="/" element={<RootRedirect />} />
|
||||
@@ -227,6 +230,32 @@ export default function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
{/* Trip invite link (#1143) — behind ProtectedRoute so an anonymous
|
||||
visitor is redirected to /login (never registration) and returns here. */}
|
||||
<Route
|
||||
path="/join/:token"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<JoinTripPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/help"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<HelpPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/help/:slug"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<HelpPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/trips/:id"
|
||||
element={
|
||||
@@ -259,6 +288,14 @@ export default function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/plugins/:pluginId"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<PluginPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/vacay"
|
||||
element={
|
||||
@@ -291,6 +328,22 @@ export default function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/collections"
|
||||
element={
|
||||
<ProtectedRoute addonId="collections">
|
||||
<CollectionsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/collections/:id"
|
||||
element={
|
||||
<ProtectedRoute addonId="collections">
|
||||
<CollectionsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/notifications"
|
||||
element={
|
||||
|
||||
+141
-17
@@ -15,7 +15,8 @@ import {
|
||||
type RegisterRequest, type LoginRequest, type ForgotPasswordRequest,
|
||||
type ResetPasswordRequest, type ChangePasswordRequest,
|
||||
type MfaVerifyLoginRequest, type MfaEnableRequest, type McpTokenCreateRequest,
|
||||
type TripAddMemberRequest, type AssignmentReorderRequest,
|
||||
type TripAddMemberRequest, type TripTransferOwnershipRequest,
|
||||
type TripCreateGuestRequest, type TripRenameGuestRequest, type AssignmentReorderRequest,
|
||||
type PackingReorderRequest, type PackingCreateBagRequest, type TodoReorderRequest,
|
||||
type TripCreateRequest, type TripUpdateRequest, type TripCopyRequest,
|
||||
type DayCreateRequest, type DayUpdateRequest, type DayReorderRequest,
|
||||
@@ -23,10 +24,11 @@ import {
|
||||
type ReservationCreateRequest, type ReservationUpdateRequest,
|
||||
type AccommodationCreateRequest, type AccommodationUpdateRequest,
|
||||
type BudgetCreateItemRequest, type BudgetUpdateItemRequest,
|
||||
type PackingCreateItemRequest, type PackingUpdateItemRequest,
|
||||
type PackingCreateItemRequest, type PackingUpdateItemRequest, type PackingSetSharingRequest,
|
||||
type TodoCreateItemRequest, type TodoUpdateItemRequest,
|
||||
type AssignmentCreateRequest, type AssignmentParticipantsRequest, type AssignmentTimeRequest,
|
||||
type PlaceBulkDeleteRequest,
|
||||
type PlaceBulkUpdateRequest,
|
||||
type DayNoteCreateRequest, type DayNoteUpdateRequest,
|
||||
type PackingImportRequest, type PackingBagMembersRequest, type PackingUpdateBagRequest,
|
||||
type PackingCategoryAssigneesRequest,
|
||||
@@ -41,9 +43,10 @@ import {
|
||||
type BookingImportPreviewItem,
|
||||
type BookingImportPreviewResponse,
|
||||
type BookingImportConfirmResponse,
|
||||
type BookingImportMode,
|
||||
} from '@trek/shared'
|
||||
import { getSocketId } from './websocket'
|
||||
import { isReachable, probeNow } from '../sync/connectivity'
|
||||
import { probeNow } from '../sync/connectivity'
|
||||
|
||||
/**
|
||||
* Validate a response payload against its @trek/shared Zod schema — but only in
|
||||
@@ -100,6 +103,7 @@ const RATE_LIMIT_MESSAGES: Record<string, string> = {
|
||||
ja: '試行回数が多すぎます。時間をおいて再度お試しください。',
|
||||
ko: '시도 횟수가 너무 많습니다. 잠시 후 다시 시도해 주세요.',
|
||||
uk: 'Занадто багато спроб. Спробуйте пізніше.',
|
||||
sv: 'För många försök. Prova igen senare.',
|
||||
}
|
||||
|
||||
function translateRateLimit(): string {
|
||||
@@ -174,13 +178,17 @@ apiClient.interceptors.response.use(
|
||||
// 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.
|
||||
if (!error.response && navigator.onLine) {
|
||||
await probeNow()
|
||||
// Both the original request and the health probe failed while the device
|
||||
// has a network interface. This matches the proxy-auth-challenge pattern
|
||||
// (CF Access / Pangolin intercept all requests and CORS-block XHR).
|
||||
// Guard with sessionStorage to prevent reload loops (server genuinely
|
||||
// down would also land here, but only reloads once).
|
||||
if (!isReachable()) {
|
||||
// Only an actual edge-proxy auth wall warrants tearing down the SW to
|
||||
// reauth: a reachable proxy (CF Access / Pangolin) that intercepts /api
|
||||
// with a cross-origin redirect or an HTML login page. A genuine offline
|
||||
// boot ALSO lands here — navigator.onLine reflects a network interface,
|
||||
// not reachability, and is routinely true on mobile while offline. So
|
||||
// gate strictly on a positive proxy signal; on plain offline do nothing
|
||||
// and let the request reject so the cached shell + IndexedDB serve the
|
||||
// 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
|
||||
if (!isAuthPublicPath(pathname) && !sessionStorage.getItem('proxy_reauth_attempted')) {
|
||||
sessionStorage.setItem('proxy_reauth_attempted', '1')
|
||||
@@ -327,11 +335,16 @@ export const tripsApi = {
|
||||
update: (id: number | string, data: TripUpdateRequest) => apiClient.put(`/trips/${id}`, data).then(r => r.data),
|
||||
delete: (id: number | string) => apiClient.delete(`/trips/${id}`).then(r => r.data),
|
||||
uploadCover: (id: number | string, formData: FormData) => apiClient.post(`/trips/${id}/cover`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data),
|
||||
searchCoverImages: (query: string) => apiClient.get('/trips/cover-images/search', { params: { query } }).then(r => r.data),
|
||||
archive: (id: number | string) => apiClient.put(`/trips/${id}`, { is_archived: true }).then(r => r.data),
|
||||
unarchive: (id: number | string) => apiClient.put(`/trips/${id}`, { is_archived: false }).then(r => r.data),
|
||||
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),
|
||||
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),
|
||||
bundle: (id: number | string) => apiClient.get(`/trips/${id}/bundle`).then(r => r.data),
|
||||
}
|
||||
@@ -372,6 +385,8 @@ export const placesApi = {
|
||||
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url, enrich } satisfies PlaceImportListRequest).then(r => r.data),
|
||||
bulkDelete: (tripId: number | string, ids: number[]) =>
|
||||
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 = {
|
||||
@@ -393,6 +408,10 @@ export const packingApi = {
|
||||
update: (tripId: number | string, id: number, data: PackingUpdateItemRequest) => apiClient.put(`/trips/${tripId}/packing/${id}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/packing/${id}`).then(r => r.data),
|
||||
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/packing/reorder`, { orderedIds } 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),
|
||||
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),
|
||||
@@ -441,6 +460,52 @@ export const adminApi = {
|
||||
updateOidc: (data: Record<string, unknown>) => apiClient.put('/admin/oidc', data).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),
|
||||
plugins: () => apiClient.get('/admin/plugins').then(r => r.data),
|
||||
pluginBrowse: () => apiClient.get('/admin/plugins/registry').then(r => r.data),
|
||||
pluginDetail: (id: string) => apiClient.get(`/admin/plugins/registry/${encodeURIComponent(id)}`).then(r => r.data),
|
||||
pluginInstall: (id: string, version?: string) => apiClient.post('/admin/plugins/install', { id, version }).then(r => r.data),
|
||||
pluginActivate: (id: string, consent?: boolean) => apiClient.post(`/admin/plugins/${id}/activate`, consent ? { consent: true } : {}).then(r => r.data),
|
||||
pluginDeactivate: (id: string) => apiClient.post(`/admin/plugins/${id}/deactivate`).then(r => r.data),
|
||||
pluginUpdate: (id: string) => apiClient.post(`/admin/plugins/${id}/update`).then(r => r.data),
|
||||
pluginUninstall: (id: string, deleteData: boolean) => apiClient.post(`/admin/plugins/${id}/uninstall`, { deleteData }).then(r => r.data),
|
||||
pluginRescan: () => apiClient.post('/admin/plugins/rescan').then(r => r.data),
|
||||
pluginErrors: (id: string) => apiClient.get(`/admin/plugins/${id}/errors`).then(r => r.data),
|
||||
pluginAudit: (id: string) => apiClient.get(`/admin/plugins/${id}/audit`).then(r => r.data),
|
||||
// Local LLM (Ollama) management for the AI-parsing addon.
|
||||
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),
|
||||
getBagTracking: () => apiClient.get('/admin/bag-tracking').then(r => r.data),
|
||||
updateBagTracking: (enabled: boolean) => apiClient.put('/admin/bag-tracking', { enabled }).then(r => r.data),
|
||||
@@ -464,7 +529,8 @@ export const adminApi = {
|
||||
updateTemplateItem: (templateId: number, itemId: number, data: { name: string }) => apiClient.put(`/admin/packing-templates/${templateId}/items/${itemId}`, data).then(r => r.data),
|
||||
deleteTemplateItem: (templateId: number, itemId: number) => apiClient.delete(`/admin/packing-templates/${templateId}/items/${itemId}`).then(r => r.data),
|
||||
listInvites: () => apiClient.get('/admin/invites').then(r => r.data),
|
||||
createInvite: (data: { max_uses: number; expires_in_days?: number }) => apiClient.post('/admin/invites', data).then(r => r.data),
|
||||
listInviteTrips: () => apiClient.get('/admin/invites/trips').then(r => r.data),
|
||||
createInvite: (data: { max_uses: number; expires_in_days?: number; trip_id?: number | null }) => apiClient.post('/admin/invites', data).then(r => r.data),
|
||||
deleteInvite: (id: number) => apiClient.delete(`/admin/invites/${id}`).then(r => r.data),
|
||||
auditLog: (params?: { limit?: number; offset?: number }) =>
|
||||
apiClient.get('/admin/audit-log', { params }).then(r => r.data),
|
||||
@@ -487,6 +553,14 @@ export const addonsApi = {
|
||||
enabled: () => apiClient.get('/addons').then(r => r.data),
|
||||
}
|
||||
|
||||
export const pluginsApi = {
|
||||
// Active plugins the client renders (page nav entries, dashboard widgets).
|
||||
active: () => apiClient.get('/plugins').then(r => r.data),
|
||||
// Call one of a plugin's own declared routes through the host proxy.
|
||||
invoke: (id: string, sub: string, init?: { method?: string; body?: unknown }) =>
|
||||
apiClient.request({ url: `/plugins/${id}${sub}`, method: init?.method || 'GET', data: init?.body }).then(r => r.data),
|
||||
}
|
||||
|
||||
export const airtrailApi = {
|
||||
getSettings: () => apiClient.get('/integrations/airtrail/settings').then(r => r.data),
|
||||
saveSettings: (data: { url: string; apiKey?: string; allowInsecureTls?: boolean; writeEnabled?: boolean }) =>
|
||||
@@ -537,9 +611,16 @@ export const journeyApi = {
|
||||
onUploadProgress: opts?.onUploadProgress,
|
||||
signal: opts?.signal,
|
||||
}).then(r => r.data),
|
||||
addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}) } satisfies JourneyProviderPhotosRequest).then(r => r.data),
|
||||
uploadGalleryVideo: (journeyId: number, formData: FormData, opts?: { onUploadProgress?: (e: import('axios').AxiosProgressEvent) => void; idempotencyKey?: string; signal?: AbortSignal }) =>
|
||||
apiClient.post(`/journeys/${journeyId}/gallery/video`, formData, {
|
||||
headers: { 'Content-Type': undefined as any, ...(opts?.idempotencyKey ? { 'X-Idempotency-Key': opts.idempotencyKey } : {}) },
|
||||
timeout: 0,
|
||||
onUploadProgress: opts?.onUploadProgress,
|
||||
signal: opts?.signal,
|
||||
}).then(r => r.data),
|
||||
addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string, mediaTypes?: string[]) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}), ...(mediaTypes ? { media_types: mediaTypes } : {}) } satisfies JourneyProviderPhotosRequest).then(r => r.data),
|
||||
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
||||
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, 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),
|
||||
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),
|
||||
deleteGalleryPhoto: (journeyId: number, journeyPhotoId: number) => apiClient.delete(`/journeys/${journeyId}/gallery/${journeyPhotoId}`).then(r => r.data),
|
||||
@@ -624,17 +705,31 @@ export const reservationsApi = {
|
||||
update: (tripId: number | string, id: number, data: ReservationUpdateRequest) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/reservations/${id}`).then(r => r.data),
|
||||
updatePositions: (tripId: number | string, positions: { id: number; day_plan_position: number }[], dayId?: number) => apiClient.put(`/trips/${tripId}/reservations/positions`, { positions, day_id: dayId }).then(r => r.data),
|
||||
importBookingPreview: (tripId: number | string, files: File[]): Promise<BookingImportPreviewResponse> => {
|
||||
importBookingPreview: (tripId: number | string, files: File[], mode: BookingImportMode = 'no-ai'): Promise<BookingImportPreviewResponse> => {
|
||||
const fd = new FormData()
|
||||
for (const f of files) fd.append('files', f)
|
||||
return apiClient.post(`/trips/${tripId}/reservations/import/booking`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
|
||||
fd.append('mode', mode)
|
||||
// No client-side timeout: kitinerary + LLM extraction routinely exceeds the
|
||||
// global 8s default (a cold local model alone can take ~45s).
|
||||
return apiClient.post(`/trips/${tripId}/reservations/import/booking`, fd, { headers: { 'Content-Type': 'multipart/form-data' }, timeout: 0 }).then(r => r.data)
|
||||
},
|
||||
importBookingConfirm: (tripId: number | string, items: BookingImportPreviewItem[]): Promise<BookingImportConfirmResponse> =>
|
||||
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 = {
|
||||
features: (): Promise<{ bookingImport: boolean }> => apiClient.get('/health/features').then(r => r.data),
|
||||
features: (): Promise<{ bookingImport: boolean; aiParsing: boolean }> => apiClient.get('/health/features').then(r => r.data),
|
||||
}
|
||||
|
||||
export const weatherApi = {
|
||||
@@ -647,6 +742,17 @@ export const configApi = {
|
||||
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 = {
|
||||
get: () => apiClient.get('/settings').then(r => r.data),
|
||||
set: (key: string, value: unknown) => {
|
||||
@@ -726,6 +832,24 @@ export const shareApi = {
|
||||
getSharedTrip: (token: string) => apiClient.get(`/shared/${token}`).then(r => r.data),
|
||||
}
|
||||
|
||||
// Public transit routing (#1065) — Transitous/MOTIS proxied through the server.
|
||||
export const transitApi = {
|
||||
geocode: (q: string, opts?: { lang?: string; near?: string }) =>
|
||||
apiClient.get('/transit/geocode', { params: { q, lang: opts?.lang, near: opts?.near } }).then(r => r.data),
|
||||
plan: (params: { from: string; to: string; time?: string; arriveBy?: boolean; modes?: string; maxTransfers?: number }) =>
|
||||
apiClient.get('/transit/plan', { params }).then(r => r.data),
|
||||
}
|
||||
|
||||
// Trip invite links (#1143) — join a trip as an existing, logged-in user.
|
||||
export const tripInviteApi = {
|
||||
getLink: (tripId: number | string) => apiClient.get(`/trips/${tripId}/invite-link`).then(r => r.data),
|
||||
createLink: (tripId: number | string, expires_in_days?: number | null) =>
|
||||
apiClient.post(`/trips/${tripId}/invite-link`, { expires_in_days: expires_in_days ?? null }).then(r => r.data),
|
||||
deleteLink: (tripId: number | string) => apiClient.delete(`/trips/${tripId}/invite-link`).then(r => r.data),
|
||||
preview: (token: string) => apiClient.get(`/trip-invites/${token}`).then(r => r.data),
|
||||
accept: (token: string) => apiClient.post(`/trip-invites/${token}/accept`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const notificationsApi = {
|
||||
getPreferences: () => apiClient.get('/notifications/preferences').then(r => r.data),
|
||||
updatePreferences: (prefs: Record<string, Record<string, boolean>>) => apiClient.put('/notifications/preferences', prefs).then(r => r.data),
|
||||
@@ -753,4 +877,4 @@ export const inAppNotificationsApi = {
|
||||
apiClient.post(`/notifications/in-app/${id}/respond`, { response }).then(r => r.data),
|
||||
}
|
||||
|
||||
export default apiClient
|
||||
export default apiClient
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import apiClient from './client'
|
||||
import type { AxiosResponse } from 'axios'
|
||||
import type {
|
||||
CollectionListResponse,
|
||||
CollectionDetailResponse,
|
||||
CollectionSaveResult,
|
||||
CollectionMembership,
|
||||
CollectionCreateRequest,
|
||||
CollectionUpdateRequest,
|
||||
CollectionSavePlaceRequest,
|
||||
CollectionSaveFromTripRequest,
|
||||
CollectionPlaceUpdateRequest,
|
||||
CollectionCopyToTripRequest,
|
||||
CollectionInviteRequest,
|
||||
CollectionRole,
|
||||
CollectionInviteActionRequest,
|
||||
CollectionInviteCancelRequest,
|
||||
CollectionStatus,
|
||||
Collection,
|
||||
CollectionPlace,
|
||||
CollectionLabel,
|
||||
CollectionLabelCreateRequest,
|
||||
CollectionLabelUpdateRequest,
|
||||
} from '@trek/shared'
|
||||
|
||||
const ax = apiClient
|
||||
const base = '/addons/collections'
|
||||
|
||||
/** Query for the library-wide "is this place already saved?" lookup. */
|
||||
export interface MembershipQuery {
|
||||
google_place_id?: string
|
||||
google_ftid?: string
|
||||
name?: string
|
||||
lat?: number
|
||||
lng?: number
|
||||
}
|
||||
|
||||
export interface CopyToTripResult {
|
||||
copied: number
|
||||
skipped: { id: number; name: string }[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Axios calls for the Collections addon (/api/addons/collections). Mirrors the
|
||||
* vacayStore api shape — each method returns the unwrapped response body and
|
||||
* uses `satisfies` on the request payloads so the shared Zod request types stay
|
||||
* the single source of truth.
|
||||
*/
|
||||
export const collectionsApi = {
|
||||
list: (): Promise<CollectionListResponse> =>
|
||||
ax.get(base).then((r: AxiosResponse) => r.data),
|
||||
get: (id: number): Promise<CollectionDetailResponse> =>
|
||||
ax.get(`${base}/${id}`).then((r: AxiosResponse) => r.data),
|
||||
create: (body: CollectionCreateRequest): Promise<{ collection: Collection }> =>
|
||||
ax.post(base, body satisfies CollectionCreateRequest).then((r: AxiosResponse) => r.data),
|
||||
update: (id: number, body: CollectionUpdateRequest): Promise<{ collection: Collection }> =>
|
||||
ax.patch(`${base}/${id}`, body satisfies CollectionUpdateRequest).then((r: AxiosResponse) => r.data),
|
||||
uploadCover: (id: number, formData: FormData): Promise<Collection> =>
|
||||
ax.post(`${base}/${id}/cover`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then((r: AxiosResponse) => r.data),
|
||||
remove: (id: number): Promise<unknown> =>
|
||||
ax.delete(`${base}/${id}`).then((r: AxiosResponse) => r.data),
|
||||
reorder: (orderedIds: number[]): Promise<unknown> =>
|
||||
ax.post(`${base}/reorder`, { orderedIds }).then((r: AxiosResponse) => r.data),
|
||||
|
||||
savePlace: (body: CollectionSavePlaceRequest): Promise<CollectionSaveResult> =>
|
||||
ax.post(`${base}/places`, body satisfies CollectionSavePlaceRequest).then((r: AxiosResponse) => r.data),
|
||||
saveFromTrip: (body: CollectionSaveFromTripRequest): Promise<CollectionSaveResult> =>
|
||||
ax.post(`${base}/places/from-trip`, body satisfies CollectionSaveFromTripRequest).then((r: AxiosResponse) => r.data),
|
||||
saveFromTripMany: (collectionId: number, tripId: number, placeIds: number[], force?: boolean): Promise<{ copied: number; skipped: { id: number; name: string }[] }> =>
|
||||
ax.post(`${base}/places/from-trip-many`, { collection_id: collectionId, source_trip_id: tripId, source_place_ids: placeIds, force }).then((r: AxiosResponse) => r.data),
|
||||
updatePlace: (pid: number, body: CollectionPlaceUpdateRequest): Promise<CollectionPlace> =>
|
||||
ax.patch(`${base}/places/${pid}`, body satisfies CollectionPlaceUpdateRequest).then((r: AxiosResponse) => r.data),
|
||||
setStatus: (pid: number, status: CollectionStatus): Promise<CollectionPlace> =>
|
||||
ax.post(`${base}/places/${pid}/status`, { status }).then((r: AxiosResponse) => r.data),
|
||||
deletePlace: (pid: number): Promise<unknown> =>
|
||||
ax.delete(`${base}/places/${pid}`).then((r: AxiosResponse) => r.data),
|
||||
deleteMany: (ids: number[]): Promise<unknown> =>
|
||||
ax.post(`${base}/places/delete-many`, { ids }).then((r: AxiosResponse) => r.data),
|
||||
|
||||
copyToTrip: (body: CollectionCopyToTripRequest): Promise<CopyToTripResult> =>
|
||||
ax.post(`${base}/copy-to-trip`, body satisfies CollectionCopyToTripRequest).then((r: AxiosResponse) => r.data),
|
||||
membership: (params: MembershipQuery): Promise<CollectionMembership> =>
|
||||
ax.get(`${base}/membership`, { params }).then((r: AxiosResponse) => r.data),
|
||||
|
||||
invite: (collectionId: number, userId: number, role?: CollectionRole): Promise<unknown> =>
|
||||
ax.post(`${base}/invite`, { collection_id: collectionId, user_id: userId, role } satisfies CollectionInviteRequest).then((r: AxiosResponse) => r.data),
|
||||
setMemberRole: (collectionId: number, userId: number, role: CollectionRole): Promise<unknown> =>
|
||||
ax.post(`${base}/members/role`, { collection_id: collectionId, user_id: userId, role }).then((r: AxiosResponse) => r.data),
|
||||
acceptInvite: (collectionId: number): Promise<unknown> =>
|
||||
ax.post(`${base}/invite/accept`, { collection_id: collectionId } satisfies CollectionInviteActionRequest).then((r: AxiosResponse) => r.data),
|
||||
declineInvite: (collectionId: number): Promise<unknown> =>
|
||||
ax.post(`${base}/invite/decline`, { collection_id: collectionId } satisfies CollectionInviteActionRequest).then((r: AxiosResponse) => r.data),
|
||||
cancelInvite: (collectionId: number, userId: number): Promise<unknown> =>
|
||||
ax.post(`${base}/invite/cancel`, { collection_id: collectionId, user_id: userId } satisfies CollectionInviteCancelRequest).then((r: AxiosResponse) => r.data),
|
||||
leave: (collectionId: number): Promise<unknown> =>
|
||||
ax.post(`${base}/leave`, { collection_id: collectionId }).then((r: AxiosResponse) => r.data),
|
||||
removeMember: (collectionId: number, userId: number): Promise<unknown> =>
|
||||
ax.post(`${base}/members/remove`, { collection_id: collectionId, user_id: userId }).then((r: AxiosResponse) => r.data),
|
||||
availableUsers: (id: number): Promise<{ users: { id: number; username: string }[] }> =>
|
||||
ax.get(`${base}/${id}/available-users`).then((r: AxiosResponse) => r.data),
|
||||
|
||||
createLabel: (collectionId: number, name: string, color?: string): Promise<CollectionLabel> =>
|
||||
ax.post(`${base}/labels`, { collection_id: collectionId, name, color } satisfies CollectionLabelCreateRequest).then((r: AxiosResponse) => r.data),
|
||||
updateLabel: (labelId: number, body: CollectionLabelUpdateRequest): Promise<CollectionLabel> =>
|
||||
ax.patch(`${base}/labels/${labelId}`, body satisfies CollectionLabelUpdateRequest).then((r: AxiosResponse) => r.data),
|
||||
deleteLabel: (labelId: number): Promise<unknown> =>
|
||||
ax.delete(`${base}/labels/${labelId}`).then((r: AxiosResponse) => r.data),
|
||||
assignLabels: (labelIds: number[], placeIds: number[]): Promise<{ changed: number }> =>
|
||||
ax.post(`${base}/labels/assign`, { label_ids: labelIds, place_ids: placeIds }).then((r: AxiosResponse) => r.data),
|
||||
unassignLabels: (labelIds: number[], placeIds: number[]): Promise<{ changed: number }> =>
|
||||
ax.post(`${base}/labels/unassign`, { label_ids: labelIds, place_ids: placeIds }).then((r: AxiosResponse) => r.data),
|
||||
}
|
||||
@@ -4,10 +4,11 @@ import { useTranslation } from '../../i18n'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane } from 'lucide-react'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane, Server, Cloud, Bookmark } from 'lucide-react'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
|
||||
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, Bookmark,
|
||||
}
|
||||
|
||||
function ImmichIcon({ size = 14 }: { size?: number }) {
|
||||
@@ -298,7 +299,12 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
</span>
|
||||
</div>
|
||||
{integrationAddons.map(addon => (
|
||||
<AddonRow key={addon.id} addon={addon} onToggle={handleToggle} t={t} />
|
||||
<div key={addon.id}>
|
||||
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
|
||||
{addon.id === 'llm_parsing' && addon.enabled && (
|
||||
<LlmParsingConfig addon={addon} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -309,6 +315,225 @@ 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 {
|
||||
addon: Addon
|
||||
onToggle: (addon: Addon) => void
|
||||
|
||||
@@ -0,0 +1,913 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
Blocks, AlertTriangle, PackageOpen, RefreshCw, Trash2, Download, Bug, X, ShieldCheck,
|
||||
ArrowUpCircle, Github, ExternalLink, ChevronDown, Check, Lock, Search,
|
||||
SlidersHorizontal, ArrowUpDown, CircleDot, MoreHorizontal, RotateCw, ArrowRight, Database, Users, LayoutDashboard,
|
||||
Radio, Luggage, Plane, Globe, Image, CalendarDays, Map, Bell, Cloud, Camera, Compass,
|
||||
BookOpen, Wallet, Puzzle,
|
||||
} from 'lucide-react'
|
||||
import { adminApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
import ToggleSwitch from '../Settings/ToggleSwitch'
|
||||
|
||||
/**
|
||||
* Admin → Plugins (#plugins). A full plugin-management surface: a segmented
|
||||
* Installed/Discover switch, a toolbar (search + type/status filters + sort), an
|
||||
* updates bar, tidy installed rows that surface each plugin's real reach as
|
||||
* capability chips, an App-Store-style registry grid, an enriched detail dialog,
|
||||
* and the update re-consent gate. Isolation health shows as a dot on the icon
|
||||
* tile; the security section at the bottom explains the model honestly.
|
||||
*/
|
||||
|
||||
interface PluginRow {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
type: string
|
||||
icon: string | null
|
||||
version: string | null
|
||||
status: string
|
||||
enabled: number
|
||||
last_error: string | null
|
||||
reviewed_at: string | null
|
||||
source_repo: string | null
|
||||
permissions: string
|
||||
capabilities: string
|
||||
}
|
||||
interface RegistryItem {
|
||||
id: string
|
||||
name: string
|
||||
author: string
|
||||
description: string
|
||||
repo: string
|
||||
homepage?: string | null
|
||||
type: string
|
||||
latest: string | null
|
||||
minTrekVersion: string | null
|
||||
reviewedAt: string | null
|
||||
screenshotUrl: string | null
|
||||
}
|
||||
interface RegistryDetail extends RegistryItem {
|
||||
size: number | null
|
||||
publishedAt: string | null
|
||||
manifest: {
|
||||
permissions: string[]
|
||||
egress: string[]
|
||||
settings: Array<{ key: string; label: string; inputType: string; scope: string; required: boolean }>
|
||||
license: string | null
|
||||
icon: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
type T = (k: string, p?: Record<string, unknown>) => string
|
||||
type TypeFilter = 'all' | 'widget' | 'page' | 'integration'
|
||||
type StatusFilter = 'all' | 'on' | 'off' | 'update' | 'err'
|
||||
type SortKey = 'name' | 'recent' | 'updates'
|
||||
|
||||
// Runtime health → dot colour on the icon tile.
|
||||
const HEALTH: Record<string, string> = {
|
||||
active: 'bg-success',
|
||||
starting: 'bg-info animate-pulse',
|
||||
error: 'bg-danger',
|
||||
inactive: 'bg-content-faint/60',
|
||||
disabled: 'bg-warning',
|
||||
incompatible: 'bg-warning',
|
||||
}
|
||||
|
||||
// Manifest `icon` is a lucide name; map the common ones, fall back to Blocks.
|
||||
const ICON_MAP: Record<string, React.ComponentType<{ size?: number; className?: string }>> = {
|
||||
Luggage, Plane, Globe, Image, CalendarDays, Map, Bell, Cloud, Camera, Compass, BookOpen, Wallet, Puzzle, Blocks,
|
||||
}
|
||||
|
||||
// Known permissions → human-readable i18n key; unknown ones render as raw code.
|
||||
const PERM_KEYS = [
|
||||
'db:own', 'db:read:trips', 'db:read:users', 'ws:broadcast:trip', 'ws:broadcast:user',
|
||||
'hook:photo-provider', 'hook:calendar-source', 'http:outbound',
|
||||
]
|
||||
|
||||
const KNOWN_TYPES = ['widget', 'page', 'integration']
|
||||
|
||||
function isNewer(a: string, b: string): boolean {
|
||||
const nums = (v: string) => v.split('-')[0].split('.').map(n => parseInt(n, 10) || 0)
|
||||
const pa = nums(a), pb = nums(b)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const x = pa[i] || 0, y = pb[i] || 0
|
||||
if (x !== y) return x > y
|
||||
}
|
||||
return !a.includes('-') && b.includes('-')
|
||||
}
|
||||
|
||||
function parseJson<T>(raw: string | null | undefined, fallback: T): T {
|
||||
try { const v = JSON.parse(raw || '') as T; return v ?? fallback } catch { return fallback }
|
||||
}
|
||||
|
||||
interface Cap { icon: React.ComponentType<{ size?: number; className?: string }>; label: string; net?: boolean }
|
||||
|
||||
// Turn a plugin's declared permissions + capabilities into the at-a-glance chips
|
||||
// that make its real reach legible without opening the detail dialog.
|
||||
function deriveCaps(perms: string[], caps: { widget?: { slot?: string } }, t: T): Cap[] {
|
||||
const out: Cap[] = []
|
||||
if (perms.includes('db:read:trips')) out.push({ icon: Database, label: t('admin.plugins.cap.readsTrips') })
|
||||
if (perms.includes('db:read:users')) out.push({ icon: Users, label: t('admin.plugins.cap.readsUsers') })
|
||||
if (caps.widget) out.push({ icon: LayoutDashboard, label: t(caps.widget.slot === 'hero' ? 'admin.plugins.cap.heroWidget' : 'admin.plugins.cap.widget') })
|
||||
if (perms.some(p => p.startsWith('ws:broadcast'))) out.push({ icon: Radio, label: t('admin.plugins.cap.realtime') })
|
||||
if (perms.includes('hook:photo-provider')) out.push({ icon: Image, label: t('admin.plugins.cap.photos') })
|
||||
if (perms.includes('hook:calendar-source')) out.push({ icon: CalendarDays, label: t('admin.plugins.cap.calendar') })
|
||||
for (const h of perms.filter(p => p.startsWith('http:outbound:')).map(p => p.slice('http:outbound:'.length)).filter(Boolean)) {
|
||||
out.push({ icon: ArrowRight, label: h, net: true })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function PluginIcon({ name, size = 20, className }: { name: string | null; size?: number; className?: string }) {
|
||||
const Icon = (name && Object.prototype.hasOwnProperty.call(ICON_MAP, name) && ICON_MAP[name]) || Blocks
|
||||
return <Icon size={size} className={className} />
|
||||
}
|
||||
|
||||
function ReviewedBadge({ t, compact }: { t: T; compact?: boolean }) {
|
||||
if (compact) return <ShieldCheck size={13} className="text-success shrink-0" aria-label={t('admin.plugins.reviewed')} />
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold bg-success-soft text-success">
|
||||
<ShieldCheck size={11} /> {t('admin.plugins.reviewed')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function TypeBadge({ type, t }: { type: string; t: T }) {
|
||||
return (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-md text-[10px] font-semibold uppercase tracking-wide bg-accent-subtle text-content-muted">
|
||||
{KNOWN_TYPES.includes(type) ? t(`admin.plugins.type.${type}` as never) : type}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AdminPluginsPanel() {
|
||||
const { t, locale } = useTranslation()
|
||||
const toast = useToast()
|
||||
const [runtimeOn, setRuntimeOn] = useState(false)
|
||||
const [plugins, setPlugins] = useState<PluginRow[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(false)
|
||||
const [busy, setBusy] = useState<string | null>(null)
|
||||
const [view, setView] = useState<'installed' | 'discover'>('installed')
|
||||
const [registry, setRegistry] = useState<RegistryItem[] | null>(null)
|
||||
const [latest, setLatest] = useState<Record<string, string>>({})
|
||||
const [detailFor, setDetailFor] = useState<RegistryItem | null>(null)
|
||||
const [errorsFor, setErrorsFor] = useState<{ id: string; rows: Array<{ ts: string; level: string; message: string }> } | null>(null)
|
||||
const [confirmUninstall, setConfirmUninstall] = useState<PluginRow | null>(null)
|
||||
// A QUEUE, not one slot: "Update All" can produce several re-consent prompts —
|
||||
// each must be shown, not silently overwritten by the last one.
|
||||
const [consentQueue, setConsentQueue] = useState<Array<{ plugin: PluginRow; version: string; newPermissions: string[]; newEgress: string[] }>>([])
|
||||
const [menu, setMenu] = useState<string | null>(null)
|
||||
|
||||
// Toolbar state.
|
||||
const [q, setQ] = useState('')
|
||||
const [typeFilter, setTypeFilter] = useState<TypeFilter>('all')
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
|
||||
const [sort, setSort] = useState<SortKey>('name')
|
||||
|
||||
const refresh = () => {
|
||||
adminApi.plugins()
|
||||
.then((d: { enabled: boolean; plugins: PluginRow[] }) => {
|
||||
setRuntimeOn(!!d.enabled)
|
||||
setPlugins(d.plugins || [])
|
||||
if ((d.plugins || []).length) {
|
||||
adminApi.pluginBrowse()
|
||||
.then((items: RegistryItem[]) => {
|
||||
const map: Record<string, string> = {}
|
||||
items.forEach((i) => { if (i.latest) map[i.id] = i.latest })
|
||||
setLatest(map)
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
})
|
||||
.catch(() => setError(true))
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
useEffect(refresh, [])
|
||||
|
||||
const act = async (id: string, fn: () => Promise<unknown>, ok: string) => {
|
||||
setBusy(id); setMenu(null)
|
||||
try { await fn(); toast.success(ok) }
|
||||
catch (e) { toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error || t('admin.plugins.actionError')) }
|
||||
finally { setBusy(null); refresh() }
|
||||
}
|
||||
|
||||
const openDiscover = () => {
|
||||
setView('discover')
|
||||
if (!registry) adminApi.pluginBrowse().then(setRegistry).catch(() => setRegistry([]))
|
||||
}
|
||||
const openErrors = (id: string) => {
|
||||
setMenu(null)
|
||||
adminApi.pluginErrors(id)
|
||||
.then((d: { errors: Array<{ ts: string; level: string; message: string }> }) => setErrorsFor({ id, rows: d.errors }))
|
||||
.catch(() => setErrorsFor({ id, rows: [] }))
|
||||
}
|
||||
|
||||
const updateAvailable = (p: PluginRow) => !!(p.version && latest[p.id] && isNewer(latest[p.id], p.version))
|
||||
const install = (id: string) => act(id, () => adminApi.pluginInstall(id), t('admin.plugins.installed'))
|
||||
const restart = (id: string) => act(id, async () => { await adminApi.pluginDeactivate(id); await adminApi.pluginActivate(id) }, t('admin.plugins.restarted'))
|
||||
const installedIds = new Set(plugins.map(p => p.id))
|
||||
|
||||
// Enable/disable a plugin. Re-enabling one whose update widened its permissions
|
||||
// must NOT grant them silently — the server answers 409 CONSENT_REQUIRED and we
|
||||
// route through the same consent dialog the Update button uses.
|
||||
const toggle = (p: PluginRow) => {
|
||||
if (busy === p.id) return
|
||||
if (p.enabled === 1) { void act(p.id, () => adminApi.pluginDeactivate(p.id), t('admin.plugins.deactivated')); return }
|
||||
setBusy(p.id); setMenu(null)
|
||||
adminApi.pluginActivate(p.id)
|
||||
.then(() => toast.success(t('admin.plugins.activated')))
|
||||
.catch((e: { response?: { status?: number; data?: { code?: string; error?: string; newPermissions?: string[]; newEgress?: string[] } } }) => {
|
||||
const d = e?.response?.data
|
||||
if (e?.response?.status === 409 && d?.code === 'CONSENT_REQUIRED') {
|
||||
setConsentQueue(qq => [...qq, { plugin: p, version: latest[p.id] ?? p.version ?? '', newPermissions: d.newPermissions ?? [], newEgress: d.newEgress ?? [] }])
|
||||
} else {
|
||||
toast.error(d?.error || t('admin.plugins.actionError'))
|
||||
}
|
||||
})
|
||||
.finally(() => { setBusy(null); refresh() })
|
||||
}
|
||||
|
||||
const runUpdate = (p: PluginRow) => {
|
||||
setBusy(p.id); setMenu(null)
|
||||
adminApi.pluginUpdate(p.id)
|
||||
.then((r: { version: string; activated: boolean; newPermissions: string[]; newEgress: string[] }) => {
|
||||
if (r.activated || (r.newPermissions.length === 0 && r.newEgress.length === 0)) toast.success(t('admin.plugins.updated'))
|
||||
else setConsentQueue(qq => [...qq, { plugin: p, version: r.version, newPermissions: r.newPermissions, newEgress: r.newEgress }])
|
||||
})
|
||||
.catch(e => toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error || t('admin.plugins.actionError')))
|
||||
.finally(() => { setBusy(null); refresh() })
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const updatable = useMemo(() => plugins.filter(updateAvailable), [plugins, latest])
|
||||
|
||||
// Installed list after search / type / status filters + sort.
|
||||
const shownInstalled = useMemo(() => {
|
||||
const term = q.trim().toLowerCase()
|
||||
let rows = plugins.filter(p => {
|
||||
const matchesText = !term || `${p.name} ${p.description ?? ''}`.toLowerCase().includes(term)
|
||||
const matchesType = typeFilter === 'all' || p.type === typeFilter
|
||||
const st = statusFilter === 'all' ? true
|
||||
: statusFilter === 'on' ? p.enabled === 1 && p.status !== 'error'
|
||||
: statusFilter === 'off' ? p.enabled === 0
|
||||
: statusFilter === 'update' ? updateAvailable(p)
|
||||
: p.status === 'error'
|
||||
return matchesText && matchesType && st
|
||||
})
|
||||
rows = [...rows].sort((a, b) => {
|
||||
if (sort === 'updates') {
|
||||
const ua = updateAvailable(a) ? 0 : 1, ub = updateAvailable(b) ? 0 : 1
|
||||
if (ua !== ub) return ua - ub
|
||||
}
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
return rows
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [plugins, q, typeFilter, statusFilter, sort, latest])
|
||||
|
||||
// Registry list after search / type filter.
|
||||
const shownRegistry = useMemo(() => {
|
||||
if (!registry) return null
|
||||
const term = q.trim().toLowerCase()
|
||||
let items = registry.filter(r => {
|
||||
const matchesText = !term || `${r.name} ${r.author} ${r.description}`.toLowerCase().includes(term)
|
||||
const matchesType = typeFilter === 'all' || r.type === typeFilter
|
||||
return matchesText && matchesType
|
||||
})
|
||||
items = [...items].sort((a, b) => a.name.localeCompare(b.name))
|
||||
return items
|
||||
}, [registry, q, typeFilter])
|
||||
|
||||
const anyFilter = q.trim() !== '' || typeFilter !== 'all' || statusFilter !== 'all'
|
||||
|
||||
return (
|
||||
<div className="relative bg-surface-card border border-edge rounded-2xl shadow-card mb-24 sm:mb-0">
|
||||
{/* Click-away layer for any open dropdown (filters or a row's ⋯ menu). */}
|
||||
{menu && <div className="fixed inset-0 z-20" onClick={() => setMenu(null)} />}
|
||||
{/* Header */}
|
||||
<div className="px-4 sm:px-6 pt-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-lg font-semibold tracking-tight text-content">{t('admin.plugins.title')}</h2>
|
||||
<p className="text-xs mt-1 text-content-muted max-w-xl">{t('admin.plugins.subtitle')}</p>
|
||||
</div>
|
||||
{runtimeOn && (
|
||||
<span className="inline-flex items-center gap-2 shrink-0 text-[11px] font-semibold text-success bg-success-soft px-2.5 py-1.5 rounded-full">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-success" /> {t('admin.plugins.runtimeOn')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Runtime-disabled notice */}
|
||||
{!runtimeOn && !loading && !error && (
|
||||
<div className="mx-4 sm:mx-6 mt-4 p-4 rounded-xl border border-warning/30 bg-warning-soft flex items-start gap-3">
|
||||
<AlertTriangle size={16} className="text-warning mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-content">{t('admin.plugins.disabledTitle')}</p>
|
||||
<p className="text-xs text-content-muted mt-0.5">{t('admin.plugins.disabledBody')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toolbar — on mobile: tabs+rescan, then full-width search, then a
|
||||
right-aligned filter row. On sm+ the wrappers collapse (sm:contents)
|
||||
so everything sits in one wrapping row. */}
|
||||
{runtimeOn && !loading && !error && (
|
||||
<div className="relative z-30 px-4 sm:px-6 py-4 flex flex-col sm:flex-row sm:flex-wrap sm:items-center gap-2.5">
|
||||
<div className="flex items-center justify-between gap-2.5 sm:contents">
|
||||
<div className="inline-flex bg-surface-tertiary border border-edge-secondary rounded-xl p-0.5 gap-0.5">
|
||||
<SegBtn active={view === 'installed'} onClick={() => setView('installed')} label={t('admin.plugins.installed')} count={plugins.length} />
|
||||
<SegBtn active={view === 'discover'} onClick={openDiscover} label={t('admin.plugins.tabDiscover')} count={registry?.length} />
|
||||
</div>
|
||||
<button onClick={() => act('__rescan', adminApi.pluginRescan, t('admin.plugins.rescanned'))} title={t('admin.plugins.rescan')}
|
||||
className="sm:hidden h-[38px] w-[38px] grid place-items-center rounded-xl border border-edge bg-surface-card text-content-muted hover:text-content hover:border-content-faint transition-colors shrink-0">
|
||||
<RefreshCw size={15} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="relative w-full sm:flex-1 sm:min-w-[160px]">
|
||||
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-content-faint pointer-events-none" />
|
||||
<input
|
||||
value={q} onChange={e => setQ(e.target.value)} type="search"
|
||||
placeholder={t('admin.plugins.searchPlaceholder')}
|
||||
className="w-full h-[38px] pl-9 pr-3 rounded-xl border border-edge bg-surface-secondary text-sm text-content placeholder:text-content-faint outline-none focus:bg-surface-card focus:border-content-faint transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 sm:gap-2.5 sm:contents">
|
||||
<FilterMenu id="type" label={t('admin.plugins.filterType')} value={typeFilter} menu={menu} setMenu={setMenu} icon={<SlidersHorizontal size={14} />}
|
||||
options={[
|
||||
['all', t('admin.plugins.allTypes')], ['widget', t('admin.plugins.type.widget')],
|
||||
['integration', t('admin.plugins.type.integration')], ['page', t('admin.plugins.type.page')],
|
||||
]}
|
||||
valueLabel={typeFilter === 'all' ? t('admin.plugins.allTypes') : t(`admin.plugins.type.${typeFilter}` as never)}
|
||||
onPick={v => setTypeFilter(v as TypeFilter)} />
|
||||
|
||||
{view === 'installed' && (
|
||||
<FilterMenu id="status" label={t('admin.plugins.filterStatus')} value={statusFilter} menu={menu} setMenu={setMenu} icon={<CircleDot size={14} />}
|
||||
options={[
|
||||
['all', t('admin.plugins.allStatuses')], ['on', t('admin.plugins.status.active')], ['off', t('admin.plugins.stateOff')],
|
||||
['update', t('admin.plugins.filterUpdate')], ['err', t('admin.plugins.status.error')],
|
||||
]}
|
||||
valueLabel={statusLabel(statusFilter, t)}
|
||||
onPick={v => setStatusFilter(v as StatusFilter)} />
|
||||
)}
|
||||
|
||||
<FilterMenu id="sort" label={t('admin.plugins.sortBy')} value={sort} menu={menu} setMenu={setMenu} icon={<ArrowUpDown size={14} />}
|
||||
options={[['name', t('admin.plugins.sortName')], ['recent', t('admin.plugins.sortRecent')], ['updates', t('admin.plugins.sortUpdates')]]}
|
||||
valueLabel={sortLabel(sort, t)}
|
||||
onPick={v => setSort(v as SortKey)} />
|
||||
|
||||
<button onClick={() => act('__rescan', adminApi.pluginRescan, t('admin.plugins.rescanned'))} title={t('admin.plugins.rescan')}
|
||||
className="hidden sm:grid h-[38px] w-[38px] place-items-center rounded-xl border border-edge bg-surface-card text-content-muted hover:text-content hover:border-content-faint transition-colors">
|
||||
<RefreshCw size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Body */}
|
||||
<div className="pb-1">
|
||||
{loading ? (
|
||||
<div className="py-14 text-center text-sm text-content-faint">{t('common.loading')}</div>
|
||||
) : error ? (
|
||||
<div className="py-14 text-center text-sm text-danger">{t('admin.plugins.loadError')}</div>
|
||||
) : !runtimeOn ? null : view === 'discover' ? (
|
||||
<RegistryGrid items={shownRegistry} busy={busy} t={t} installedIds={installedIds}
|
||||
onInstall={install} onOpenDetail={setDetailFor} filtered={anyFilter} />
|
||||
) : plugins.length === 0 ? (
|
||||
<EmptyState t={t} onDiscover={openDiscover} />
|
||||
) : (
|
||||
<div className="px-2 sm:px-3">
|
||||
{updatable.length > 0 && statusFilter !== 'err' && (
|
||||
<div className="mx-1.5 sm:mx-3 mb-2 mt-1 flex items-center gap-2.5 px-3.5 py-2.5 rounded-xl bg-warning-soft border border-warning/30">
|
||||
<ArrowUpCircle size={16} className="text-warning shrink-0" />
|
||||
<span className="text-xs text-content-secondary">{t('admin.plugins.updatesAvailable', { count: updatable.length })}</span>
|
||||
<button onClick={() => updatable.forEach(runUpdate)}
|
||||
className="ml-auto text-xs font-semibold px-3 py-1.5 rounded-lg bg-warning text-white hover:opacity-90 transition-opacity">
|
||||
{t('admin.plugins.updateAll')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{shownInstalled.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<Search size={26} className="text-content-faint/60 mx-auto mb-3" />
|
||||
<p className="text-sm text-content-faint">{t('admin.plugins.noMatchInstalled')}</p>
|
||||
</div>
|
||||
) : shownInstalled.map(p => (
|
||||
<InstalledRow key={p.id} p={p} t={t} busy={busy} menu={menu} setMenu={setMenu}
|
||||
hasUpdate={updateAvailable(p)} latestVer={latest[p.id]}
|
||||
onToggle={() => toggle(p)}
|
||||
onUpdate={() => runUpdate(p)} onRestart={() => restart(p.id)}
|
||||
onErrors={() => openErrors(p.id)} onUninstall={() => { setMenu(null); setConfirmUninstall(p) }} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SecurityInfo t={t} />
|
||||
|
||||
{/* Registry detail dialog */}
|
||||
{detailFor && (
|
||||
<PluginDetailModal item={detailFor} t={t} locale={locale} busy={busy}
|
||||
installed={installedIds.has(detailFor.id)} onInstall={install} onClose={() => setDetailFor(null)} />
|
||||
)}
|
||||
|
||||
{/* Error-log modal */}
|
||||
{errorsFor && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={() => setErrorsFor(null)}>
|
||||
<div className="bg-surface-card border border-edge rounded-2xl w-full max-w-2xl max-h-[70vh] flex flex-col shadow-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="px-5 py-3.5 border-b border-edge-secondary flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-content flex items-center gap-2"><Bug size={15} /> {errorsFor.id} — {t('admin.plugins.errorLog')}</span>
|
||||
<button onClick={() => setErrorsFor(null)} className="text-content-faint hover:text-content"><X size={16} /></button>
|
||||
</div>
|
||||
<div className="p-4 overflow-y-auto text-xs font-mono">
|
||||
{errorsFor.rows.length === 0 ? <p className="text-content-faint py-4 text-center">{t('admin.plugins.noErrors')}</p> :
|
||||
errorsFor.rows.map((r, i) => (
|
||||
<div key={i} className="py-1.5 border-b border-edge-secondary/50 last:border-0 flex gap-2">
|
||||
<span className={`shrink-0 font-semibold ${r.level === 'error' ? 'text-danger' : 'text-warning'}`}>{r.level}</span>
|
||||
<span className="text-content-faint shrink-0">{r.ts}</span>
|
||||
<span className="text-content-muted break-all">{r.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={!!confirmUninstall}
|
||||
onClose={() => setConfirmUninstall(null)}
|
||||
onConfirm={async () => {
|
||||
const p = confirmUninstall!; setConfirmUninstall(null)
|
||||
await act(p.id, () => adminApi.pluginUninstall(p.id, true), t('admin.plugins.uninstalled'))
|
||||
}}
|
||||
title={t('admin.plugins.uninstallTitle')}
|
||||
message={t('admin.plugins.uninstallBody')}
|
||||
/>
|
||||
|
||||
{consentQueue[0] && (
|
||||
<UpdateConsentDialog
|
||||
data={consentQueue[0]} t={t}
|
||||
onApprove={async () => {
|
||||
const c = consentQueue[0]; setConsentQueue(qq => qq.slice(1))
|
||||
// consent:true — the ONLY path that may widen a plugin's granted rights.
|
||||
await act(c.plugin.id, () => adminApi.pluginActivate(c.plugin.id, true), t('admin.plugins.updated'))
|
||||
}}
|
||||
onLater={() => { setConsentQueue(qq => qq.slice(1)); toast.success(t('admin.plugins.updateKeptOff')) }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function statusLabel(s: StatusFilter, t: T): string {
|
||||
return s === 'all' ? t('admin.plugins.allStatuses') : s === 'on' ? t('admin.plugins.status.active')
|
||||
: s === 'off' ? t('admin.plugins.stateOff') : s === 'update' ? t('admin.plugins.filterUpdate') : t('admin.plugins.status.error')
|
||||
}
|
||||
function sortLabel(s: SortKey, t: T): string {
|
||||
return s === 'name' ? t('admin.plugins.sortName') : s === 'recent' ? t('admin.plugins.sortRecent') : t('admin.plugins.sortUpdates')
|
||||
}
|
||||
|
||||
function SegBtn({ active, onClick, label, count }: { active: boolean; onClick: () => void; label: string; count?: number }) {
|
||||
return (
|
||||
<button onClick={onClick} role="tab" aria-selected={active}
|
||||
className={`inline-flex items-center gap-2 text-[13px] font-medium px-3.5 py-1.5 rounded-lg transition-colors ${
|
||||
active ? 'bg-surface-card text-content shadow-card' : 'text-content-muted hover:text-content'}`}>
|
||||
{label}
|
||||
{count != null && (
|
||||
<span className={`inline-flex items-center justify-center min-w-[18px] h-[18px] px-1.5 rounded-full text-[11px] font-bold tabular-nums ${
|
||||
active ? 'bg-accent text-accent-text' : 'bg-surface-card text-content-muted'}`}>{count}</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function FilterMenu({ id, label, valueLabel, options, onPick, value, menu, setMenu, icon }: {
|
||||
id: string; label: string; valueLabel: string
|
||||
options: Array<[string, string]>; onPick: (v: string) => void; value: string
|
||||
menu: string | null; setMenu: (v: string | null) => void; icon?: React.ReactNode
|
||||
}) {
|
||||
const open = menu === id
|
||||
const active = value !== options[0]?.[0]
|
||||
return (
|
||||
<div className="relative">
|
||||
<button onClick={() => setMenu(open ? null : id)} title={`${label}: ${valueLabel}`}
|
||||
className={`relative h-[38px] w-[38px] sm:w-auto px-0 sm:px-3 inline-flex items-center justify-center sm:justify-start gap-1.5 rounded-xl border bg-surface-card text-[13px] text-content-secondary transition-colors whitespace-nowrap hover:border-content-faint ${
|
||||
active ? 'border-content-faint' : 'border-edge'}`}>
|
||||
{icon}
|
||||
<span className="hidden sm:inline">{label}: </span>
|
||||
<span className="hidden sm:inline font-semibold text-content">{valueLabel}</span>
|
||||
<ChevronDown size={13} className={`hidden sm:block transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
{active && <span className="sm:hidden absolute -top-1 -right-1 w-2 h-2 rounded-full bg-accent ring-2 ring-surface-card" />}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute top-11 right-0 z-30 min-w-[180px] max-w-[calc(100vw-2rem)] p-1.5 rounded-xl border border-edge bg-surface-card shadow-elevated">
|
||||
{options.map(([v, lbl]) => (
|
||||
<button key={v} onClick={() => { onPick(v); setMenu(null) }}
|
||||
className={`w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-[13px] transition-colors hover:bg-surface-tertiary ${
|
||||
value === v ? 'text-content font-semibold' : 'text-content-secondary'}`}>
|
||||
{lbl}<Check size={15} className={`ml-auto text-accent ${value === v ? 'opacity-100' : 'opacity-0'}`} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyState({ t, onDiscover }: { t: T; onDiscover: () => void }) {
|
||||
return (
|
||||
<div className="py-16 text-center">
|
||||
<div className="w-14 h-14 rounded-2xl bg-surface-tertiary grid place-items-center mx-auto mb-4">
|
||||
<PackageOpen size={26} className="text-content-faint" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-content-muted">{t('admin.plugins.empty')}</p>
|
||||
<button onClick={onDiscover} className="mt-4 inline-flex items-center gap-1.5 text-xs font-semibold px-4 py-2 rounded-lg bg-accent text-accent-text">
|
||||
<Download size={14} /> {t('admin.plugins.tabDiscover')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, onToggle, onUpdate, onRestart, onErrors, onUninstall }: {
|
||||
p: PluginRow; t: T; busy: string | null; menu: string | null; setMenu: (v: string | null) => void
|
||||
hasUpdate: boolean; latestVer?: string
|
||||
onToggle: () => void; onUpdate: () => void; onRestart: () => void; onErrors: () => void; onUninstall: () => void
|
||||
}) {
|
||||
const caps = deriveCaps(parseJson<string[]>(p.permissions, []), parseJson<{ widget?: { slot?: string } }>(p.capabilities, {}), t)
|
||||
const menuOpen = menu === `row:${p.id}`
|
||||
return (
|
||||
<div className="group relative flex items-center gap-3 sm:gap-4 px-2.5 sm:px-3 py-3.5 rounded-2xl hover:bg-surface-secondary transition-colors">
|
||||
<div className="relative shrink-0">
|
||||
<div className="w-[46px] h-[46px] rounded-[13px] grid place-items-center bg-surface-tertiary border border-edge-secondary">
|
||||
<PluginIcon name={p.icon} size={22} className="text-content-secondary" />
|
||||
</div>
|
||||
<span className={`absolute -right-0.5 -bottom-0.5 w-[13px] h-[13px] rounded-full ring-[2.5px] ring-surface-card ${HEALTH[p.status] || HEALTH.inactive}`}
|
||||
title={t(`admin.plugins.status.${p.status}` as never)} />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[14.5px] font-semibold tracking-[-.006em] text-content">{p.name}</span>
|
||||
{p.version && <span className="text-[11.5px] text-content-faint font-medium tabular-nums">v{p.version}</span>}
|
||||
{p.reviewed_at && <ReviewedBadge t={t} compact />}
|
||||
</div>
|
||||
{p.description && <p className="text-[12.5px] text-content-muted mt-0.5 truncate">{p.description}</p>}
|
||||
{p.status === 'error' && p.last_error ? (
|
||||
<div className="flex items-center gap-1.5 mt-1.5 text-[11.5px] text-danger">
|
||||
<AlertTriangle size={13} className="shrink-0" /><span className="truncate">{p.last_error}</span>
|
||||
</div>
|
||||
) : caps.length > 0 && (
|
||||
<div className="hidden sm:flex items-center gap-1.5 flex-wrap mt-2">
|
||||
{caps.map((c, i) => (
|
||||
<span key={i} className={`inline-flex items-center gap-1.5 text-[11px] font-medium px-2 py-[3px] rounded-md border ${
|
||||
c.net ? 'text-info border-info/25 bg-info-soft' : 'text-content-secondary border-edge-secondary bg-surface-tertiary'}`}>
|
||||
<c.icon size={12} className={c.net ? 'text-info' : 'text-content-muted'} />{c.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 sm:gap-2 shrink-0">
|
||||
{hasUpdate && (
|
||||
<button onClick={onUpdate} disabled={busy === p.id} title={t('admin.plugins.updateTo', { version: latestVer })}
|
||||
className="inline-flex items-center gap-1.5 text-[11.5px] font-semibold px-2 sm:px-2.5 py-1.5 rounded-full text-warning bg-warning-soft border border-warning/30 hover:opacity-90 transition-opacity disabled:opacity-50">
|
||||
<ArrowUpCircle size={13} /> <span className="hidden sm:inline">{t('admin.plugins.updateTo', { version: latestVer })}</span>
|
||||
</button>
|
||||
)}
|
||||
<span className={`hidden sm:inline text-xs font-medium min-w-[42px] text-right ${p.enabled === 1 && p.status !== 'error' ? 'text-content-secondary' : 'text-content-faint'}`}>
|
||||
{p.enabled === 1 ? t('admin.plugins.status.active') : t('admin.plugins.stateOff')}
|
||||
</span>
|
||||
<ToggleSwitch on={p.enabled === 1} label={t('admin.plugins.enabledToggle')} onToggle={onToggle} />
|
||||
<div className="relative">
|
||||
<button onClick={() => setMenu(menuOpen ? null : `row:${p.id}`)}
|
||||
className="w-[34px] h-[34px] grid place-items-center rounded-lg text-content-faint hover:text-content hover:bg-surface-tertiary transition-colors">
|
||||
<MoreHorizontal size={17} />
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div className="absolute top-10 right-0 z-30 min-w-[180px] p-1.5 rounded-xl border border-edge bg-surface-card shadow-elevated">
|
||||
{p.enabled === 1 && (
|
||||
<MenuItem icon={<RotateCw size={14} />} label={t('admin.plugins.restart')} onClick={onRestart} />
|
||||
)}
|
||||
<MenuItem icon={<Bug size={14} />} label={t('admin.plugins.viewErrors')} onClick={onErrors} />
|
||||
{p.source_repo && (
|
||||
<a href={`https://github.com/${p.source_repo}`} target="_blank" rel="noreferrer" onClick={() => setMenu(null)}
|
||||
className="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-[13px] text-content-secondary hover:bg-surface-tertiary transition-colors">
|
||||
<Github size={14} /> {t('admin.plugins.sourceRepo')}
|
||||
</a>
|
||||
)}
|
||||
<div className="my-1 border-t border-edge-secondary" />
|
||||
<MenuItem icon={<Trash2 size={14} />} label={t('common.delete')} danger onClick={onUninstall} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MenuItem({ icon, label, onClick, danger }: { icon: React.ReactNode; label: string; onClick: () => void; danger?: boolean }) {
|
||||
return (
|
||||
<button onClick={onClick}
|
||||
className={`w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-[13px] transition-colors ${
|
||||
danger ? 'text-danger hover:bg-danger-soft' : 'text-content-secondary hover:bg-surface-tertiary'}`}>
|
||||
{icon} {label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function Screenshot({ url, className, iconSize = 28 }: { url: string | null; className: string; iconSize?: number }) {
|
||||
const [failed, setFailed] = useState(false)
|
||||
return (
|
||||
<div className={`bg-surface-tertiary overflow-hidden ${className}`}>
|
||||
{url && !failed ? (
|
||||
<img src={url} alt="" loading="lazy" className="w-full h-full object-cover" onError={() => setFailed(true)} />
|
||||
) : (
|
||||
<div className="w-full h-full grid place-items-center bg-gradient-to-br from-surface-tertiary to-surface-secondary">
|
||||
<Blocks size={iconSize} className="text-content-faint/50" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, filtered }: {
|
||||
items: RegistryItem[] | null
|
||||
onInstall: (id: string) => void
|
||||
onOpenDetail: (item: RegistryItem) => void
|
||||
busy: string | null
|
||||
t: T
|
||||
installedIds: Set<string>
|
||||
filtered: boolean
|
||||
}) {
|
||||
if (!items) return <div className="py-14 text-center text-sm text-content-faint">{t('common.loading')}</div>
|
||||
if (items.length === 0) return (
|
||||
<div className="py-14 text-center">
|
||||
<Search size={26} className="text-content-faint/60 mx-auto mb-3" />
|
||||
<p className="text-sm text-content-faint">{filtered ? t('admin.plugins.noMatchRegistry') : t('admin.plugins.registryEmpty')}</p>
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3.5 sm:gap-4 px-4 sm:px-6 pb-5 pt-1">
|
||||
{items.map(item => {
|
||||
const installed = installedIds.has(item.id)
|
||||
return (
|
||||
<div key={item.id} role="button" tabIndex={0} onClick={() => onOpenDetail(item)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onOpenDetail(item) } }}
|
||||
className="group border border-edge rounded-2xl bg-surface-card overflow-hidden flex flex-col cursor-pointer hover:-translate-y-0.5 hover:shadow-elevated hover:border-edge-faint transition-all duration-150">
|
||||
<div className="relative">
|
||||
<Screenshot url={item.screenshotUrl} className="aspect-[16/10]" iconSize={24} />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/45 to-transparent" />
|
||||
{item.reviewedAt && (
|
||||
<span className="absolute top-2.5 right-2.5 inline-flex items-center gap-1 text-[10.5px] font-semibold text-white bg-black/40 backdrop-blur-sm rounded-full px-2 py-1">
|
||||
<ShieldCheck size={12} /> {t('admin.plugins.reviewed')}
|
||||
</span>
|
||||
)}
|
||||
<div className="absolute left-3 -bottom-4 w-11 h-11 rounded-xl bg-surface-card border border-edge grid place-items-center shadow-card z-[1]">
|
||||
<PluginIcon name={item.type === 'widget' ? 'Blocks' : null} size={22} className="text-content-secondary" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-6 px-3.5 pb-3.5 flex flex-col flex-1">
|
||||
<span className="text-sm font-semibold tracking-[-.006em] text-content truncate">{item.name}</span>
|
||||
<span className="text-[11.5px] text-content-faint mt-0.5">{item.author}</span>
|
||||
<p className="text-xs text-content-muted mt-2 line-clamp-2 flex-1">{item.description}</p>
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<TypeBadge type={item.type} t={t} />
|
||||
{item.latest && <span className="text-[10.5px] text-content-faint tabular-nums">v{item.latest}</span>}
|
||||
<button onClick={e => { e.stopPropagation(); onInstall(item.id) }} disabled={busy === item.id || installed}
|
||||
className="ml-auto text-xs font-semibold px-3.5 py-1.5 rounded-lg bg-accent text-accent-text hover:bg-accent-hover disabled:opacity-50 disabled:bg-surface-tertiary disabled:text-content-faint transition-colors">
|
||||
{installed ? t('admin.plugins.installed') : t('admin.plugins.install')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// A permission rendered human-readable when known, else as its raw code.
|
||||
function PermLabel({ perm, t }: { perm: string; t: T }) {
|
||||
return PERM_KEYS.includes(perm)
|
||||
? <span>{t(`admin.plugins.perm.${perm}` as never)}</span>
|
||||
: <code className="font-mono text-[11px] bg-surface-tertiary px-1.5 py-0.5 rounded">{perm}</code>
|
||||
}
|
||||
|
||||
function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, locale }: {
|
||||
item: RegistryItem; installed: boolean; busy: string | null
|
||||
onInstall: (id: string) => void; onClose: () => void; t: T; locale: string
|
||||
}) {
|
||||
const [detail, setDetail] = useState<RegistryDetail | null>(null)
|
||||
const [failed, setFailed] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
adminApi.pluginDetail(item.id)
|
||||
.then((d: RegistryDetail) => { if (alive) setDetail(d) })
|
||||
.catch(() => { if (alive) setFailed(true) })
|
||||
return () => { alive = false }
|
||||
}, [item.id])
|
||||
|
||||
const manifest = detail?.manifest ?? null
|
||||
const caps = manifest ? deriveCaps(manifest.permissions, {}, t) : []
|
||||
const repoUrl = `https://github.com/${item.repo}`
|
||||
const homepage = item.homepage && /^https?:\/\//i.test(item.homepage) && item.homepage !== repoUrl ? item.homepage : null
|
||||
const sizeKb = detail?.size ? Math.max(1, Math.round(detail.size / 1024)) : null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
|
||||
<div className="bg-surface-card border border-edge rounded-2xl w-full max-w-xl max-h-[88vh] overflow-auto shadow-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="relative">
|
||||
<Screenshot url={item.screenshotUrl} className="w-full aspect-[16/9]" iconSize={36} />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent" />
|
||||
<button onClick={onClose} className="absolute top-3 right-3 w-8 h-8 grid place-items-center rounded-lg bg-black/40 text-white hover:bg-black/60 transition-colors"><X size={16} /></button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 sm:gap-3.5 px-4 sm:px-5 -mt-7 relative z-[1]">
|
||||
<div className="w-14 h-14 rounded-[15px] bg-surface-card border border-edge grid place-items-center shadow-card shrink-0">
|
||||
<PluginIcon name={manifest?.icon ?? null} size={28} className="text-content-secondary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 pt-8">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="text-lg font-semibold tracking-tight text-content">{item.name}</h3>
|
||||
{item.reviewedAt && <ReviewedBadge t={t} compact />}
|
||||
</div>
|
||||
<p className="text-[12.5px] text-content-faint mt-0.5">{item.author}{item.latest ? ` · v${item.latest}` : ''}</p>
|
||||
</div>
|
||||
<button onClick={() => onInstall(item.id)} disabled={busy === item.id || installed}
|
||||
className="self-end text-[13px] font-semibold px-3 sm:px-4 py-2 rounded-lg bg-accent text-accent-text hover:bg-accent-hover disabled:opacity-50 disabled:bg-surface-tertiary disabled:text-content-faint transition-colors shrink-0">
|
||||
{installed ? t('admin.plugins.installed') : t('admin.plugins.install')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-4 sm:px-5 pt-4 pb-5">
|
||||
<p className="text-[13.5px] text-content-secondary leading-relaxed">{item.description}</p>
|
||||
{failed && <p className="text-xs text-danger mt-3">{t('admin.plugins.detailError')}</p>}
|
||||
|
||||
{manifest && (
|
||||
<div className="mt-5">
|
||||
<h4 className="text-[11px] font-semibold uppercase tracking-wider text-content-muted">{t('admin.plugins.accessTitle')}</h4>
|
||||
{caps.filter(c => !c.net).length === 0 && !manifest.permissions.includes('db:own') ? (
|
||||
<p className="text-xs text-content-faint mt-2">{t('admin.plugins.noAccess')}</p>
|
||||
) : (
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{caps.filter(c => !c.net).map((c, i) => (
|
||||
<div key={i} className="flex items-start gap-2.5 text-[13px] text-content-secondary py-0.5">
|
||||
<c.icon size={15} className="text-accent mt-0.5 shrink-0" /><span>{c.label}</span>
|
||||
</div>
|
||||
))}
|
||||
{manifest.permissions.includes('db:own') && (
|
||||
<div className="flex items-start gap-2.5 text-[13px] text-content-secondary py-0.5">
|
||||
<Database size={15} className="text-accent mt-0.5 shrink-0" /><span>{t('admin.plugins.perm.db:own')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{manifest && manifest.egress.length > 0 && (
|
||||
<div className="mt-5">
|
||||
<h4 className="text-[11px] font-semibold uppercase tracking-wider text-content-muted">{t('admin.plugins.connectsTitle')}</h4>
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
{manifest.egress.map(h => (
|
||||
<code key={h} className="text-[12px] font-mono text-info bg-info-soft rounded-md px-2 py-1">{h}</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{manifest && manifest.settings.length > 0 && (
|
||||
<div className="mt-5">
|
||||
<h4 className="text-[11px] font-semibold uppercase tracking-wider text-content-muted">{t('admin.plugins.setupTitle')}</h4>
|
||||
<ul className="mt-2 space-y-1.5">
|
||||
{manifest.settings.map(s => (
|
||||
<li key={s.key} className="flex items-center gap-2 text-xs text-content-muted flex-wrap">
|
||||
<span className="font-medium">{s.label}</span>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-surface-tertiary text-content-faint">{t(`admin.plugins.scope.${s.scope}` as never)}</span>
|
||||
{s.required && <span className="text-[10px] px-1.5 py-0.5 rounded-full bg-warning-soft text-warning">{t('admin.plugins.fieldRequired')}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-5">
|
||||
<h4 className="text-[11px] font-semibold uppercase tracking-wider text-content-muted">{t('admin.plugins.detailsTitle')}</h4>
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-3 mt-2.5">
|
||||
{item.latest && <Meta k={t('admin.plugins.metaVersion')} v={`v${item.latest}`} />}
|
||||
{sizeKb && <Meta k={t('admin.plugins.metaSize')} v={`${sizeKb} KB`} />}
|
||||
{item.minTrekVersion && <Meta k={t('admin.plugins.metaRequires')} v={`TREK ${item.minTrekVersion}+`} />}
|
||||
{item.reviewedAt && <Meta k={t('admin.plugins.metaReviewed')} v={new Date(item.reviewedAt).toLocaleDateString(locale)} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 px-4 sm:px-5 py-3.5 border-t border-edge-secondary bg-surface-secondary">
|
||||
<a href={repoUrl} target="_blank" rel="noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-lg border border-edge bg-surface-card text-content-secondary hover:text-content hover:border-content-faint transition-colors">
|
||||
<Github size={13} /> {t('admin.plugins.sourceRepo')}
|
||||
</a>
|
||||
{homepage && (
|
||||
<a href={homepage} target="_blank" rel="noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-lg border border-edge bg-surface-card text-content-secondary hover:text-content hover:border-content-faint transition-colors">
|
||||
<ExternalLink size={13} /> {t('admin.plugins.homepage')}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Meta({ k, v }: { k: string; v: string }) {
|
||||
return <div><div className="text-[12px] text-content-faint">{k}</div><div className="text-[12.5px] font-medium text-content mt-0.5">{v}</div></div>
|
||||
}
|
||||
|
||||
function UpdateConsentDialog({ data, t, onApprove, onLater }: {
|
||||
data: { plugin: PluginRow; version: string; newPermissions: string[]; newEgress: string[] }
|
||||
t: T; onApprove: () => void; onLater: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onLater}>
|
||||
<div className="bg-surface-card border border-edge rounded-2xl w-full max-w-md shadow-modal overflow-hidden" onClick={e => e.stopPropagation()}>
|
||||
<div className="px-5 py-4 border-b border-edge-secondary flex items-start gap-3">
|
||||
<div className="w-9 h-9 rounded-lg bg-warning-soft grid place-items-center shrink-0"><ShieldCheck size={18} className="text-warning" /></div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-content">{t('admin.plugins.updateConsentTitle')}</h3>
|
||||
<p className="text-xs text-content-muted mt-1">{t('admin.plugins.updateConsentBody', { name: data.plugin.name, version: data.version })}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
{data.newPermissions.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-content-muted">{t('admin.plugins.updateNewPermissions')}</h4>
|
||||
<ul className="mt-2 space-y-1.5">
|
||||
{data.newPermissions.map(perm => (
|
||||
<li key={perm} className="flex items-start gap-2 text-xs text-content-muted"><Check size={13} className="text-warning mt-0.5 shrink-0" /><PermLabel perm={perm} t={t} /></li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{data.newEgress.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-content-muted">{t('admin.plugins.updateNewEgress')}</h4>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{data.newEgress.map(host => <code key={host} className="font-mono text-[11px] bg-surface-tertiary px-1.5 py-0.5 rounded text-content-muted">{host}</code>)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-5 py-3.5 border-t border-edge-secondary bg-surface-secondary flex items-center justify-end gap-2">
|
||||
<button onClick={onLater} className="text-xs font-medium px-3.5 py-2 rounded-lg border border-edge text-content-muted hover:text-content hover:bg-surface-tertiary transition-colors">{t('admin.plugins.updateLater')}</button>
|
||||
<button onClick={onApprove} className="text-xs font-semibold px-4 py-2 rounded-lg bg-accent text-accent-text hover:bg-accent-hover transition-colors">{t('admin.plugins.updateApprove')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Footer: a plain-language note on what "Reviewed" means, plus a collapsible
|
||||
// panel that lays out how plugins are contained, the limits, and the worst case.
|
||||
function SecurityInfo({ t }: { t: T }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const sections: Array<[string, string]> = [
|
||||
['admin.plugins.security.isolationTitle', 'admin.plugins.security.isolationBody'],
|
||||
['admin.plugins.security.permsTitle', 'admin.plugins.security.permsBody'],
|
||||
['admin.plugins.security.limitsTitle', 'admin.plugins.security.limitsBody'],
|
||||
['admin.plugins.security.worstTitle', 'admin.plugins.security.worstBody'],
|
||||
['admin.plugins.security.reviewedTitle', 'admin.plugins.security.reviewedBody'],
|
||||
['admin.plugins.security.trustTitle', 'admin.plugins.security.trustBody'],
|
||||
]
|
||||
return (
|
||||
<div className="border-t border-edge-secondary bg-surface-secondary rounded-b-2xl overflow-hidden">
|
||||
<div className="px-4 sm:px-6 py-3.5 flex items-start gap-2">
|
||||
<ShieldCheck size={14} className="text-content-faint shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-content-muted">{t('admin.plugins.reviewedMeaning')}</p>
|
||||
</div>
|
||||
<button onClick={() => setOpen(o => !o)}
|
||||
className="w-full px-4 sm:px-6 py-2.5 border-t border-edge-secondary flex items-center justify-between gap-2 text-xs font-medium text-content-secondary hover:text-content hover:bg-surface-tertiary transition-colors">
|
||||
<span className="flex items-center gap-2"><Lock size={13} className="shrink-0" /> <span className="text-left">{t('admin.plugins.security.title')}</span></span>
|
||||
<ChevronDown size={15} className={`shrink-0 transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="px-4 sm:px-6 py-4 border-t border-edge-secondary grid gap-x-8 gap-y-4 sm:grid-cols-2">
|
||||
{sections.map(([h, b]) => (
|
||||
<div key={h}>
|
||||
<h4 className="text-[12.5px] font-semibold text-content">{t(h as never)}</h4>
|
||||
<p className="text-xs text-content-muted mt-1 leading-relaxed">{t(b as never)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -473,10 +473,10 @@ export default function BackupPanel() {
|
||||
<AlertTriangle size={20} className="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-white" style={{ margin: 0, fontSize: 16, fontWeight: 700 }}>
|
||||
<h3 className="text-white" style={{ margin: 0, fontSize: 'calc(16px * var(--fs-scale-subtitle, 1))', fontWeight: 700 }}>
|
||||
{t('backup.restoreConfirmTitle')}
|
||||
</h3>
|
||||
<p className="text-[rgba(255,255,255,0.8)]" style={{ margin: '2px 0 0', fontSize: 12 }}>
|
||||
<p className="text-[rgba(255,255,255,0.8)]" style={{ margin: '2px 0 0', fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>
|
||||
{restoreConfirm.filename}
|
||||
</p>
|
||||
</div>
|
||||
@@ -484,11 +484,11 @@ export default function BackupPanel() {
|
||||
|
||||
{/* Body */}
|
||||
<div style={{ padding: '20px 24px' }}>
|
||||
<p className="text-gray-700 dark:text-gray-300" style={{ fontSize: 13, lineHeight: 1.6, margin: 0 }}>
|
||||
<p className="text-gray-700 dark:text-gray-300" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', lineHeight: 1.6, margin: 0 }}>
|
||||
{t('backup.restoreWarning')}
|
||||
</p>
|
||||
|
||||
<div style={{ marginTop: 14, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
|
||||
<div style={{ marginTop: 14, padding: '10px 12px', borderRadius: 10, fontSize: 'calc(12px * var(--fs-scale-body, 1))', 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"
|
||||
>
|
||||
{t('backup.restoreTip')}
|
||||
@@ -500,14 +500,14 @@ export default function BackupPanel() {
|
||||
<button
|
||||
onClick={() => setRestoreConfirm(null)}
|
||||
className="text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
|
||||
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={executeRestore}
|
||||
className="bg-[#dc2626] text-white"
|
||||
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
|
||||
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = '#b91c1c'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = '#dc2626'}
|
||||
>
|
||||
|
||||
@@ -7,7 +7,16 @@ import Section from '../Settings/Section'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { MapView } from '../Map/MapView'
|
||||
import { CURRENCIES, SYMBOLS } from '../Budget/BudgetPanel.constants'
|
||||
import type { Place } from '../../types'
|
||||
import type { DistanceUnit, Place } from '../../types'
|
||||
import {
|
||||
MAPBOX_DEFAULT_STYLE,
|
||||
defaultStyleForProvider,
|
||||
getStylePresets,
|
||||
isOpenFreeMapStyle,
|
||||
normalizeStyleForProvider,
|
||||
styleSettingKey,
|
||||
type GlMapProvider,
|
||||
} from '../Map/glProviders'
|
||||
|
||||
const MAP_PRESETS = [
|
||||
{ name: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' },
|
||||
@@ -19,6 +28,7 @@ const MAP_PRESETS = [
|
||||
|
||||
type Defaults = {
|
||||
temperature_unit?: string
|
||||
distance_unit?: DistanceUnit
|
||||
dark_mode?: string | boolean
|
||||
time_format?: string
|
||||
default_currency?: string
|
||||
@@ -27,18 +37,22 @@ type Defaults = {
|
||||
map_provider?: string
|
||||
mapbox_access_token?: string
|
||||
mapbox_style?: string
|
||||
maplibre_style?: string
|
||||
mapbox_3d_enabled?: boolean
|
||||
mapbox_quality_mode?: boolean
|
||||
}
|
||||
|
||||
const MAPBOX_STYLE_PRESETS = [
|
||||
{ name: 'Standard', url: 'mapbox://styles/mapbox/standard' },
|
||||
{ name: 'Streets', url: 'mapbox://styles/mapbox/streets-v12' },
|
||||
{ name: 'Outdoors', url: 'mapbox://styles/mapbox/outdoors-v12' },
|
||||
{ name: 'Light', url: 'mapbox://styles/mapbox/light-v11' },
|
||||
{ name: 'Dark', url: 'mapbox://styles/mapbox/dark-v11' },
|
||||
{ name: 'Satellite Streets', url: 'mapbox://styles/mapbox/satellite-streets-v12' },
|
||||
]
|
||||
type MapProvider = 'leaflet' | GlMapProvider
|
||||
|
||||
function normalizeProvider(value: unknown): MapProvider {
|
||||
return value === 'mapbox-gl' || value === 'maplibre-gl' ? value : 'leaflet'
|
||||
}
|
||||
|
||||
function styleForProvider(provider: MapProvider, style?: string | null): string {
|
||||
if (provider === 'leaflet') return style || MAPBOX_DEFAULT_STYLE
|
||||
if (provider === 'mapbox-gl' && isOpenFreeMapStyle(style)) return MAPBOX_DEFAULT_STYLE
|
||||
return normalizeStyleForProvider(provider, style)
|
||||
}
|
||||
|
||||
function OptionRow({
|
||||
label,
|
||||
@@ -75,7 +89,7 @@ function OptionButton({
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
|
||||
fontFamily: 'inherit', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500,
|
||||
border: active ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: active ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
@@ -98,10 +112,11 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
|
||||
useEffect(() => {
|
||||
adminApi.getDefaultUserSettings().then((data: Defaults) => {
|
||||
const provider = normalizeProvider(data.map_provider)
|
||||
setDefaults(data)
|
||||
setMapTileUrl(data.map_tile_url || '')
|
||||
setMapboxToken(data.mapbox_access_token || '')
|
||||
setMapboxStyle(data.mapbox_style || '')
|
||||
setMapboxStyle(provider === 'leaflet' ? (data.mapbox_style || '') : styleForProvider(provider, provider === 'maplibre-gl' ? data.maplibre_style : data.mapbox_style))
|
||||
setLoaded(true)
|
||||
}).catch(() => setLoaded(true))
|
||||
}, [])
|
||||
@@ -122,7 +137,10 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
setDefaults(updated)
|
||||
if (key === 'map_tile_url') setMapTileUrl('')
|
||||
if (key === 'mapbox_access_token') setMapboxToken('')
|
||||
if (key === 'mapbox_style') setMapboxStyle('')
|
||||
if (key === 'mapbox_style' || key === 'maplibre_style') {
|
||||
const provider = normalizeProvider(defaults.map_provider)
|
||||
setMapboxStyle(provider === 'leaflet' ? '' : defaultStyleForProvider(provider))
|
||||
}
|
||||
toast.success(t('admin.defaultSettings.reset'))
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : t('common.error'))
|
||||
@@ -168,10 +186,24 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
}], [])
|
||||
|
||||
if (!loaded) {
|
||||
return <p className="text-content-faint" style={{ fontSize: 12, fontStyle: 'italic', padding: 16 }}>Loading…</p>
|
||||
return <p className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontStyle: 'italic', padding: 16 }}>Loading…</p>
|
||||
}
|
||||
|
||||
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 (
|
||||
<Section title={t('admin.defaultSettings.title')} icon={Settings2}>
|
||||
@@ -212,6 +244,22 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
))}
|
||||
</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 */}
|
||||
<OptionRow label={<>{t('settings.timeFormat')} <ResetButton field="time_format" /></>}>
|
||||
{([
|
||||
@@ -316,19 +364,21 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
{([
|
||||
{ value: 'leaflet', label: t('admin.defaultSettings.providerLeaflet') },
|
||||
{ value: 'mapbox-gl', label: t('admin.defaultSettings.providerMapbox') },
|
||||
{ value: 'maplibre-gl', label: t('admin.defaultSettings.providerMapLibre') },
|
||||
] as const).map(opt => (
|
||||
<OptionButton
|
||||
key={opt.value}
|
||||
active={(defaults.map_provider || 'leaflet') === opt.value}
|
||||
onClick={() => save({ map_provider: opt.value })}
|
||||
active={mapProvider === opt.value}
|
||||
onClick={() => saveMapProvider(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
|
||||
{defaults.map_provider === 'mapbox-gl' && (
|
||||
{mapProvider !== 'leaflet' && (
|
||||
<div style={{ marginTop: 16, display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||
{mapProvider === 'mapbox-gl' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
||||
{t('admin.defaultSettings.mapboxToken')}
|
||||
@@ -346,17 +396,18 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
/>
|
||||
<p className="text-xs mt-1 text-content-faint">{t('admin.defaultSettings.mapboxTokenHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
||||
{t('admin.defaultSettings.mapboxStyle')}
|
||||
<ResetButton field="mapbox_style" />
|
||||
<ResetButton field={styleKey} />
|
||||
</label>
|
||||
<CustomSelect
|
||||
value={mapboxStyle}
|
||||
onChange={(value: string) => { if (value) { setMapboxStyle(value); save({ mapbox_style: value }) } }}
|
||||
onChange={(value: string) => { if (value) { setMapboxStyle(value); save({ [styleKey]: value }) } }}
|
||||
placeholder={t('admin.defaultSettings.mapboxStylePlaceholder')}
|
||||
options={MAPBOX_STYLE_PRESETS.map(p => ({ value: p.url, label: p.name }))}
|
||||
options={glStylePresets.map(p => ({ value: p.url, label: p.name }))}
|
||||
size="sm"
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
@@ -364,12 +415,18 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
type="text"
|
||||
value={mapboxStyle}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapboxStyle(e.target.value)}
|
||||
onBlur={() => save({ mapbox_style: mapboxStyle })}
|
||||
placeholder="mapbox://styles/mapbox/standard"
|
||||
onBlur={() => {
|
||||
const nextStyle = normalizeStyleForProvider(mapProvider, mapboxStyle)
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{mapProvider === 'mapbox-gl' && (
|
||||
<>
|
||||
<OptionRow label={<>{t('admin.defaultSettings.mapbox3d')} <ResetButton field="mapbox_3d_enabled" /></>}>
|
||||
{([
|
||||
{ value: true, label: t('settings.on') || 'On' },
|
||||
@@ -391,6 +448,8 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
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
|
||||
)
|
||||
}
|
||||
@@ -2,8 +2,8 @@ export const CURRENCIES = [
|
||||
'EUR', 'USD', 'GBP', 'JPY', 'CHF', 'CZK', 'PLN', 'SEK', 'NOK', 'DKK',
|
||||
'TRY', 'THB', 'AUD', 'CAD', 'NZD', 'BRL', 'MXN', 'INR', 'IDR', 'MYR',
|
||||
'PHP', 'SGD', 'KRW', 'CNY', 'HKD', 'TWD', 'ZAR', 'AED', 'SAR', 'ILS',
|
||||
'EGP', 'MAD', 'HUF', 'RON', 'BGN', 'HRK', 'ISK', 'RUB', 'UAH', 'BDT',
|
||||
'LKR', 'VND', 'CLP', 'COP', 'PEN', 'ARS',
|
||||
'EGP', 'MAD', 'HUF', 'RON', 'BGN', 'HRK', 'ISK', 'RUB', 'UAH', 'KGS',
|
||||
'BDT', 'LKR', 'VND', 'CLP', 'COP', 'PEN', 'ARS',
|
||||
]
|
||||
|
||||
export const SYMBOLS: Record<string, string> = {
|
||||
@@ -13,8 +13,8 @@ export const SYMBOLS: Record<string, string> = {
|
||||
PHP: '₱', SGD: 'S$', KRW: '₩', CNY: '¥', HKD: 'HK$', TWD: 'NT$',
|
||||
ZAR: 'R', AED: 'د.إ', SAR: '﷼', ILS: '₪', EGP: 'E£', MAD: 'MAD',
|
||||
HUF: 'Ft', RON: 'lei', BGN: 'лв', HRK: 'kn', ISK: 'kr', RUB: '₽',
|
||||
UAH: '₴', BDT: '৳', LKR: 'Rs', VND: '₫', CLP: 'CL$', COP: 'CO$',
|
||||
PEN: 'S/.', ARS: 'AR$',
|
||||
UAH: '₴', KGS: 'сом', BDT: '৳', LKR: 'Rs', VND: '₫', CLP: 'CL$',
|
||||
COP: 'CO$', PEN: 'S/.', ARS: 'AR$',
|
||||
}
|
||||
|
||||
export const PIE_COLORS = ['#6366f1', '#ec4899', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', '#ef4444', '#14b8a6', '#f97316', '#06b6d4', '#84cc16', '#a855f7']
|
||||
|
||||
@@ -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' }}>
|
||||
<Calculator size={28} color="#6b7280" />
|
||||
</div>
|
||||
<h2 style={{ fontSize: 20, fontWeight: 700, color: 'var(--text-primary)', margin: '0 0 8px' }}>{t('budget.emptyTitle')}</h2>
|
||||
<p style={{ fontSize: 14, color: 'var(--text-muted)', margin: '0 0 24px', lineHeight: 1.5 }}>{t('budget.emptyText')}</p>
|
||||
<h2 style={{ fontSize: 'calc(20px * var(--fs-scale-title, 1))', 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>
|
||||
{canEdit && (
|
||||
<div style={{ display: 'flex', gap: 6, justifyContent: 'center', alignItems: 'stretch', maxWidth: 320, margin: '0 auto' }}>
|
||||
<input value={newCategoryName} onChange={e => setNewCategoryName(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleAddCategory()}
|
||||
placeholder={t('budget.emptyPlaceholder')}
|
||||
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 }} />
|
||||
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 }} />
|
||||
<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 }}>
|
||||
<Plus size={16} />
|
||||
@@ -65,7 +65,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
padding: '14px 16px 14px 22px',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, flexWrap: 'wrap',
|
||||
}}>
|
||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em', flexShrink: 0 }}>
|
||||
<h2 style={{ margin: 0, fontSize: 'calc(18px * var(--fs-scale-subtitle, 1))', fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em', flexShrink: 0 }}>
|
||||
{t('budget.title')}
|
||||
</h2>
|
||||
<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)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleAddCategory() }}
|
||||
placeholder={t('budget.categoryName')}
|
||||
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)' }}
|
||||
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)' }}
|
||||
/>
|
||||
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
|
||||
title={t('budget.addCategory')}
|
||||
style={{
|
||||
appearance: 'none', border: 'none', cursor: newCategoryName.trim() ? 'pointer' : 'default', fontFamily: 'inherit',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
padding: '9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
|
||||
padding: '9px 14px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500,
|
||||
background: 'var(--accent)', color: 'var(--accent-text)', flexShrink: 0,
|
||||
opacity: newCategoryName.trim() ? 1 : 0.4,
|
||||
transition: 'opacity 0.15s ease',
|
||||
@@ -105,7 +105,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
style={{
|
||||
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
padding: '9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
|
||||
padding: '9px 14px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500,
|
||||
background: 'var(--accent)', color: 'var(--accent-text)', flexShrink: 0,
|
||||
transition: 'opacity 0.15s ease',
|
||||
}}
|
||||
|
||||
@@ -23,7 +23,7 @@ export default function AddItemRow({ onAdd, t }: AddItemRowProps) {
|
||||
setTimeout(() => nameRef.current?.focus(), 50)
|
||||
}
|
||||
|
||||
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)' }
|
||||
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)' }
|
||||
|
||||
return (
|
||||
<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()}
|
||||
placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 60, margin: '0 auto' }} />
|
||||
</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: 12, textAlign: 'center' }}>-</td>
|
||||
<td className="hidden lg: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: 'calc(12px * var(--fs-scale-body, 1))', 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 sm:table-cell" style={{ padding: '4px 6px', textAlign: 'center' }}>
|
||||
<div style={{ maxWidth: 90, margin: '0 auto' }}>
|
||||
<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 })}
|
||||
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) }}
|
||||
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%' }}
|
||||
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%' }}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<span style={{ fontWeight: 600, fontSize: 13 }}>{cat}</span>
|
||||
<span style={{ fontWeight: 600, fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>{cat}</span>
|
||||
{canEdit && (
|
||||
<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 }}
|
||||
@@ -119,7 +119,7 @@ export default function BudgetCategoryTable({ cat, grouped, categoryColor, canEd
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 500, opacity: 0.9 }}>{fmt(subtotal, currency)}</span>
|
||||
<span style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, opacity: 0.9 }}>{fmt(subtotal, currency)}</span>
|
||||
{canEdit && (
|
||||
<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 }}
|
||||
@@ -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 />
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ fontSize: 11, color: item.expense_date ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{item.expense_date || '—'}</span>
|
||||
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: item.expense_date ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{item.expense_date || '—'}</span>
|
||||
)}
|
||||
</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}
|
||||
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) } }}
|
||||
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 }}
|
||||
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 }}
|
||||
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}
|
||||
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',
|
||||
color: display ? 'var(--text-primary)' : 'var(--text-faint)', fontSize: 13, ...style }}
|
||||
color: display ? 'var(--text-primary)' : 'var(--text-faint)', fontSize: 'calc(13px * var(--fs-scale-body, 1))', ...style }}
|
||||
onMouseEnter={e => { if (!readOnly) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { if (!readOnly) e.currentTarget.style.background = 'transparent' }}>
|
||||
{display || placeholder || '-'}
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface TripMember {
|
||||
id: number
|
||||
username: string
|
||||
avatar_url?: string | null
|
||||
is_guest?: boolean
|
||||
}
|
||||
|
||||
// ── Chip with custom tooltip ─────────────────────────────────────────────────
|
||||
@@ -56,13 +57,13 @@ export function ChipWithTooltip({ label, avatarUrl, size = 20, paid, onClick }:
|
||||
pointerEvents: 'none', zIndex: 10000, whiteSpace: 'nowrap',
|
||||
display: 'flex', alignItems: 'center', gap: 5,
|
||||
background: 'var(--bg-card, white)', color: 'var(--text-primary, #111827)',
|
||||
fontSize: 11, fontWeight: 500, padding: '5px 10px', borderRadius: 8,
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500, padding: '5px 10px', borderRadius: 8,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint, #e5e7eb)',
|
||||
}}>
|
||||
{label}
|
||||
{paid && (
|
||||
<span style={{
|
||||
fontSize: 9, fontWeight: 700, padding: '1px 5px', borderRadius: 4,
|
||||
fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 700, padding: '1px 5px', borderRadius: 4,
|
||||
background: 'rgba(34,197,94,0.15)', color: '#16a34a',
|
||||
textTransform: 'uppercase', letterSpacing: '0.03em',
|
||||
}}>Paid</span>
|
||||
@@ -151,14 +152,14 @@ export default function BudgetMemberChips({ members = [], tripMembers = [], onSe
|
||||
<button key={tm.id} onClick={() => toggleMember(tm.id)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, width: '100%', padding: '5px 8px',
|
||||
borderRadius: 6, border: 'none', background: isActive ? 'var(--bg-hover)' : 'none', cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 11, color: 'var(--text-primary)', textAlign: 'left',
|
||||
fontFamily: 'inherit', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-primary)', textAlign: 'left',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isActive) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'none' }}
|
||||
>
|
||||
<div style={{
|
||||
width: 18, height: 18, borderRadius: '50%', background: 'var(--bg-tertiary)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 8, fontWeight: 700,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 'calc(8px * var(--fs-scale-caption, 1))', fontWeight: 700,
|
||||
color: 'var(--text-muted)', overflow: 'hidden', flexShrink: 0,
|
||||
}}>
|
||||
{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' }}>
|
||||
<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={{ fontSize: 13.5, fontWeight: 500, letterSpacing: '-0.01em', color: theme.text }}>{p.username}</div>
|
||||
<div style={{ fontSize: 11, color: theme.faint, marginTop: 1 }}>{percent}%</div>
|
||||
<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: 'calc(11px * var(--fs-scale-caption, 1))', color: theme.faint, marginTop: 1 }}>{percent}%</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 13.5, fontWeight: 600, color: theme.text, letterSpacing: '-0.01em' }}>{fmt(p.total_assigned)}</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>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -46,7 +46,7 @@ export default function PieChart({ segments, size = 200, totalLabel }: PieChartP
|
||||
boxShadow: 'inset 0 0 12px rgba(0,0,0,0.04)',
|
||||
}}>
|
||||
<Wallet size={18} color="var(--text-faint)" style={{ marginBottom: 2 }} />
|
||||
<span style={{ fontSize: 10, color: 'var(--text-faint)', fontWeight: 500 }}>{totalLabel}</span>
|
||||
<span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', fontWeight: 500 }}>{totalLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -47,7 +47,7 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has
|
||||
<Wallet size={20} strokeWidth={2} />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 11, color: theme.faint, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.09em' }}>{t('budget.totalBudget')}</div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -58,13 +58,13 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has
|
||||
const [integerPart, decimalPart] = decimals > 0 ? full.split(sep) : [full, '']
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 4, letterSpacing: '-0.03em', lineHeight: 1 }}>
|
||||
<span style={{ fontSize: 38, fontWeight: 700 }}>{integerPart}</span>
|
||||
{decimalPart && <span style={{ fontSize: 22, fontWeight: 500, color: theme.sub }}>{sep}{decimalPart}</span>}
|
||||
<span style={{ fontSize: 22, fontWeight: 500, color: theme.sub, marginLeft: 2 }}>{SYMBOLS[currency] || currency}</span>
|
||||
<span style={{ fontSize: 'calc(38px * var(--fs-scale-title, 1))', fontWeight: 700 }}>{integerPart}</span>
|
||||
{decimalPart && <span style={{ fontSize: 'calc(22px * var(--fs-scale-title, 1))', 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>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
<div style={{ color: theme.faint, fontSize: 12, marginTop: 8, fontWeight: 500, letterSpacing: '0.04em', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<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 }}>
|
||||
<span>{currency}</span>
|
||||
</div>
|
||||
|
||||
@@ -78,7 +78,7 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has
|
||||
<button onClick={() => setSettlementOpen(v => !v)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, width: '100%',
|
||||
background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit',
|
||||
color: theme.sub, fontSize: 11, fontWeight: 600, letterSpacing: 0.5,
|
||||
color: theme.sub, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, letterSpacing: 0.5,
|
||||
}}>
|
||||
{settlementOpen ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
|
||||
{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,
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border-faint)',
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.12)',
|
||||
fontSize: 11, fontWeight: 400, color: 'var(--text-secondary)', lineHeight: 1.5, textAlign: 'left',
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 400, color: 'var(--text-secondary)', lineHeight: 1.5, textAlign: 'left',
|
||||
}}>
|
||||
{t('budget.settlementInfo')}
|
||||
</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} />
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 5 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 700, color: '#ef4444', letterSpacing: '-0.01em' }}>
|
||||
<span style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 700, color: '#ef4444', letterSpacing: '-0.01em' }}>
|
||||
{fmt(flow.amount, currency)}
|
||||
</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' }}>
|
||||
@@ -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 && (
|
||||
<div style={{ marginTop: 8, borderTop: `1px solid ${theme.divider}`, paddingTop: 12 }}>
|
||||
<div style={{ fontSize: 10, fontWeight: 700, color: theme.faint, textTransform: 'uppercase', letterSpacing: '0.11em', marginBottom: 10 }}>
|
||||
<div style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, color: theme.faint, textTransform: 'uppercase', letterSpacing: '0.11em', marginBottom: 10 }}>
|
||||
{t('budget.netBalances')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
@@ -140,13 +140,13 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has
|
||||
return (
|
||||
<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} />
|
||||
<span style={{ flex: 1, fontSize: 13, color: theme.text, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
<span style={{ flex: 1, fontSize: 'calc(13px * var(--fs-scale-body, 1))', color: theme.text, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{b.username}
|
||||
</span>
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
padding: '4px 10px', borderRadius: 8,
|
||||
fontSize: 12, fontWeight: 700, letterSpacing: '-0.01em',
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 700, letterSpacing: '-0.01em',
|
||||
background: positive ? 'rgba(16,185,129,0.13)' : 'rgba(239,68,68,0.13)',
|
||||
color: positive ? '#10b981' : '#ef4444',
|
||||
}}>
|
||||
@@ -192,7 +192,7 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has
|
||||
<PieChartIcon size={18} strokeWidth={2} />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 11, color: theme.faint, textTransform: 'uppercase', letterSpacing: '0.09em', fontWeight: 600 }}>{t('budget.byCategory')}</div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -226,12 +226,12 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has
|
||||
})}
|
||||
</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={{ fontSize: 10.5, color: theme.faint, textTransform: 'uppercase', letterSpacing: '0.12em', fontWeight: 700 }}>{t('budget.total')}</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 700, letterSpacing: '-0.03em', lineHeight: 1, display: 'flex', alignItems: 'baseline', gap: 2 }}>
|
||||
<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: 'calc(22px * var(--fs-scale-title, 1))', fontWeight: 700, letterSpacing: '-0.03em', lineHeight: 1, display: 'flex', alignItems: 'baseline', gap: 2 }}>
|
||||
<span>{totalInt}</span>
|
||||
{totalDec && <span style={{ fontSize: 13, fontWeight: 500, color: theme.sub }}>{decimalSep}{totalDec}</span>}
|
||||
{totalDec && <span style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, color: theme.sub }}>{decimalSep}{totalDec}</span>}
|
||||
</div>
|
||||
<div style={{ fontSize: 10.5, color: theme.faint, fontWeight: 500, marginTop: 2 }}>{currency}</div>
|
||||
<div style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', color: theme.faint, fontWeight: 500, marginTop: 2 }}>{currency}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -256,13 +256,13 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has
|
||||
boxShadow: `0 0 12px ${seg.color}80`,
|
||||
}} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<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: 11.5, color: theme.sub, fontWeight: 500, marginTop: 1 }}>{fmt(seg.value, currency)}</div>
|
||||
<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: 'calc(11.5px * var(--fs-scale-caption, 1))', color: theme.sub, fontWeight: 500, marginTop: 1 }}>{fmt(seg.value, currency)}</div>
|
||||
</div>
|
||||
<span style={{
|
||||
flexShrink: 0,
|
||||
padding: '4px 9px', borderRadius: 7,
|
||||
fontSize: 11, fontWeight: 700, letterSpacing: '-0.01em',
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, letterSpacing: '-0.01em',
|
||||
background: `${seg.color}26`,
|
||||
border: `1px solid ${seg.color}40`,
|
||||
color: chipColor,
|
||||
|
||||
@@ -91,7 +91,7 @@ describe('CostsPanel — settlements in the ledger', () => {
|
||||
expect(screen.getByText('Dinner')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('auto-splits the total across participants and rebalances a pinned amount on save', async () => {
|
||||
it('supports custom split amounts on save', async () => {
|
||||
let posted: Record<string, unknown> | null = null
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
|
||||
@@ -107,24 +107,52 @@ describe('CostsPanel — settlements in the ledger', () => {
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
|
||||
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Dinner')
|
||||
const nums = () => screen.getAllByRole('spinbutton') as HTMLInputElement[]
|
||||
await user.type(nums()[0], '100') // total → auto equal-split across the 2 participants
|
||||
await waitFor(() => expect(nums()[1].value).toBe('50'))
|
||||
expect(nums()[2].value).toBe('50')
|
||||
// Pin the first participant to 30 → the other non-pinned field rebalances to 70.
|
||||
await user.clear(nums()[1]); await user.type(nums()[1], '30')
|
||||
await waitFor(() => expect(nums()[2].value).toBe('70'))
|
||||
const nums = () => screen.getAllByPlaceholderText('0.00') as HTMLInputElement[]
|
||||
await user.type(nums()[0], '100') // total = 100
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Custom/i }))
|
||||
|
||||
const customInputs = screen.getAllByPlaceholderText('50.00')
|
||||
await user.type(customInputs[0], '30')
|
||||
await user.type(customInputs[1], '70')
|
||||
|
||||
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(100)
|
||||
expect(posted!.payers).toEqual(expect.arrayContaining([
|
||||
expect(posted!.payers).toEqual([
|
||||
expect.objectContaining({ amount: 100 })
|
||||
])
|
||||
expect(posted!.members).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ user_id: 1, amount: 30 }),
|
||||
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 () => {
|
||||
const item = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Hotel' }), total_price: 90, payers: [], members: [{ user_id: 1, username: 'alice', paid: 0 }] }
|
||||
server.use(
|
||||
@@ -135,4 +163,95 @@ describe('CostsPanel — settlements in the ledger', () => {
|
||||
await screen.findByText('Hotel')
|
||||
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:')
|
||||
})
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -647,7 +647,7 @@ describe('CollabChat', () => {
|
||||
let foundBigEmoji = false;
|
||||
while (el) {
|
||||
const styleAttr = el.getAttribute('style');
|
||||
if (styleAttr && styleAttr.includes('font-size: 40px')) {
|
||||
if (styleAttr && styleAttr.includes('font-size: calc(40px')) {
|
||||
foundBigEmoji = true;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8,
|
||||
padding: '6px 10px', borderRadius: 10, background: 'var(--bg-secondary)',
|
||||
borderLeft: '3px solid #007AFF', fontSize: 12, color: 'var(--text-muted)',
|
||||
borderLeft: '3px solid #007AFF', fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-muted)',
|
||||
}}>
|
||||
<Reply size={12} style={{ flexShrink: 0, opacity: 0.5 }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>
|
||||
@@ -67,7 +67,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
disabled={!canEdit}
|
||||
style={{
|
||||
flex: 1, resize: 'none', border: '1px solid var(--border-primary)', borderRadius: 20,
|
||||
padding: '8px 14px', fontSize: 14, lineHeight: 1.4, fontFamily: 'inherit',
|
||||
padding: '8px 14px', fontSize: 'calc(14px * var(--fs-scale-body, 1))', lineHeight: 1.4, fontFamily: 'inherit',
|
||||
background: 'var(--bg-input)', color: 'var(--text-primary)', outline: 'none',
|
||||
maxHeight: 100, overflowY: 'hidden',
|
||||
opacity: canEdit ? 1 : 0.5,
|
||||
|
||||
@@ -49,7 +49,7 @@ export function EmojiPicker({ onSelect, onClose, anchorRef, containerRef }: Emoj
|
||||
<button key={c} onClick={() => setCat(c)} style={{
|
||||
flex: 1, padding: '4px 0', borderRadius: 6, border: 'none', cursor: 'pointer',
|
||||
background: cat === c ? 'var(--bg-hover)' : 'transparent',
|
||||
color: 'var(--text-primary)', fontSize: 10, fontWeight: 600, fontFamily: 'inherit',
|
||||
color: 'var(--text-primary)', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, fontFamily: 'inherit',
|
||||
}}>
|
||||
{c}
|
||||
</button>
|
||||
|
||||
@@ -45,17 +45,17 @@ export function LinkPreview({ url, tripId, own, onLoad }: LinkPreviewProps) {
|
||||
)}
|
||||
<div style={{ padding: '8px 10px' }}>
|
||||
{domain && (
|
||||
<div style={{ fontSize: 10, fontWeight: 600, color: own ? 'rgba(255,255,255,0.5)' : 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3, marginBottom: 2 }}>
|
||||
<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 }}>
|
||||
{data.site_name || domain}
|
||||
</div>
|
||||
)}
|
||||
{data.title && (
|
||||
<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' }}>
|
||||
<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' }}>
|
||||
{data.title}
|
||||
</div>
|
||||
)}
|
||||
{data.description && (
|
||||
<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' }}>
|
||||
<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' }}>
|
||||
{data.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -14,8 +14,8 @@ export function ChatMessages(props: any) {
|
||||
{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' }}>
|
||||
<MessageCircle size={40} strokeWidth={1.2} style={{ opacity: 0.4 }} />
|
||||
<span style={{ fontSize: 14, fontWeight: 600 }}>{t('collab.chat.empty')}</span>
|
||||
<span style={{ fontSize: 12, opacity: 0.6, fontFamily: 'var(--font-subtext)' }}>{t('collab.chat.emptyDesc') || ''}</span>
|
||||
<span style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', 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>
|
||||
</div>
|
||||
) : (
|
||||
<div ref={scrollRef} onScroll={checkAtBottom} className="chat-scroll" style={{
|
||||
@@ -25,7 +25,7 @@ export function ChatMessages(props: any) {
|
||||
{hasMore && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '4px 0 10px' }}>
|
||||
<button onClick={handleLoadMore} disabled={loadingMore} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, fontWeight: 600,
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600,
|
||||
color: 'var(--text-muted)', background: 'var(--bg-secondary)', border: '1px solid var(--border-faint)',
|
||||
borderRadius: 99, padding: '5px 14px', cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
@@ -51,13 +51,13 @@ export function ChatMessages(props: any) {
|
||||
<React.Fragment key={msg.id}>
|
||||
{showDate && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '14px 0 6px' }}>
|
||||
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', background: 'var(--bg-secondary)', padding: '3px 12px', borderRadius: 99, letterSpacing: 0.3, textTransform: 'uppercase' }}>
|
||||
<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' }}>
|
||||
{formatDateSeparator(msg.created_at, t)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '4px 0' }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-faint)', fontStyle: 'italic' }}>
|
||||
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', fontStyle: 'italic' }}>
|
||||
{msg.username} {t('collab.chat.deletedMessage') || 'deleted a message'} · {formatTime(msg.created_at, is12h)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -76,7 +76,7 @@ export function ChatMessages(props: any) {
|
||||
{showDate && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '14px 0 6px' }}>
|
||||
<span style={{
|
||||
fontSize: 10, fontWeight: 600, color: 'var(--text-faint)',
|
||||
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',
|
||||
}}>
|
||||
@@ -103,7 +103,7 @@ export function ChatMessages(props: any) {
|
||||
<div style={{
|
||||
width: 28, height: 28, borderRadius: '50%', background: 'var(--bg-tertiary)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 11, fontWeight: 700, color: 'var(--text-muted)',
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-muted)',
|
||||
}}>
|
||||
{(msg.username || '?')[0].toUpperCase()}
|
||||
</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 }}>
|
||||
{/* Username for others at group start */}
|
||||
{!own && isNewGroup && (
|
||||
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', marginBottom: 2, paddingLeft: 4 }}>
|
||||
<span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', marginBottom: 2, paddingLeft: 4 }}>
|
||||
{msg.username}
|
||||
</span>
|
||||
)}
|
||||
@@ -138,7 +138,7 @@ export function ChatMessages(props: any) {
|
||||
}}
|
||||
>
|
||||
{bigEmoji ? (
|
||||
<div style={{ fontSize: 40, lineHeight: 1.2, padding: '2px 0' }}>
|
||||
<div style={{ fontSize: 'calc(40px * var(--fs-scale-title, 1))', lineHeight: 1.2, padding: '2px 0' }}>
|
||||
{msg.text}
|
||||
</div>
|
||||
) : (
|
||||
@@ -146,16 +146,16 @@ export function ChatMessages(props: any) {
|
||||
background: own ? '#007AFF' : 'var(--bg-secondary)',
|
||||
color: own ? '#fff' : 'var(--text-primary)',
|
||||
borderRadius: br, padding: hasReply ? '4px 4px 8px 4px' : '8px 14px',
|
||||
fontSize: 14, lineHeight: 1.4, wordBreak: 'break-word', whiteSpace: 'pre-wrap',
|
||||
fontSize: 'calc(14px * var(--fs-scale-body, 1))', lineHeight: 1.4, wordBreak: 'break-word', whiteSpace: 'pre-wrap',
|
||||
}}>
|
||||
{/* Inline reply quote */}
|
||||
{hasReply && (
|
||||
<div style={{
|
||||
padding: '5px 10px', marginBottom: 4, borderRadius: 12,
|
||||
background: own ? 'rgba(255,255,255,0.15)' : 'var(--bg-tertiary)',
|
||||
fontSize: 12, lineHeight: 1.3,
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))', lineHeight: 1.3,
|
||||
}}>
|
||||
<div style={{ fontWeight: 600, fontSize: 11, opacity: 0.7, marginBottom: 1 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', opacity: 0.7, marginBottom: 1 }}>
|
||||
{msg.reply_username || ''}
|
||||
</div>
|
||||
<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 */}
|
||||
{isLastInGroup && (
|
||||
<span style={{ fontSize: 9, color: 'var(--text-faint)', marginTop: 2, padding: '0 4px' }}>
|
||||
<span style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginTop: 2, padding: '0 4px' }}>
|
||||
{formatTime(msg.created_at, is12h)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -34,14 +34,14 @@ export function ReactionBadge({ reaction, currentUserId, onReact }: ReactionBadg
|
||||
}}
|
||||
>
|
||||
<TwemojiImg emoji={reaction.emoji} size={16} />
|
||||
{reaction.count > 1 && <span style={{ fontSize: 10, fontWeight: 700, color: 'var(--text-muted)', minWidth: 8 }}>{reaction.count}</span>}
|
||||
{reaction.count > 1 && <span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-muted)', minWidth: 8 }}>{reaction.count}</span>}
|
||||
</button>
|
||||
{hover && names && ReactDOM.createPortal(
|
||||
<div style={{
|
||||
position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)',
|
||||
pointerEvents: 'none', zIndex: 10000, whiteSpace: 'nowrap',
|
||||
background: 'var(--bg-card, white)', color: 'var(--text-primary, #111827)',
|
||||
fontSize: 11, fontWeight: 500, padding: '5px 10px', borderRadius: 8,
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500, padding: '5px 10px', borderRadius: 8,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint, #e5e7eb)',
|
||||
}}>
|
||||
{names}
|
||||
|
||||
@@ -243,7 +243,7 @@ function CollabNotesLoading({ t }: NotesState) {
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', fontFamily: FONT }}>
|
||||
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border-faint)' }}>
|
||||
<h3 style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-primary)', margin: 0, fontFamily: FONT }}>
|
||||
<h3 style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)', margin: 0, fontFamily: FONT }}>
|
||||
{t('collab.notes.title')}
|
||||
</h3>
|
||||
</div>
|
||||
@@ -263,7 +263,7 @@ function CollabNotesHeader({ t, canEdit, setShowSettings, setShowNewModal }: Not
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', flexShrink: 0 }}>
|
||||
<h3 style={{
|
||||
fontSize: 12, fontWeight: 600, color: 'var(--text-muted)', margin: 0, fontFamily: FONT,
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-muted)', margin: 0, fontFamily: FONT,
|
||||
letterSpacing: 0.3, textTransform: 'uppercase', display: 'flex', alignItems: 'center', gap: 7,
|
||||
}}>
|
||||
<StickyNote size={14} color="var(--text-faint)" />
|
||||
@@ -277,7 +277,7 @@ function CollabNotesHeader({ t, canEdit, setShowSettings, setShowNewModal }: Not
|
||||
<Settings size={14} />
|
||||
</button>}
|
||||
{canEdit && <button onClick={() => setShowNewModal(true)}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px', background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 11, fontWeight: 600, fontFamily: FONT, border: 'none', cursor: 'pointer', whiteSpace: 'nowrap' }}>
|
||||
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' }}>
|
||||
<Plus size={12} />
|
||||
{t('collab.notes.new')}
|
||||
</button>}
|
||||
@@ -292,7 +292,7 @@ function CollabCategoryPills({ categories, activeCategory, setActiveCategory, t
|
||||
<button
|
||||
onClick={() => setActiveCategory(null)}
|
||||
style={{
|
||||
flexShrink: 0, borderRadius: 99, padding: '3px 10px', fontSize: 10, fontWeight: 600, fontFamily: FONT,
|
||||
flexShrink: 0, borderRadius: 99, padding: '3px 10px', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, fontFamily: FONT,
|
||||
border: activeCategory === null ? '1px solid var(--accent)' : '1px solid var(--border-faint)',
|
||||
background: activeCategory === null ? 'var(--accent)' : 'transparent',
|
||||
color: activeCategory === null ? 'var(--accent-text)' : 'var(--text-secondary)',
|
||||
@@ -306,7 +306,7 @@ function CollabCategoryPills({ categories, activeCategory, setActiveCategory, t
|
||||
key={cat}
|
||||
onClick={() => setActiveCategory(prev => prev === cat ? null : cat)}
|
||||
style={{
|
||||
flexShrink: 0, borderRadius: 99, padding: '3px 10px', fontSize: 10, fontWeight: 600, fontFamily: FONT,
|
||||
flexShrink: 0, borderRadius: 99, padding: '3px 10px', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, fontFamily: FONT,
|
||||
border: activeCategory === cat ? '1px solid var(--accent)' : '1px solid var(--border-faint)',
|
||||
background: activeCategory === cat ? 'var(--accent)' : 'transparent',
|
||||
color: activeCategory === cat ? 'var(--accent-text)' : 'var(--text-secondary)',
|
||||
@@ -334,10 +334,10 @@ function CollabNotesGrid(S: NotesState) {
|
||||
padding: '48px 20px', textAlign: 'center', height: '100%',
|
||||
}}>
|
||||
<Pencil size={36} color="var(--text-faint)" style={{ marginBottom: 12 }} />
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4, fontFamily: FONT }}>
|
||||
<div style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4, fontFamily: FONT }}>
|
||||
{t('collab.notes.empty')}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-faint)', fontFamily: FONT }}>
|
||||
<div style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-faint)', fontFamily: FONT }}>
|
||||
{t('collab.notes.emptyDesc') || 'Create a note to get started'}
|
||||
</div>
|
||||
</div>
|
||||
@@ -397,10 +397,10 @@ function ViewNoteModal(S: NotesState) {
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
|
||||
}}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 17, fontWeight: 600, color: 'var(--text-primary)' }}>{viewingNote.title}</div>
|
||||
<div style={{ fontSize: 'calc(17px * var(--fs-scale-subtitle, 1))', fontWeight: 600, color: 'var(--text-primary)' }}>{viewingNote.title}</div>
|
||||
{viewingNote.category && (
|
||||
<span style={{
|
||||
display: 'inline-block', marginTop: 4, fontSize: 10, fontWeight: 600,
|
||||
display: 'inline-block', marginTop: 4, fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600,
|
||||
color: getCategoryColor(viewingNote.category),
|
||||
background: `${getCategoryColor(viewingNote.category)}18`,
|
||||
padding: '2px 8px', borderRadius: 6,
|
||||
@@ -422,11 +422,11 @@ function ViewNoteModal(S: NotesState) {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="collab-note-md-full" style={{ padding: '16px 20px', overflowY: 'auto', fontSize: 14, color: 'var(--text-primary)', lineHeight: 1.7 }}>
|
||||
<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 }}>
|
||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{viewingNote.content || ''}</Markdown>
|
||||
{(viewingNote.attachments || []).length > 0 && (
|
||||
<div style={{ marginTop: 16, paddingTop: 16, borderTop: '1px solid var(--border-primary)' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 10 }}>{t('files.title')}</div>
|
||||
<div style={{ 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={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{(viewingNote.attachments || []).map(a => {
|
||||
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)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }}>
|
||||
<span style={{ fontSize: 10, fontWeight: 700, color: a.mime_type === 'application/pdf' ? '#ef4444' : 'var(--text-muted)', letterSpacing: 0.3 }}>{ext}</span>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
<span style={{ fontSize: 9, color: 'var(--text-faint)', textAlign: 'center', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', width: '100%' }}>{a.original_name}</span>
|
||||
<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>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { avatarSrc } from '../../utils/avatarSrc'
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
@@ -28,7 +29,7 @@ interface NoteCardProps {
|
||||
export function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdit, onView, onPreviewFile, getCategoryColor, tripId, t }: NoteCardProps) {
|
||||
const [hovered, setHovered] = useState(false)
|
||||
|
||||
const author = note.author || note.user || { username: note.username, avatar: note.avatar_url || (note.avatar ? `/uploads/avatars/${note.avatar}` : null) }
|
||||
const author = note.author || note.user || { username: note.username, avatar: note.avatar_url || avatarSrc(note.avatar) }
|
||||
const color = getCategoryColor ? getCategoryColor(note.category) : (note.color || '#6366f1')
|
||||
|
||||
const handleTogglePin = useCallback(() => {
|
||||
@@ -63,11 +64,11 @@ export function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdi
|
||||
}}>
|
||||
{!!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={{ fontSize: 11, fontWeight: 700, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{note.title}
|
||||
</span>
|
||||
{note.category && (
|
||||
<span style={{ fontSize: 8, fontWeight: 600, color, background: `${color}18`, padding: '2px 6px', borderRadius: 99, flexShrink: 0, letterSpacing: '0.02em', textTransform: 'uppercase' }}>
|
||||
<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' }}>
|
||||
{note.category}
|
||||
</span>
|
||||
)}
|
||||
@@ -115,7 +116,7 @@ export function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdi
|
||||
marginBottom: 6, pointerEvents: 'none', opacity: 0, transition: 'opacity 0.12s',
|
||||
whiteSpace: 'nowrap', zIndex: 10,
|
||||
background: 'var(--bg-card)', color: 'var(--text-primary)',
|
||||
fontSize: 11, fontWeight: 500, padding: '5px 10px', borderRadius: 8,
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500, padding: '5px 10px', borderRadius: 8,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint)',
|
||||
}}>
|
||||
{author.username}
|
||||
@@ -137,7 +138,7 @@ export function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdi
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{note.content && (
|
||||
<div className="collab-note-md" style={{
|
||||
fontSize: 11.5, color: 'var(--text-muted)', lineHeight: 1.5, margin: 0,
|
||||
fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', color: 'var(--text-muted)', lineHeight: 1.5, margin: 0,
|
||||
maxHeight: '4.5em', overflow: 'hidden',
|
||||
wordBreak: 'break-word', fontFamily: FONT,
|
||||
}}>
|
||||
@@ -151,14 +152,14 @@ export function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdi
|
||||
{/* Website */}
|
||||
{note.website && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
|
||||
<span style={{ fontSize: 7, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3 }}>Link</span>
|
||||
<span style={{ fontSize: 'calc(7px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3 }}>Link</span>
|
||||
<WebsiteThumbnail url={note.website} tripId={tripId} color={color} />
|
||||
</div>
|
||||
)}
|
||||
{/* Files */}
|
||||
{(note.attachments || []).length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
|
||||
<span style={{ fontSize: 7, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3 }}>{t('files.title')}</span>
|
||||
<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>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{(note.attachments || []).slice(0, note.website ? 1 : 2).map(a => {
|
||||
const isImage = a.mime_type?.startsWith('image/')
|
||||
@@ -179,12 +180,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)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }}>
|
||||
<span style={{ fontSize: 9, fontWeight: 700, color: a.mime_type === 'application/pdf' ? '#ef4444' : 'var(--text-muted)', letterSpacing: 0.3 }}>{ext}</span>
|
||||
<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>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{(note.attachments?.length || 0) > (note.website ? 1 : 2) && (
|
||||
<span style={{ fontSize: 8, color: 'var(--text-faint)', textAlign: 'center' }}>+{(note.attachments?.length || 0) - (note.website ? 1 : 2)}</span>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -71,7 +71,7 @@ export function CategorySettingsModal({ onClose, categories, categoryColors, onS
|
||||
}} onClick={e => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px 12px', borderBottom: '1px solid var(--border-faint)' }}>
|
||||
<h3 style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>
|
||||
<h3 style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>
|
||||
{t('collab.notes.categorySettings') || 'Category Settings'}
|
||||
</h3>
|
||||
<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 */}
|
||||
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{allCats.length === 0 && (
|
||||
<p style={{ fontSize: 12, color: 'var(--text-faint)', textAlign: 'center', padding: 16 }}>
|
||||
<p style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-faint)', textAlign: 'center', padding: 16 }}>
|
||||
{t('collab.notes.noCategoriesYet') || 'No categories yet'}
|
||||
</p>
|
||||
)}
|
||||
@@ -119,7 +119,7 @@ export function CategorySettingsModal({ onClose, categories, categoryColors, onS
|
||||
placeholder={t('collab.notes.newCategory')}
|
||||
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',
|
||||
fontSize: 'calc(13px * var(--fs-scale-body, 1))', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none',
|
||||
}} />
|
||||
<button onClick={handleAddCategory} disabled={!newCatName.trim()} style={{
|
||||
background: newCatName.trim() ? 'var(--accent)' : 'var(--border-primary)', color: 'var(--accent-text)',
|
||||
@@ -133,7 +133,7 @@ export function CategorySettingsModal({ onClose, categories, categoryColors, onS
|
||||
{/* Save */}
|
||||
<button onClick={handleSave} style={{
|
||||
width: '100%', borderRadius: 99, padding: '9px 14px', background: 'var(--accent)', color: 'var(--accent-text)',
|
||||
fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', marginTop: 8,
|
||||
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, border: 'none', cursor: 'pointer', marginTop: 8,
|
||||
}}>
|
||||
{t('collab.notes.save')}
|
||||
</button>
|
||||
|
||||
@@ -21,12 +21,12 @@ export function EditableCatName({ name, onRename }: EditableCatNameProps) {
|
||||
if (editing) {
|
||||
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) } }}
|
||||
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' }} />
|
||||
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' }} />
|
||||
}
|
||||
|
||||
return (
|
||||
<span onClick={() => { setValue(name); setEditing(true) }}
|
||||
style={{ flex: 1, fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', cursor: 'pointer', padding: '2px 0' }}
|
||||
style={{ flex: 1, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)', cursor: 'pointer', padding: '2px 0' }}
|
||||
title="Click to rename">
|
||||
{name}
|
||||
</span>
|
||||
|
||||
@@ -37,7 +37,7 @@ export function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
|
||||
: <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' }}>
|
||||
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.7)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '70%' }}>{file.original_name}</span>
|
||||
<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>
|
||||
<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={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 */
|
||||
<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 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{file.original_name}</span>
|
||||
<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>
|
||||
<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: 11, 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: 'calc(11px * var(--fs-scale-caption, 1))', 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>
|
||||
</div>
|
||||
</div>
|
||||
{(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}>
|
||||
<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: 14, padding: 0 }}>Download</button>
|
||||
<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>
|
||||
</p>
|
||||
</object>
|
||||
) : (
|
||||
<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: 14, padding: 0 }}>Download {file.original_name}</button>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -118,7 +118,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
|
||||
borderBottom: '1px solid var(--border-faint)',
|
||||
}}>
|
||||
<h3 style={{
|
||||
fontSize: 14,
|
||||
fontSize: 'calc(14px * var(--fs-scale-body, 1))',
|
||||
fontWeight: 700,
|
||||
color: 'var(--text-primary)',
|
||||
margin: 0,
|
||||
@@ -153,7 +153,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
|
||||
{/* Title */}
|
||||
<div>
|
||||
<div style={{
|
||||
fontSize: 9,
|
||||
fontSize: 'calc(9px * var(--fs-scale-caption, 1))',
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-faint)',
|
||||
textTransform: 'uppercase',
|
||||
@@ -173,7 +173,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
|
||||
border: '1px solid var(--border-primary)',
|
||||
borderRadius: 10,
|
||||
padding: '8px 12px',
|
||||
fontSize: 13,
|
||||
fontSize: 'calc(13px * var(--fs-scale-body, 1))',
|
||||
background: 'var(--bg-input)',
|
||||
color: 'var(--text-primary)',
|
||||
fontFamily: 'inherit',
|
||||
@@ -186,7 +186,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
|
||||
{/* Content */}
|
||||
<div>
|
||||
<div style={{
|
||||
fontSize: 9,
|
||||
fontSize: 'calc(9px * var(--fs-scale-caption, 1))',
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-faint)',
|
||||
textTransform: 'uppercase',
|
||||
@@ -205,7 +205,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
|
||||
border: '1px solid var(--border-primary)',
|
||||
borderRadius: 10,
|
||||
padding: '8px 12px',
|
||||
fontSize: 13,
|
||||
fontSize: 'calc(13px * var(--fs-scale-body, 1))',
|
||||
background: 'var(--bg-input)',
|
||||
color: 'var(--text-primary)',
|
||||
fontFamily: 'inherit',
|
||||
@@ -220,7 +220,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
|
||||
|
||||
{/* Category pills */}
|
||||
<div>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 6, fontFamily: FONT }}>
|
||||
<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 }}>
|
||||
{t('collab.notes.category')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
@@ -229,7 +229,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
|
||||
const active = category === cat
|
||||
return (
|
||||
<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: 11, 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: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: FONT }}>
|
||||
{cat}
|
||||
</button>
|
||||
)
|
||||
@@ -239,17 +239,17 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
|
||||
|
||||
{/* Website */}
|
||||
<div>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4, fontFamily: FONT }}>
|
||||
<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 }}>
|
||||
{t('collab.notes.website')}
|
||||
</div>
|
||||
<input value={website} onChange={e => setWebsite(e.target.value)}
|
||||
placeholder={t('collab.notes.websitePlaceholder')}
|
||||
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' }} />
|
||||
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' }} />
|
||||
</div>
|
||||
|
||||
{/* File attachments */}
|
||||
{canUploadFiles && <div>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4, fontFamily: FONT }}>
|
||||
<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 }}>
|
||||
{t('collab.notes.attachFiles')}
|
||||
</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 = '' }} />
|
||||
@@ -258,7 +258,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
|
||||
{existingAttachments.map(a => {
|
||||
const isImage = a.mime_type?.startsWith('image/')
|
||||
return (
|
||||
<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)' }}>
|
||||
<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)' }}>
|
||||
{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}
|
||||
<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 */}
|
||||
{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: 11, 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: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-muted)' }}>
|
||||
{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' }}>
|
||||
<X size={10} />
|
||||
@@ -277,7 +277,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
|
||||
</div>
|
||||
))}
|
||||
<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: 11, 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: 'calc(11px * var(--fs-scale-caption, 1))', fontFamily: FONT, display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
<Plus size={11} /> {t('files.attach') || 'Add'}
|
||||
</button>
|
||||
</div>
|
||||
@@ -293,7 +293,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
|
||||
padding: '7px 14px',
|
||||
background: canSubmit ? 'var(--accent)' : 'var(--border-primary)',
|
||||
color: canSubmit ? 'var(--accent-text)' : 'var(--text-faint)',
|
||||
fontSize: 12,
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
|
||||
fontWeight: 600,
|
||||
fontFamily: FONT,
|
||||
border: 'none',
|
||||
|
||||
@@ -37,7 +37,7 @@ export function WebsiteThumbnail({ url, tripId, color }: WebsiteThumbnailProps)
|
||||
) : (
|
||||
<>
|
||||
<ExternalLink size={14} color="var(--text-muted)" />
|
||||
<span style={{ fontSize: 7, fontWeight: 600, color: 'var(--text-muted)', maxWidth: 42, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textAlign: 'center' }}>
|
||||
<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' }}>
|
||||
{domain}
|
||||
</span>
|
||||
</>
|
||||
|
||||
@@ -175,7 +175,7 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
|
||||
padding: '8px 0', borderRadius: 10, border: 'none', cursor: 'pointer',
|
||||
background: active ? 'var(--accent)' : 'transparent',
|
||||
color: active ? 'var(--accent-text)' : 'var(--text-muted)',
|
||||
fontSize: 11, fontWeight: 600, fontFamily: 'inherit',
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, fontFamily: 'inherit',
|
||||
transition: 'all 0.15s',
|
||||
}}>
|
||||
{tab.label}
|
||||
|
||||
@@ -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}>
|
||||
<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)' }}>
|
||||
<h3 style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>{t('collab.polls.new')}</h3>
|
||||
<h3 style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', 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>
|
||||
</div>
|
||||
<div style={{ padding: '14px 16px 16px', display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{/* 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: 13, background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box' }} />
|
||||
<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>
|
||||
<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' }} />
|
||||
</div>
|
||||
|
||||
{/* 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={{ 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={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{options.map((opt, i) => (
|
||||
<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}`}
|
||||
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' }} />
|
||||
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' }} />
|
||||
{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>
|
||||
)}
|
||||
</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: 12, 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: 'calc(12px * var(--fs-scale-body, 1))', fontFamily: FONT }}>
|
||||
<Plus size={12} /> {t('collab.polls.addOption')}
|
||||
</button>
|
||||
</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>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)', fontFamily: FONT }}>{t('collab.polls.multiChoice')}</span>
|
||||
<span style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-muted)', fontFamily: FONT }}>{t('collab.polls.multiChoice')}</span>
|
||||
</label>
|
||||
|
||||
{/* Submit */}
|
||||
<button type="submit" disabled={!canSubmit} style={{
|
||||
width: '100%', borderRadius: 99, padding: '9px 14px', background: canSubmit ? 'var(--accent)' : 'var(--border-primary)',
|
||||
color: canSubmit ? 'var(--accent-text)' : 'var(--text-faint)', fontSize: 13, fontWeight: 600, border: 'none', cursor: canSubmit ? 'pointer' : 'default', fontFamily: FONT,
|
||||
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,
|
||||
}}>
|
||||
{submitting ? '...' : t('collab.polls.create')}
|
||||
</button>
|
||||
@@ -168,7 +168,7 @@ function VoterChip({ voter, offset }: VoterChipProps) {
|
||||
style={{
|
||||
width: 18, height: 18, borderRadius: '50%', background: 'var(--bg-tertiary)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 7, fontWeight: 700, color: 'var(--text-muted)', overflow: 'hidden',
|
||||
fontSize: 'calc(7px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-muted)', overflow: 'hidden',
|
||||
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()}
|
||||
@@ -178,7 +178,7 @@ function VoterChip({ voter, offset }: VoterChipProps) {
|
||||
position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)',
|
||||
pointerEvents: 'none', zIndex: 10000, whiteSpace: 'nowrap',
|
||||
background: 'var(--bg-card)', color: 'var(--text-primary)',
|
||||
fontSize: 11, fontWeight: 500, padding: '5px 10px', borderRadius: 8,
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500, padding: '5px 10px', borderRadius: 8,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint)',
|
||||
}}>
|
||||
{voter.username}
|
||||
@@ -217,26 +217,26 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
|
||||
background: isClosed ? 'var(--bg-secondary)' : 'transparent',
|
||||
}}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.35, wordBreak: 'break-word' }}>
|
||||
<div style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.35, wordBreak: 'break-word' }}>
|
||||
{poll.question}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 4, flexWrap: 'wrap' }}>
|
||||
{isClosed && (
|
||||
<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 }}>
|
||||
<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 }}>
|
||||
<Lock size={8} /> {t('collab.polls.closed')}
|
||||
</span>
|
||||
)}
|
||||
{remaining && !isClosed && (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 9, fontWeight: 600, color: '#f59e0b', background: '#f59e0b18', padding: '2px 7px', borderRadius: 99 }}>
|
||||
<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 }}>
|
||||
<Clock size={8} /> {remaining}
|
||||
</span>
|
||||
)}
|
||||
{poll.multi_choice && (
|
||||
<span style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', background: 'var(--bg-tertiary)', padding: '2px 7px', borderRadius: 99 }}>
|
||||
<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 }}>
|
||||
{t('collab.polls.multiChoice')}
|
||||
</span>
|
||||
)}
|
||||
<span style={{ fontSize: 9, color: 'var(--text-faint)' }}>
|
||||
<span style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}>
|
||||
{total} {total === 1 ? 'vote' : 'votes'}
|
||||
</span>
|
||||
</div>
|
||||
@@ -303,7 +303,7 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
|
||||
|
||||
{/* Label */}
|
||||
<span style={{
|
||||
flex: 1, fontSize: 13, fontWeight: myVote || isWinner ? 600 : 400,
|
||||
flex: 1, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: myVote || isWinner ? 600 : 400,
|
||||
color: 'var(--text-primary)', position: 'relative', zIndex: 1,
|
||||
}}>
|
||||
{typeof opt === 'string' ? opt : opt.text}
|
||||
@@ -321,7 +321,7 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
|
||||
{/* Percentage */}
|
||||
{(hasVoted || isClosed) && (
|
||||
<span style={{
|
||||
fontSize: 12, fontWeight: 700, color: myVote ? '#007AFF' : 'var(--text-muted)',
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 700, color: myVote ? '#007AFF' : 'var(--text-muted)',
|
||||
position: 'relative', zIndex: 1, minWidth: 32, textAlign: 'right',
|
||||
}}>
|
||||
{pct}%
|
||||
@@ -443,14 +443,14 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', fontFamily: FONT }}>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', flexShrink: 0 }}>
|
||||
<h3 style={{ margin: 0, fontSize: 12, fontWeight: 600, color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 7, letterSpacing: 0.3, textTransform: 'uppercase' }}>
|
||||
<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' }}>
|
||||
<BarChart3 size={14} color="var(--text-faint)" />
|
||||
{t('collab.polls.title')}
|
||||
</h3>
|
||||
{canEdit && (
|
||||
<button onClick={() => setShowForm(true)} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px',
|
||||
background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 11, fontWeight: 600,
|
||||
background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600,
|
||||
fontFamily: FONT, border: 'none', cursor: 'pointer',
|
||||
}}>
|
||||
<Plus size={12} /> {t('collab.polls.new')}
|
||||
@@ -463,8 +463,8 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
|
||||
{polls.length === 0 ? (
|
||||
<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 }} />
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>{t('collab.polls.empty')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-faint)' }}>{t('collab.polls.emptyHint')}</div>
|
||||
<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: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-faint)' }}>{t('collab.polls.emptyHint')}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
@@ -474,7 +474,7 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
|
||||
{closedPolls.length > 0 && (
|
||||
<>
|
||||
{activePolls.length > 0 && (
|
||||
<div style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3, padding: '8px 0 2px' }}>
|
||||
<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' }}>
|
||||
{t('collab.polls.closedSection') || 'Closed'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import { avatarSrc } from '../../utils/avatarSrc'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@@ -91,7 +92,7 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
|
||||
padding: '10px 14px', display: 'flex', alignItems: 'center', gap: 7, flexShrink: 0,
|
||||
}}>
|
||||
<Sparkles size={14} color="var(--text-faint)" />
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-muted)', letterSpacing: 0.3, textTransform: 'uppercase' }}>
|
||||
<span style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-muted)', letterSpacing: 0.3, textTransform: 'uppercase' }}>
|
||||
{t('collab.whatsNext.title') || "What's Next"}
|
||||
</span>
|
||||
</div>
|
||||
@@ -101,8 +102,8 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
|
||||
{upcoming.length === 0 ? (
|
||||
<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 }} />
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>{t('collab.whatsNext.empty')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-faint)' }}>{t('collab.whatsNext.emptyHint')}</div>
|
||||
<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: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-faint)' }}>{t('collab.whatsNext.emptyHint')}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
@@ -114,7 +115,7 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
|
||||
<React.Fragment key={item.id}>
|
||||
{showDayHeader && (
|
||||
<div style={{
|
||||
fontSize: 10, fontWeight: 500, color: 'var(--text-faint)',
|
||||
fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 500, color: 'var(--text-faint)',
|
||||
textTransform: 'uppercase', letterSpacing: 0.5,
|
||||
padding: idx === 0 ? '0 4px 4px' : '8px 4px 4px',
|
||||
}}>
|
||||
@@ -132,15 +133,15 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
|
||||
>
|
||||
{/* Time column */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minWidth: 44, flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-primary)', whiteSpace: 'nowrap', lineHeight: 1 }}>
|
||||
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-primary)', whiteSpace: 'nowrap', lineHeight: 1 }}>
|
||||
{item.time ? formatTime(item.time, is12h) : 'TBD'}
|
||||
</span>
|
||||
{item.endTime && (
|
||||
<>
|
||||
<span style={{ fontSize: 7, color: 'var(--text-faint)', fontWeight: 600, letterSpacing: 0.3, margin: '2px 0', textTransform: 'uppercase' }}>
|
||||
<span style={{ fontSize: 'calc(7px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', fontWeight: 600, letterSpacing: 0.3, margin: '2px 0', textTransform: 'uppercase' }}>
|
||||
{t('collab.whatsNext.until') || 'bis'}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-primary)', whiteSpace: 'nowrap', lineHeight: 1 }}>
|
||||
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-primary)', whiteSpace: 'nowrap', lineHeight: 1 }}>
|
||||
{formatTime(item.endTime, is12h)}
|
||||
</span>
|
||||
</>
|
||||
@@ -152,13 +153,13 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
|
||||
|
||||
{/* Details */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-primary)', lineHeight: 1.3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
<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' }}>
|
||||
{item.name}
|
||||
</div>
|
||||
{item.address && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 3, marginTop: 2 }}>
|
||||
<MapPin size={9} color="var(--text-faint)" style={{ flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 10, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
<span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{item.address}
|
||||
</span>
|
||||
</div>
|
||||
@@ -175,15 +176,15 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
|
||||
<div style={{
|
||||
width: 16, height: 16, borderRadius: '50%', background: 'var(--bg-secondary)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 7, fontWeight: 700, color: 'var(--text-muted)',
|
||||
fontSize: 'calc(7px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-muted)',
|
||||
overflow: 'hidden', flexShrink: 0,
|
||||
}}>
|
||||
{p.avatar
|
||||
? <img src={`/uploads/avatars/${p.avatar}`} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
? <img src={avatarSrc(p.avatar)!} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: p.username?.[0]?.toUpperCase()
|
||||
}
|
||||
</div>
|
||||
<span style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)' }}>{p.username}</span>
|
||||
<span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 500, color: 'var(--text-muted)' }}>{p.username}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import { Search, MapPin, Plus, Loader2, Link2, Trash2, Check, X } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import MarkdownToolbar from '../Journey/MarkdownToolbar'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { collectionsApi } from '../../api/collections'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
import { normalizeLinkUrl, STATUS_META, STATUS_ORDER } from '../../pages/collections/collectionsModel'
|
||||
import type { Category, TranslationFn } from '../../types'
|
||||
import type { CollectionLink, CollectionStatus } from '@trek/shared'
|
||||
|
||||
type MapsPlace = Record<string, unknown>
|
||||
const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined)
|
||||
const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : typeof v === 'string' && v !== '' ? Number(v) : undefined)
|
||||
|
||||
interface AddPlaceToCollectionModalProps {
|
||||
isOpen: boolean
|
||||
collectionId: number
|
||||
collectionName: string
|
||||
categories: Category[]
|
||||
onClose: () => void
|
||||
onAdded: () => void
|
||||
t: TranslationFn
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a place to the current list — everything in one view: a search field that
|
||||
* fills in the location when a result is picked, plus name / category / status /
|
||||
* markdown description / links, all editable together before saving. Stays open
|
||||
* after each add so several places can be added in a row.
|
||||
*/
|
||||
export default function AddPlaceToCollectionModal({ isOpen, collectionId, collectionName, categories, onClose, onAdded, t }: AddPlaceToCollectionModalProps): React.ReactElement {
|
||||
const { language } = useTranslation()
|
||||
const toast = useToast()
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState<MapsPlace[]>([])
|
||||
const [searching, setSearching] = useState(false)
|
||||
// The picked location (address/coords/ids) plus the editable fields.
|
||||
const [picked, setPicked] = useState<MapsPlace | null>(null)
|
||||
const [name, setName] = useState('')
|
||||
const [categoryId, setCategoryId] = useState<number | null>(null)
|
||||
const [description, setDescription] = useState('')
|
||||
const [links, setLinks] = useState<CollectionLink[]>([])
|
||||
const [status, setStatus] = useState<CollectionStatus>('idea')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const descRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const reset = () => { setQuery(''); setResults([]); setPicked(null); setName(''); setCategoryId(null); setDescription(''); setLinks([]); setStatus('idea') }
|
||||
useEffect(() => { if (!isOpen) reset() }, [isOpen])
|
||||
|
||||
const search = async () => {
|
||||
if (!query.trim()) return
|
||||
setSearching(true)
|
||||
try {
|
||||
const res = await mapsApi.search(query, language)
|
||||
setResults((res.places as MapsPlace[]) || [])
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('places.mapsSearchError')))
|
||||
} finally {
|
||||
setSearching(false)
|
||||
}
|
||||
}
|
||||
|
||||
const pick = (r: MapsPlace) => { setPicked(r); setName(str(r.name) ?? ''); setResults([]); setQuery(str(r.name) ?? query) }
|
||||
const setLink = (i: number, patch: Partial<CollectionLink>) => setLinks(links.map((l, idx) => (idx === i ? { ...l, ...patch } : l)))
|
||||
|
||||
const save = async () => {
|
||||
const cleanName = name.trim()
|
||||
if (!cleanName) return
|
||||
const cleanLinks = links.map(l => ({ label: l.label?.trim() || undefined, url: normalizeLinkUrl(l.url) })).filter(l => l.url)
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await collectionsApi.savePlace({
|
||||
collection_id: collectionId,
|
||||
name: cleanName,
|
||||
address: (picked && str(picked.address)) ?? null,
|
||||
lat: (picked && num(picked.lat)) ?? null,
|
||||
lng: (picked && num(picked.lng)) ?? null,
|
||||
google_place_id: (picked && str(picked.google_place_id)) ?? null,
|
||||
google_ftid: (picked && str(picked.google_ftid)) ?? null,
|
||||
osm_id: (picked && str(picked.osm_id)) ?? null,
|
||||
website: (picked && str(picked.website)) ?? null,
|
||||
phone: (picked && str(picked.phone)) ?? null,
|
||||
category_id: categoryId,
|
||||
description: description.trim() || null,
|
||||
links: cleanLinks,
|
||||
status,
|
||||
force: true,
|
||||
})
|
||||
if (res.duplicate) toast.info(t('collections.duplicateWarning'))
|
||||
else { toast.success(t('collections.addedToList', { name: collectionName })); onAdded() }
|
||||
reset()
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const address = picked ? str(picked.address) : undefined
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={t('collections.addPlace')}
|
||||
size="md"
|
||||
footer={
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" onClick={onClose} className="px-3 py-1.5 rounded-lg border border-edge text-content-secondary text-[13px] hover:bg-surface-hover">{t('common.cancel')}</button>
|
||||
<button type="button" onClick={save} disabled={saving || !name.trim()} className="px-3 py-1.5 rounded-lg bg-accent text-accent-text text-[13px] font-semibold disabled:opacity-50 inline-flex items-center gap-1.5">
|
||||
{saving ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />} {t('common.add')}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Search — picking a result fills the location below */}
|
||||
<div className="relative">
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-content-faint" />
|
||||
<input
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); search() } }}
|
||||
placeholder={t('collections.addPlaceSearch')}
|
||||
className="w-full pl-9 pr-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[14px] outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
<button type="button" onClick={search} disabled={!query.trim() || searching} className="px-4 py-2 rounded-lg bg-accent text-accent-text text-[13px] font-semibold disabled:opacity-50 inline-flex items-center gap-2">
|
||||
{searching ? <Loader2 size={15} className="animate-spin" /> : <Search size={15} />}
|
||||
{t('common.search')}
|
||||
</button>
|
||||
</div>
|
||||
{results.length > 0 && (
|
||||
<div className="absolute z-20 left-0 right-0 mt-1.5 max-h-[280px] overflow-y-auto rounded-xl border border-edge bg-surface-card shadow-lg p-1.5 flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between px-2 py-1">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-content-faint">{t('common.search')}</span>
|
||||
<button type="button" onClick={() => setResults([])} className="p-1 rounded-md text-content-faint hover:text-content hover:bg-surface-hover" aria-label={t('common.close')}><X size={13} /></button>
|
||||
</div>
|
||||
{results.map((r, i) => (
|
||||
<button key={i} type="button" onClick={() => pick(r)} className="flex items-center gap-3 px-2.5 py-2 rounded-lg text-left hover:bg-surface-hover transition-colors">
|
||||
<div className="w-8 h-8 min-w-[32px] rounded-lg bg-surface-secondary flex items-center justify-center text-content-faint shrink-0"><MapPin size={15} /></div>
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<span className="text-[13px] font-semibold text-content truncate">{str(r.name)}</span>
|
||||
{str(r.address) && <span className="text-[11.5px] text-content-faint truncate">{str(r.address)}</span>}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="block text-[12px] font-medium text-content-secondary mb-1.5">{t('common.name')}</label>
|
||||
<input value={name} onChange={e => setName(e.target.value)} placeholder={t('common.name')} className="w-full px-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[14px] outline-none focus:border-accent" />
|
||||
{address && <div className="flex items-center gap-1.5 mt-1.5 text-[12px] text-content-faint"><MapPin size={12} /> {address}</div>}
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{STATUS_ORDER.map(s => {
|
||||
const Icon = STATUS_META[s].icon
|
||||
const on = status === s
|
||||
return (
|
||||
<button key={s} type="button" onClick={() => setStatus(s)} className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[12px] font-semibold border transition-colors ${on ? 'bg-inverse text-inverse-text border-transparent' : 'bg-surface-card text-content-secondary border-edge hover:bg-surface-hover'}`}>
|
||||
<Icon size={13} style={{ color: on ? undefined : STATUS_META[s].color }} /> {t(STATUS_META[s].labelKey)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
{categories.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-[12px] font-medium text-content-secondary mb-1.5">{t('collections.category')}</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<button type="button" onClick={() => setCategoryId(null)} className={`px-3 py-1.5 rounded-full text-[12px] font-medium border transition-colors ${categoryId == null ? 'bg-inverse text-inverse-text border-transparent' : 'bg-surface-card text-content-secondary border-edge hover:bg-surface-hover'}`}>
|
||||
{t('collections.noCategory')}
|
||||
</button>
|
||||
{categories.map(cat => {
|
||||
const Icon = getCategoryIcon(cat.icon ?? undefined)
|
||||
const on = categoryId === cat.id
|
||||
const col = cat.color || '#6366f1'
|
||||
return (
|
||||
<button
|
||||
key={cat.id}
|
||||
type="button"
|
||||
onClick={() => setCategoryId(cat.id)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[12px] font-medium border transition-colors bg-surface-card border-edge hover:bg-surface-hover"
|
||||
style={on ? { color: col, background: `color-mix(in oklch, ${col} 15%, transparent)`, borderColor: `color-mix(in oklch, ${col} 40%, transparent)` } : undefined}
|
||||
>
|
||||
<Icon size={13} /> {cat.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-[12px] font-medium text-content-secondary mb-1.5">{t('collections.description')}</label>
|
||||
<MarkdownToolbar textareaRef={descRef} onUpdate={setDescription} />
|
||||
<textarea ref={descRef} value={description} onChange={e => setDescription(e.target.value)} rows={3} placeholder={t('collections.descriptionPlaceholder')} className="w-full px-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[13px] outline-none focus:border-accent resize-y" />
|
||||
{description.trim() && (
|
||||
<div className="collab-note-md mt-2 text-[13px] text-content-secondary"><Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{description}</Markdown></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Links */}
|
||||
<div>
|
||||
<label className="block text-[12px] font-medium text-content-secondary mb-1.5">{t('collections.links')}</label>
|
||||
<div className="flex flex-col gap-2">
|
||||
{links.map((l, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<input value={l.label ?? ''} onChange={e => setLink(i, { label: e.target.value })} placeholder={t('collections.linkLabel')} className="w-28 shrink-0 px-2.5 py-1.5 rounded-lg border border-edge bg-surface-input text-content text-[12.5px] outline-none focus:border-accent" />
|
||||
<input value={l.url} onChange={e => setLink(i, { url: e.target.value })} placeholder="https://…" className="flex-1 min-w-0 px-2.5 py-1.5 rounded-lg border border-edge bg-surface-input text-content text-[12.5px] outline-none focus:border-accent" />
|
||||
<button type="button" onClick={() => setLinks(links.filter((_, idx) => idx !== i))} className="p-1.5 rounded-md text-content-faint hover:text-danger hover:bg-danger-soft" aria-label={t('common.delete')}><Trash2 size={14} /></button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" onClick={() => setLinks([...links, { url: '' }])} className="inline-flex items-center gap-1.5 self-start px-2.5 py-1.5 rounded-lg border border-dashed border-edge text-content-secondary text-[12.5px] font-medium hover:bg-surface-hover">
|
||||
<Plus size={14} /> <Link2 size={13} /> {t('collections.addLink')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Check, Loader2, Settings2, Tags } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import type { CollectionLabel } from '@trek/shared'
|
||||
import type { TranslationFn } from '../../types'
|
||||
|
||||
interface BulkAssignLabelModalProps {
|
||||
isOpen: boolean
|
||||
labels: CollectionLabel[]
|
||||
/** Number of selected places the labels will be added to. */
|
||||
count: number
|
||||
onAssign: (labelIds: number[]) => Promise<void> | void
|
||||
/** Open the label manager to create labels first. */
|
||||
onManage: () => void
|
||||
onClose: () => void
|
||||
t: TranslationFn
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick one or more of the list's labels to add to every selected place. Additive
|
||||
* — it never removes labels a place already has. When the list has no labels yet,
|
||||
* it points the user at the label manager instead.
|
||||
*/
|
||||
export default function BulkAssignLabelModal({ isOpen, labels, count, onAssign, onManage, onClose, t }: BulkAssignLabelModalProps): React.ReactElement {
|
||||
const [picked, setPicked] = useState<number[]>([])
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const toggle = (id: number) => setPicked(picked.includes(id) ? picked.filter(x => x !== id) : [...picked, id])
|
||||
|
||||
const assign = async () => {
|
||||
if (picked.length === 0 || busy) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await onAssign(picked)
|
||||
setPicked([])
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={t('collections.labels.assignN', { count })} size="sm">
|
||||
{labels.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 py-6 text-center">
|
||||
<Tags size={26} className="text-content-faint" />
|
||||
<p className="text-[13px] text-content-faint">{t('collections.labels.emptyHint')}</p>
|
||||
<button type="button" onClick={onManage} className="flex items-center gap-1.5 px-3 py-2 rounded-lg border border-edge text-[13px] text-content hover:bg-surface-hover">
|
||||
<Settings2 size={14} /> {t('collections.labels.manage')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1 max-h-[46vh] overflow-y-auto -mx-1 px-1">
|
||||
{labels.map(l => {
|
||||
const on = picked.includes(l.id)
|
||||
return (
|
||||
<button
|
||||
key={l.id}
|
||||
type="button"
|
||||
onClick={() => toggle(l.id)}
|
||||
className={`flex items-center gap-2.5 px-3 py-2.5 rounded-xl border text-left transition-colors ${on ? 'border-accent bg-accent/10' : 'border-edge bg-surface-card hover:bg-surface-hover'}`}
|
||||
>
|
||||
<span className="w-3.5 h-3.5 rounded-full shrink-0" style={{ background: l.color || '#6366f1' }} />
|
||||
<span className="flex-1 min-w-0 text-[13px] font-medium text-content truncate">{l.name}</span>
|
||||
{on && <Check size={15} className="text-accent shrink-0" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2 border-t border-edge">
|
||||
<button type="button" onClick={onManage} className="mr-auto flex items-center gap-1.5 px-3 py-2 rounded-lg text-[13px] text-content-secondary hover:bg-surface-hover">
|
||||
<Settings2 size={14} /> {t('collections.labels.manage')}
|
||||
</button>
|
||||
<button type="button" onClick={onClose} className="px-3 py-2 rounded-lg border border-edge text-content-secondary text-[13px] hover:bg-surface-hover">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={assign}
|
||||
disabled={picked.length === 0 || busy}
|
||||
className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-accent text-white text-[13px] font-semibold hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{busy ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />} {t('collections.labels.assign')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
// FE-COMP-COLFILTERBAR-001 to FE-COMP-COLFILTERBAR-008
|
||||
import React from 'react';
|
||||
import { render, screen, within } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { resetAllStores } from '../../../tests/helpers/store';
|
||||
import { useTranslation } from '../../i18n/TranslationContext';
|
||||
import type { CategoryOption } from '../../pages/collections/collectionsModel';
|
||||
import type { StatusFilter } from '../../store/collectionStore';
|
||||
import CollectionFilterBar from './CollectionFilterBar';
|
||||
|
||||
// The component takes `t` as a prop; pull the REAL translation fn from context so
|
||||
// visible labels are actual English strings (All / Idea / Want to go / Visited / Select).
|
||||
type HarnessProps = Omit<React.ComponentProps<typeof CollectionFilterBar>, 't'>;
|
||||
|
||||
function Harness(props: HarnessProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
return <CollectionFilterBar {...props} t={t} />;
|
||||
}
|
||||
|
||||
const CATEGORY_OPTIONS: CategoryOption[] = [
|
||||
{ id: 1, name: 'Food', color: '#f00', icon: null, count: 2 },
|
||||
];
|
||||
|
||||
function makeProps(overrides: Partial<HarnessProps> = {}): HarnessProps {
|
||||
return {
|
||||
statusFilter: 'all' as StatusFilter,
|
||||
counts: { all: 3, idea: 1, want: 1, visited: 1 },
|
||||
categoryFilter: 'all',
|
||||
categoryOptions: CATEGORY_OPTIONS,
|
||||
onStatusFilter: vi.fn(),
|
||||
onCategoryFilter: vi.fn(),
|
||||
showLabels: false,
|
||||
labelOptions: [],
|
||||
labelFilter: [],
|
||||
onLabelFilter: vi.fn(),
|
||||
canManageLabels: false,
|
||||
onManageLabels: vi.fn(),
|
||||
showSelect: true,
|
||||
selectMode: false,
|
||||
onToggleSelect: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
describe('CollectionFilterBar', () => {
|
||||
it('FE-COMP-COLFILTERBAR-001: renders the status dropdown showing the current "All" filter', () => {
|
||||
render(<Harness {...makeProps()} />);
|
||||
// Both dropdown triggers currently read "All" (status=all, category=all).
|
||||
// With a category present there are exactly two "All" triggers: status + category.
|
||||
expect(screen.getAllByRole('button', { name: 'All' })).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('FE-COMP-COLFILTERBAR-002: opening the status dropdown reveals the status options', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness {...makeProps()} />);
|
||||
|
||||
// First "All" trigger is the status dropdown (rendered before the category one).
|
||||
const statusTrigger = screen.getAllByRole('button', { name: 'All' })[0];
|
||||
expect(statusTrigger).toHaveAttribute('aria-expanded', 'false');
|
||||
await user.click(statusTrigger);
|
||||
|
||||
const listbox = screen.getByRole('listbox');
|
||||
expect(within(listbox).getByRole('option', { name: /Idea/i })).toBeInTheDocument();
|
||||
expect(within(listbox).getByRole('option', { name: /Want to go/i })).toBeInTheDocument();
|
||||
expect(within(listbox).getByRole('option', { name: /Visited/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLFILTERBAR-003: clicking a status option calls onStatusFilter with that status', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onStatusFilter = vi.fn();
|
||||
render(<Harness {...makeProps({ onStatusFilter })} />);
|
||||
|
||||
await user.click(screen.getAllByRole('button', { name: 'All' })[0]);
|
||||
const listbox = screen.getByRole('listbox');
|
||||
await user.click(within(listbox).getByRole('option', { name: /Want to go/i }));
|
||||
|
||||
expect(onStatusFilter).toHaveBeenCalledTimes(1);
|
||||
expect(onStatusFilter).toHaveBeenCalledWith('want');
|
||||
});
|
||||
|
||||
it('FE-COMP-COLFILTERBAR-004: the category dropdown is present when categoryOptions is non-empty', () => {
|
||||
render(<Harness {...makeProps()} />);
|
||||
// Two dropdown triggers = status + category.
|
||||
const triggers = screen.getAllByRole('button', { name: 'All' });
|
||||
expect(triggers).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('FE-COMP-COLFILTERBAR-005: the category dropdown is hidden when categoryOptions is empty', () => {
|
||||
render(<Harness {...makeProps({ categoryOptions: [] })} />);
|
||||
// Only the status dropdown remains.
|
||||
expect(screen.getAllByRole('button', { name: 'All' })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('FE-COMP-COLFILTERBAR-006: clicking a category option calls onCategoryFilter with the category id', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCategoryFilter = vi.fn();
|
||||
render(<Harness {...makeProps({ onCategoryFilter })} />);
|
||||
|
||||
// Second "All" trigger is the category dropdown.
|
||||
await user.click(screen.getAllByRole('button', { name: 'All' })[1]);
|
||||
const listbox = screen.getByRole('listbox');
|
||||
await user.click(within(listbox).getByRole('option', { name: /Food/i }));
|
||||
|
||||
expect(onCategoryFilter).toHaveBeenCalledTimes(1);
|
||||
expect(onCategoryFilter).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('FE-COMP-COLFILTERBAR-007: clicking the Select button calls onToggleSelect', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onToggleSelect = vi.fn();
|
||||
render(<Harness {...makeProps({ onToggleSelect })} />);
|
||||
|
||||
const selectBtn = screen.getByRole('button', { name: 'Select' });
|
||||
expect(selectBtn).toHaveAttribute('aria-pressed', 'false');
|
||||
await user.click(selectBtn);
|
||||
|
||||
expect(onToggleSelect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-COMP-COLFILTERBAR-008: showSelect=false hides the Select button', () => {
|
||||
render(<Harness {...makeProps({ showSelect: false })} />);
|
||||
expect(screen.queryByRole('button', { name: 'Select' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { ChevronDown, Check, Layers, Tag, Tags, CheckSquare } from 'lucide-react'
|
||||
import type { StatusFilter } from '../../store/collectionStore'
|
||||
import type { TranslationFn } from '../../types'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { STATUS_META, STATUS_ORDER } from '../../pages/collections/collectionsModel'
|
||||
import type { CategoryOption, LabelOption } from '../../pages/collections/collectionsModel'
|
||||
|
||||
interface Opt {
|
||||
key: string | number
|
||||
label: string
|
||||
icon?: React.ReactNode
|
||||
count?: number
|
||||
}
|
||||
|
||||
/** Small custom dropdown — compact trigger + click-away popover. */
|
||||
function Dropdown({ current, options, onSelect, lead }: {
|
||||
current: string | number
|
||||
options: Opt[]
|
||||
onSelect: (key: string | number) => void
|
||||
lead: React.ReactNode
|
||||
}): React.ReactElement {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onDoc = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) }
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) }
|
||||
document.addEventListener('mousedown', onDoc)
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => { document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey) }
|
||||
}, [open])
|
||||
|
||||
const cur = options.find(o => o.key === current) ?? options[0]
|
||||
return (
|
||||
<div className="col-filter" ref={ref}>
|
||||
<button type="button" className={`col-filter-btn${open ? ' open' : ''}`} onClick={() => setOpen(o => !o)} aria-haspopup="listbox" aria-expanded={open}>
|
||||
{cur.icon ?? lead}
|
||||
<span className="col-filter-lbl">{cur.label}</span>
|
||||
<ChevronDown size={14} className="col-filter-chev" />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="col-filter-pop" role="listbox">
|
||||
{options.map(o => (
|
||||
<button
|
||||
key={o.key}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={o.key === current}
|
||||
className={`col-filter-opt${o.key === current ? ' on' : ''}`}
|
||||
onClick={() => { onSelect(o.key); setOpen(false) }}
|
||||
>
|
||||
{o.icon ?? <span className="col-filter-dot ghost" />}
|
||||
<span className="col-filter-lbl">{o.label}</span>
|
||||
{o.count != null && <span className="col-filter-count">{o.count}</span>}
|
||||
{o.key === current && <Check size={13} className="col-filter-check" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface CollectionFilterBarProps {
|
||||
statusFilter: StatusFilter
|
||||
counts: Record<StatusFilter, number>
|
||||
categoryFilter: number | 'all'
|
||||
categoryOptions: CategoryOption[]
|
||||
onStatusFilter: (f: StatusFilter) => void
|
||||
onCategoryFilter: (f: number | 'all') => void
|
||||
// Per-collection labels (hidden on the "All saved" union).
|
||||
showLabels: boolean
|
||||
labelOptions: LabelOption[]
|
||||
labelFilter: number[]
|
||||
onLabelFilter: (ids: number[]) => void
|
||||
canManageLabels: boolean
|
||||
onManageLabels: () => void
|
||||
showSelect: boolean
|
||||
selectMode: boolean
|
||||
onToggleSelect: () => void
|
||||
t: TranslationFn
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter row above the places — a status dropdown (All / Idea / Want / Visited
|
||||
* with counts) and, when the list has categorised places, a category dropdown.
|
||||
* Custom compact dropdowns so they barely take any space.
|
||||
*/
|
||||
export default function CollectionFilterBar({
|
||||
statusFilter, counts, categoryFilter, categoryOptions, onStatusFilter, onCategoryFilter,
|
||||
showLabels, labelOptions, labelFilter, onLabelFilter, canManageLabels, onManageLabels,
|
||||
showSelect, selectMode, onToggleSelect, t,
|
||||
}: CollectionFilterBarProps): React.ReactElement {
|
||||
const statusOpts: Opt[] = [
|
||||
{ key: 'all', label: t('common.all'), count: counts.all },
|
||||
...STATUS_ORDER.map(s => {
|
||||
const Icon = STATUS_META[s].icon
|
||||
return { key: s, label: t(STATUS_META[s].labelKey), icon: <Icon size={13} style={{ color: STATUS_META[s].color }} />, count: counts[s] }
|
||||
}),
|
||||
]
|
||||
|
||||
const catTotal = categoryOptions.reduce((n, c) => n + c.count, 0)
|
||||
const catOpts: Opt[] = [
|
||||
{ key: 'all', label: t('common.all'), count: catTotal },
|
||||
...categoryOptions.map(c => {
|
||||
const Icon = getCategoryIcon(c.icon ?? undefined)
|
||||
return { key: c.id, label: c.name, icon: <Icon size={13} style={{ color: c.color ?? undefined }} />, count: c.count }
|
||||
}),
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="col-filterbar">
|
||||
<Dropdown current={statusFilter} options={statusOpts} onSelect={k => onStatusFilter(k as StatusFilter)} lead={<Layers size={13} />} />
|
||||
{categoryOptions.length > 0 && (
|
||||
<Dropdown current={categoryFilter} options={catOpts} onSelect={k => onCategoryFilter(k as number | 'all')} lead={<Tag size={13} />} />
|
||||
)}
|
||||
{showSelect && (
|
||||
<button type="button" onClick={onToggleSelect} className={`col-filter-btn col-filter-select${selectMode ? ' open' : ''}`} aria-pressed={selectMode}>
|
||||
<CheckSquare size={14} /> <span className="col-filter-lbl">{t('collections.select')}</span>
|
||||
</button>
|
||||
)}
|
||||
{showLabels && (labelOptions.length > 0 || canManageLabels) && (
|
||||
<div className="col-labelfilter">
|
||||
{labelOptions.map(l => {
|
||||
const on = labelFilter.includes(l.id)
|
||||
return (
|
||||
<button
|
||||
key={l.id}
|
||||
type="button"
|
||||
className={`col-labelchip${on ? ' on' : ''}`}
|
||||
style={{ ['--label' as string]: l.color ?? 'var(--accent)' }}
|
||||
onClick={() => onLabelFilter(on ? labelFilter.filter(id => id !== l.id) : [...labelFilter, l.id])}
|
||||
aria-pressed={on}
|
||||
>
|
||||
<span className="col-labelchip-dot" />
|
||||
<span className="col-filter-lbl">{l.name}</span>
|
||||
{l.count > 0 && <span className="col-filter-count">{l.count}</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{canManageLabels && (
|
||||
<button type="button" className="col-labelchip col-labelchip-manage" onClick={onManageLabels} title={t('collections.labels.manage')}>
|
||||
<Tags size={13} />
|
||||
<span className="col-filter-lbl">{labelOptions.length ? t('collections.labels.manage') : t('collections.labels.add')}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import React from 'react'
|
||||
import { avatarSrc } from '../../utils/avatarSrc'
|
||||
import { Share2, Users, Link2, Pencil } from 'lucide-react'
|
||||
import type { CollectionMember, CollectionLink } from '@trek/shared'
|
||||
import type { TranslationFn } from '../../types'
|
||||
|
||||
const AV_COLORS = ['#6366f1', '#ec4899', '#14b8a6', '#f97316', '#8b5cf6', '#3b82f6', '#ef4444', '#22c55e']
|
||||
|
||||
function initials(name: string): string {
|
||||
return name.trim().split(/\s+/).slice(0, 2).map(w => w[0]?.toUpperCase() ?? '').join('') || '?'
|
||||
}
|
||||
|
||||
interface CollectionHeroProps {
|
||||
eyebrow: string
|
||||
title: string
|
||||
/** List colour — drives the gradient wash (or tints the cover image). */
|
||||
color: string
|
||||
coverImage?: string | null
|
||||
description?: string | null
|
||||
links?: CollectionLink[]
|
||||
/** Accepted members (owner first) — shown as an avatar stack when shared. */
|
||||
members: CollectionMember[]
|
||||
canShare: boolean
|
||||
isOwner: boolean
|
||||
canEdit: boolean
|
||||
onEdit: () => void
|
||||
shareMemberCount: number
|
||||
onShare: () => void
|
||||
t: TranslationFn
|
||||
}
|
||||
|
||||
/**
|
||||
* The page header — a colour-washed (or cover-image) glass hero that gives the
|
||||
* active list an identity: an eyebrow with the sharing state + member avatars,
|
||||
* the big list name, an optional description + link chips, and a Share action
|
||||
* top-right. Filtering lives in the toolbar above the places, not here.
|
||||
* Modelled on the dashboard hero-trip.
|
||||
*/
|
||||
function linkHost(url: string): string {
|
||||
try { return new URL(url).hostname.replace(/^www\./, '') } catch { return url }
|
||||
}
|
||||
|
||||
export default function CollectionHero({
|
||||
eyebrow, title, color, coverImage, description, links,
|
||||
members, canShare, isOwner, canEdit, onEdit, shareMemberCount, onShare, t,
|
||||
}: CollectionHeroProps): React.ReactElement {
|
||||
const accepted = members.filter(m => m.status === 'accepted' || m.is_owner)
|
||||
const showAvatars = accepted.length > 1
|
||||
const shown = accepted.slice(0, 5)
|
||||
const extra = accepted.length - shown.length
|
||||
|
||||
return (
|
||||
<header className="col-hero" style={{ ['--hero-color' as string]: color }}>
|
||||
{coverImage ? (
|
||||
<>
|
||||
<img className="col-hero-img" src={coverImage} alt="" />
|
||||
<div className="col-hero-tint" />
|
||||
</>
|
||||
) : (
|
||||
<div className="col-hero-bg" />
|
||||
)}
|
||||
<div className="col-hero-scrim" />
|
||||
|
||||
<div className="col-hero-content">
|
||||
<div className="col-hero-eyebrow">
|
||||
<span>{eyebrow}</span>
|
||||
{showAvatars && (
|
||||
<span className="members">
|
||||
{shown.map(m => (
|
||||
m.avatar
|
||||
? <img key={m.user_id} className="col-av" src={avatarSrc(m.avatar)!} alt={m.username} />
|
||||
: <span key={m.user_id} className="col-av" style={{ background: AV_COLORS[m.user_id % AV_COLORS.length] }}>{initials(m.username)}</span>
|
||||
))}
|
||||
{extra > 0 && <span className="col-av" style={{ background: 'rgba(255,255,255,.28)' }}>+{extra}</span>}
|
||||
</span>
|
||||
)}
|
||||
{links && links.length > 0 && (
|
||||
<span className="col-hero-links">
|
||||
{links.map((l, i) => (
|
||||
<a key={i} href={l.url} target="_blank" rel="noopener noreferrer" className="col-hero-link" onClick={e => e.stopPropagation()}>
|
||||
<Link2 size={12} /> {l.label || linkHost(l.url)}
|
||||
</a>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="col-hero-titlerow">
|
||||
<h1 className="col-hero-title">{title}</h1>
|
||||
<div className="col-hero-actions">
|
||||
{canEdit && (
|
||||
<button type="button" onClick={onEdit} aria-label={t('common.edit')} title={t('common.edit')} className="col-glass-btn">
|
||||
<Pencil size={15} />
|
||||
<span className="txt">{t('common.edit')}</span>
|
||||
</button>
|
||||
)}
|
||||
{canShare && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onShare}
|
||||
aria-label={isOwner ? t('collections.share.button') : t('collections.shared')}
|
||||
title={isOwner ? t('collections.share.button') : t('collections.shared')}
|
||||
className={`col-glass-btn${isOwner && shareMemberCount > 0 ? ' has-count' : ''}`}
|
||||
>
|
||||
{isOwner ? <Share2 size={15} /> : <Users size={15} />}
|
||||
<span className="txt">{isOwner ? t('collections.share.button') : t('collections.shared')}</span>
|
||||
{isOwner && shareMemberCount > 0 && <span className="cnt">{shareMemberCount}</span>}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{description && <p className="col-hero-desc">{description}</p>}
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
// FE-COMP-COLLIST-001 to FE-COMP-COLLIST-010
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { CollectionPlace } from '@trek/shared';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser } from '../../../tests/helpers/factories';
|
||||
import { useTranslation } from '../../i18n/TranslationContext';
|
||||
import CollectionList from './CollectionList';
|
||||
|
||||
// A saved-place row calls PlaceAvatar, which reads placesPhotosEnabled from the
|
||||
// auth store. Seeding it false (and leaving image_url unset) keeps the avatar a
|
||||
// plain category icon — no photoService fetch, no network in the test.
|
||||
|
||||
// Two inline CollectionPlace literals: one categorised (idea), one bare (want).
|
||||
const cafe = {
|
||||
id: 101,
|
||||
collection_id: 1,
|
||||
name: 'Blue Bottle Coffee',
|
||||
address: '123 Market St, San Francisco',
|
||||
status: 'idea',
|
||||
category: { id: 5, name: 'Cafe', color: '#f59e0b', icon: 'Coffee' },
|
||||
image_url: null,
|
||||
} as unknown as CollectionPlace;
|
||||
|
||||
const bridge = {
|
||||
id: 202,
|
||||
collection_id: 1,
|
||||
name: 'Golden Gate Bridge',
|
||||
address: 'Golden Gate, San Francisco',
|
||||
status: 'want',
|
||||
image_url: null,
|
||||
} as unknown as CollectionPlace;
|
||||
|
||||
const places = [cafe, bridge];
|
||||
|
||||
// Grab the real English translation fn so status labels match visible strings.
|
||||
function TFnProbe({ onReady }: { onReady: (t: ReturnType<typeof useTranslation>['t']) => void }) {
|
||||
const { t } = useTranslation();
|
||||
onReady(t);
|
||||
return null;
|
||||
}
|
||||
|
||||
let t: ReturnType<typeof useTranslation>['t'];
|
||||
render(<TFnProbe onReady={fn => { t = fn; }} />);
|
||||
|
||||
interface Handlers {
|
||||
onOpenPlace: ReturnType<typeof vi.fn>;
|
||||
onStatusChange: ReturnType<typeof vi.fn>;
|
||||
onToggleSelect: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
function renderList(over: Partial<{
|
||||
selectMode: boolean;
|
||||
selectedIds: number[];
|
||||
selectedPlaceId: number | null;
|
||||
onStatusChange: ((placeId: number, status: string) => void) | undefined;
|
||||
}> = {}, handlers?: Handlers) {
|
||||
const h = handlers ?? {
|
||||
onOpenPlace: vi.fn(),
|
||||
onStatusChange: vi.fn(),
|
||||
onToggleSelect: vi.fn(),
|
||||
};
|
||||
const onStatusChange = 'onStatusChange' in over ? over.onStatusChange : h.onStatusChange;
|
||||
render(
|
||||
<CollectionList
|
||||
places={places}
|
||||
labels={[]}
|
||||
selectedPlaceId={over.selectedPlaceId ?? null}
|
||||
selectMode={over.selectMode ?? false}
|
||||
selectedIds={over.selectedIds ?? []}
|
||||
onOpenPlace={h.onOpenPlace as (id: number) => void}
|
||||
onStatusChange={onStatusChange as never}
|
||||
onToggleSelect={h.onToggleSelect as (id: number) => void}
|
||||
t={t}
|
||||
/>,
|
||||
);
|
||||
return h;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
seedStore(useAuthStore, { user: buildUser(), placesPhotosEnabled: false });
|
||||
});
|
||||
|
||||
describe('CollectionList', () => {
|
||||
it('FE-COMP-COLLIST-001: renders both place names', () => {
|
||||
renderList();
|
||||
expect(screen.getByText('Blue Bottle Coffee')).toBeInTheDocument();
|
||||
expect(screen.getByText('Golden Gate Bridge')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLLIST-002: renders both place addresses', () => {
|
||||
renderList();
|
||||
expect(screen.getByText('123 Market St, San Francisco')).toBeInTheDocument();
|
||||
expect(screen.getByText('Golden Gate, San Francisco')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLLIST-003: renders the category name for the categorised place', () => {
|
||||
renderList();
|
||||
expect(screen.getByText('Cafe')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLLIST-004: clicking a row calls onOpenPlace with the place id', async () => {
|
||||
const user = userEvent.setup();
|
||||
const h = renderList();
|
||||
const row = screen.getByText('Blue Bottle Coffee').closest('.col-lrow') as HTMLElement;
|
||||
await user.click(row);
|
||||
expect(h.onOpenPlace).toHaveBeenCalledWith(101);
|
||||
expect(h.onToggleSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLLIST-005: in select mode a row click calls onToggleSelect instead of onOpenPlace', async () => {
|
||||
const user = userEvent.setup();
|
||||
const h = renderList({ selectMode: true });
|
||||
const row = screen.getByText('Golden Gate Bridge').closest('.col-lrow') as HTMLElement;
|
||||
await user.click(row);
|
||||
expect(h.onToggleSelect).toHaveBeenCalledWith(202);
|
||||
expect(h.onOpenPlace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLLIST-006: the status badge is an interactive button when onStatusChange is provided', () => {
|
||||
renderList();
|
||||
// idea → label "Idea"; the badge is a role=button span with aria-label = label.
|
||||
expect(screen.getByRole('button', { name: 'Idea' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Want to go' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLLIST-007: clicking the interactive badge cycles the status without opening the place', async () => {
|
||||
const user = userEvent.setup();
|
||||
const h = renderList();
|
||||
await user.click(screen.getByRole('button', { name: 'Idea' }));
|
||||
// idea → want, and the badge stops propagation so the row does not open.
|
||||
expect(h.onStatusChange).toHaveBeenCalledWith(101, 'want');
|
||||
expect(h.onOpenPlace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLLIST-008: the status badge is read-only (not a button) when onStatusChange is undefined', () => {
|
||||
renderList({ onStatusChange: undefined });
|
||||
// Label text still renders...
|
||||
expect(screen.getByText('Idea')).toBeInTheDocument();
|
||||
// ...but there is no interactive status button for it.
|
||||
expect(screen.queryByRole('button', { name: 'Idea' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Want to go' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLLIST-009: in select mode the status badge is read-only even with onStatusChange provided', () => {
|
||||
renderList({ selectMode: true });
|
||||
expect(screen.getByText('Idea')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Idea' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLLIST-010: renders one clickable row per place', () => {
|
||||
renderList();
|
||||
// Each place contributes a role=button row; badges add their own buttons too,
|
||||
// but every place name must sit inside a .col-lrow row element.
|
||||
expect(screen.getByText('Blue Bottle Coffee').closest('.col-lrow')).toBeInTheDocument();
|
||||
expect(screen.getByText('Golden Gate Bridge').closest('.col-lrow')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import React, { useEffect, useMemo, useRef } from 'react'
|
||||
import { Check, MapPin } from 'lucide-react'
|
||||
import type { CollectionPlace, CollectionStatus, CollectionLabel } from '@trek/shared'
|
||||
import type { TranslationFn } from '../../types'
|
||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import StatusBadge from './StatusBadge'
|
||||
|
||||
interface CollectionListProps {
|
||||
places: CollectionPlace[]
|
||||
labels: CollectionLabel[]
|
||||
selectedPlaceId: number | null
|
||||
selectMode: boolean
|
||||
selectedIds: number[]
|
||||
onOpenPlace: (id: number) => void
|
||||
onStatusChange?: (placeId: number, status: CollectionStatus) => void
|
||||
onToggleSelect: (id: number) => void
|
||||
t: TranslationFn
|
||||
}
|
||||
|
||||
/**
|
||||
* List view — one glass row per saved place with a photo avatar, name +
|
||||
* category/address, and a one-tap status cycle on the badge. Click the row to
|
||||
* open the place (or toggle it in select mode).
|
||||
*/
|
||||
export default function CollectionList({
|
||||
places, labels, selectedPlaceId, selectMode, selectedIds, onOpenPlace, onStatusChange, onToggleSelect, t,
|
||||
}: CollectionListProps): React.ReactElement {
|
||||
const labelsById = useMemo(() => new Map(labels.map(l => [l.id, l])), [labels])
|
||||
// Bring the selected row into view — e.g. when it was picked from the map.
|
||||
const selectedRef = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
selectedRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
}, [selectedPlaceId])
|
||||
|
||||
return (
|
||||
<div className="col-listview">
|
||||
{places.map(place => {
|
||||
const selected = selectedIds.includes(place.id)
|
||||
const active = selectedPlaceId === place.id
|
||||
const placeLabels = (place.label_ids ?? []).map(id => labelsById.get(id)).filter(Boolean) as CollectionLabel[]
|
||||
return (
|
||||
<div
|
||||
key={place.id}
|
||||
ref={active ? selectedRef : undefined}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => (selectMode ? onToggleSelect(place.id) : onOpenPlace(place.id))}
|
||||
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (selectMode) onToggleSelect(place.id); else onOpenPlace(place.id) } }}
|
||||
className={`col-lrow${active || selected ? ' sel' : ''}`}
|
||||
>
|
||||
{selectMode ? (
|
||||
<span className={`col-lcheck${selected ? ' on' : ''}`}>{selected && <Check size={14} strokeWidth={3} />}</span>
|
||||
) : (
|
||||
<PlaceAvatar place={place} size={40} category={place.category ? { color: place.category.color ?? undefined, icon: place.category.icon ?? undefined } : null} />
|
||||
)}
|
||||
<div className="li">
|
||||
<div className="t">{place.name}</div>
|
||||
{place.address && (
|
||||
<div className="s">
|
||||
<MapPin size={11} />
|
||||
<span>{place.address}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-lrow-end">
|
||||
{placeLabels.slice(0, 2).map(l => (
|
||||
<span key={l.id} className="col-lrow-label" style={{ ['--label' as string]: l.color || 'var(--accent)' }} title={l.name}>
|
||||
<span className="col-labelchip-dot" /> {l.name}
|
||||
</span>
|
||||
))}
|
||||
{placeLabels.length > 2 && <span className="col-lrow-label more" title={placeLabels.map(l => l.name).join(', ')}>+{placeLabels.length - 2}</span>}
|
||||
{place.category?.name && (() => {
|
||||
const CatIcon = getCategoryIcon(place.category.icon ?? undefined)
|
||||
return (
|
||||
<>
|
||||
<span className="col-lrow-cat" style={{ ['--cat' as string]: place.category.color || '#6366f1' }}>
|
||||
<CatIcon size={11} /> {place.category.name}
|
||||
</span>
|
||||
<span className="col-lrow-div" aria-hidden />
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
<StatusBadge status={place.status} onChange={selectMode || !onStatusChange ? undefined : next => onStatusChange(place.id, next)} t={t} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from 'react'
|
||||
import { MapViewAuto } from '../Map/MapViewAuto'
|
||||
import type { CollectionPlace } from '@trek/shared'
|
||||
import { mappablePlaces } from '../../pages/collections/collectionsModel'
|
||||
|
||||
interface CollectionMapProps {
|
||||
places: CollectionPlace[]
|
||||
selectedPlaceId: number | null
|
||||
onOpenPlace: (id: number) => void
|
||||
/** Clicking the map background clears the selection. */
|
||||
onDeselect?: () => void
|
||||
dark: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Map view — reuses the trip map stack (MapViewAuto → Leaflet / GL with marker
|
||||
* clustering). One of the three list views; clicking a marker selects the place.
|
||||
* The parent `.col-mapwrap` supplies the rounded, bordered box + height, so this
|
||||
* just fills it.
|
||||
*/
|
||||
export default function CollectionMap({ places, selectedPlaceId, onOpenPlace, onDeselect, dark }: CollectionMapProps): React.ReactElement {
|
||||
const pts = mappablePlaces(places)
|
||||
const center: [number, number] = pts.length > 0
|
||||
? [pts[0].lat as number, pts[0].lng as number]
|
||||
: [48.8566, 2.3522]
|
||||
const tileUrl = dark
|
||||
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
|
||||
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%' }}>
|
||||
<MapViewAuto
|
||||
places={pts}
|
||||
selectedPlaceId={selectedPlaceId}
|
||||
hoverDisabled
|
||||
onMarkerClick={onOpenPlace}
|
||||
onMapClick={onDeselect ? () => onDeselect() : undefined}
|
||||
center={center}
|
||||
zoom={pts.length > 0 ? 6 : 3}
|
||||
tileUrl={tileUrl}
|
||||
fitKey={pts.length}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import React from 'react'
|
||||
import { PanelLeftClose, PanelLeftOpen, Search, Plus } from 'lucide-react'
|
||||
import type { CollectionPlace } from '@trek/shared'
|
||||
import type { TranslationFn } from '../../types'
|
||||
import CollectionMap from './CollectionMap'
|
||||
|
||||
interface CollectionMapPanelProps {
|
||||
places: CollectionPlace[]
|
||||
selectedPlaceId: number | null
|
||||
onSelect: (id: number) => void
|
||||
onDeselect: () => void
|
||||
dark: boolean
|
||||
/** Render the floating map controls (desktop). Mobile drives view from the toolbar. */
|
||||
overlay: boolean
|
||||
/** 'list' = split (map can be expanded); 'map' = full (list collapsed). */
|
||||
view: 'list' | 'map'
|
||||
onToggleView: () => void
|
||||
/** Show a "+" to add a place to the current list (real lists only). */
|
||||
canAddPlace: boolean
|
||||
onAddPlace: () => void
|
||||
search: string
|
||||
onSearch: (v: string) => void
|
||||
t: TranslationFn
|
||||
}
|
||||
|
||||
/**
|
||||
* The map surface for the collections page — the map plus its floating controls:
|
||||
* a top-left cluster (collapse/expand the list, toggle bulk-select) and a
|
||||
* top-right search box. Used both in the desktop split and the full-map view.
|
||||
*/
|
||||
export default function CollectionMapPanel({
|
||||
places, selectedPlaceId, onSelect, onDeselect, dark, overlay, view, onToggleView,
|
||||
canAddPlace, onAddPlace, search, onSearch, t,
|
||||
}: CollectionMapPanelProps): React.ReactElement {
|
||||
return (
|
||||
<div className="col-map-shell">
|
||||
<CollectionMap
|
||||
places={places}
|
||||
selectedPlaceId={selectedPlaceId}
|
||||
onOpenPlace={onSelect}
|
||||
onDeselect={onDeselect}
|
||||
dark={dark}
|
||||
/>
|
||||
{overlay && (
|
||||
<div className="col-map-topbar">
|
||||
<div className="col-map-group">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleView}
|
||||
className="col-map-btn"
|
||||
aria-label={view === 'map' ? t('collections.showList') : t('collections.expandMap')}
|
||||
title={view === 'map' ? t('collections.showList') : t('collections.expandMap')}
|
||||
>
|
||||
{view === 'map' ? <PanelLeftOpen size={17} /> : <PanelLeftClose size={17} />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="col-map-group right">
|
||||
{canAddPlace && (
|
||||
<button type="button" onClick={onAddPlace} className="col-map-btn" aria-label={t('collections.addPlace')} title={t('collections.addPlace')}>
|
||||
<Plus size={17} />
|
||||
</button>
|
||||
)}
|
||||
<div className="col-map-search">
|
||||
<Search size={15} />
|
||||
<input value={search} onChange={e => onSearch(e.target.value)} placeholder={t('collections.search')} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Search, Bookmark, Loader2, ChevronDown, Check, Layers } from 'lucide-react'
|
||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||
import { collectionsApi } from '../../api/collections'
|
||||
import { STATUS_META, STATUS_ORDER } from '../../pages/collections/collectionsModel'
|
||||
import type { CollectionPlace, CollectionStatus } from '@trek/shared'
|
||||
import type { TranslationFn } from '../../types'
|
||||
|
||||
interface LocationBias {
|
||||
low: { lat: number; lng: number }
|
||||
high: { lat: number; lng: number }
|
||||
}
|
||||
|
||||
interface ListMeta { id: number; name: string; color: string | null }
|
||||
|
||||
interface CollectionPickerProps {
|
||||
/** Trip bounding box used for autocomplete — sorts the saved places by
|
||||
* proximity to the trip so the relevant ones surface first. */
|
||||
bias?: LocationBias
|
||||
/** Fills the place form from the chosen saved place (handleSelectMapsResult). */
|
||||
onSelect: (place: CollectionPlace) => void
|
||||
t: TranslationFn
|
||||
}
|
||||
|
||||
function distanceTo(p: CollectionPlace, center: { lat: number; lng: number }): number {
|
||||
if (p.lat == null || p.lng == null) return Number.POSITIVE_INFINITY
|
||||
const dlat = p.lat - center.lat
|
||||
const dlng = p.lng - center.lng
|
||||
return dlat * dlat + dlng * dlng
|
||||
}
|
||||
|
||||
interface Opt { key: string | number; label: string; icon?: React.ReactNode; count?: number }
|
||||
|
||||
/** Compact click-away dropdown (Tailwind — this panel lives outside .trek-dash). */
|
||||
function FilterDropdown({ current, options, onSelect, lead }: {
|
||||
current: string | number
|
||||
options: Opt[]
|
||||
onSelect: (key: string | number) => void
|
||||
lead: React.ReactNode
|
||||
}): React.ReactElement {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onDoc = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) }
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) }
|
||||
document.addEventListener('mousedown', onDoc)
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => { document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey) }
|
||||
}, [open])
|
||||
const cur = options.find(o => o.key === current) ?? options[0]
|
||||
return (
|
||||
<div className="relative min-w-0 flex-1" ref={ref}>
|
||||
<button type="button" onClick={() => setOpen(o => !o)} aria-haspopup="listbox" aria-expanded={open}
|
||||
className={`w-full flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border bg-surface-input text-[12px] font-medium text-content-secondary transition-colors ${open ? 'border-accent' : 'border-edge hover:bg-surface-hover'}`}>
|
||||
<span className="shrink-0 text-content-faint">{cur.icon ?? lead}</span>
|
||||
<span className="flex-1 min-w-0 truncate text-left">{cur.label}</span>
|
||||
<ChevronDown size={13} className="shrink-0 text-content-faint" />
|
||||
</button>
|
||||
{open && (
|
||||
<div role="listbox" className="absolute z-30 left-0 right-0 mt-1 max-h-[240px] overflow-y-auto p-1 rounded-xl border border-edge bg-surface-card shadow-lg flex flex-col gap-0.5">
|
||||
{options.map(o => (
|
||||
<button key={o.key} type="button" role="option" aria-selected={o.key === current} onClick={() => { onSelect(o.key); setOpen(false) }}
|
||||
className={`flex items-center gap-2 px-2 py-1.5 rounded-lg text-[12.5px] text-left transition-colors hover:bg-surface-hover ${o.key === current ? 'text-content font-semibold' : 'text-content-secondary'}`}>
|
||||
<span className="shrink-0 text-content-faint">{o.icon}</span>
|
||||
<span className="flex-1 min-w-0 truncate">{o.label}</span>
|
||||
{o.count != null && <span className="shrink-0 text-[11px] text-content-faint tabular-nums">{o.count}</span>}
|
||||
{o.key === current && <Check size={13} className="shrink-0 text-accent" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Right-hand column of the desktop add-place modal: the user's saved collection
|
||||
* places, searchable, filterable by list + status, and proximity-sorted, so a
|
||||
* place saved on an earlier trip can be dropped straight into the form.
|
||||
* Desktop only — gated by the caller.
|
||||
*/
|
||||
export default function CollectionPicker({ bias, onSelect, t }: CollectionPickerProps): React.ReactElement {
|
||||
const [places, setPlaces] = useState<CollectionPlace[]>([])
|
||||
const [lists, setLists] = useState<ListMeta[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
const [listFilter, setListFilter] = useState<number | 'all'>('all')
|
||||
const [statusFilter, setStatusFilter] = useState<CollectionStatus | 'all'>('all')
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
collectionsApi.list()
|
||||
.then(async (res) => {
|
||||
const detail = await Promise.all(res.collections.map(c => collectionsApi.get(c.id).catch(() => null)))
|
||||
if (cancelled) return
|
||||
const merged: CollectionPlace[] = []
|
||||
for (const d of detail) {
|
||||
if (!d) continue
|
||||
for (const p of d.places) merged.push(p)
|
||||
}
|
||||
setLists(res.collections.map(c => ({ id: c.id, name: c.name, color: c.color ?? null })))
|
||||
setPlaces(merged)
|
||||
})
|
||||
.catch(() => { if (!cancelled) { setPlaces([]); setLists([]) } })
|
||||
.finally(() => { if (!cancelled) setLoading(false) })
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
const center = useMemo(
|
||||
() => (bias ? { lat: (bias.low.lat + bias.high.lat) / 2, lng: (bias.low.lng + bias.high.lng) / 2 } : null),
|
||||
[bias],
|
||||
)
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const q = search.trim().toLowerCase()
|
||||
const list = places.filter(p => {
|
||||
if (listFilter !== 'all' && p.collection_id !== listFilter) return false
|
||||
if (statusFilter !== 'all' && p.status !== statusFilter) return false
|
||||
if (!q) return true
|
||||
return p.name.toLowerCase().includes(q) || (p.address ?? '').toLowerCase().includes(q)
|
||||
})
|
||||
if (center) list.sort((a, b) => distanceTo(a, center) - distanceTo(b, center))
|
||||
else list.sort((a, b) => a.name.localeCompare(b.name))
|
||||
return list
|
||||
}, [places, search, center, listFilter, statusFilter])
|
||||
|
||||
const listOpts: Opt[] = [
|
||||
{ key: 'all', label: t('collections.picker.allLists'), icon: <Layers size={13} />, count: places.length },
|
||||
...lists.map(l => ({
|
||||
key: l.id,
|
||||
label: l.name,
|
||||
icon: <span className="w-2.5 h-2.5 rounded-full inline-block" style={{ background: l.color || '#6366f1' }} />,
|
||||
count: places.filter(p => p.collection_id === l.id).length,
|
||||
})),
|
||||
]
|
||||
const statusOpts: Opt[] = [
|
||||
{ key: 'all', label: t('common.all') },
|
||||
...STATUS_ORDER.map(s => {
|
||||
const Icon = STATUS_META[s].icon
|
||||
return { key: s, label: t(STATUS_META[s].labelKey), icon: <Icon size={13} style={{ color: STATUS_META[s].color }} /> }
|
||||
}),
|
||||
]
|
||||
|
||||
return (
|
||||
<aside className="w-full sm:w-64 shrink-0 flex flex-col rounded-xl border border-edge bg-surface-secondary overflow-hidden self-stretch">
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-edge shrink-0">
|
||||
<Bookmark size={15} className="text-accent" />
|
||||
<span className="text-[13px] font-semibold text-content">{t('collections.picker.title')}</span>
|
||||
</div>
|
||||
<div className="p-2.5 flex flex-col gap-2 shrink-0">
|
||||
<div className="relative">
|
||||
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-content-faint" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder={t('collections.picker.search')}
|
||||
className="w-full pl-8 pr-3 py-1.5 rounded-lg border border-edge bg-surface-input text-content text-[13px] outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
{lists.length > 0 && (
|
||||
<div className="flex gap-2">
|
||||
<FilterDropdown current={listFilter} options={listOpts} onSelect={k => setListFilter(k as number | 'all')} lead={<Layers size={13} />} />
|
||||
<FilterDropdown current={statusFilter} options={statusOpts} onSelect={k => setStatusFilter(k as CollectionStatus | 'all')} lead={<Bookmark size={13} />} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-2 pb-2">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-10 text-content-faint">
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
</div>
|
||||
) : visible.length === 0 ? (
|
||||
<p className="text-center text-[12px] text-content-faint py-10 px-3">{t('collections.picker.empty')}</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{visible.map(place => (
|
||||
<button
|
||||
key={place.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(place)}
|
||||
title={t('collections.picker.use')}
|
||||
className="flex items-center gap-2.5 px-2 py-2 rounded-lg text-left hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
<PlaceAvatar place={place} size={32} category={place.category ? { color: place.category.color ?? undefined, icon: place.category.icon ?? undefined } : null} />
|
||||
<span className="flex flex-col min-w-0">
|
||||
<span className="text-[12.5px] font-medium text-content truncate">{place.name}</span>
|
||||
{place.address && <span className="text-[11px] text-content-faint truncate">{place.address}</span>}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// FE-COMP-COLDETAIL-001 to FE-COMP-COLDETAIL-010
|
||||
import React from 'react';
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser } from '../../../tests/helpers/factories';
|
||||
import type { CollectionPlace } from '@trek/shared';
|
||||
import { useTranslation } from '../../i18n/TranslationContext';
|
||||
import CollectionPlaceDetail from './CollectionPlaceDetail';
|
||||
|
||||
// The component takes `t` as a PROP (not from context), so wrap it in a tiny
|
||||
// consumer that feeds it the real English `t` from the TranslationProvider the
|
||||
// test render helper mounts. That way we can assert on visible English strings.
|
||||
type DetailProps = React.ComponentProps<typeof CollectionPlaceDetail>;
|
||||
function TranslatedDetail(props: Omit<DetailProps, 't'>): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
return <CollectionPlaceDetail {...props} t={t} />;
|
||||
}
|
||||
|
||||
// Place literal per spec: no image_url / lat / lng / provider id, so the cover
|
||||
// stays a gradient and status is 'idea'.
|
||||
const place: CollectionPlace = {
|
||||
id: 1,
|
||||
collection_id: 10,
|
||||
name: 'Test Cafe',
|
||||
status: 'idea',
|
||||
description: 'Nice spot',
|
||||
address: 'Somewhere',
|
||||
links: [{ url: 'https://x.com' }],
|
||||
category: { id: 1, name: 'Food', color: '#f00', icon: null },
|
||||
};
|
||||
|
||||
function renderDetail(overrides: Partial<Omit<DetailProps, 't'>> = {}) {
|
||||
const props = {
|
||||
place,
|
||||
canEdit: true,
|
||||
canDelete: true,
|
||||
categories: [],
|
||||
labels: [],
|
||||
anchorRect: null,
|
||||
onClose: vi.fn(),
|
||||
onSetStatus: vi.fn(),
|
||||
onSave: vi.fn().mockResolvedValue(undefined),
|
||||
onCopyToTrip: vi.fn(),
|
||||
onRemove: vi.fn(),
|
||||
...overrides,
|
||||
} as Omit<DetailProps, 't'>;
|
||||
render(<TranslatedDetail {...props} />);
|
||||
return props;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
seedStore(useAuthStore, { user: buildUser(), placesPhotosEnabled: false });
|
||||
// The detail sheet asks the maps provider for a cover photo on mount when a
|
||||
// place carries no image of its own — stub it so nothing hits the network.
|
||||
server.use(
|
||||
http.get('/api/maps/place-photo/:id', () =>
|
||||
HttpResponse.json({ photoUrl: null, attribution: null }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
describe('CollectionPlaceDetail', () => {
|
||||
it('FE-COMP-COLDETAIL-001: renders the place name, address and description', async () => {
|
||||
renderDetail();
|
||||
expect(await screen.findByRole('heading', { name: 'Test Cafe' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Somewhere')).toBeInTheDocument();
|
||||
expect(screen.getByText('Nice spot')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── Editor / admin (canEdit + canDelete) ────────────────────────────────────
|
||||
it('FE-COMP-COLDETAIL-002: shows Edit and Remove buttons when canEdit && canDelete', async () => {
|
||||
renderDetail({ canEdit: true, canDelete: true });
|
||||
expect(await screen.findByRole('button', { name: 'Edit' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Remove from list' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLDETAIL-003: clicking a status option calls onSetStatus when canEdit', async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = renderDetail({ canEdit: true, canDelete: true });
|
||||
const visited = await screen.findByRole('button', { name: 'Visited' });
|
||||
await user.click(visited);
|
||||
expect(props.onSetStatus).toHaveBeenCalledTimes(1);
|
||||
expect(props.onSetStatus).toHaveBeenCalledWith('visited');
|
||||
});
|
||||
|
||||
it('FE-COMP-COLDETAIL-004: current status option is pressed, others are not', async () => {
|
||||
renderDetail({ canEdit: true, canDelete: true });
|
||||
expect(await screen.findByRole('button', { name: 'Idea' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByRole('button', { name: 'Want to go' })).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
|
||||
it('FE-COMP-COLDETAIL-005: entering edit mode reveals name input + Save', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDetail({ canEdit: true });
|
||||
await user.click(await screen.findByRole('button', { name: 'Edit' }));
|
||||
expect(screen.getByDisplayValue('Test Cafe')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Save/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── Viewer (no edit / no delete) ────────────────────────────────────────────
|
||||
it('FE-COMP-COLDETAIL-006: hides Edit and Remove buttons when canEdit=false && canDelete=false', async () => {
|
||||
renderDetail({ canEdit: false, canDelete: false });
|
||||
// Wait for the async photo effect to settle before asserting absence.
|
||||
expect(await screen.findByRole('button', { name: 'Copy to trip' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Remove from list' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLDETAIL-007: clicking a status option does NOT call onSetStatus when canEdit=false', async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = renderDetail({ canEdit: false, canDelete: false });
|
||||
const visited = await screen.findByRole('button', { name: 'Visited' });
|
||||
await user.click(visited);
|
||||
expect(props.onSetStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLDETAIL-008: status segment still renders (read-only) for viewers', async () => {
|
||||
renderDetail({ canEdit: false, canDelete: false });
|
||||
expect(await screen.findByRole('button', { name: 'Idea' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Visited' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── Copy-to-trip is available in read mode regardless of permissions ─────────
|
||||
it('FE-COMP-COLDETAIL-009: Copy to trip button fires onCopyToTrip (editor)', async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = renderDetail({ canEdit: true, canDelete: true });
|
||||
await user.click(await screen.findByRole('button', { name: 'Copy to trip' }));
|
||||
expect(props.onCopyToTrip).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-COMP-COLDETAIL-010: Copy to trip button fires onCopyToTrip (viewer)', async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = renderDetail({ canEdit: false, canDelete: false });
|
||||
await user.click(await screen.findByRole('button', { name: 'Copy to trip' }));
|
||||
expect(props.onCopyToTrip).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import { X, Pencil, Copy, Trash2, MapPin, Link2, Plus, ExternalLink, Check, Tag, Tags } from 'lucide-react'
|
||||
import type { CollectionPlace, CollectionStatus, CollectionLink, CollectionLabel } from '@trek/shared'
|
||||
import type { Category, TranslationFn } from '../../types'
|
||||
import MarkdownToolbar from '../Journey/MarkdownToolbar'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { entityGradient } from '../../utils/gradients'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { STATUS_META, STATUS_ORDER, normalizeLinkUrl } from '../../pages/collections/collectionsModel'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
|
||||
function linkHost(url: string): string {
|
||||
try { return new URL(url).hostname.replace(/^www\./, '') } catch { return url }
|
||||
}
|
||||
|
||||
interface CollectionPlaceDetailProps {
|
||||
place: CollectionPlace
|
||||
canEdit: boolean
|
||||
canDelete: boolean
|
||||
categories: Category[]
|
||||
/** The active list's custom labels, for the assign chips. */
|
||||
labels: CollectionLabel[]
|
||||
/** When set, dock the sheet over that column (desktop split) instead of centred. */
|
||||
anchorRect?: { left: number; width: number } | null
|
||||
onClose: () => void
|
||||
onSetStatus: (status: CollectionStatus) => void
|
||||
onSave: (patch: { name?: string; description?: string | null; links?: CollectionLink[]; category_id?: number | null; label_ids?: number[] }) => Promise<void>
|
||||
onCopyToTrip: () => void
|
||||
onRemove: () => void
|
||||
t: TranslationFn
|
||||
}
|
||||
|
||||
function StatusSegment({ status, onSet, t }: { status: CollectionStatus; onSet: (s: CollectionStatus) => void; t: TranslationFn }): React.ReactElement {
|
||||
return (
|
||||
<div className="col-detail-seg" role="group">
|
||||
{STATUS_ORDER.map(s => {
|
||||
const Icon = STATUS_META[s].icon
|
||||
const on = status === s
|
||||
return (
|
||||
<button key={s} type="button" aria-pressed={on} onClick={() => onSet(s)} className={on ? 'on' : ''}>
|
||||
<Icon size={14} style={{ color: on ? STATUS_META[s].color : undefined }} /> {t(STATUS_META[s].labelKey)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Bottom detail sheet for a saved place — an opaque, clearly-sectioned card
|
||||
* (cover → meta → status → description → links) docked over the list column.
|
||||
* Read mode renders the description as markdown + link chips; edit mode swaps in
|
||||
* name / category / markdown description / links, saving via updatePlace. Status
|
||||
* is an always-live segmented control (auto-saves).
|
||||
*/
|
||||
export default function CollectionPlaceDetail({
|
||||
place, canEdit, canDelete, categories, labels, anchorRect, onClose, onSetStatus, onSave, onCopyToTrip, onRemove, t,
|
||||
}: CollectionPlaceDetailProps): React.ReactElement {
|
||||
const toast = useToast()
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [name, setName] = useState(place.name)
|
||||
const [categoryId, setCategoryId] = useState<number | null>(place.category_id ?? null)
|
||||
const [description, setDescription] = useState(place.description ?? '')
|
||||
const [links, setLinks] = useState<CollectionLink[]>(place.links ?? [])
|
||||
const [labelIds, setLabelIds] = useState<number[]>(place.label_ids ?? [])
|
||||
const [saving, setSaving] = useState(false)
|
||||
// A higher-res photo pulled from the maps provider when the place has none of
|
||||
// its own — the list avatar's little thumbnail is too low-res for the cover.
|
||||
const [fetchedPhoto, setFetchedPhoto] = useState<string | null>(null)
|
||||
const descRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
// Reset only when a DIFFERENT place is opened (keyed on id, not on every field).
|
||||
useEffect(() => {
|
||||
setEditing(false)
|
||||
setName(place.name)
|
||||
setCategoryId(place.category_id ?? null)
|
||||
setDescription(place.description ?? '')
|
||||
setLinks(place.links ?? [])
|
||||
setLabelIds(place.label_ids ?? [])
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [place.id])
|
||||
|
||||
// Fetch a cover photo when the place doesn't carry its own image.
|
||||
useEffect(() => {
|
||||
setFetchedPhoto(null)
|
||||
if (place.image_url) return
|
||||
const photoId = place.google_place_id || place.osm_id || (place.lat != null && place.lng != null ? `${place.lat},${place.lng}` : null)
|
||||
if (!photoId) return
|
||||
let cancelled = false
|
||||
mapsApi.placePhoto(photoId, place.lat ?? undefined, place.lng ?? undefined, place.name)
|
||||
.then(res => { if (!cancelled && res?.photoUrl) setFetchedPhoto(res.photoUrl) })
|
||||
.catch(() => {})
|
||||
return () => { cancelled = true }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [place.id])
|
||||
|
||||
const banner = place.image_url || fetchedPhoto
|
||||
const setLink = (i: number, patch: Partial<CollectionLink>) => setLinks(links.map((l, idx) => (idx === i ? { ...l, ...patch } : l)))
|
||||
const toggleLabel = (id: number) => setLabelIds(labelIds.includes(id) ? labelIds.filter(x => x !== id) : [...labelIds, id])
|
||||
const resetForm = () => { setEditing(false); setName(place.name); setCategoryId(place.category_id ?? null); setDescription(place.description ?? ''); setLinks(place.links ?? []); setLabelIds(place.label_ids ?? []) }
|
||||
const assignedLabels = labels.filter(l => (place.label_ids ?? []).includes(l.id))
|
||||
|
||||
const save = async () => {
|
||||
const cleanLinks = links.map(l => ({ label: l.label?.trim() || undefined, url: normalizeLinkUrl(l.url) })).filter(l => l.url)
|
||||
setSaving(true)
|
||||
try {
|
||||
await onSave({ name: name.trim() || place.name, description: description.trim() || null, links: cleanLinks, category_id: categoryId, label_ids: labelIds })
|
||||
setEditing(false)
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const dockStyle = anchorRect ? { left: anchorRect.left, width: anchorRect.width, transform: 'none' as const } : undefined
|
||||
const CatIcon = getCategoryIcon(place.category?.icon)
|
||||
|
||||
return (
|
||||
<div className={`col-detail${anchorRect ? ' docked' : ''}`} style={dockStyle} onClick={e => e.stopPropagation()}>
|
||||
<div className="col-detail-cover" style={banner ? undefined : { backgroundImage: entityGradient(place.id) }}>
|
||||
{banner && <img src={banner} alt="" />}
|
||||
<div className="col-detail-cover-scrim" />
|
||||
{place.category?.name && (
|
||||
<span className="col-detail-cover-cat" style={{ ['--cat' as string]: place.category.color || '#6366f1' }}>
|
||||
<CatIcon size={12} /> {place.category.name}
|
||||
</span>
|
||||
)}
|
||||
<button type="button" className="col-detail-close" onClick={onClose} aria-label={t('common.close')}><X size={16} /></button>
|
||||
<div className="col-detail-head">
|
||||
{editing
|
||||
? <input value={name} onChange={e => setName(e.target.value)} className="col-detail-name-input" autoFocus aria-label={t('collections.listName')} />
|
||||
: <h2 className="col-detail-name">{place.name}</h2>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-detail-body">
|
||||
{/* Meta (view only) */}
|
||||
{!editing && place.address && (
|
||||
<div className="col-detail-meta">
|
||||
<span className="col-detail-addr"><MapPin size={12} /> {place.address}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status — live for editors, read-only for viewers */}
|
||||
<StatusSegment status={place.status} onSet={canEdit ? onSetStatus : () => {}} t={t} />
|
||||
|
||||
{editing ? (
|
||||
<div className="col-detail-edit">
|
||||
{/* Category */}
|
||||
<div className="col-detail-field">
|
||||
<div className="col-detail-label"><Tag size={12} /> {t('collections.category')}</div>
|
||||
<div className="col-detail-cats">
|
||||
<button type="button" onClick={() => setCategoryId(null)} className={`col-detail-cat${categoryId == null ? ' on' : ''}`}>{t('collections.noCategory')}</button>
|
||||
{categories.map(cat => {
|
||||
const Icon = getCategoryIcon(cat.icon ?? undefined)
|
||||
const on = categoryId === cat.id
|
||||
return (
|
||||
<button key={cat.id} type="button" onClick={() => setCategoryId(cat.id)} className={`col-detail-cat${on ? ' on' : ''}`} style={{ ['--cat' as string]: cat.color || '#6366f1' }}>
|
||||
<Icon size={12} /> {cat.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{/* Labels */}
|
||||
{labels.length > 0 && (
|
||||
<div className="col-detail-field">
|
||||
<div className="col-detail-label"><Tags size={12} /> {t('collections.labels.title')}</div>
|
||||
<div className="col-detail-cats">
|
||||
{labels.map(l => {
|
||||
const on = labelIds.includes(l.id)
|
||||
return (
|
||||
<button key={l.id} type="button" onClick={() => toggleLabel(l.id)} className={`col-detail-cat${on ? ' on' : ''}`} style={{ ['--cat' as string]: l.color || '#6366f1' }}>
|
||||
<span className="col-labelchip-dot" /> {l.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Description */}
|
||||
<div className="col-detail-field">
|
||||
<div className="col-detail-label">{t('collections.description')}</div>
|
||||
<MarkdownToolbar textareaRef={descRef} onUpdate={setDescription} />
|
||||
<textarea ref={descRef} value={description} onChange={e => setDescription(e.target.value)} rows={4} placeholder={t('collections.descriptionPlaceholder')} className="col-detail-textarea" />
|
||||
</div>
|
||||
{/* Links */}
|
||||
<div className="col-detail-field">
|
||||
<div className="col-detail-label">{t('collections.links')}</div>
|
||||
<div className="col-detail-links-edit">
|
||||
{links.map((l, i) => (
|
||||
<div key={i} className="col-detail-link-row">
|
||||
<input value={l.label ?? ''} onChange={e => setLink(i, { label: e.target.value })} placeholder={t('collections.linkLabel')} className="col-detail-input w-28" />
|
||||
<input value={l.url} onChange={e => setLink(i, { url: e.target.value })} placeholder="https://…" className="col-detail-input flex-1" />
|
||||
<button type="button" onClick={() => setLinks(links.filter((_, idx) => idx !== i))} className="col-detail-icon-btn" aria-label={t('common.delete')}><Trash2 size={14} /></button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" onClick={() => setLinks([...links, { url: '' }])} className="col-detail-add-link"><Plus size={13} /> <Link2 size={12} /> {t('collections.addLink')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{assignedLabels.length > 0 && (
|
||||
<div className="col-detail-labels">
|
||||
{assignedLabels.map(l => (
|
||||
<span key={l.id} className="col-labelchip on static" style={{ ['--label' as string]: l.color || 'var(--accent)' }}>
|
||||
<span className="col-labelchip-dot" /> {l.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{place.description && (
|
||||
<div className="col-detail-md collab-note-md">
|
||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{place.description}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
{place.links && place.links.length > 0 && (
|
||||
<div className="col-detail-links">
|
||||
{place.links.map((l, i) => (
|
||||
<a key={i} href={l.url} target="_blank" rel="noopener noreferrer" className="col-detail-link">
|
||||
<ExternalLink size={13} /> {l.label || linkHost(l.url)}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="col-detail-footer">
|
||||
{editing ? (
|
||||
<>
|
||||
<button type="button" onClick={resetForm} className="col-detail-btn">{t('common.cancel')}</button>
|
||||
<button type="button" onClick={save} disabled={saving} className="col-detail-btn primary"><Check size={14} /> {t('common.save')}</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{canEdit && <button type="button" onClick={() => setEditing(true)} className="col-detail-btn"><Pencil size={14} /> {t('common.edit')}</button>}
|
||||
<button type="button" onClick={onCopyToTrip} className="col-detail-btn"><Copy size={14} /> {t('collections.copyToTrip')}</button>
|
||||
<div className="col-detail-footer-spacer" />
|
||||
{canDelete && <button type="button" onClick={onRemove} className="col-detail-btn danger"><Trash2 size={14} /> {t('collections.removeFromList')}</button>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { Search, MapPin, Loader2, Copy, CalendarDays } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { tripsApi } from '../../api/client'
|
||||
import { getApiErrorMessage } from '../../utils/apiError'
|
||||
import { formatDate } from '../../utils/formatters'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { TranslationFn } from '../../types'
|
||||
|
||||
interface TripOption {
|
||||
id: number
|
||||
title: string
|
||||
start_date?: string | null
|
||||
end_date?: string | null
|
||||
cover_image?: string | null
|
||||
}
|
||||
|
||||
interface CopyToTripModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
/** The collection place ids to copy. */
|
||||
placeIds: number[]
|
||||
/** Delegates to collectionStore.copyToTrip; returns the server reconcile result. */
|
||||
onCopy: (tripId: number) => Promise<{ copied: number; skipped: { id: number; name: string }[] }>
|
||||
t: TranslationFn
|
||||
}
|
||||
|
||||
/**
|
||||
* Trip picker for "Copy to trip" — lists the user's trips (searchable), copies
|
||||
* the selected collection places into the chosen trip and reconciles the server
|
||||
* dedup result into a copied / skipped-duplicates toast. Works for a single
|
||||
* place (detail panel) and bulk select-mode ("Copy N to trip").
|
||||
*/
|
||||
export default function CopyToTripModal({ isOpen, onClose, placeIds, onCopy, t }: CopyToTripModalProps): React.ReactElement | null {
|
||||
const toast = useToast()
|
||||
const { language } = useTranslation()
|
||||
const [trips, setTrips] = useState<TripOption[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
const [busyTripId, setBusyTripId] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setSearch('')
|
||||
tripsApi.list()
|
||||
.then((res: { trips?: TripOption[] }) => { if (!cancelled) setTrips(res.trips ?? []) })
|
||||
.catch(() => { if (!cancelled) setTrips([]) })
|
||||
.finally(() => { if (!cancelled) setLoading(false) })
|
||||
return () => { cancelled = true }
|
||||
}, [isOpen])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase()
|
||||
if (!q) return trips
|
||||
return trips.filter(tr => (tr.title ?? '').toLowerCase().includes(q))
|
||||
}, [trips, search])
|
||||
|
||||
const dateRange = (tr: TripOption): string => {
|
||||
const s = formatDate(tr.start_date, language)
|
||||
const e = formatDate(tr.end_date, language)
|
||||
if (s && e) return `${s} – ${e}`
|
||||
return s || e || ''
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const handleCopy = async (tripId: number) => {
|
||||
if (busyTripId != null || placeIds.length === 0) return
|
||||
setBusyTripId(tripId)
|
||||
try {
|
||||
const res = await onCopy(tripId)
|
||||
if (res.copied > 0) {
|
||||
toast.success(t('collections.copiedCount', { count: res.copied }))
|
||||
}
|
||||
if (res.skipped.length > 0) {
|
||||
toast.info(t('collections.skippedDuplicates', { count: res.skipped.length }))
|
||||
}
|
||||
if (res.copied === 0 && res.skipped.length === 0) {
|
||||
toast.info(t('collections.copyNothing'))
|
||||
}
|
||||
onClose()
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
setBusyTripId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
title={placeIds.length > 1 ? t('collections.copyN', { count: placeIds.length }) : t('collections.copyToTripTitle')}
|
||||
size="sm"
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="relative">
|
||||
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-content-faint" />
|
||||
<input
|
||||
autoFocus
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder={t('collections.copyToTripSearch')}
|
||||
className="w-full pl-8 pr-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[13px] outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-10 text-content-faint">
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="text-center text-[13px] text-content-faint py-10">{t('collections.noTrips')}</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1 max-h-[50vh] overflow-y-auto -mx-1 px-1">
|
||||
{filtered.map(trip => {
|
||||
const busy = busyTripId === trip.id
|
||||
return (
|
||||
<button
|
||||
key={trip.id}
|
||||
type="button"
|
||||
onClick={() => handleCopy(trip.id)}
|
||||
disabled={busy}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-xl border border-edge bg-surface-card text-left hover:bg-surface-hover transition-colors disabled:opacity-60"
|
||||
>
|
||||
<span className="w-9 h-9 rounded-lg bg-surface-secondary flex items-center justify-center shrink-0 overflow-hidden text-content-faint">
|
||||
{trip.cover_image ? <img src={trip.cover_image} alt="" className="w-full h-full object-cover" /> : <MapPin size={15} />}
|
||||
</span>
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block text-[13px] font-medium text-content truncate">{trip.title}</span>
|
||||
{dateRange(trip) && (
|
||||
<span className="flex items-center gap-1 text-[11.5px] text-content-faint truncate">
|
||||
<CalendarDays size={11} className="shrink-0" /> {dateRange(trip)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{busy ? <Loader2 size={15} className="animate-spin text-content-faint shrink-0" /> : <Copy size={15} className="text-content-faint shrink-0" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Plus, Trash2, Loader2 } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import type { CollectionLabel, CollectionLabelUpdateRequest } from '@trek/shared'
|
||||
import type { TranslationFn } from '../../types'
|
||||
|
||||
const SWATCHES = ['#6366f1', '#0ea5e9', '#10b981', '#f59e0b', '#ef4444', '#ec4899', '#8b5cf6', '#64748b']
|
||||
|
||||
interface LabelManagerProps {
|
||||
isOpen: boolean
|
||||
labels: CollectionLabel[]
|
||||
onCreate: (name: string, color?: string) => Promise<void> | void
|
||||
onUpdate: (labelId: number, body: CollectionLabelUpdateRequest) => Promise<void> | void
|
||||
onDelete: (labelId: number) => Promise<void> | void
|
||||
onClose: () => void
|
||||
t: TranslationFn
|
||||
}
|
||||
|
||||
/** Swatch row shared by the create form and each row's recolor control. */
|
||||
function Swatches({ value, onPick }: { value: string; onPick: (c: string) => void }): React.ReactElement {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{SWATCHES.map(c => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => onPick(c)}
|
||||
className={`w-5 h-5 rounded-full border transition-transform ${value.toLowerCase() === c ? 'border-content scale-110' : 'border-transparent'}`}
|
||||
style={{ background: c }}
|
||||
aria-label={c}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** One existing label: inline rename (save on blur/Enter), recolor, delete. */
|
||||
function LabelRow({ label, onUpdate, onDelete, t }: {
|
||||
label: CollectionLabel
|
||||
onUpdate: LabelManagerProps['onUpdate']
|
||||
onDelete: LabelManagerProps['onDelete']
|
||||
t: TranslationFn
|
||||
}): React.ReactElement {
|
||||
const [name, setName] = useState(label.name)
|
||||
const [color, setColor] = useState(label.color || '#6366f1')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const commitName = async () => {
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed || trimmed === label.name) { setName(label.name); return }
|
||||
setBusy(true)
|
||||
try { await onUpdate(label.id, { name: trimmed }) } finally { setBusy(false) }
|
||||
}
|
||||
const pickColor = async (c: string) => {
|
||||
setColor(c)
|
||||
setBusy(true)
|
||||
try { await onUpdate(label.id, { color: c }) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-2.5 py-2 rounded-xl border border-edge bg-surface-card">
|
||||
<span className="w-3.5 h-3.5 rounded-full shrink-0" style={{ background: color }} />
|
||||
<input
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
onBlur={commitName}
|
||||
onKeyDown={e => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }}
|
||||
maxLength={60}
|
||||
className="flex-1 min-w-0 bg-transparent text-[13px] text-content outline-none"
|
||||
aria-label={t('collections.labels.name')}
|
||||
/>
|
||||
<Swatches value={color} onPick={pickColor} />
|
||||
{busy && <Loader2 size={14} className="animate-spin text-content-faint shrink-0" />}
|
||||
<button type="button" onClick={() => onDelete(label.id)} className="p-1 text-content-faint hover:text-danger shrink-0" aria-label={t('common.delete')}>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Manage a list's custom labels — create, rename, recolor and delete. Available
|
||||
* to any member who can edit the list; the labels are shared by the whole list.
|
||||
*/
|
||||
export default function LabelManager({ isOpen, labels, onCreate, onUpdate, onDelete, onClose, t }: LabelManagerProps): React.ReactElement {
|
||||
const [newName, setNewName] = useState('')
|
||||
const [newColor, setNewColor] = useState(SWATCHES[0])
|
||||
const [adding, setAdding] = useState(false)
|
||||
|
||||
const add = async () => {
|
||||
const trimmed = newName.trim()
|
||||
if (!trimmed || adding) return
|
||||
setAdding(true)
|
||||
try {
|
||||
await onCreate(trimmed, newColor)
|
||||
setNewName('')
|
||||
setNewColor(SWATCHES[0])
|
||||
} finally {
|
||||
setAdding(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={t('collections.labels.manage')} size="sm">
|
||||
<div className="flex flex-col gap-3">
|
||||
{labels.length === 0 ? (
|
||||
<p className="text-center text-[13px] text-content-faint py-4">{t('collections.labels.empty')}</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5 max-h-[46vh] overflow-y-auto -mx-1 px-1">
|
||||
{labels.map(l => <LabelRow key={l.id} label={l} onUpdate={onUpdate} onDelete={onDelete} t={t} />)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2 pt-3 border-t border-edge">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3.5 h-3.5 rounded-full shrink-0" style={{ background: newColor }} />
|
||||
<input
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') add() }}
|
||||
maxLength={60}
|
||||
placeholder={t('collections.labels.namePlaceholder')}
|
||||
className="flex-1 min-w-0 px-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[13px] outline-none focus:border-accent"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={add}
|
||||
disabled={!newName.trim() || adding}
|
||||
className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-accent text-white text-[13px] font-semibold hover:opacity-90 disabled:opacity-50 shrink-0"
|
||||
>
|
||||
{adding ? <Loader2 size={14} className="animate-spin" /> : <Plus size={14} />} {t('collections.labels.add')}
|
||||
</button>
|
||||
</div>
|
||||
<Swatches value={newColor} onPick={setNewColor} />
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { ImagePlus, Link2, Plus, Trash2, Search, Loader2 } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import { useCollectionStore } from '../../store/collectionStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { tripsApi } from '../../api/client'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
import { normalizeLinkUrl } from '../../pages/collections/collectionsModel'
|
||||
import type { TranslationFn } from '../../types'
|
||||
import type { Collection, CollectionLink } from '@trek/shared'
|
||||
|
||||
interface CoverSearchPhoto {
|
||||
id: string
|
||||
url: string
|
||||
thumb: string
|
||||
description?: string | null
|
||||
photographer?: string | null
|
||||
}
|
||||
|
||||
const SWATCHES = ['#6366f1', '#ec4899', '#14b8a6', '#f97316', '#8b5cf6', '#ef4444', '#3b82f6', '#22c55e']
|
||||
|
||||
interface ListEditorModalProps {
|
||||
/** null = closed, 'new' = create, a Collection = edit that list. */
|
||||
target: Collection | 'new' | null
|
||||
onClose: () => void
|
||||
onCreated: (id: number) => void
|
||||
/** Owner-only: hand off to the delete-confirm flow when editing a list. */
|
||||
onRequestDelete: (id: number) => void
|
||||
t: TranslationFn
|
||||
}
|
||||
|
||||
/**
|
||||
* Create / edit a list — name, colour, an optional cover image (tinted with the
|
||||
* list colour in the hero), a description and a set of links. On create it makes
|
||||
* the list then uploads the cover to the new id; on edit it patches + re-uploads.
|
||||
*/
|
||||
export default function ListEditorModal({ target, onClose, onCreated, onRequestDelete, t }: ListEditorModalProps): React.ReactElement | null {
|
||||
const createCollection = useCollectionStore(s => s.createCollection)
|
||||
const updateCollection = useCollectionStore(s => s.updateCollection)
|
||||
const uploadCover = useCollectionStore(s => s.uploadCover)
|
||||
const toast = useToast()
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const editing = target && target !== 'new' ? target : null
|
||||
const [name, setName] = useState('')
|
||||
const [color, setColor] = useState('#6366f1')
|
||||
const [description, setDescription] = useState('')
|
||||
const [links, setLinks] = useState<CollectionLink[]>([])
|
||||
const [coverFile, setCoverFile] = useState<File | null>(null)
|
||||
const [coverPreview, setCoverPreview] = useState<string | null>(null)
|
||||
const [pendingUnsplashUrl, setPendingUnsplashUrl] = useState<string | null>(null)
|
||||
const [coverQuery, setCoverQuery] = useState('')
|
||||
const [coverResults, setCoverResults] = useState<CoverSearchPhoto[]>([])
|
||||
const [searchingCover, setSearchingCover] = useState(false)
|
||||
const coverSeq = useRef(0)
|
||||
const [saving, setSaving] = useState(false)
|
||||
// Remembers a freshly created list id so a retry after a cover-upload failure
|
||||
// updates it instead of creating a duplicate list.
|
||||
const [createdId, setCreatedId] = useState<number | null>(null)
|
||||
const objectUrl = useRef<string | null>(null)
|
||||
|
||||
const dropObjectUrl = () => { if (objectUrl.current) { URL.revokeObjectURL(objectUrl.current); objectUrl.current = null } }
|
||||
|
||||
// (Re)seed the form whenever the target changes.
|
||||
useEffect(() => {
|
||||
if (!target) return
|
||||
setName(editing?.name ?? '')
|
||||
setColor(editing?.color ?? '#6366f1')
|
||||
setDescription(editing?.description ?? '')
|
||||
setLinks(editing?.links ?? [])
|
||||
setCoverFile(null)
|
||||
dropObjectUrl()
|
||||
setCoverPreview(editing?.cover_image ?? null)
|
||||
setPendingUnsplashUrl(null)
|
||||
setCoverQuery('')
|
||||
setCoverResults([])
|
||||
setCreatedId(null)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [target])
|
||||
|
||||
// Revoke the last preview blob on unmount.
|
||||
useEffect(() => () => dropObjectUrl(), [])
|
||||
|
||||
if (!target) return null
|
||||
|
||||
const pickCover = (file: File | undefined) => {
|
||||
if (!file) return
|
||||
dropObjectUrl()
|
||||
const url = URL.createObjectURL(file)
|
||||
objectUrl.current = url
|
||||
setCoverFile(file)
|
||||
setCoverPreview(url)
|
||||
setPendingUnsplashUrl(null) // an uploaded file wins over a picked Unsplash photo
|
||||
}
|
||||
|
||||
const searchCover = async () => {
|
||||
const query = coverQuery.trim() || name.trim()
|
||||
if (!query) return
|
||||
const seq = ++coverSeq.current
|
||||
setSearchingCover(true)
|
||||
try {
|
||||
const data = await tripsApi.searchCoverImages(query)
|
||||
if (seq !== coverSeq.current) return
|
||||
setCoverResults(data.photos || [])
|
||||
} catch {
|
||||
if (seq === coverSeq.current) setCoverResults([])
|
||||
} finally {
|
||||
if (seq === coverSeq.current) setSearchingCover(false)
|
||||
}
|
||||
}
|
||||
|
||||
const pickUnsplash = (photo: CoverSearchPhoto) => {
|
||||
if (!photo.url) return
|
||||
dropObjectUrl()
|
||||
setCoverFile(null)
|
||||
setPendingUnsplashUrl(photo.url)
|
||||
setCoverPreview(photo.url)
|
||||
}
|
||||
|
||||
const setLink = (i: number, patch: Partial<CollectionLink>) =>
|
||||
setLinks(links.map((l, idx) => (idx === i ? { ...l, ...patch } : l)))
|
||||
|
||||
const save = async () => {
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) return
|
||||
// Normalise + keep only links with a url; drop blank rows.
|
||||
const cleanLinks = links
|
||||
.map(l => ({ label: l.label?.trim() || undefined, url: normalizeLinkUrl(l.url) }))
|
||||
.filter(l => l.url)
|
||||
const payload = { name: trimmed, color, description: description.trim() || null, links: cleanLinks, ...(pendingUnsplashUrl ? { cover_image: pendingUnsplashUrl } : {}) }
|
||||
setSaving(true)
|
||||
try {
|
||||
// A prior create that failed at the cover step left `createdId` set — reuse
|
||||
// it so a retry updates that list instead of creating a duplicate.
|
||||
let id = editing?.id ?? createdId
|
||||
if (id != null) {
|
||||
await updateCollection(id, payload)
|
||||
} else {
|
||||
const created = await createCollection(payload)
|
||||
id = created?.id ?? null
|
||||
setCreatedId(id)
|
||||
}
|
||||
if (id != null && coverFile) await uploadCover(id, coverFile)
|
||||
if (!editing && id != null) onCreated(id)
|
||||
onClose()
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
title={editing ? t('collections.editListTitle') : t('collections.newList')}
|
||||
size="md"
|
||||
footer={
|
||||
<div className="flex items-center gap-2">
|
||||
{editing && editing.is_owner !== false && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { onClose(); onRequestDelete(editing.id) }}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-danger text-[13px] font-medium hover:bg-danger-soft"
|
||||
>
|
||||
<Trash2 size={14} /> {t('collections.deleteList')}
|
||||
</button>
|
||||
)}
|
||||
<div className="flex justify-end gap-2 ml-auto">
|
||||
<button type="button" onClick={onClose} className="px-3 py-1.5 rounded-lg border border-edge text-content-secondary text-[13px] hover:bg-surface-hover">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button type="button" onClick={save} disabled={!name.trim() || saving} className="px-3 py-1.5 rounded-lg bg-accent text-accent-text text-[13px] font-semibold disabled:opacity-50">
|
||||
{editing ? t('common.save') : t('collections.create')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Cover */}
|
||||
<div>
|
||||
<label className="block text-[12px] font-medium text-content-secondary mb-1.5">{t('collections.coverImage')}</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
className="relative w-full h-32 rounded-xl overflow-hidden border border-edge bg-surface-secondary flex items-center justify-center text-content-faint hover:border-accent transition-colors"
|
||||
>
|
||||
{coverPreview && <img src={coverPreview} alt="" className="absolute inset-0 w-full h-full object-cover" />}
|
||||
<div className="absolute inset-0" style={{ background: `linear-gradient(135deg, ${color}55, transparent 70%)` }} />
|
||||
<span className="relative flex items-center gap-2 text-[13px] font-medium" style={{ color: coverPreview ? '#fff' : undefined, textShadow: coverPreview ? '0 1px 4px rgba(0,0,0,.5)' : undefined }}>
|
||||
<ImagePlus size={16} /> {coverPreview ? t('collections.changeCover') : t('collections.addCover')}
|
||||
</span>
|
||||
</button>
|
||||
<input ref={fileRef} type="file" accept="image/jpeg,image/png,image/gif,image/webp" className="hidden" onChange={e => pickCover(e.target.files?.[0])} />
|
||||
|
||||
{/* Unsplash cover search — same source as trip creation */}
|
||||
<div className="mt-2 flex gap-2">
|
||||
<input
|
||||
value={coverQuery}
|
||||
onChange={e => setCoverQuery(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); searchCover() } }}
|
||||
placeholder={t('dashboard.unsplashSearchPlaceholder')}
|
||||
className="flex-1 min-w-0 px-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[13px] outline-none focus:border-accent"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={searchCover}
|
||||
disabled={searchingCover || (!coverQuery.trim() && !name.trim())}
|
||||
className="px-3 py-2 rounded-lg border border-edge text-content-secondary text-[13px] hover:bg-surface-hover disabled:opacity-40 inline-flex items-center gap-1.5 whitespace-nowrap"
|
||||
>
|
||||
{searchingCover ? <Loader2 size={14} className="animate-spin" /> : <Search size={14} />}
|
||||
{t('dashboard.searchUnsplash')}
|
||||
</button>
|
||||
</div>
|
||||
{coverResults.length > 0 && (
|
||||
<div className="grid grid-cols-3 gap-2 mt-2">
|
||||
{coverResults.map(photo => (
|
||||
<button
|
||||
type="button"
|
||||
key={photo.id}
|
||||
onClick={() => pickUnsplash(photo)}
|
||||
aria-label={photo.photographer || 'Unsplash'}
|
||||
className={`relative h-20 overflow-hidden rounded-lg border transition-colors ${coverPreview === photo.url ? 'border-accent ring-2 ring-accent/25' : 'border-edge hover:border-accent'}`}
|
||||
>
|
||||
<img src={photo.thumb} alt={photo.description || ''} loading="lazy" className="w-full h-full object-cover" />
|
||||
{photo.photographer && (
|
||||
<span className="absolute inset-x-0 bottom-0 truncate bg-black/55 px-1.5 py-1 text-[10px] text-white">{photo.photographer}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="block text-[12px] font-medium text-content-secondary mb-1.5">{t('collections.listName')}</label>
|
||||
<input
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && name.trim()) save() }}
|
||||
placeholder={t('collections.listNamePlaceholder')}
|
||||
className="w-full px-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[14px] outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Colour */}
|
||||
<div>
|
||||
<label className="block text-[12px] font-medium text-content-secondary mb-2">{t('collections.listColor')}</label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{SWATCHES.map(col => (
|
||||
<button
|
||||
key={col}
|
||||
type="button"
|
||||
onClick={() => setColor(col)}
|
||||
className="w-7 h-7 rounded-full transition-transform hover:scale-110"
|
||||
style={{ background: col, outline: color === col ? '2px solid var(--accent)' : 'none', outlineOffset: 2 }}
|
||||
aria-label={col}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-[12px] font-medium text-content-secondary mb-1.5">{t('collections.description')}</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
placeholder={t('collections.descriptionPlaceholder')}
|
||||
className="w-full px-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[13px] outline-none focus:border-accent resize-y"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Links */}
|
||||
<div>
|
||||
<label className="block text-[12px] font-medium text-content-secondary mb-1.5">{t('collections.links')}</label>
|
||||
<div className="flex flex-col gap-2">
|
||||
{links.map((l, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<input
|
||||
value={l.label ?? ''}
|
||||
onChange={e => setLink(i, { label: e.target.value })}
|
||||
placeholder={t('collections.linkLabel')}
|
||||
className="w-32 shrink-0 px-2.5 py-1.5 rounded-lg border border-edge bg-surface-input text-content text-[12.5px] outline-none focus:border-accent"
|
||||
/>
|
||||
<input
|
||||
value={l.url}
|
||||
onChange={e => setLink(i, { url: e.target.value })}
|
||||
placeholder="https://…"
|
||||
className="flex-1 min-w-0 px-2.5 py-1.5 rounded-lg border border-edge bg-surface-input text-content text-[12.5px] outline-none focus:border-accent"
|
||||
/>
|
||||
<button type="button" onClick={() => setLinks(links.filter((_, idx) => idx !== i))} className="p-1.5 rounded-md text-content-faint hover:text-danger hover:bg-danger-soft" aria-label={t('common.delete')}>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" onClick={() => setLinks([...links, { url: '' }])} className="inline-flex items-center gap-1.5 self-start px-2.5 py-1.5 rounded-lg border border-dashed border-edge text-content-secondary text-[12.5px] font-medium hover:bg-surface-hover">
|
||||
<Plus size={14} /> <Link2 size={13} /> {t('collections.addLink')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import React from 'react'
|
||||
import { Plus, Layers, Users } from 'lucide-react'
|
||||
import type { Collection } from '@trek/shared'
|
||||
import type { TranslationFn } from '../../types'
|
||||
import { ALL_SAVED } from '../../store/collectionStore'
|
||||
import type { ActiveCollectionId, IncomingCollectionInvite } from '../../store/collectionStore'
|
||||
|
||||
interface ListsRailProps {
|
||||
ownedLists: Collection[]
|
||||
sharedLists: Collection[]
|
||||
activeId: ActiveCollectionId
|
||||
incomingInvites: IncomingCollectionInvite[]
|
||||
onSelect: (id: ActiveCollectionId) => void
|
||||
onNewList: () => void
|
||||
onAcceptInvite: (id: number) => void
|
||||
onDeclineInvite: (id: number) => void
|
||||
t: TranslationFn
|
||||
}
|
||||
|
||||
function ListRow({ list, active, onSelect }: { list: Collection; active: boolean; onSelect: (id: number) => void }): React.ReactElement {
|
||||
return (
|
||||
<div className="col-row">
|
||||
<button type="button" onClick={() => onSelect(list.id)} className={`col-row-btn${active ? ' on' : ''}`}>
|
||||
<span className="dot" style={{ background: list.color || '#6366f1' }} />
|
||||
<span className="nm">{list.name}</span>
|
||||
<span className="ct">{list.place_count ?? 0}</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Left rail of the user's lists: a "New list" action, the "All saved" union
|
||||
* pseudo-list, owned lists (colour dot + count), a shared section, and an
|
||||
* incoming-invites block. Selecting a list makes it active; editing / deleting
|
||||
* happens from the Edit button in the hero of the active list.
|
||||
*/
|
||||
export default function ListsRail(props: ListsRailProps): React.ReactElement {
|
||||
const {
|
||||
ownedLists, sharedLists, activeId, incomingInvites,
|
||||
onSelect, onNewList, onAcceptInvite, onDeclineInvite, t,
|
||||
} = props
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={onNewList} className="col-rail-new">
|
||||
<Plus size={16} /> {t('collections.newList')}
|
||||
</button>
|
||||
|
||||
<div className="col-row">
|
||||
<button type="button" onClick={() => onSelect(ALL_SAVED)} className={`col-row-btn${activeId === ALL_SAVED ? ' on' : ''}`}>
|
||||
<span className="ico"><Layers size={16} /></span>
|
||||
<span className="nm">{t('collections.allSaved')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{ownedLists.length > 0 && <div className="col-rail-sep" />}
|
||||
{ownedLists.map(list => (
|
||||
<ListRow key={list.id} list={list} active={activeId === list.id} onSelect={onSelect} />
|
||||
))}
|
||||
|
||||
{sharedLists.length > 0 && (
|
||||
<>
|
||||
<div className="col-rail-label"><Users size={12} /> {t('collections.shared')}</div>
|
||||
{sharedLists.map(list => (
|
||||
<ListRow key={list.id} list={list} active={activeId === list.id} onSelect={onSelect} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{incomingInvites.length > 0 && (
|
||||
<>
|
||||
<div className="col-rail-label">
|
||||
{t('collections.invites.title')}
|
||||
<span className="badge">{incomingInvites.length}</span>
|
||||
</div>
|
||||
{incomingInvites.map(inv => (
|
||||
<div key={inv.collection_id} className="col-invite">
|
||||
<div className="t">{inv.name}</div>
|
||||
<div className="s">{t('collections.invites.from')} {inv.from.username}</div>
|
||||
<div className="col-invite-actions">
|
||||
<button type="button" onClick={() => onAcceptInvite(inv.collection_id)} className="col-invite-accept">
|
||||
{t('collections.invites.accept')}
|
||||
</button>
|
||||
<button type="button" onClick={() => onDeclineInvite(inv.collection_id)} className="col-invite-decline">
|
||||
{t('collections.invites.decline')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// FE-COMP-MOVETOLIST-001 to FE-COMP-MOVETOLIST-009
|
||||
import { render, screen, within } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { Collection } from '@trek/shared';
|
||||
import { useTranslation } from '../../i18n/TranslationContext';
|
||||
import MoveToListModal from './MoveToListModal';
|
||||
|
||||
// The modal receives `t` as a prop rather than reading context, so a tiny harness
|
||||
// pulls the REAL English translator from the provider and forwards it. That keeps
|
||||
// the assertions against real strings ("Move 2 to another list") instead of keys.
|
||||
function Harness(props: Omit<React.ComponentProps<typeof MoveToListModal>, 't'>) {
|
||||
const { t } = useTranslation();
|
||||
return <MoveToListModal {...props} t={t} />;
|
||||
}
|
||||
|
||||
const listA: Collection = { id: 11, owner_id: 1, name: 'Weekend in Rome', color: '#ef4444', place_count: 3 };
|
||||
const listB: Collection = { id: 22, owner_id: 1, name: 'Tokyo Food Tour', color: '#22c55e', place_count: 7 };
|
||||
|
||||
function renderModal(overrides: Partial<React.ComponentProps<typeof MoveToListModal>> = {}) {
|
||||
const props = {
|
||||
mode: 'move' as const,
|
||||
lists: [listA, listB],
|
||||
count: 2,
|
||||
onPick: vi.fn().mockResolvedValue(undefined),
|
||||
onClose: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
render(<Harness {...props} />);
|
||||
return props;
|
||||
}
|
||||
|
||||
describe('MoveToListModal', () => {
|
||||
it('FE-COMP-MOVETOLIST-001: renders every candidate list name', () => {
|
||||
renderModal();
|
||||
expect(screen.getByText('Weekend in Rome')).toBeInTheDocument();
|
||||
expect(screen.getByText('Tokyo Food Tour')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MOVETOLIST-002: title reflects the count in move mode', () => {
|
||||
renderModal({ mode: 'move', count: 2 });
|
||||
// collections.moveToListTitle = 'Move {count} to another list'
|
||||
expect(screen.getByRole('heading', { name: 'Move 2 to another list' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MOVETOLIST-003: title reflects the count in copy mode', () => {
|
||||
renderModal({ mode: 'copy', count: 5 });
|
||||
// collections.duplicateToListTitle = 'Duplicate {count} to another list'
|
||||
expect(screen.getByRole('heading', { name: 'Duplicate 5 to another list' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MOVETOLIST-004: shows the place count subtitle per list', () => {
|
||||
renderModal();
|
||||
// collections.placeCount = '{count} places'
|
||||
expect(screen.getByText('3 places')).toBeInTheDocument();
|
||||
expect(screen.getByText('7 places')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MOVETOLIST-005: clicking a row calls onPick with that list id', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onPick } = renderModal();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Tokyo Food Tour/i }));
|
||||
|
||||
expect(onPick).toHaveBeenCalledTimes(1);
|
||||
expect(onPick).toHaveBeenCalledWith(22);
|
||||
});
|
||||
|
||||
it('FE-COMP-MOVETOLIST-006: empty lists shows the "no other lists" message and renders no rows', () => {
|
||||
renderModal({ lists: [] });
|
||||
// collections.noOtherLists = 'No other lists yet'
|
||||
expect(screen.getByText('No other lists yet')).toBeInTheDocument();
|
||||
// No selectable list rows exist (only the modal close button remains).
|
||||
expect(screen.queryByRole('button', { name: /Weekend in Rome|Tokyo Food Tour|places/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MOVETOLIST-007: move mode renders a trailing arrow icon on each row', () => {
|
||||
renderModal({ mode: 'move' });
|
||||
const row = screen.getByRole('button', { name: /Weekend in Rome/i });
|
||||
expect(row.querySelector('.lucide-arrow-right')).toBeTruthy();
|
||||
expect(row.querySelector('.lucide-copy')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('FE-COMP-MOVETOLIST-008: copy mode renders a trailing copy icon on each row', () => {
|
||||
renderModal({ mode: 'copy' });
|
||||
const row = screen.getByRole('button', { name: /Weekend in Rome/i });
|
||||
expect(row.querySelector('.lucide-copy')).toBeTruthy();
|
||||
expect(row.querySelector('.lucide-arrow-right')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('FE-COMP-MOVETOLIST-009: a second click while the first pick is pending is ignored', async () => {
|
||||
const user = userEvent.setup();
|
||||
// Never resolves, so the modal stays "busy" after the first click.
|
||||
const onPick = vi.fn().mockReturnValue(new Promise<void>(() => {}));
|
||||
renderModal({ onPick });
|
||||
|
||||
const rome = screen.getByRole('button', { name: /Weekend in Rome/i });
|
||||
const tokyo = screen.getByRole('button', { name: /Tokyo Food Tour/i });
|
||||
await user.click(rome);
|
||||
// Rows disable while busy; a click on another row must not fire a second pick.
|
||||
await user.click(tokyo);
|
||||
|
||||
expect(onPick).toHaveBeenCalledTimes(1);
|
||||
expect(onPick).toHaveBeenCalledWith(11);
|
||||
// Confirm the busy state actually disabled the rows.
|
||||
expect(within(tokyo).queryByText('Tokyo Food Tour')).toBeInTheDocument();
|
||||
expect(tokyo).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import { Search, ArrowRight, Copy, Loader2, Bookmark } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import type { Collection } from '@trek/shared'
|
||||
import type { TranslationFn } from '../../types'
|
||||
|
||||
interface MoveToListModalProps {
|
||||
mode: 'move' | 'copy'
|
||||
/** Candidate target lists (owned, excluding the current one). */
|
||||
lists: Collection[]
|
||||
/** Number of selected places. */
|
||||
count: number
|
||||
onPick: (targetId: number) => Promise<void> | void
|
||||
onClose: () => void
|
||||
t: TranslationFn
|
||||
}
|
||||
|
||||
/**
|
||||
* Target-list picker for moving or duplicating the selected places into another
|
||||
* of the user's lists. `mode` only changes the wording + the trailing icon; the
|
||||
* action itself is the parent's onPick.
|
||||
*/
|
||||
export default function MoveToListModal({ mode, lists, count, onPick, onClose, t }: MoveToListModalProps): React.ReactElement {
|
||||
const [search, setSearch] = useState('')
|
||||
const [busy, setBusy] = useState<number | null>(null)
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase()
|
||||
return q ? lists.filter(l => l.name.toLowerCase().includes(q)) : lists
|
||||
}, [lists, search])
|
||||
|
||||
const pick = async (id: number) => {
|
||||
if (busy != null) return
|
||||
setBusy(id)
|
||||
try { await onPick(id) } finally { setBusy(null) }
|
||||
}
|
||||
|
||||
const Trailing = mode === 'move' ? ArrowRight : Copy
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
title={mode === 'move' ? t('collections.moveToListTitle', { count }) : t('collections.duplicateToListTitle', { count })}
|
||||
size="sm"
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
{lists.length > 3 && (
|
||||
<div className="relative">
|
||||
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-content-faint" />
|
||||
<input
|
||||
autoFocus
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder={t('collections.copyToTripSearch')}
|
||||
className="w-full pl-8 pr-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[13px] outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{filtered.length === 0 ? (
|
||||
<p className="text-center text-[13px] text-content-faint py-8">{t('collections.noOtherLists')}</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1 max-h-[50vh] overflow-y-auto -mx-1 px-1">
|
||||
{filtered.map(list => {
|
||||
const isBusy = busy === list.id
|
||||
return (
|
||||
<button
|
||||
key={list.id}
|
||||
type="button"
|
||||
onClick={() => pick(list.id)}
|
||||
disabled={busy != null}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-xl border border-edge bg-surface-card text-left hover:bg-surface-hover transition-colors disabled:opacity-60"
|
||||
>
|
||||
<span className="w-9 h-9 min-w-[36px] rounded-lg flex items-center justify-center shrink-0 text-white" style={{ background: list.color || '#6366f1' }}>
|
||||
<Bookmark size={15} />
|
||||
</span>
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block text-[13px] font-semibold text-content truncate">{list.name}</span>
|
||||
<span className="block text-[11.5px] text-content-faint">{t('collections.placeCount', { count: list.place_count ?? 0 })}</span>
|
||||
</span>
|
||||
{isBusy ? <Loader2 size={15} className="animate-spin text-content-faint shrink-0" /> : <Trailing size={15} className="text-content-faint shrink-0" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import React, { useEffect, useMemo, useState, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Bookmark, BookmarkCheck, Check, Loader2, Plus } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { collectionsApi } from '../../api/collections'
|
||||
import { useSaveToCollectionStore } from '../../store/saveToCollectionStore'
|
||||
import { getApiErrorMessage } from '../../utils/apiError'
|
||||
import type { Collection, CollectionMembership } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* Globally-mounted list picker for the "Save to Collection" entry points
|
||||
* (PlaceInspector footer button + the two trip-sidebar context menus). Reads the
|
||||
* active target from saveToCollectionStore, shows every list the user owns or
|
||||
* co-owns, and toggles the place in/out of each — a check marks the lists that
|
||||
* already hold it. Each change refreshes membership and bumps the store version
|
||||
* so the inspector bookmark indicator stays in sync. One mount, no prop drilling.
|
||||
*/
|
||||
export default function SaveToCollectionModal(): React.ReactElement | null {
|
||||
const target = useSaveToCollectionStore(s => s.target)
|
||||
const close = useSaveToCollectionStore(s => s.close)
|
||||
const bumpVersion = useSaveToCollectionStore(s => s.bumpVersion)
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [lists, setLists] = useState<Collection[]>([])
|
||||
const [membership, setMembership] = useState<CollectionMembership | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [busyId, setBusyId] = useState<number | null>(null)
|
||||
|
||||
const membershipQuery = useMemo(() => {
|
||||
if (!target) return null
|
||||
return {
|
||||
google_place_id: target.google_place_id ?? undefined,
|
||||
google_ftid: target.google_ftid ?? undefined,
|
||||
name: target.name,
|
||||
lat: target.lat ?? undefined,
|
||||
lng: target.lng ?? undefined,
|
||||
}
|
||||
}, [target])
|
||||
|
||||
const refreshMembership = useCallback(async () => {
|
||||
if (!membershipQuery) return
|
||||
try {
|
||||
const m = await collectionsApi.membership(membershipQuery)
|
||||
setMembership(m)
|
||||
} catch {
|
||||
setMembership({ saved: false, lists: [] })
|
||||
}
|
||||
}, [membershipQuery])
|
||||
|
||||
// Load lists + membership whenever the picker opens for a new target.
|
||||
useEffect(() => {
|
||||
if (!target) return
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setMembership(null)
|
||||
Promise.all([collectionsApi.list().catch(() => ({ collections: [], incomingInvites: [] })), membershipQuery ? collectionsApi.membership(membershipQuery).catch(() => ({ saved: false, lists: [] as CollectionMembership['lists'] })) : Promise.resolve({ saved: false, lists: [] as CollectionMembership['lists'] })])
|
||||
.then(([listRes, m]) => {
|
||||
if (cancelled) return
|
||||
setLists(listRes.collections)
|
||||
setMembership(m)
|
||||
})
|
||||
.finally(() => { if (!cancelled) setLoading(false) })
|
||||
return () => { cancelled = true }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [target])
|
||||
|
||||
if (!target) return null
|
||||
|
||||
const savedByCollection = new Map<number, number>()
|
||||
for (const l of membership?.lists ?? []) savedByCollection.set(l.collection_id, l.place_id)
|
||||
|
||||
const handleToggle = async (list: Collection) => {
|
||||
if (busyId != null) return
|
||||
const savedPlaceId = savedByCollection.get(list.id)
|
||||
setBusyId(list.id)
|
||||
try {
|
||||
if (savedPlaceId != null) {
|
||||
await collectionsApi.deletePlace(savedPlaceId)
|
||||
toast.success(t('collections.removedFromList', { name: list.name }))
|
||||
} else {
|
||||
await collectionsApi.savePlace({
|
||||
collection_id: list.id,
|
||||
source_trip_id: target.source_trip_id ?? null,
|
||||
source_place_id: target.source_place_id ?? null,
|
||||
name: target.name,
|
||||
description: target.description ?? null,
|
||||
lat: target.lat ?? null,
|
||||
lng: target.lng ?? null,
|
||||
address: target.address ?? null,
|
||||
category_id: target.category_id ?? null,
|
||||
price: target.price ?? null,
|
||||
currency: target.currency ?? null,
|
||||
notes: target.notes ?? null,
|
||||
image_url: target.image_url ?? null,
|
||||
google_place_id: target.google_place_id ?? null,
|
||||
google_ftid: target.google_ftid ?? null,
|
||||
osm_id: target.osm_id ?? null,
|
||||
website: target.website ?? null,
|
||||
phone: target.phone ?? null,
|
||||
force: true,
|
||||
})
|
||||
toast.success(t('collections.addedToList', { name: list.name }))
|
||||
}
|
||||
await refreshMembership()
|
||||
bumpVersion()
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
onClose={close}
|
||||
title={t('collections.pickList')}
|
||||
size="sm"
|
||||
footer={
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { close(); navigate('/collections') }}
|
||||
className="text-[13px] font-medium text-accent hover:underline"
|
||||
>
|
||||
{t('collections.viewInCollection')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={close}
|
||||
className="px-3 py-1.5 rounded-lg border border-edge text-content-secondary text-[13px] hover:bg-surface-hover"
|
||||
>
|
||||
{t('common.close')}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-[13px] font-semibold text-content truncate">{target.name}</p>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8 text-content-faint">
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
</div>
|
||||
) : lists.length === 0 ? (
|
||||
<div className="flex flex-col items-center text-center py-8 px-4">
|
||||
<div className="w-11 h-11 rounded-2xl bg-surface-secondary flex items-center justify-center mb-3 text-content-faint">
|
||||
<Bookmark size={20} />
|
||||
</div>
|
||||
<p className="text-[13px] text-content-faint mb-3">{t('collections.noListsYet')}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { close(); navigate('/collections') }}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-accent text-accent-text text-[13px] font-semibold"
|
||||
>
|
||||
<Plus size={14} /> {t('collections.newList')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1 max-h-[50vh] overflow-y-auto -mx-1 px-1">
|
||||
{lists.map(list => {
|
||||
const saved = savedByCollection.has(list.id)
|
||||
const busy = busyId === list.id
|
||||
return (
|
||||
<button
|
||||
key={list.id}
|
||||
type="button"
|
||||
onClick={() => handleToggle(list)}
|
||||
disabled={busy}
|
||||
className={`flex items-center gap-3 px-3 py-2.5 rounded-xl border text-left transition-colors disabled:opacity-60 ${saved ? 'border-accent bg-accent-subtle' : 'border-edge bg-surface-card hover:bg-surface-hover'}`}
|
||||
>
|
||||
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ background: list.color || 'var(--accent)' }} />
|
||||
<span className="flex-1 min-w-0 text-[13px] font-medium text-content truncate">{list.name}</span>
|
||||
{list.is_owner === false && (
|
||||
<span className="text-[10px] uppercase font-semibold text-content-faint">{t('collections.shared')}</span>
|
||||
)}
|
||||
<span className={`w-5 h-5 rounded-md flex items-center justify-center shrink-0 ${saved ? 'bg-accent text-accent-text' : 'border border-edge text-transparent'}`}>
|
||||
{busy ? <Loader2 size={13} className="animate-spin text-content-faint" /> : saved ? <BookmarkCheck size={13} /> : <Check size={13} />}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { Search, Bookmark, ArrowRight, Loader2, Plus } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { collectionsApi } from '../../api/collections'
|
||||
import { getApiErrorMessage } from '../../utils/apiError'
|
||||
import type { Collection } from '@trek/shared'
|
||||
|
||||
interface SaveTripPlacesToListModalProps {
|
||||
isOpen: boolean
|
||||
tripId: number
|
||||
/** The selected trip place ids to copy into the chosen list. */
|
||||
placeIds: number[]
|
||||
onClose: () => void
|
||||
/** Called after a successful save (e.g. to clear the trip selection). */
|
||||
onDone: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk "save to collection" for the trip place list: pick one of the user's lists
|
||||
* and copy every selected trip place into it at once (server dedups by name/coords).
|
||||
*/
|
||||
export default function SaveTripPlacesToListModal({ isOpen, tripId, placeIds, onClose, onDone }: SaveTripPlacesToListModalProps): React.ReactElement | null {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const [lists, setLists] = useState<Collection[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
const [busyId, setBusyId] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setSearch('')
|
||||
collectionsApi.list()
|
||||
// Only lists the user can add to (their own or an editor/admin share). The
|
||||
// server still enforces this; here we drop lists that are clearly read-only.
|
||||
.then(res => { if (!cancelled) setLists((res.collections ?? []).filter(c => c.is_owner !== false)) })
|
||||
.catch(() => { if (!cancelled) setLists([]) })
|
||||
.finally(() => { if (!cancelled) setLoading(false) })
|
||||
return () => { cancelled = true }
|
||||
}, [isOpen])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase()
|
||||
return q ? lists.filter(l => l.name.toLowerCase().includes(q)) : lists
|
||||
}, [lists, search])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const pick = async (list: Collection) => {
|
||||
if (busyId != null || placeIds.length === 0) return
|
||||
setBusyId(list.id)
|
||||
try {
|
||||
const res = await collectionsApi.saveFromTripMany(list.id, tripId, placeIds)
|
||||
if (res.copied > 0) toast.success(t('collections.addedNToList', { count: res.copied, name: list.name }))
|
||||
if (res.skipped.length > 0) toast.info(t('collections.skippedDuplicates', { count: res.skipped.length }))
|
||||
if (res.copied === 0 && res.skipped.length === 0) toast.info(t('collections.copyNothing'))
|
||||
onDone()
|
||||
onClose()
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen onClose={onClose} title={t('collections.saveNToList', { count: placeIds.length })} size="sm">
|
||||
<div className="flex flex-col gap-3">
|
||||
{lists.length > 5 && (
|
||||
<div className="relative">
|
||||
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-content-faint" />
|
||||
<input
|
||||
autoFocus
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder={t('collections.copyToTripSearch')}
|
||||
className="w-full pl-8 pr-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[13px] outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-10 text-content-faint"><Loader2 size={20} className="animate-spin" /></div>
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="text-center text-[13px] text-content-faint py-8">{t('collections.noOwnLists')}</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1 max-h-[50vh] overflow-y-auto -mx-1 px-1">
|
||||
{filtered.map(list => {
|
||||
const busy = busyId === list.id
|
||||
return (
|
||||
<button
|
||||
key={list.id}
|
||||
type="button"
|
||||
onClick={() => pick(list)}
|
||||
disabled={busyId != null}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-xl border border-edge bg-surface-card text-left hover:bg-surface-hover transition-colors disabled:opacity-60"
|
||||
>
|
||||
<span className="w-9 h-9 min-w-[36px] rounded-lg flex items-center justify-center shrink-0 text-white" style={{ background: list.color || '#6366f1' }}>
|
||||
<Bookmark size={15} />
|
||||
</span>
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block text-[13px] font-semibold text-content truncate">{list.name}</span>
|
||||
<span className="block text-[11.5px] text-content-faint">{t('collections.placeCount', { count: list.place_count ?? 0 })}</span>
|
||||
</span>
|
||||
{busy ? <Loader2 size={15} className="animate-spin text-content-faint shrink-0" /> : <ArrowRight size={15} className="text-content-faint shrink-0" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[11.5px] text-content-faint inline-flex items-center gap-1.5"><Plus size={12} /> {t('collections.saveToListHint')}</p>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { avatarSrc } from '../../utils/avatarSrc'
|
||||
import { UserPlus, UserMinus, Loader2, Clock, Crown, LogOut } from 'lucide-react'
|
||||
import type { CollectionMember, CollectionRole } from '@trek/shared'
|
||||
import Modal from '../shared/Modal'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useCollectionStore } from '../../store/collectionStore'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { collectionsApi } from '../../api/collections'
|
||||
import { getApiErrorMessage } from '../../utils/apiError'
|
||||
import type { TranslationFn } from '../../types'
|
||||
|
||||
interface ShareCollectionModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
collectionId: number
|
||||
collectionName: string
|
||||
isOwner: boolean
|
||||
members: CollectionMember[]
|
||||
/** Called after the current (member) user successfully leaves the list. */
|
||||
onAfterLeave: () => void
|
||||
t: TranslationFn
|
||||
}
|
||||
|
||||
const ROLE_ORDER: CollectionRole[] = ['viewer', 'editor', 'admin']
|
||||
|
||||
function MemberAvatar({ member }: { member: CollectionMember }): React.ReactElement {
|
||||
const initial = (member.username || '?').charAt(0).toUpperCase()
|
||||
return (
|
||||
<span className="w-8 h-8 rounded-full shrink-0 overflow-hidden flex items-center justify-center bg-surface-secondary text-content-secondary text-[12px] font-semibold">
|
||||
{member.avatar ? (
|
||||
<img src={avatarSrc(member.avatar)!} alt="" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
initial
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fusion-share surface for a single list (blueprint 4.4 / 4.8). The OWNER sees the
|
||||
* member roster with accepted/pending status, can invite a user from
|
||||
* GET /:id/available-users and cancel a pending invite. A non-owner MEMBER sees the
|
||||
* roster read-only plus a "Leave shared list" action (the server blocks the owner
|
||||
* from leaving). Incoming invites are accepted/declined from the lists rail, not here.
|
||||
*/
|
||||
export default function ShareCollectionModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
collectionId,
|
||||
collectionName,
|
||||
isOwner,
|
||||
members,
|
||||
onAfterLeave,
|
||||
t,
|
||||
}: ShareCollectionModalProps): React.ReactElement | null {
|
||||
const toast = useToast()
|
||||
const currentUserId = useAuthStore(s => s.user?.id)
|
||||
const invite = useCollectionStore(s => s.invite)
|
||||
const cancelInvite = useCollectionStore(s => s.cancelInvite)
|
||||
const removeMember = useCollectionStore(s => s.removeMember)
|
||||
const setMemberRole = useCollectionStore(s => s.setMemberRole)
|
||||
const leave = useCollectionStore(s => s.leave)
|
||||
|
||||
const [availableUsers, setAvailableUsers] = useState<{ id: number; username: string }[]>([])
|
||||
const [selectedUserId, setSelectedUserId] = useState<number | ''>('')
|
||||
const [inviteRole, setInviteRole] = useState<CollectionRole>('editor')
|
||||
const [settingRoleId, setSettingRoleId] = useState<number | null>(null)
|
||||
const [inviting, setInviting] = useState(false)
|
||||
const [cancellingId, setCancellingId] = useState<number | null>(null)
|
||||
const [removingId, setRemovingId] = useState<number | null>(null)
|
||||
const [confirmLeave, setConfirmLeave] = useState(false)
|
||||
const [leaving, setLeaving] = useState(false)
|
||||
|
||||
// Load the invitable users whenever an owner opens the modal.
|
||||
useEffect(() => {
|
||||
if (!isOpen || !isOwner) return
|
||||
let cancelled = false
|
||||
collectionsApi.availableUsers(collectionId)
|
||||
.then(data => { if (!cancelled) setAvailableUsers(data.users) })
|
||||
.catch(() => { if (!cancelled) setAvailableUsers([]) })
|
||||
return () => { cancelled = true }
|
||||
}, [isOpen, isOwner, collectionId, members.length])
|
||||
|
||||
// Reset transient state on close.
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setSelectedUserId('')
|
||||
setConfirmLeave(false)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
const sortedMembers = useMemo(() => {
|
||||
// Owner first, then accepted, then pending — alphabetised within each band.
|
||||
const rank = (m: CollectionMember) => (m.is_owner ? 0 : m.status === 'accepted' ? 1 : 2)
|
||||
return [...members].sort((a, b) => rank(a) - rank(b) || a.username.localeCompare(b.username))
|
||||
}, [members])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const handleInvite = async () => {
|
||||
if (selectedUserId === '' || inviting) return
|
||||
setInviting(true)
|
||||
try {
|
||||
await invite(collectionId, Number(selectedUserId), inviteRole)
|
||||
toast.success(t('collections.invite.sent'))
|
||||
setSelectedUserId('')
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('collections.invite.error')))
|
||||
} finally {
|
||||
setInviting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSetRole = async (userId: number, role: CollectionRole) => {
|
||||
setSettingRoleId(userId)
|
||||
try {
|
||||
await setMemberRole(collectionId, userId, role)
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
setSettingRoleId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = async (userId: number) => {
|
||||
if (cancellingId != null) return
|
||||
setCancellingId(userId)
|
||||
try {
|
||||
await cancelInvite(collectionId, userId)
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
setCancellingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemove = async (userId: number) => {
|
||||
if (removingId != null) return
|
||||
setRemovingId(userId)
|
||||
try {
|
||||
await removeMember(collectionId, userId)
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
setRemovingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLeave = async () => {
|
||||
setLeaving(true)
|
||||
try {
|
||||
await leave(collectionId)
|
||||
toast.success(t('collections.share.left'))
|
||||
onAfterLeave()
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
setLeaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
title={t('collections.share.titleNamed', { name: collectionName })}
|
||||
size="xl"
|
||||
>
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* Member roster */}
|
||||
<div>
|
||||
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-content-faint mb-2.5 flex items-center gap-2">
|
||||
{t('collections.share.members')}
|
||||
<span className="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-surface-secondary text-content-secondary text-[10px] font-bold tabular-nums">{sortedMembers.length}</span>
|
||||
</h3>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{sortedMembers.map(member => {
|
||||
const isSelf = member.user_id === currentUserId
|
||||
const pending = member.status === 'pending'
|
||||
return (
|
||||
<div
|
||||
key={member.user_id}
|
||||
className={`flex items-center gap-3 px-3 py-2.5 rounded-xl border border-edge bg-surface-secondary/50 ${pending ? 'opacity-90' : ''}`}
|
||||
>
|
||||
<MemberAvatar member={member} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[13.5px] font-semibold text-content truncate flex items-center gap-1.5">
|
||||
{member.username}
|
||||
{isSelf && <span className="text-content-faint font-normal text-[12px]">({t('collections.share.you')})</span>}
|
||||
</p>
|
||||
{member.email && !pending && (
|
||||
<p className="text-[11.5px] text-content-faint truncate">{member.email}</p>
|
||||
)}
|
||||
</div>
|
||||
{member.is_owner ? (
|
||||
<span className="inline-flex items-center gap-1 text-[10.5px] font-semibold uppercase tracking-wide px-2 py-1 rounded-full bg-amber-500/12 text-amber-600 dark:text-amber-400 shrink-0">
|
||||
<Crown size={11} /> {t('collections.share.owner')}
|
||||
</span>
|
||||
) : pending ? (
|
||||
<span className="inline-flex items-center gap-1 text-[10.5px] font-semibold uppercase tracking-wide px-2 py-1 rounded-full bg-amber-500/12 text-amber-600 dark:text-amber-400 shrink-0">
|
||||
<Clock size={11} /> {t('collections.share.pending')}
|
||||
</span>
|
||||
) : isOwner ? (
|
||||
<div className="w-[118px] shrink-0">
|
||||
<CustomSelect
|
||||
size="sm"
|
||||
value={member.role ?? 'editor'}
|
||||
onChange={v => handleSetRole(member.user_id, v as CollectionRole)}
|
||||
options={ROLE_ORDER.map(r => ({ value: r, label: t(`collections.role.${r}`) }))}
|
||||
disabled={settingRoleId === member.user_id}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-[10.5px] font-semibold uppercase tracking-wide px-2 py-1 rounded-full bg-surface-secondary text-content-secondary shrink-0">
|
||||
{t(`collections.role.${member.role ?? 'editor'}`)}
|
||||
</span>
|
||||
)}
|
||||
{isOwner && pending && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCancel(member.user_id)}
|
||||
disabled={cancellingId === member.user_id}
|
||||
className="shrink-0 text-[11px] font-medium px-2 py-1 rounded-md text-content-faint hover:text-danger hover:bg-danger-soft transition-colors disabled:opacity-50"
|
||||
>
|
||||
{cancellingId === member.user_id ? <Loader2 size={12} className="animate-spin" /> : t('collections.share.cancel')}
|
||||
</button>
|
||||
)}
|
||||
{isOwner && !member.is_owner && member.status === 'accepted' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemove(member.user_id)}
|
||||
disabled={removingId === member.user_id}
|
||||
title={t('collections.share.remove')}
|
||||
aria-label={t('collections.share.remove')}
|
||||
className="shrink-0 p-1 rounded-md text-content-faint hover:text-danger hover:bg-danger-soft transition-colors disabled:opacity-50"
|
||||
>
|
||||
{removingId === member.user_id ? <Loader2 size={12} className="animate-spin" /> : <UserMinus size={13} />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isOwner ? (
|
||||
/* Owner: invite UI */
|
||||
<div className="pt-1 border-t border-edge-secondary">
|
||||
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-content-faint mt-4 mb-2">
|
||||
{t('collections.share.invite')}
|
||||
</h3>
|
||||
<p className="text-[12px] text-content-muted mb-3">{t('collections.share.inviteHint')}</p>
|
||||
{availableUsers.length === 0 ? (
|
||||
<p className="text-[12px] text-content-faint text-center py-3">{t('collections.share.noUsers')}</p>
|
||||
) : (
|
||||
<div className="flex items-stretch gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<CustomSelect
|
||||
value={selectedUserId}
|
||||
onChange={v => setSelectedUserId(v === '' ? '' : Number(v))}
|
||||
options={availableUsers.map(u => ({ value: u.id, label: u.username }))}
|
||||
placeholder={t('collections.share.inviteUser')}
|
||||
searchable
|
||||
/>
|
||||
</div>
|
||||
<div className="w-[128px] shrink-0">
|
||||
<CustomSelect
|
||||
size="sm"
|
||||
value={inviteRole}
|
||||
onChange={v => setInviteRole(v as CollectionRole)}
|
||||
options={ROLE_ORDER.map(r => ({ value: r, label: t(`collections.role.${r}`) }))}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleInvite}
|
||||
disabled={selectedUserId === '' || inviting}
|
||||
className="shrink-0 inline-flex items-center gap-1.5 px-3 py-2 rounded-lg bg-accent text-accent-text text-[13px] font-semibold hover:bg-accent-hover transition-colors disabled:opacity-50"
|
||||
>
|
||||
{inviting ? <Loader2 size={14} className="animate-spin" /> : <UserPlus size={14} />}
|
||||
<span className="hidden sm:inline">{t('collections.share.sendInvite')}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* Member: read-only roster + leave */
|
||||
<div className="pt-1 border-t border-edge-secondary">
|
||||
<p className="text-[12px] text-content-muted mt-4 mb-3">{t('collections.share.memberHint')}</p>
|
||||
{confirmLeave ? (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<p className="text-[13px] text-content-secondary">{t('collections.share.leaveConfirm')}</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmLeave(false)}
|
||||
className="px-3 py-1.5 rounded-lg border border-edge text-content-secondary text-[13px] hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLeave}
|
||||
disabled={leaving}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-danger text-white text-[13px] font-semibold hover:opacity-90 transition-opacity disabled:opacity-50"
|
||||
>
|
||||
{leaving ? <Loader2 size={14} className="animate-spin" /> : <LogOut size={14} />}
|
||||
{t('collections.share.leave')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmLeave(true)}
|
||||
className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-2 rounded-lg border border-edge text-danger text-[13px] font-medium hover:bg-danger-soft transition-colors"
|
||||
>
|
||||
<LogOut size={14} /> {t('collections.share.leave')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// FE-COMP-STATUSBADGE-001 to FE-COMP-STATUSBADGE-013
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { CollectionStatus } from '@trek/shared';
|
||||
import { resetAllStores } from '../../../tests/helpers/store';
|
||||
import { useTranslation } from '../../i18n/TranslationContext';
|
||||
import StatusBadge from './StatusBadge';
|
||||
|
||||
// StatusBadge takes a `t` prop (TranslationFn) rather than reading the context
|
||||
// itself, so this harness pulls the real English `t` out of the provider that
|
||||
// the render helper wraps around us and forwards it. That way assertions run
|
||||
// against real translated strings, not i18n keys.
|
||||
type BadgeProps = {
|
||||
status: CollectionStatus;
|
||||
onChange?: (next: CollectionStatus) => void;
|
||||
showLabel?: boolean;
|
||||
onCover?: boolean;
|
||||
};
|
||||
|
||||
function Badge(props: BadgeProps) {
|
||||
const { t } = useTranslation();
|
||||
return <StatusBadge {...props} t={t} />;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
describe('StatusBadge', () => {
|
||||
// ── Current-status label ─────────────────────────────────────────────────────
|
||||
|
||||
it('FE-COMP-STATUSBADGE-001: renders the "Idea" label for the idea status', () => {
|
||||
render(<Badge status="idea" />);
|
||||
expect(screen.getByText('Idea')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-STATUSBADGE-002: renders the "Want to go" label for the want status', () => {
|
||||
render(<Badge status="want" />);
|
||||
expect(screen.getByText('Want to go')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-STATUSBADGE-003: renders the "Visited" label for the visited status', () => {
|
||||
render(<Badge status="visited" />);
|
||||
expect(screen.getByText('Visited')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-STATUSBADGE-004: hides the label when showLabel is false', () => {
|
||||
render(<Badge status="idea" showLabel={false} />);
|
||||
expect(screen.queryByText('Idea')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── One-tap cycle: idea → want → visited → idea ──────────────────────────────
|
||||
|
||||
it('FE-COMP-STATUSBADGE-005: exposes a button role when onChange is supplied', () => {
|
||||
render(<Badge status="idea" onChange={vi.fn()} />);
|
||||
expect(screen.getByRole('button', { name: 'Idea' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-STATUSBADGE-006: clicking an idea badge calls onChange with "want"', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
render(<Badge status="idea" onChange={onChange} />);
|
||||
await user.click(screen.getByRole('button', { name: 'Idea' }));
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
expect(onChange).toHaveBeenCalledWith('want');
|
||||
});
|
||||
|
||||
it('FE-COMP-STATUSBADGE-007: clicking a want badge calls onChange with "visited"', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
render(<Badge status="want" onChange={onChange} />);
|
||||
await user.click(screen.getByRole('button', { name: 'Want to go' }));
|
||||
expect(onChange).toHaveBeenCalledWith('visited');
|
||||
});
|
||||
|
||||
it('FE-COMP-STATUSBADGE-008: clicking a visited badge wraps around to "idea"', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
render(<Badge status="visited" onChange={onChange} />);
|
||||
await user.click(screen.getByRole('button', { name: 'Visited' }));
|
||||
expect(onChange).toHaveBeenCalledWith('idea');
|
||||
});
|
||||
|
||||
it('FE-COMP-STATUSBADGE-009: pressing Enter cycles the status', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
render(<Badge status="idea" onChange={onChange} />);
|
||||
const badge = screen.getByRole('button', { name: 'Idea' });
|
||||
badge.focus();
|
||||
await user.keyboard('{Enter}');
|
||||
expect(onChange).toHaveBeenCalledWith('want');
|
||||
});
|
||||
|
||||
it('FE-COMP-STATUSBADGE-010: interactive badge advertises the cycle hint in its title', () => {
|
||||
render(<Badge status="idea" onChange={vi.fn()} />);
|
||||
// English: 'collections.status.cycleHint' = 'tap to change'
|
||||
expect(screen.getByRole('button', { name: 'Idea' })).toHaveAttribute(
|
||||
'title',
|
||||
'Idea — tap to change',
|
||||
);
|
||||
});
|
||||
|
||||
// ── Read-only (onChange omitted) ─────────────────────────────────────────────
|
||||
|
||||
it('FE-COMP-STATUSBADGE-011: renders as a static badge with no button role when onChange is omitted', () => {
|
||||
render(<Badge status="want" />);
|
||||
expect(screen.getByText('Want to go')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-STATUSBADGE-012: read-only badge uses the plain label as its title (no cycle hint)', () => {
|
||||
render(<Badge status="visited" />);
|
||||
const label = screen.getByText('Visited');
|
||||
const pill = label.parentElement as HTMLElement;
|
||||
expect(pill).toHaveAttribute('title', 'Visited');
|
||||
expect(pill.getAttribute('title')).not.toMatch(/tap to change/i);
|
||||
});
|
||||
|
||||
it('FE-COMP-STATUSBADGE-013: clicking a read-only badge does not throw', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Badge status="idea" />);
|
||||
// No interactive handler wired up — the click must be a harmless no-op.
|
||||
await user.click(screen.getByText('Idea'));
|
||||
expect(screen.getByText('Idea')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import React from 'react'
|
||||
import type { CollectionStatus } from '@trek/shared'
|
||||
import type { TranslationFn } from '../../types'
|
||||
import { STATUS_META, nextStatus } from '../../pages/collections/collectionsModel'
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: CollectionStatus
|
||||
/** One-tap cycle: idea → want → visited → idea. Omit for a read-only badge. */
|
||||
onChange?: (next: CollectionStatus) => void
|
||||
showLabel?: boolean
|
||||
size?: number
|
||||
/** Dark-glass variant for a pill sitting over a photo cover / the hero. */
|
||||
onCover?: boolean
|
||||
t: TranslationFn
|
||||
}
|
||||
|
||||
/**
|
||||
* Coloured per-place status pill (idea / want-to-go / visited). When `onChange`
|
||||
* is supplied a single tap cycles the status optimistically; otherwise it is a
|
||||
* static badge. Two skins: the default surface pill (list / inspector) and the
|
||||
* `onCover` dark-glass pill that stays legible on top of a photo cover. Styled
|
||||
* with utility classes only, so it works both inside and outside `.trek-dash`.
|
||||
*/
|
||||
export default function StatusBadge({ status, onChange, showLabel = true, size = 13, onCover = false, t }: StatusBadgeProps): React.ReactElement {
|
||||
const meta = STATUS_META[status]
|
||||
const Icon = meta.icon
|
||||
const label = t(meta.labelKey)
|
||||
const color = onCover ? meta.coverColor : meta.color
|
||||
const interactive = !!onChange
|
||||
|
||||
const cycle = (e: React.SyntheticEvent) => {
|
||||
if (!onChange) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onChange(nextStatus(status))
|
||||
}
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<Icon size={size} style={{ color }} strokeWidth={2.4} />
|
||||
{showLabel && <span className="font-semibold" style={{ color }}>{label}</span>}
|
||||
</>
|
||||
)
|
||||
|
||||
const skin = onCover
|
||||
? 'bg-black/45 border-white/25 text-white'
|
||||
: 'bg-surface-card/85 border-edge text-content'
|
||||
const className = `inline-flex items-center gap-1.5 rounded-full text-[11px] leading-none border backdrop-blur-md ${showLabel ? 'px-2.5 py-1' : 'p-1.5'} ${skin}`
|
||||
|
||||
if (!interactive) {
|
||||
return <span className={className} title={label}>{content}</span>
|
||||
}
|
||||
|
||||
// Rendered as a role=button span (not a native <button>) on purpose: inside
|
||||
// the .trek-dash scope the global `.trek-dash button` reset would strip the
|
||||
// pill's background/border/padding, and the pill also has to sit inside the
|
||||
// grid card's own clickable element without nesting one button in another.
|
||||
return (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={cycle}
|
||||
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') cycle(e) }}
|
||||
title={`${label} — ${t('collections.status.cycleHint')}`}
|
||||
aria-label={label}
|
||||
className={`${className} transition-transform hover:scale-110 active:scale-95 cursor-pointer select-none`}
|
||||
>
|
||||
{content}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Place } from '../../types'
|
||||
import type { SaveToCollectionTarget } from '../../store/saveToCollectionStore'
|
||||
|
||||
/**
|
||||
* Build a Save-to-Collection picker target from a trip pool place, carrying the
|
||||
* provenance ids so the saved copy remembers its origin trip/place.
|
||||
*/
|
||||
export function placeToSaveTarget(place: Place): SaveToCollectionTarget {
|
||||
return {
|
||||
name: place.name,
|
||||
source_trip_id: place.trip_id ?? null,
|
||||
source_place_id: place.id,
|
||||
description: place.description ?? null,
|
||||
lat: place.lat ?? null,
|
||||
lng: place.lng ?? null,
|
||||
address: place.address ?? null,
|
||||
category_id: place.category_id ?? null,
|
||||
price: place.price ?? null,
|
||||
currency: place.currency ?? null,
|
||||
notes: place.notes ?? null,
|
||||
image_url: place.image_url ?? null,
|
||||
google_place_id: place.google_place_id ?? null,
|
||||
google_ftid: place.google_ftid ?? null,
|
||||
osm_id: place.osm_id ?? null,
|
||||
website: place.website ?? null,
|
||||
phone: place.phone ?? null,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Bookmark, ArrowRight, MapPin } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { collectionsApi } from '../../api/collections'
|
||||
import { entityGradient } from '../../utils/gradients'
|
||||
import type { Collection } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* Dashboard sidebar widget — a glassy `.tool` card that surfaces the user's
|
||||
* saved-place LISTS as compact colour-washed badges (a mini version of the
|
||||
* collections hero): each badge shows the list's cover image (tinted with its
|
||||
* colour) or a colour gradient, its name and place count, and jumps to that
|
||||
* list. Fetches only list() (per-list place_count), so no N+1.
|
||||
*/
|
||||
export default function CollectionsWidget({ onOpen }: { onOpen: () => void }): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const [lists, setLists] = useState<Collection[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const data = await collectionsApi.list()
|
||||
if (!cancelled) setLists(data.collections)
|
||||
} catch {
|
||||
if (!cancelled) setLists([])
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="tool">
|
||||
<div className="tool-head">
|
||||
<div className="tool-title"><Bookmark size={14} /> {t('collections.widget.title')}</div>
|
||||
<button className="tool-action" aria-label={t('collections.widget.title')} onClick={onOpen}>
|
||||
<ArrowRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{loading ? null : lists.length === 0 ? (
|
||||
<div className="col-empty">{t('collections.widget.empty')}</div>
|
||||
) : (
|
||||
<div className="col-badges">
|
||||
{lists.slice(0, 6).map(list => (
|
||||
<button
|
||||
key={list.id}
|
||||
className="col-badge"
|
||||
style={{ ['--badge-color' as string]: list.color || '#6366f1' }}
|
||||
onClick={() => navigate(`/collections/${list.id}`)}
|
||||
>
|
||||
{list.cover_image
|
||||
? <img className="col-badge-media" src={list.cover_image} alt="" />
|
||||
: <div className="col-badge-media" style={{ backgroundImage: entityGradient(list.id) }} />}
|
||||
<div className="col-badge-tint" />
|
||||
<div className="col-badge-body">
|
||||
<span className="col-badge-name">{list.name}</span>
|
||||
<span className="col-badge-count"><MapPin size={11} /> {list.place_count ?? 0}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
export const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other'])
|
||||
export const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other'])
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FileText, FileImage, File, Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route } from 'lucide-react'
|
||||
import { FileText, FileImage, File, FileVideo, Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route } from 'lucide-react'
|
||||
import { downloadFile } from '../../utils/fileDownload'
|
||||
|
||||
export function isImage(mimeType?: string | null) {
|
||||
@@ -6,9 +6,30 @@ export function isImage(mimeType?: string | null) {
|
||||
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) {
|
||||
if (!mimeType) return File
|
||||
if (mimeType === 'application/pdf') return FileText
|
||||
if (isVideo(mimeType)) return FileVideo
|
||||
if (isImage(mimeType)) return FileImage
|
||||
return File
|
||||
}
|
||||
|
||||
@@ -15,6 +15,15 @@ vi.mock('../../api/authUrl', () => ({
|
||||
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
|
||||
vi.mock('../../api/client', async (importOriginal) => {
|
||||
const original = (await importOriginal()) as any;
|
||||
@@ -289,6 +298,21 @@ 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', () => {
|
||||
const files = [buildFile({ uploaded_by_name: 'Alice Smith' })];
|
||||
render(<FileManager {...defaultProps} files={files} />);
|
||||
|
||||
@@ -2,23 +2,27 @@ import { useFileManager, type FileManagerProps } from './useFileManager'
|
||||
import { ImageLightbox } from './FileManagerImageLightbox'
|
||||
import { AssignModal } from './FileManagerAssignModal'
|
||||
import { PdfPreviewModal } from './FileManagerPdfPreviewModal'
|
||||
import { MarkdownPreviewModal } from './FileManagerMarkdownPreviewModal'
|
||||
import { isMarkdown } from './FileManager.helpers'
|
||||
import { FileManagerToolbar } from './FileManagerToolbar'
|
||||
import { TrashView } from './FileManagerTrashView'
|
||||
import { FilesView } from './FileManagerFilesView'
|
||||
|
||||
export default function FileManager(props: FileManagerProps) {
|
||||
const S = useFileManager(props)
|
||||
const { lightboxIndex, setLightboxIndex, imageFiles, assignFileId, previewFile, handlePaste, showTrash } = S
|
||||
const { lightboxIndex, setLightboxIndex, mediaFiles, assignFileId, previewFile, handlePaste, showTrash } = S
|
||||
return (
|
||||
<div className="flex flex-col h-full" style={{ fontFamily: "var(--font-system)" }} onPaste={handlePaste} tabIndex={-1}>
|
||||
{/* Lightbox */}
|
||||
{lightboxIndex !== null && <ImageLightbox files={imageFiles} initialIndex={lightboxIndex} onClose={() => setLightboxIndex(null)} />}
|
||||
{lightboxIndex !== null && <ImageLightbox files={mediaFiles} initialIndex={lightboxIndex} onClose={() => setLightboxIndex(null)} />}
|
||||
|
||||
{/* Assign modal */}
|
||||
{assignFileId && <AssignModal {...S} />}
|
||||
|
||||
{/* PDF preview modal */}
|
||||
{previewFile && <PdfPreviewModal {...S} />}
|
||||
{/* Document preview modal (markdown is rendered inline; everything else PDF/object) */}
|
||||
{previewFile && (isMarkdown(previewFile.mime_type, previewFile.original_name)
|
||||
? <MarkdownPreviewModal {...S} />
|
||||
: <PdfPreviewModal {...S} />)}
|
||||
|
||||
{/* Toolbar */}
|
||||
<FileManagerToolbar {...S} />
|
||||
|
||||
@@ -17,8 +17,8 @@ export function AssignModal(S: FileManagerState) {
|
||||
}} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ padding: '16px 20px 12px', borderBottom: '1px solid var(--border-primary)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--text-primary)' }}>{t('files.assignTitle')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-faint)', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
<div style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', 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' }}>
|
||||
{files.find(f => f.id === assignFileId)?.original_name || ''}
|
||||
</div>
|
||||
</div>
|
||||
@@ -27,7 +27,7 @@ export function AssignModal(S: FileManagerState) {
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ padding: '8px 12px 0' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '0 2px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||
<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 }}>
|
||||
{t('files.noteLabel') || 'Note'}
|
||||
</div>
|
||||
<input
|
||||
@@ -43,7 +43,7 @@ export function AssignModal(S: FileManagerState) {
|
||||
}}
|
||||
onKeyDown={e => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }}
|
||||
style={{
|
||||
width: '100%', padding: '7px 10px', fontSize: 13, borderRadius: 8,
|
||||
width: '100%', padding: '7px 10px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', borderRadius: 8,
|
||||
border: '1px solid var(--border-primary)', background: 'var(--bg-secondary)',
|
||||
color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none',
|
||||
}}
|
||||
@@ -91,7 +91,7 @@ export function AssignModal(S: FileManagerState) {
|
||||
}
|
||||
}} style={{
|
||||
width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: isLinked ? 'var(--bg-hover)' : 'none',
|
||||
border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)',
|
||||
border: 'none', cursor: 'pointer', fontSize: 'calc(13px * var(--fs-scale-body, 1))', color: 'var(--text-primary)',
|
||||
borderRadius: 8, fontFamily: 'inherit', fontWeight: isLinked ? 600 : 400,
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
}}
|
||||
@@ -106,18 +106,18 @@ export function AssignModal(S: FileManagerState) {
|
||||
|
||||
const placesSection = places.length > 0 && (
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||
<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 }}>
|
||||
{t('files.assignPlace')}
|
||||
</div>
|
||||
{dayGroups.map(({ day, dayPlaces }) => (
|
||||
<div key={day.id}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', padding: '8px 10px 2px' }}>
|
||||
<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' }}>
|
||||
<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)
|
||||
return badge ? (
|
||||
<span style={{
|
||||
fontSize: 10, fontWeight: 600, color: 'var(--text-faint)',
|
||||
fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)',
|
||||
background: 'var(--bg-tertiary)', padding: '1px 6px', borderRadius: 999,
|
||||
}}>{badge}</span>
|
||||
) : null
|
||||
@@ -128,7 +128,7 @@ export function AssignModal(S: FileManagerState) {
|
||||
))}
|
||||
{unassigned.length > 0 && (
|
||||
<div>
|
||||
{dayGroups.length > 0 && <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', padding: '8px 10px 2px' }}>{t('files.unassigned')}</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>}
|
||||
{unassigned.map(placeBtn)}
|
||||
</div>
|
||||
)}
|
||||
@@ -166,7 +166,7 @@ export function AssignModal(S: FileManagerState) {
|
||||
}
|
||||
}} style={{
|
||||
width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: isLinked ? 'var(--bg-hover)' : 'none',
|
||||
border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)',
|
||||
border: 'none', cursor: 'pointer', fontSize: 'calc(13px * var(--fs-scale-body, 1))', color: 'var(--text-primary)',
|
||||
borderRadius: 8, fontFamily: 'inherit', fontWeight: isLinked ? 600 : 400,
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
}}
|
||||
@@ -183,7 +183,7 @@ export function AssignModal(S: FileManagerState) {
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{bookingReservations.length > 0 && (
|
||||
<>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||
<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 }}>
|
||||
{t('files.assignBooking')}
|
||||
</div>
|
||||
{bookingReservations.map(reservationBtn)}
|
||||
@@ -191,7 +191,7 @@ export function AssignModal(S: FileManagerState) {
|
||||
)}
|
||||
{transportReservations.length > 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 }}>
|
||||
<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 }}>
|
||||
{t('files.assignTransport')}
|
||||
</div>
|
||||
{transportReservations.map(reservationBtn)}
|
||||
|
||||
@@ -32,7 +32,7 @@ export function AvatarChip({ name, avatarUrl, size = 20 }: { name: string; avata
|
||||
<div style={{
|
||||
position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)',
|
||||
background: 'var(--bg-elevated)', color: 'var(--text-primary)',
|
||||
fontSize: 11, fontWeight: 500, padding: '3px 8px', borderRadius: 6,
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500, padding: '3px 8px', borderRadius: 6,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)', whiteSpace: 'nowrap', zIndex: 9999,
|
||||
pointerEvents: 'none',
|
||||
}}>
|
||||
|
||||
@@ -22,15 +22,15 @@ export function FilesView(S: FileManagerState) {
|
||||
<input {...getInputProps()} />
|
||||
<Upload size={24} style={{ margin: '0 auto 8px', color: isDragActive ? 'var(--text-secondary)' : 'var(--text-faint)', display: 'block' }} />
|
||||
{uploading ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, fontSize: 13, color: 'var(--text-secondary)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, fontSize: 'calc(13px * var(--fs-scale-body, 1))', 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' }} />
|
||||
{t('files.uploading')}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', fontWeight: 500, margin: 0 }}>{t('files.dropzone')}</p>
|
||||
<p style={{ fontSize: 11.5, color: 'var(--text-faint)', marginTop: 3 }}>{t('files.dropzoneHint')}</p>
|
||||
<p style={{ fontSize: 10, color: 'var(--text-faint)', marginTop: 6, opacity: 0.7 }}>
|
||||
<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: 'calc(11.5px * var(--fs-scale-caption, 1))', 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 }}>
|
||||
{(allowedFileTypes || 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv').toUpperCase().split(',').join(', ')} · Max 50 MB
|
||||
</p>
|
||||
</>
|
||||
@@ -48,14 +48,14 @@ export function FilesView(S: FileManagerState) {
|
||||
...(files.some(f => f.note_id) ? [{ id: 'collab', label: t('files.filterCollab') || 'Collab' }] : []),
|
||||
].map(tab => (
|
||||
<button key={tab.id} onClick={() => setFilterType(tab.id)} style={{
|
||||
padding: '4px 12px', borderRadius: 99, border: 'none', cursor: 'pointer', fontSize: 12,
|
||||
padding: '4px 12px', borderRadius: 99, border: 'none', cursor: 'pointer', fontSize: 'calc(12px * var(--fs-scale-body, 1))',
|
||||
fontFamily: 'inherit', transition: 'all 0.12s',
|
||||
background: filterType === tab.id ? 'var(--accent)' : 'transparent',
|
||||
color: filterType === tab.id ? 'var(--accent-text)' : 'var(--text-muted)',
|
||||
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>
|
||||
))}
|
||||
<span style={{ marginLeft: 'auto', fontSize: 11.5, color: 'var(--text-faint)', alignSelf: 'center' }}>
|
||||
<span style={{ marginLeft: 'auto', fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', alignSelf: 'center' }}>
|
||||
{filteredFiles.length === 1 ? t('files.countSingular') : t('files.count', { count: filteredFiles.length })}
|
||||
</span>
|
||||
</div>
|
||||
@@ -65,8 +65,8 @@ export function FilesView(S: FileManagerState) {
|
||||
{filteredFiles.length === 0 ? (
|
||||
<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' }} />
|
||||
<p style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', margin: '0 0 4px' }}>{t('files.empty')}</p>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-faint)', margin: 0 }}>{t('files.emptyHint')}</p>
|
||||
<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: 'calc(13px * var(--fs-scale-body, 1))', color: 'var(--text-faint)', margin: 0 }}>{t('files.emptyHint')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { ExternalLink, Download, X, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { ExternalLink, Download, X, ChevronLeft, ChevronRight, Play } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { TripFile } from '../../types'
|
||||
import { getAuthUrl } from '../../api/authUrl'
|
||||
import { openFile as openFileUrl } from '../../utils/fileDownload'
|
||||
import { triggerDownload } from './FileManager.helpers'
|
||||
import { triggerDownload, isVideo } from './FileManager.helpers'
|
||||
import VideoPlayer from '../Journey/VideoPlayer'
|
||||
|
||||
// Image lightbox with gallery navigation
|
||||
interface ImageLightboxProps {
|
||||
@@ -20,10 +21,14 @@ export function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxPro
|
||||
const [touchStart, setTouchStart] = useState<number | null>(null)
|
||||
const file = files[index]
|
||||
|
||||
const fileIsVideo = isVideo(file?.mime_type)
|
||||
|
||||
useEffect(() => {
|
||||
setImgSrc('')
|
||||
if (file) getAuthUrl(file.url, 'download').then(setImgSrc)
|
||||
}, [file?.url])
|
||||
// Images use a one-shot signed URL; a video must use the plain same-origin
|
||||
// URL (cookie auth) so its many Range requests all authenticate (#823).
|
||||
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 goNext = () => setIndex(i => Math.min(files.length - 1, i + 1))
|
||||
@@ -71,7 +76,7 @@ export function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxPro
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', flexShrink: 0 }} onClick={e => e.stopPropagation()}>
|
||||
<span style={{ fontSize: 12, color: 'rgba(255,255,255,0.7)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>
|
||||
<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 }}>
|
||||
{file.original_name}
|
||||
<span style={{ marginLeft: 8, color: 'rgba(255,255,255,0.4)' }}>{index + 1} / {files.length}</span>
|
||||
</span>
|
||||
@@ -98,7 +103,13 @@ export function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxPro
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative', minHeight: 0 }}
|
||||
onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
{navBtn('left', goPrev, hasPrev)}
|
||||
{imgSrc && <img src={imgSrc} alt={file.original_name} style={{ maxWidth: '85vw', maxHeight: '80vh', objectFit: 'contain', borderRadius: 8, display: 'block' }} onClick={e => e.stopPropagation()} />}
|
||||
{fileIsVideo ? (
|
||||
<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)}
|
||||
</div>
|
||||
|
||||
@@ -115,14 +126,20 @@ export function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxPro
|
||||
}
|
||||
|
||||
function ThumbImg({ file, active, onClick }: { file: TripFile & { url: string }; active: boolean; onClick: () => void }) {
|
||||
const fileIsVideo = isVideo(file.mime_type)
|
||||
const [src, setSrc] = useState('')
|
||||
useEffect(() => { getAuthUrl(file.url, 'download').then(setSrc) }, [file.url])
|
||||
// Videos have no stored thumbnail and can't render as an <img>; show a play
|
||||
// placeholder and don't mint a download token for them (#823).
|
||||
useEffect(() => { if (!fileIsVideo) getAuthUrl(file.url, 'download').then(setSrc) }, [file.url, fileIsVideo])
|
||||
return (
|
||||
<button onClick={onClick} style={{
|
||||
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',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'rgba(255,255,255,0.7)',
|
||||
}}>
|
||||
{src && <img src={src} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />}
|
||||
{fileIsVideo
|
||||
? <Play size={16} fill="currentColor" />
|
||||
: (src && <img src={src} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
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()}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{previewFile.original_name}</span>
|
||||
<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: 12, 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: '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' }}
|
||||
onMouseEnter={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-muted)'}>
|
||||
<ExternalLink size={13} /> {t('files.openTab')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => triggerDownload(previewFile.url, previewFile.original_name)}
|
||||
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' }}
|
||||
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' }}
|
||||
onMouseEnter={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-muted)'}>
|
||||
<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'
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%', background: isPdf ? '#ef44441a' : 'var(--bg-tertiary)' }}>
|
||||
<span style={{ fontSize: 9, fontWeight: 700, color: isPdf ? '#ef4444' : 'var(--text-muted)', letterSpacing: 0.3 }}>{ext}</span>
|
||||
<span style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 700, color: isPdf ? '#ef4444' : 'var(--text-muted)', letterSpacing: 0.3 }}>{ext}</span>
|
||||
</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}
|
||||
<span
|
||||
onClick={() => !isTrash && openFile(file)}
|
||||
style={{ fontWeight: 500, fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: isTrash ? 'default' : 'pointer' }}
|
||||
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' }}
|
||||
>
|
||||
{file.original_name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{file.description && (
|
||||
<p style={{ fontSize: 11.5, color: 'var(--text-faint)', margin: '2px 0 0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{file.description}</p>
|
||||
<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>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 6, marginTop: 4 }}>
|
||||
{file.file_size && <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formatSize(file.file_size)}</span>}
|
||||
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formatDateWithLocale(file.created_at, locale)}</span>
|
||||
{file.file_size && <span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', 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>
|
||||
|
||||
{linkedPlaces.map(p => (
|
||||
<SourceBadge key={p.id} icon={MapPin} label={`${t('files.sourcePlan')} · ${p.name}`} />
|
||||
|
||||
@@ -8,7 +8,7 @@ export function SourceBadge({ icon: Icon, label }: SourceBadgeProps) {
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
fontSize: 10.5, color: '#4b5563',
|
||||
fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', color: '#4b5563',
|
||||
background: 'var(--bg-tertiary)', border: '1px solid var(--border-primary)',
|
||||
borderRadius: 6, padding: '2px 7px',
|
||||
fontWeight: 500, maxWidth: '100%', overflow: 'hidden',
|
||||
|
||||
@@ -10,7 +10,7 @@ export function FileManagerToolbar(S: FileManagerState) {
|
||||
padding: '14px 16px 14px 22px',
|
||||
display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap',
|
||||
}}>
|
||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em', flexShrink: 0 }}>
|
||||
<h2 style={{ margin: 0, fontSize: 'calc(18px * var(--fs-scale-subtitle, 1))', fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em', flexShrink: 0 }}>
|
||||
{showTrash ? (t('files.trash') || 'Trash') : t('files.title')}
|
||||
</h2>
|
||||
|
||||
@@ -40,7 +40,7 @@ export function FileManagerToolbar(S: FileManagerState) {
|
||||
style={{
|
||||
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
padding: '6px 12px', borderRadius: 99, fontSize: 13, whiteSpace: 'nowrap',
|
||||
padding: '6px 12px', borderRadius: 99, fontSize: 'calc(13px * var(--fs-scale-body, 1))', whiteSpace: 'nowrap',
|
||||
background: active ? 'var(--bg-card)' : 'transparent',
|
||||
color: active ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||
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}
|
||||
{'label' in tab && tab.label}
|
||||
<span style={{
|
||||
fontSize: 10, fontWeight: 600,
|
||||
fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600,
|
||||
background: active ? 'var(--bg-tertiary)' : 'rgba(0,0,0,0.06)',
|
||||
color: 'var(--text-faint)',
|
||||
padding: '1px 6px', borderRadius: 99, minWidth: 16, textAlign: 'center',
|
||||
@@ -66,7 +66,7 @@ export function FileManagerToolbar(S: FileManagerState) {
|
||||
<button onClick={toggleTrash} style={{
|
||||
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
padding: '9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
|
||||
padding: '9px 14px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500,
|
||||
background: 'var(--accent)', color: 'var(--accent-text)',
|
||||
flexShrink: 0, marginLeft: 'auto',
|
||||
opacity: showTrash ? 1 : 0.88,
|
||||
|
||||
@@ -10,7 +10,7 @@ export function TrashView(S: FileManagerState) {
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 12 }}>
|
||||
<button onClick={handleEmptyTrash} style={{
|
||||
padding: '5px 12px', borderRadius: 8, border: '1px solid #fecaca',
|
||||
background: '#fef2f2', color: '#dc2626', fontSize: 12, fontWeight: 500,
|
||||
background: '#fef2f2', color: '#dc2626', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
{t('files.emptyTrash') || 'Empty Trash'}
|
||||
@@ -24,7 +24,7 @@ export function TrashView(S: FileManagerState) {
|
||||
) : trashFiles.length === 0 ? (
|
||||
<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' }} />
|
||||
<p style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', margin: '0 0 4px' }}>{t('files.trashEmpty') || 'Trash is empty'}</p>
|
||||
<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>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useTranslation, translateApiError } from '../../i18n'
|
||||
import { filesApi } from '../../api/client'
|
||||
import type { Place, Reservation, TripFile, Day, AssignmentsMap } from '../../types'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { getAuthUrl } from '../../api/authUrl'
|
||||
import { isImage } from './FileManager.helpers'
|
||||
import { isImage, isMedia } from './FileManager.helpers'
|
||||
|
||||
export interface FileManagerProps {
|
||||
files?: TripFile[]
|
||||
@@ -119,8 +119,8 @@ export function useFileManager({ files = [], onUpload, onDelete, onUpdate, place
|
||||
if (lastId && (places.length > 0 || reservations.length > 0)) {
|
||||
setAssignFileId(lastId)
|
||||
}
|
||||
} catch {
|
||||
toast.error(t('files.uploadError'))
|
||||
} catch (err) {
|
||||
toast.error(translateApiError(t, err, 'files.uploadError'))
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
@@ -184,11 +184,12 @@ export function useFileManager({ files = [], onUpload, onDelete, onUpdate, place
|
||||
}
|
||||
}
|
||||
|
||||
const imageFiles = filteredFiles.filter(f => isImage(f.mime_type))
|
||||
// Image OR video — both open in the lightbox; videos play there (#823).
|
||||
const mediaFiles = filteredFiles.filter(f => isMedia(f.mime_type))
|
||||
|
||||
const openFile = (file) => {
|
||||
if (isImage(file.mime_type)) {
|
||||
const idx = imageFiles.findIndex(f => f.id === file.id)
|
||||
if (isMedia(file.mime_type)) {
|
||||
const idx = mediaFiles.findIndex(f => f.id === file.id)
|
||||
setLightboxIndex(idx >= 0 ? idx : 0)
|
||||
} else {
|
||||
setPreviewFile(file)
|
||||
@@ -202,7 +203,7 @@ export function useFileManager({ files = [], onUpload, onDelete, onUpdate, place
|
||||
toggleTrash, refreshFiles, handleStar, handleRestore, handlePermanentDelete, handleEmptyTrash,
|
||||
previewFile, setPreviewFile, previewFileUrl, assignFileId, setAssignFileId,
|
||||
getRootProps, getInputProps, isDragActive, handlePaste, filteredFiles, handleDelete,
|
||||
handleAssign, imageFiles, openFile,
|
||||
handleAssign, mediaFiles, openFile,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ export default function JournalBody({ text, dark }: Props) {
|
||||
<pre style={{
|
||||
background: dark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.04)',
|
||||
borderRadius: 8, padding: 14, overflowX: 'auto',
|
||||
fontSize: 13, fontFamily: 'monospace', margin: '12px 0',
|
||||
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontFamily: 'monospace', margin: '12px 0',
|
||||
}}>
|
||||
<code>{children}</code>
|
||||
</pre>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { RefreshCw, Camera, Image, Plus, X } from 'lucide-react'
|
||||
import { RefreshCw, Camera, Image, Plus, X, Play } from 'lucide-react'
|
||||
import { normalizeImageFiles } from '../../utils/convertHeic'
|
||||
import { isVideoFile } from '../../utils/videoPoster'
|
||||
import { useJourneyStore } from '../../store/journeyStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { journeyApi, addonsApi } from '../../api/client'
|
||||
@@ -66,7 +67,11 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
|
||||
if (!files?.length) return
|
||||
setGalleryProgress({ done: 0, total: files.length })
|
||||
try {
|
||||
const normalized = await normalizeImageFiles(files)
|
||||
// Videos skip HEIC normalization; only images are converted (#823).
|
||||
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, {
|
||||
onProgress: p => setGalleryProgress({ done: p.done, total: p.total }),
|
||||
})
|
||||
@@ -110,7 +115,7 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input ref={galleryFileRef} type="file" accept="image/*" multiple onChange={handleGalleryUpload} className="hidden" />
|
||||
<input ref={galleryFileRef} type="file" accept="image/*,video/*" multiple onChange={handleGalleryUpload} className="hidden" />
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4 flex-wrap gap-2">
|
||||
@@ -158,13 +163,26 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
|
||||
className="relative aspect-square rounded-lg overflow-hidden cursor-pointer group"
|
||||
onClick={() => onPhotoClick(allPhotos, i)}
|
||||
>
|
||||
<img
|
||||
src={photoUrl(photo, 'thumbnail')}
|
||||
alt={photo.caption || ''}
|
||||
className="w-full h-full object-cover transition-transform group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
{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
|
||||
src={photoUrl(photo, 'thumbnail')}
|
||||
alt={photo.caption || ''}
|
||||
className="w-full h-full object-cover transition-transform group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
)}
|
||||
<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 */}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleDeletePhoto(photo.id) }}
|
||||
@@ -205,10 +223,10 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
|
||||
for (const group of groups) {
|
||||
try {
|
||||
if (entryId) {
|
||||
const result = await journeyApi.addProviderPhotos(entryId, pickerProvider!, group.assetIds, undefined, group.passphrase)
|
||||
const result = await journeyApi.addProviderPhotos(entryId, pickerProvider!, group.assetIds, undefined, group.passphrase, group.mediaTypes)
|
||||
added += result.added || 0
|
||||
} else {
|
||||
const result = await journeyApi.addProviderPhotosToGallery(journeyId, pickerProvider!, group.assetIds, group.passphrase)
|
||||
const result = await journeyApi.addProviderPhotosToGallery(journeyId, pickerProvider!, group.assetIds, group.passphrase, group.mediaTypes)
|
||||
added += result.added || 0
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -13,7 +13,7 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
|
||||
trips: JourneyTrip[]
|
||||
existingAssetIds: Set<string>
|
||||
onClose: () => void
|
||||
onAdd: (groups: Array<{ assetIds: string[]; passphrase?: string }>, entryId: number | null) => Promise<void>
|
||||
onAdd: (groups: Array<{ assetIds: string[]; passphrase?: string; mediaTypes?: string[] }>, entryId: number | null) => Promise<void>
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
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 [searchFrom, setSearchFrom] = useState('')
|
||||
const [searchTo, setSearchTo] = useState('')
|
||||
const [selected, setSelected] = useState<Map<string, { albumId?: string; passphrase?: string }>>(new Map())
|
||||
const [selected, setSelected] = useState<Map<string, { albumId?: string; passphrase?: string; mediaType?: string }>>(new Map())
|
||||
const [customFrom, setCustomFrom] = useState('')
|
||||
const [customTo, setCustomTo] = useState('')
|
||||
const [targetEntryId, setTargetEntryId] = useState<number | null>(null)
|
||||
@@ -123,7 +123,8 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
|
||||
if (next.has(id)) {
|
||||
next.delete(id)
|
||||
} else {
|
||||
next.set(id, { albumId: selectedAlbum ?? undefined, passphrase: selectedAlbumPassphrase })
|
||||
const mediaType = (photos as any[]).find(p => p.id === id)?.mediaType
|
||||
next.set(id, { albumId: selectedAlbum ?? undefined, passphrase: selectedAlbumPassphrase, mediaType })
|
||||
}
|
||||
return next
|
||||
})
|
||||
@@ -293,7 +294,7 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
|
||||
if (allSelected) {
|
||||
setSelected(new Map())
|
||||
} else {
|
||||
setSelected(new Map(selectable.map((a: any) => [a.id, { albumId: selectedAlbum ?? undefined, passphrase: selectedAlbumPassphrase }])))
|
||||
setSelected(new Map(selectable.map((a: any) => [a.id, { albumId: selectedAlbum ?? undefined, passphrase: selectedAlbumPassphrase, mediaType: a.mediaType }])))
|
||||
}
|
||||
}}
|
||||
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"
|
||||
@@ -396,13 +397,14 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const groupMap = new Map<string | undefined, string[]>()
|
||||
for (const [assetId, { passphrase }] of selected.entries()) {
|
||||
const list = groupMap.get(passphrase) || []
|
||||
list.push(assetId)
|
||||
groupMap.set(passphrase, list)
|
||||
const groupMap = new Map<string | undefined, { assetIds: string[]; mediaTypes: string[] }>()
|
||||
for (const [assetId, { passphrase, mediaType }] of selected.entries()) {
|
||||
const g = groupMap.get(passphrase) || { assetIds: [], mediaTypes: [] }
|
||||
g.assetIds.push(assetId)
|
||||
g.mediaTypes.push(mediaType === 'video' ? 'video' : 'image')
|
||||
groupMap.set(passphrase, g)
|
||||
}
|
||||
const groups = [...groupMap.entries()].map(([passphrase, assetIds]) => ({ assetIds, passphrase }))
|
||||
const groups = [...groupMap.entries()].map(([passphrase, g]) => ({ assetIds: g.assetIds, mediaTypes: g.mediaTypes, passphrase }))
|
||||
onAdd(groups, targetEntryId)
|
||||
}}
|
||||
disabled={selected.size === 0}
|
||||
|
||||
@@ -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)'}`,
|
||||
color: dark ? '#fff' : '#18181B',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', fontSize: 16, fontWeight: 700, lineHeight: 1,
|
||||
cursor: 'pointer', fontSize: 'calc(16px * var(--fs-scale-subtitle, 1))', fontWeight: 700, lineHeight: 1,
|
||||
}}
|
||||
>+</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)'}`,
|
||||
color: dark ? '#fff' : '#18181B',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', fontSize: 16, fontWeight: 700, lineHeight: 1,
|
||||
cursor: 'pointer', fontSize: 'calc(16px * var(--fs-scale-subtitle, 1))', fontWeight: 700, lineHeight: 1,
|
||||
}}
|
||||
>−</button>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { forwardRef, useImperativeHandle, useRef } from 'react'
|
||||
import { forwardRef, lazy, Suspense, useImperativeHandle, useRef } from 'react'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import JourneyMap, { type JourneyMapHandle } from './JourneyMap'
|
||||
import JourneyMapGL, { type JourneyMapGLHandle } from './JourneyMapGL'
|
||||
import 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.
|
||||
export type JourneyMapAutoHandle = JourneyMapHandle
|
||||
@@ -37,8 +41,9 @@ const JourneyMapAuto = forwardRef<JourneyMapAutoHandle, Props>(function JourneyM
|
||||
const glRef = useRef<JourneyMapGLHandle>(null)
|
||||
|
||||
// Fall back to Leaflet when the user selected Mapbox GL but hasn't
|
||||
// supplied a token yet — otherwise the map would just show a stub.
|
||||
const useGL = provider === 'mapbox-gl' && !!token
|
||||
// supplied a token yet. MapLibre/OpenFreeMap is tokenless.
|
||||
const useGL = provider === 'maplibre-gl' || (provider === 'mapbox-gl' && !!token)
|
||||
const glProvider = provider === 'maplibre-gl' ? 'maplibre-gl' : 'mapbox-gl'
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
highlightMarker: (id) => (useGL ? glRef.current : leafletRef.current)?.highlightMarker(id),
|
||||
@@ -47,8 +52,12 @@ const JourneyMapAuto = forwardRef<JourneyMapAutoHandle, Props>(function JourneyM
|
||||
}), [useGL])
|
||||
|
||||
if (useGL) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return <JourneyMapGL ref={glRef} {...(props as any)} />
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
{/* 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
|
||||
return <JourneyMap ref={leafletRef} {...(props as any)} />
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useEffect, useRef, useImperativeHandle, forwardRef, useCallback } from 'react'
|
||||
import mapboxgl from 'mapbox-gl'
|
||||
import maplibregl from 'maplibre-gl'
|
||||
import 'mapbox-gl/dist/mapbox-gl.css'
|
||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup'
|
||||
import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from '../Map/glProviders'
|
||||
|
||||
export interface JourneyMapGLHandle {
|
||||
highlightMarker: (id: string | null) => void
|
||||
@@ -32,6 +35,7 @@ interface Props {
|
||||
onMarkerClick?: (id: string, type?: string) => void
|
||||
fullScreen?: boolean
|
||||
paddingBottom?: number
|
||||
glProvider?: GlMapProvider
|
||||
}
|
||||
|
||||
interface Item {
|
||||
@@ -95,8 +99,10 @@ function ensureJourneyPopupStyle() {
|
||||
const s = document.createElement('style')
|
||||
s.id = 'trek-journey-popup-style'
|
||||
s.textContent = `
|
||||
.mapboxgl-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,
|
||||
.maplibregl-popup.trek-journey-popup { pointer-events: none; animation: trek-journey-popup-in 180ms ease-out; }
|
||||
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-content,
|
||||
.maplibregl-popup.trek-journey-popup .maplibregl-popup-content {
|
||||
padding: 9px 14px 10px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
@@ -108,20 +114,24 @@ function ensureJourneyPopupStyle() {
|
||||
min-width: 160px;
|
||||
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);
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
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-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-bottom-color: rgba(24, 24, 27, 0.88);
|
||||
}
|
||||
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-close-button { display: none; }
|
||||
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-close-button,
|
||||
.maplibregl-popup.trek-journey-popup .maplibregl-popup-close-button { display: none; }
|
||||
.trek-journey-popup-title {
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
@@ -132,7 +142,8 @@ function ensureJourneyPopupStyle() {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-title { color: #FAFAFA; }
|
||||
.mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-title,
|
||||
.maplibregl-popup.trek-journey-popup.trek-dark .trek-journey-popup-title { color: #FAFAFA; }
|
||||
.trek-journey-popup-sub {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
@@ -143,7 +154,8 @@ function ensureJourneyPopupStyle() {
|
||||
line-height: 1.35;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-sub { color: #A1A1AA; }
|
||||
.mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-sub,
|
||||
.maplibregl-popup.trek-journey-popup.trek-dark .trek-journey-popup-sub { color: #A1A1AA; }
|
||||
.trek-journey-popup-place {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
@@ -194,20 +206,29 @@ function markerHtml(dayColor: string, dayLabel: number, highlighted: boolean): H
|
||||
const EMPTY_TRAIL: { lat: number; lng: number }[] = []
|
||||
|
||||
const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL(
|
||||
{ entries, trail, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom },
|
||||
{ entries, trail, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, glProvider = 'mapbox-gl' },
|
||||
ref
|
||||
) {
|
||||
const stableTrail = trail || EMPTY_TRAIL
|
||||
const mapboxStyle = useSettingsStore(s => s.settings.mapbox_style || 'mapbox://styles/mapbox/standard')
|
||||
const rawMapboxStyle = useSettingsStore(s => s.settings.mapbox_style || MAPBOX_DEFAULT_STYLE)
|
||||
const rawMaplibreStyle = useSettingsStore(s => s.settings.maplibre_style || '')
|
||||
const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '')
|
||||
const mapbox3d = useSettingsStore(s => s.settings.mapbox_3d_enabled !== false)
|
||||
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 mapRef = useRef<mapboxgl.Map | null>(null)
|
||||
const markersRef = useRef<Map<string, mapboxgl.Marker>>(new Map())
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const mapRef = useRef<any | null>(null)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const markersRef = useRef<Map<string, any>>(new Map())
|
||||
const itemsRef = useRef<Item[]>([])
|
||||
const highlightedRef = useRef<string | null>(null)
|
||||
const popupRef = useRef<mapboxgl.Popup | null>(null)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const popupRef = useRef<any | null>(null)
|
||||
const onMarkerClickRef = useRef(onMarkerClick)
|
||||
onMarkerClickRef.current = onMarkerClick
|
||||
const darkRef = useRef(dark)
|
||||
@@ -247,7 +268,7 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
|
||||
const el = popupRef.current.getElement()
|
||||
if (el) el.classList.toggle('trek-dark', !!darkRef.current)
|
||||
} else {
|
||||
popupRef.current = new mapboxgl.Popup({
|
||||
popupRef.current = new gl.Popup({
|
||||
closeButton: false,
|
||||
closeOnClick: false,
|
||||
closeOnMove: false,
|
||||
@@ -260,7 +281,7 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
|
||||
.setHTML(html)
|
||||
.addTo(mapRef.current)
|
||||
}
|
||||
}, [])
|
||||
}, [gl])
|
||||
|
||||
const hidePopup = useCallback(() => {
|
||||
if (popupRef.current) {
|
||||
@@ -305,11 +326,11 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
|
||||
mapRef.current.flyTo({
|
||||
center: marker.getLngLat(),
|
||||
zoom: Math.max(mapRef.current.getZoom(), 14),
|
||||
pitch: mapbox3d ? 45 : 0,
|
||||
pitch: enableMapbox3d ? 45 : 0,
|
||||
duration: 600,
|
||||
})
|
||||
} catch { /* map not yet ready */ }
|
||||
}, [highlightMarker, mapbox3d])
|
||||
}, [highlightMarker, enableMapbox3d])
|
||||
|
||||
const invalidateSize = useCallback(() => {
|
||||
try { mapRef.current?.resize() } catch { /* map not yet ready */ }
|
||||
@@ -320,39 +341,46 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
|
||||
// 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.
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || !mapboxToken) return
|
||||
mapboxgl.accessToken = mapboxToken
|
||||
if (!containerRef.current || (!isMapLibre && !mapboxToken)) return
|
||||
if (!isMapLibre) mapboxgl.accessToken = mapboxToken
|
||||
|
||||
const items = buildItems(entries)
|
||||
itemsRef.current = items
|
||||
|
||||
const bounds = new mapboxgl.LngLatBounds()
|
||||
const bounds = new gl.LngLatBounds()
|
||||
items.forEach(i => bounds.extend([i.lng, i.lat]))
|
||||
stableTrail.forEach(p => bounds.extend([p.lng, p.lat]))
|
||||
const hasPoints = items.length > 0 || stableTrail.length > 0
|
||||
|
||||
const map = new mapboxgl.Map({
|
||||
const mapOptions: Record<string, unknown> = {
|
||||
container: containerRef.current,
|
||||
style: mapboxStyle,
|
||||
style: glStyle,
|
||||
center: hasPoints ? bounds.getCenter() : [0, 30],
|
||||
zoom: hasPoints ? 2 : 1,
|
||||
pitch: mapbox3d && fullScreen ? 45 : 0,
|
||||
pitch: enableMapbox3d && fullScreen ? 45 : 0,
|
||||
attributionControl: true,
|
||||
antialias: mapboxQuality,
|
||||
projection: mapboxQuality ? 'globe' : 'mercator',
|
||||
})
|
||||
}
|
||||
if (!isMapLibre) mapOptions.projection = mapboxQuality ? 'globe' : 'mercator'
|
||||
|
||||
const map = new gl.Map(mapOptions as any)
|
||||
mapRef.current = map
|
||||
|
||||
map.on('load', () => {
|
||||
if (mapbox3d) {
|
||||
if (!isStandardFamily(mapboxStyle) && wantsTerrain(mapboxStyle)) addTerrainAndSky(map)
|
||||
if (supportsCustom3d(mapboxStyle)) addCustom3dBuildings(map, !!darkRef.current)
|
||||
if (enableMapbox3d) {
|
||||
if (!isStandardFamily(glStyle) && wantsTerrain(glStyle)) addTerrainAndSky(map)
|
||||
if (supportsCustom3d(glStyle)) addCustom3dBuildings(map, !!darkRef.current)
|
||||
}
|
||||
// Flatten Mapbox Standard's built-in DEM so HTML markers (at Z=0)
|
||||
// stay pinned to their coordinates at every zoom and pitch.
|
||||
if (mapboxStyle === 'mapbox://styles/mapbox/standard') {
|
||||
if (glStyle === MAPBOX_DEFAULT_STYLE) {
|
||||
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
|
||||
if (items.length > 1) {
|
||||
@@ -383,7 +411,7 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
|
||||
// markers
|
||||
items.forEach((item) => {
|
||||
const el = markerHtml(item.dayColor, item.dayLabel, false)
|
||||
const marker = new mapboxgl.Marker({ element: el, anchor: 'bottom' })
|
||||
const marker = new gl.Marker({ element: el, anchor: 'bottom' })
|
||||
.setLngLat([item.lng, item.lat])
|
||||
.addTo(map)
|
||||
el.addEventListener('click', (ev) => {
|
||||
@@ -400,7 +428,7 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
|
||||
map.fitBounds(bounds, {
|
||||
padding: { top: 50, bottom: pb, left: 50, right: 50 },
|
||||
maxZoom: 16,
|
||||
pitch: mapbox3d && fullScreen ? 45 : 0,
|
||||
pitch: enableMapbox3d && fullScreen ? 45 : 0,
|
||||
duration: 0,
|
||||
})
|
||||
} catch { /* empty bounds */ }
|
||||
@@ -418,7 +446,7 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
|
||||
try { map.remove() } catch { /* noop */ }
|
||||
mapRef.current = null
|
||||
}
|
||||
}, [entries, stableTrail, mapboxStyle, mapboxToken, mapbox3d, mapboxQuality, fullScreen, paddingBottom])
|
||||
}, [entries, stableTrail, glProvider, glStyle, mapboxToken, enableMapbox3d, mapboxQuality, fullScreen, paddingBottom])
|
||||
|
||||
// external activeMarkerId → highlight + flyTo
|
||||
useEffect(() => {
|
||||
@@ -431,15 +459,15 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
|
||||
mapRef.current.flyTo({
|
||||
center: marker.getLngLat(),
|
||||
zoom: Math.max(mapRef.current.getZoom(), 12),
|
||||
pitch: mapbox3d && fullScreen ? 45 : 0,
|
||||
pitch: enableMapbox3d && fullScreen ? 45 : 0,
|
||||
duration: 500,
|
||||
})
|
||||
} catch { /* map not ready */ }
|
||||
}, 50)
|
||||
return () => clearTimeout(t)
|
||||
}, [activeMarkerId, highlightMarker, mapbox3d, fullScreen])
|
||||
}, [activeMarkerId, highlightMarker, enableMapbox3d, fullScreen])
|
||||
|
||||
if (!mapboxToken) {
|
||||
if (!isMapLibre && !mapboxToken) {
|
||||
return (
|
||||
<div
|
||||
style={{ position: 'relative', height: height === 9999 ? '100%' : height, width: '100%', borderRadius: 'inherit', overflow: 'hidden' }}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { ChevronLeft, ChevronRight, X } from 'lucide-react'
|
||||
import VideoPlayer from './VideoPlayer'
|
||||
|
||||
interface LightboxPhoto {
|
||||
id: string
|
||||
@@ -8,6 +9,7 @@ interface LightboxPhoto {
|
||||
provider?: string
|
||||
asset_id?: string | null
|
||||
owner_id?: number | null
|
||||
mediaType?: string | null
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -81,7 +83,7 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose }: Props
|
||||
>
|
||||
{/* Top bar */}
|
||||
<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: 13, fontWeight: 500 }}>
|
||||
<span style={{ color: 'rgba(255,255,255,0.5)', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500 }}>
|
||||
{idx + 1} / {photos.length}
|
||||
</span>
|
||||
<button onClick={onClose} style={{
|
||||
@@ -107,17 +109,21 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose }: Props
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Photo */}
|
||||
<img
|
||||
key={photo.id}
|
||||
src={photo.src}
|
||||
alt={photo.caption || ''}
|
||||
style={{
|
||||
maxWidth: '92vw', maxHeight: '92vh',
|
||||
objectFit: 'contain', borderRadius: 4,
|
||||
animation: 'fadeIn 0.15s ease',
|
||||
}}
|
||||
/>
|
||||
{/* Photo or video */}
|
||||
{photo.mediaType === 'video' ? (
|
||||
<VideoPlayer key={photo.id} src={photo.src} />
|
||||
) : (
|
||||
<img
|
||||
key={photo.id}
|
||||
src={photo.src}
|
||||
alt={photo.caption || ''}
|
||||
style={{
|
||||
maxWidth: '92vw', maxHeight: '92vh',
|
||||
objectFit: 'contain', borderRadius: 4,
|
||||
animation: 'fadeIn 0.15s ease',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Next button */}
|
||||
{hasNext && (
|
||||
@@ -137,7 +143,7 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose }: Props
|
||||
{photo.caption && (
|
||||
<div style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', zIndex: 5, maxWidth: '70%', textAlign: 'center' }}>
|
||||
<p style={{
|
||||
fontSize: 14, fontStyle: 'italic',
|
||||
fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontStyle: 'italic',
|
||||
color: 'rgba(255,255,255,0.75)', margin: 0, lineHeight: 1.5,
|
||||
background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(8px)',
|
||||
padding: '6px 14px', borderRadius: 10,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user