Compare commits

...

36 Commits

Author SHA1 Message Date
Maurice 4df9a20e55 feat(packing): three-tier sharing — personal, shared-with-people, common pool (#858)
Rework the private-packing flag into a full sharing model. Every item is now
Common (the group pool — where all existing items live, so nothing breaks),
Personal (private to its owner) or Shared with specific people (it shows up on
those travelers' own lists, marked "by <bringer>"). is_private discriminates
restricted from common; a new packing_item_recipients table holds who a shared
item covers, and packing_item_contributors records "I can bring that too"
pledges on Common items.

The panel gains a Gemeinsam / Meine Liste view switch, each item a sharing
control (owner sets the tier + the people it covers), and Common items can be
co-brought or cloned onto your personal list. Visibility is enforced server-side
in listItems and the WebSocket broadcasts are scoped to exactly who can see an
item across every tier transition. All 22 locales stay in parity.
2026-06-30 16:15:24 +02:00
Maurice e56930ddaf feat(trips): guest members for accountless participants (#1362, #1291)
Add "guest" trip participants — people without a Trek account who can still be
assigned to costs, packing, to-dos and day-plan activities. A guest is a
credential-less users row (is_guest=1) joined into trip_members, so it is
assignable everywhere a real member is, with the cost-splitting, settlement,
packing and assignment paths working unchanged.

Guests are firewalled from everything account-related: they can never sign in
(password, OIDC and reset lookups skip them), never appear in the global user
directory, the member-add picker or admin user management, are never resolved as
notification recipients, can't be invited to another trip, and can't be made
owner. The trip owner manages guests from the share dialog in a dedicated,
clearly-labelled section (add / rename / remove), and guests carry a "Guest"
badge wherever members are picked. All 22 locales stay in parity.
2026-06-30 14:56:57 +02:00
Maurice 3ecf7e5bef fix(packing): drop the always-true guard in the row drag handler (#969)
The onDragOver guard `drag.isDragging || true` is a constant condition (eslint
no-constant-condition). The handler is already gated by canDrag, so run the
drag-over logic directly, matching the to-do row.
2026-06-30 14:05:01 +02:00
Maurice c100cab90f i18n: translate the booking link field across all locales (#935)
Fan out reservations.urlLabel / reservations.urlPlaceholder to the remaining
locales so the dedicated booking URL field is localised everywhere.
2026-06-30 13:55:24 +02:00
Maurice 99e428a6c5 feat(trips): transfer trip ownership to a member (#973)
Add POST /api/trips/:id/transfer so the owner can hand a trip to one of its
existing members. The swap runs in a transaction: the new owner takes
trips.user_id and the former owner is kept on as a regular member, so nobody
loses access. The endpoint is owner-only, writes a trip.transfer_ownership
audit entry and broadcasts the refreshed trip. The members modal gains a
"Make owner" action, shown only to the current owner.
2026-06-30 13:55:17 +02:00
Maurice 3146e0f8b3 feat(lists): reorder packing/to-do lists and private packing items (#969, #858)
Add drag-to-reorder to the packing and to-do lists, mirroring the budget
panel's native HTML5 drag pattern. A drag within a filtered/grouped view is
mapped back onto the global order so untouched items keep their place, and the
order persists optimistically via the existing reorder endpoints.

Packing items can now be marked private (#858): a private item is visible only
to its owner. createItem/bulkImport stamp the owner, listItems filters by the
viewer, and the WebSocket broadcasts are scoped to the owner so a private item
never reaches another member's screen — including the public/private toggle
transitions. Owners get a lock toggle and a private indicator on their items.
2026-06-30 13:55:03 +02:00
Maurice 2a490cf532 feat(files): render uploaded Markdown files inline (#1345)
Markdown (.md/.markdown) is now an allowed upload type and opens in a rendered preview in the file manager instead of just downloading. Reuses the existing react-markdown stack with rehype-sanitize (these are untrusted uploads, so output is sanitized) and detects markdown by extension first since browsers send unreliable MIME for .md.
2026-06-30 12:54:18 +02:00
Maurice ef9e22b34d feat(bookings): add a dedicated URL field to reservations (#935)
Bookings get a first-class url column (migration) instead of users pasting links into notes. It's editable in the booking modal and rendered as a clickable link on the reservation card. The reservation request schemas are open passthroughs, so only the entity schema + service SQL enumerate it.
2026-06-30 12:49:34 +02:00
Maurice ad64df42ed test(video): cover the new upload-handler branches
Add controller tests for the gallery-video route (success / no-video / not-allowed / cleanup-on-reject), the per-asset media_types loops (gallery + entry, batch + single), and the file-manager per-type cap + unlink-on-rejection — restoring branch coverage on src/nest above the 80% gate.
2026-06-30 12:27:28 +02:00
Maurice 4af35b162e test(video): update gallery accept selector + complete fileService mocks
The gallery upload input now accepts image/*,video/* — update the two JourneyDetailPage selectors that matched the old value. The files/journey e2e suites mock fileService and were missing the new MAX_VIDEO_SIZE / isVideoExtension / isVideoMime exports, which broke module load.
2026-06-30 12:27:28 +02:00
Maurice 20c1858b23 fix(video): harden upload handling and fix video playback edge cases
Security: the gallery-video poster is now always stored as .jpg instead of the client-supplied extension, so a poster declared image/* but named x.html / x.js can't be written with that extension and served inline same-origin; local gallery files are also served with X-Content-Type-Options: nosniff.

Robustness: rejected/unauthorised uploads no longer orphan their bytes on disk (the gallery-video and file-manager handlers unlink before throwing); the file-manager per-type size cap is keyed on the extension like the filter, so a real video labelled application/octet-stream isn't wrongly rejected. UX: the file-manager thumbnail strip shows a play placeholder for video instead of a broken image; shared (public) journeys now return media_type and play videos with a play badge; and a poster-less video shows a neutral tile instead of a broken thumbnail.
2026-06-30 12:27:28 +02:00
Maurice e986c9ab27 feat(video): upload and play videos in the trip file manager
The file manager (which already attaches files to a place/activity) now accepts video uploads up to the larger video cap — other types stay at the document limit — and the lightbox plays them with the Plyr player over the plain same-origin download URL, so cookie auth and HTTP Range both work. Videos are excluded from the offline blob prefetch so one clip can't evict a trip's documents.
2026-06-30 12:27:28 +02:00
Maurice 61ffdb553e test(photos): assert the forwarded Range arg on the original stream
Follow-up to the Range-aware photo proxy.
2026-06-30 12:27:28 +02:00
Maurice 1abc9b2bc7 feat(video): link and stream Immich videos in the journey gallery
Immich timeline and album listings no longer filter out videos; each asset now carries its media type, which the provider picker forwards when linking. A linked video streams through Immich's transcoded /video/playback endpoint, and the asset proxy forwards the viewer's Range header (and passes 206/Content-Range back) so the player can seek. Synology video stays excluded until its stream API is verified.

Adds media_type/media_types to the provider-photos request contract.
2026-06-30 12:27:28 +02:00
Maurice 8713443665 feat(video): use Plyr for the gallery video player
Swaps the bare <video> element for a Plyr-wrapped player so playback controls match a consistent, cleaner skin. The instance is created per source and destroyed on unmount, so the lightbox stops playback when you navigate away.
2026-06-30 12:27:28 +02:00
Maurice c92c02e1b8 feat(video): play local gallery videos in the journey gallery
Picking a video in the journey gallery now captures a poster frame + duration in the browser and uploads the raw clip; the grid shows the poster with a play badge and the lightbox plays it with a native video player (HTTP Range seeking). Images keep their existing HEIC-normalised path. No server-side transcoding.

Server media_type work was committed separately.
2026-06-30 12:27:28 +02:00
Maurice 993d9bf713 feat(video): media_type discriminator + local gallery video upload (server)
trek_photos gains a media_type column (migration) so the registry can hold video as well as images. A new POST :id/gallery/video endpoint accepts a video plus a client-captured poster (500 MB cap, video MIME/extension allowlist), stores the poster as the thumbnail, and the photo stream serves the poster for the thumbnail kind and the raw file (HTTP Range) for the original — without running the image thumbnailer on video bytes.
2026-06-30 12:27:28 +02:00
Maurice c7e4b2781b docs(wiki): document force-offline, selective storage and conflicts 2026-06-30 10:04:15 +02:00
Maurice a88cd772cf i18n(offline): offline settings strings across all locales 2026-06-30 10:04:15 +02:00
Maurice 98d11d4267 feat(offline): Settings -> Offline controls and a status banner
The Offline tab gains a force-offline switch, a prepare-for-offline download with progress, per-trip and map-tile storage toggles, and a conflict resolver with a default strategy. The floating status pill now reflects forced-offline and unresolved conflicts.
2026-06-30 10:04:15 +02:00
Maurice 6707dac4a9 feat(offline): force-offline mode, selective sync and a conflict queue
A force-offline override routes every read to the cache and every write to the queue; preparing for offline downloads trip data, documents and map tiles up front and waits for them to finish. Map tiles and individual trips can be left out of the cache. Queued edits carry the version they were based on so the queue can surface server conflicts for a keep-mine / keep-theirs decision; chained offline edits to one entity no longer conflict with each other, and evicting a trip preserves its unsynced writes.
2026-06-30 10:04:15 +02:00
Maurice c552472b63 feat(offline): detect update conflicts on the server for places and packing
Update handlers accept an optional X-Base-Updated-At token and reject a stale overwrite with 409, returning the current server row. An absent token keeps the existing last-write-wins behaviour, so older clients are unaffected. packing_items gains an updated_at column (migration + stamped on every insert) so it can take part in conflict detection too.
2026-06-30 10:04:15 +02:00
Maurice 5fd66f4833 feat(map): include the day's route in the map fit (#1128)
Selecting a day already fits the map to that day's destinations; this also
folds the route polyline into the bounds. BoundsController fits the
destinations immediately, then re-fits once — when the day's route finishes
computing asynchronously — to destinations + the full route, so a route that
bulges past its stops (a detour or ferry) stays in view. One-shot per day
selection, so later route-profile toggles don't re-zoom.
2026-06-30 00:04:38 +02:00
Maurice 50609b078a feat(places): bulk "change category" from the selection toolbar
Closes the UI half of #1168: in the Places selection mode, a new tag button
before delete opens a category picker that applies one category (or "No
category") to every selected place in a single request. Adds a REST
/places/bulk-update endpoint reusing updatePlacesMany, an offline-aware repo +
store action that patches both the place pool and the day-assignment
projections, undo grouped by each place's prior category, and the i18n keys
across all locales.
2026-06-29 23:19:33 +02:00
Maurice 42b45dcd82 feat(dashboard): show the year on trip dates from other years
Trip dates only showed month + day, so trips from other years were ambiguous
(#1323). Dashboard cards and the boarding-pass hero now include the year, and
so does the shared formatDate (planner day headers etc.) — but only when it
isn't the current year, so this year's trips stay compact. Order and
punctuation follow the locale (EN "Sep 10, 2026", DE "10. Sep 2026").
2026-06-29 22:29:57 +02:00
Maurice 9dd9057b7b feat(mcp): add bulk_update_places tool
Apply the same field values to many places in one call instead of one
update_place per place — e.g. re-categorising 80 POIs at once. Adds the
updatePlacesMany service (one transaction, trip-scoped, partial patch
built on updatePlace) and the bulk_update_places MCP tool with the usual
demo/access/place_edit guards and a place:updated broadcast per place.
2026-06-29 22:29:57 +02:00
Maurice 23987c76bb harden calendar feeds: absolute URLs, real disable, folding, schema sync
- Resolve feed URLs against the request host when APP_URL is unset, so the
  webcal:// / Add-to-Google links work on a default install (not just behind a
  configured reverse proxy).
- Give the public link a real off switch: POST enables, PUT rotates, DELETE
  clears the token (feed_token = NULL). The subscribe dialog no longer mints a
  token just from being opened — the user opts in explicitly.
- Fold ICS content lines at 75 octets (UTF-8 safe) in exportICS, so download
  and feed both stay RFC 5545-compliant for long/non-ASCII summaries.
- Extract VEVENTs by structural line scan instead of a lazy END:VEVENT regex
  that user text could truncate.
- URL-encode the Google Calendar cid; mirror feed_token into schema.ts.
- Collapse the duplicated all-trips modal into the shared IcsSubscribeModal.
2026-06-29 21:53:06 +02:00
michael-bohr 7173e82fe8 feat(feeds): subscribable ICS calendar feeds for trips
Adds TripIt-style live calendar subscriptions alongside the existing one-time
.ics download. A trip (or all of a user's trips) exposes a secret, revocable
feed URL that Google/Apple/Outlook poll to stay in sync.

- Public read endpoints GET /api/feed/trip/:token.ics and /api/feed/user/:token.ics
  (no auth — the secret token is the credential), reusing the existing exportICS()
  generator and adding REFRESH-INTERVAL / X-PUBLISHED-TTL hints.
- JWT-guarded token endpoints to generate (lazy, idempotent) and regenerate/revoke
  per-trip and per-user feed tokens; tokens stored in nullable feed_token columns.
- All-trips feed excludes archived trips and trips ended >90 days ago.
- UI: ICS toolbar button becomes a Download/Subscribe menu; modal offers one-click
  "Add to Google Calendar" (render?cid=webcal://) and a webcal:// link for
  Apple/Outlook, plus copy-link fallbacks. All-trips feed reachable from dashboard.
- Feed base URL read from the existing APP_URL env var.

Purely additive: new endpoints + two nullable columns, no breaking changes.

Tests: server/tests/e2e/feeds.e2e.test.ts covers lazy token generate + idempotency,
regenerate-invalidates-old, 401/404 auth+access, public feed content-type + hint
injection, unknown-token 404, and the archived/>90-day all-trips exclusion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 21:53:06 +02:00
Maurice 72dfa2c60c docs(helm): clean up existingClaim notes
Strip stray zero-width characters from the persistence docs, move the PVC
note out of the ENCRYPTION_KEY usage block into its own Persistence section
in NOTES.txt, and document that persistence.enabled=false falls back to an
ephemeral emptyDir.
2026-06-29 21:00:25 +02:00
yael-tramier d19305bda4 fix(helm): emptyDir is used as a fallback when persistence is disabled. 2026-06-29 21:00:25 +02:00
yael-tramier 7aa2f6e4f2 feat(helm): Add existingClaim variable for custom PVC usage. 2026-06-29 21:00:25 +02:00
Maurice 3e64cb86a6 chore(i18n): sync Vietnamese with latest dev keys
Add the keys dev gained since this PR opened so the new vi locale keeps full
parity: the help namespace (wiki help center), settings appearance options,
costs split modes, dashboard Unsplash cover search, the insecure-cookie login
hint, nav.help and the admin group labels.
2026-06-29 20:48:51 +02:00
leeduc e4efcf0840 feat(i18n): add Vietnamese translations 2026-06-29 20:48:51 +02:00
Zorth Thorch e34f40b686 feat(costs): Splitwise-like cost splitting
Add per-payer and per-member custom split amounts with Equally, Custom and
Ticket split modes on top of the existing equal split, keep legacy "paid by"
expenses working, and document the modes in the Budget Tracking wiki page.
2026-06-29 20:34:24 +02:00
Maurice 3701ab6cad feat(auth): explain the plain-HTTP secure-cookie gotcha on login
When the server issues a Secure session cookie but the request arrived over
plain HTTP (the common LAN install over http://ip:3000), the browser drops
the cookie and the next request dead-ends on a bare "Access token required" —
the top source of avoidable install issues. The login response now flags this
exact case and the login page shows a localized box explaining the fix (use
HTTPS, or set COOKIE_SECURE=false) with a link to the Troubleshooting guide.
It only triggers in the real failure case, never for correct HTTPS setups.
2026-06-29 18:32:58 +02:00
Maurice e91f592f22 feat(help): embed the TREK wiki as an in-app help centre
Add a Help section (profile menu, /help) that renders the GitHub wiki inside
TREK. /api/help fetches the wiki markdown — the nav from _Sidebar.md, pages,
and proxied images — from GitHub and caches it (1h TTL, serves stale on
outage), so it auto-syncs on wiki edits with no redeploy and the client never
calls GitHub directly. The page is styled to match TREK with a section
sidebar, search and react-markdown; wiki [[links]] are rewritten to in-app
routes and HTML-comment placeholders are stripped. Page state lives in a
useHelp() hook per the page pattern. Adds nav.help and a help namespace
across all locales.
2026-06-29 18:32:58 +02:00
451 changed files with 13077 additions and 668 deletions
+3 -1
View File
@@ -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/
+2
View File
@@ -40,6 +40,8 @@ See `values.yaml` for more options.
## Notes
- Ingress is off by default. Enable and configure hosts for your domain.
- PVCs use the cluster's default StorageClass. Set `persistence.data.storageClassName` and/or `persistence.uploads.storageClassName` to bind a specific class.
- 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.
+6
View File
@@ -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.
+10 -2
View File
@@ -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 }}
+3 -1
View File
@@ -1,4 +1,4 @@
{{- if .Values.persistence.enabled }}
{{- if and .Values.persistence.enabled (not .Values.persistence.data.existingClaim) }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
@@ -18,7 +18,9 @@ spec:
resources:
requests:
storage: {{ .Values.persistence.data.size }}
{{- end }}
---
{{- if and .Values.persistence.enabled (not .Values.persistence.uploads.existingClaim) }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
+5
View File
@@ -101,15 +101,20 @@ 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:
+1
View File
@@ -38,6 +38,7 @@
"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",
+17
View File
@@ -13,6 +13,7 @@ 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'
@@ -221,6 +222,22 @@ export default function App() {
</ProtectedRoute>
}
/>
<Route
path="/help"
element={
<ProtectedRoute>
<HelpPage />
</ProtectedRoute>
}
/>
<Route
path="/help/:slug"
element={
<ProtectedRoute>
<HelpPage />
</ProtectedRoute>
}
/>
<Route
path="/trips/:id"
element={
+34 -4
View File
@@ -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,
@@ -339,6 +341,10 @@ export const tripsApi = {
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),
}
@@ -379,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 = {
@@ -400,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),
@@ -579,9 +591,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),
@@ -703,6 +722,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) => {
@@ -7,6 +7,7 @@ export interface TripMember {
id: number
username: string
avatar_url?: string | null
is_guest?: boolean
}
// ── Chip with custom tooltip ─────────────────────────────────────────────────
@@ -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: [] })),
@@ -108,18 +108,22 @@ 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.getAllByPlaceholderText('0.00') 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'))
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 }),
]))
@@ -194,4 +198,60 @@ describe('CostsPanel — settlements in the ledger', () => {
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:')
})
})
+391 -98
View File
@@ -18,6 +18,69 @@ import { SYMBOLS, CURRENCIES, SPLIT_COLORS } from './BudgetPanel.constants'
import { COST_CATEGORY_LIST, catMeta } from './costsCategories'
import type { BudgetItem } from '../../types'
import type { TripMember } from './BudgetPanelMemberChips'
import GuestBadge from '../shared/GuestBadge'
export function splitEqualShares(total: number, members: { user_id: number }[], itemId: number): Record<number, number> {
const n = members.length
if (n === 0) return {}
const totalCents = Math.round(total * 100)
const baseCents = Math.floor(totalCents / n)
const remainder = totalCents % n
const shares: Record<number, number> = {}
const sortedMembers = [...members].sort((a, b) => a.user_id - b.user_id)
const startIndex = itemId % n
for (let i = 0; i < n; i++) {
const member = sortedMembers[i]
const hasExtraCent = ((i - startIndex + n) % n) < remainder
shares[member.user_id] = (baseCents + (hasExtraCent ? 1 : 0)) / 100
}
return shares
}
export interface TicketItem {
id: string
name: string
price: string
participants: Set<number>
}
export function calculateTicketShares(items: TicketItem[]): { shares: Record<number, number>; total: number } {
const shares: Record<number, number> = {}
let totalCents = 0
for (const item of items) {
const priceNum = parseFloat(item.price) || 0
const priceCents = Math.round(priceNum * 100)
totalCents += priceCents
const partIds = [...item.participants]
const n = partIds.length
if (n === 0) continue
const baseCents = Math.floor(priceCents / n)
const remainder = priceCents % n
const sortedPartIds = [...partIds].sort((a, b) => a - b)
for (let i = 0; i < n; i++) {
const id = sortedPartIds[i]
const hasExtraCent = i < remainder
const shareCents = baseCents + (hasExtraCent ? 1 : 0)
shares[id] = (shares[id] || 0) + shareCents
}
}
const finalShares: Record<number, number> = {}
for (const id of Object.keys(shares)) {
finalShares[Number(id)] = shares[Number(id)] / 100
}
return { shares: finalShares, total: totalCents / 100 }
}
interface CostsPanelProps {
tripId: number
@@ -105,9 +168,14 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
const baseTotal = (e: BudgetItem) => convert(e.total_price || 0, curOf(e))
const myPaidOf = (e: BudgetItem) => (e.payers || []).filter(p => p.user_id === me).reduce((a, p) => a + convert(p.amount, curOf(e)), 0)
const myShareOf = (e: BudgetItem) => {
const n = (e.members || []).length
if (!n || !(e.members || []).some(m => m.user_id === me)) return 0
return baseTotal(e) / n
const myMember = (e.members || []).find(m => m.user_id === me)
if (!myMember) return 0
if (myMember.amount !== null && myMember.amount !== undefined) {
return convert(myMember.amount, curOf(e))
}
const shares = splitEqualShares(e.total_price || 0, e.members || [], e.id)
const myShare = shares[me] || 0
return convert(myShare, curOf(e))
}
const totals = useMemo(() => {
@@ -790,11 +858,6 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
const [cat, setCat] = useState<string>(editing ? catMeta(editing.category).key : (prefill?.category || 'food'))
const [currency, setCurrency] = useState((editing?.currency || base).toUpperCase())
const [day, setDay] = useState(editing?.expense_date || new Date().toISOString().slice(0, 10))
// One participant list: a person is "in" the split and may have paid an amount.
// Entering the total auto-distributes it equally across the non-pinned participants;
// touching an amount pins it and the rest rebalance so the paid amounts always sum
// back to the total. Leaving every amount blank = an unfinished expense (counts
// toward the trip total only, never settlements, until who-paid is filled in).
const [total, setTotal] = useState<string>(() => {
if (editing) return editing.total_price ? String(editing.total_price) : ''
if (prefill?.amount != null) return String(prefill.amount)
@@ -802,89 +865,192 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
})
const [participants, setParticipants] = useState<Set<number>>(() =>
editing ? new Set((editing.members || []).map(m => m.user_id)) : new Set(people.map(p => p.id)))
const [paid, setPaid] = useState<Record<number, string>>(() => {
// Payer state: 0 represents "Nobody (planning entry)"
const [payerId, setPayerId] = useState<number>(() => {
const existingPayer = (editing?.payers || []).find(p => p.amount > 0)
return existingPayer ? existingPayer.user_id : me
})
const [splitMode, setSplitMode] = useState<'equally' | 'custom' | 'ticket'>(() => {
if (editing?.note && editing.note.startsWith('TICKETJSON:')) {
return 'ticket'
}
if (editing && editing.members && editing.members.length > 0) {
const hasCustom = editing.members.some(m => m.amount !== null && m.amount !== undefined)
return hasCustom ? 'custom' : 'equally'
}
return 'equally'
})
const [ticketItems, setTicketItems] = useState<TicketItem[]>(() => {
if (editing?.note && editing.note.startsWith('TICKETJSON:')) {
try {
const parsed = JSON.parse(editing.note.slice(11))
return (parsed.items || []).map((item: any) => ({
id: String(Math.random()),
name: item.name,
price: String(item.price),
participants: new Set(item.parts || [])
}))
} catch {
return []
}
}
return []
})
const [customAmounts, setCustomAmounts] = useState<Record<number, string>>(() => {
const m: Record<number, string> = {}
for (const p of editing?.payers || []) if (p.amount > 0) m[p.user_id] = String(p.amount)
if (editing && editing.members) {
for (const member of editing.members) {
if (member.amount !== null && member.amount !== undefined) {
m[member.user_id] = String(member.amount)
}
}
}
return m
})
// Amounts the user pinned by typing — kept out of the auto-rebalance. Existing
// payer amounts load as pinned so opening an expense never reshuffles them.
const [dirty, setDirty] = useState<Set<number>>(() =>
new Set((editing?.payers || []).filter(p => p.amount > 0).map(p => p.user_id)))
const [saving, setSaving] = useState(false)
const totalNum = parseFloat(total) || 0
const paidSum = round2([...participants].reduce((a, id) => a + (parseFloat(paid[id]) || 0), 0))
const paidEntered = paidSum > 0
const balanced = Math.abs(paidSum - totalNum) < 0.01
const each = participants.size > 0 ? totalNum / participants.size : 0
// No participants = a recorded total with nobody to split with (e.g. a booking
// paid on-site later). It saves as an "unfinished" expense (#1286); selecting
// people only adds the who-owes-whom split on top.
const valid = name.trim().length > 0 && totalNum > 0 && (!paidEntered || balanced)
const isTicketMode = splitMode === 'ticket'
// Spread `amount` across `n` people in whole cents so the parts sum back exactly.
const splitCents = (amount: number, n: number): number[] => {
if (n <= 0) return []
const cents = Math.max(0, Math.round(amount * 100))
const base = Math.floor(cents / n), rem = cents - base * n
return Array.from({ length: n }, (_, i) => (base + (i < rem ? 1 : 0)) / 100)
}
// Recompute the non-pinned participants so every paid amount sums to the total.
const rebalance = (paidMap: Record<number, string>, dirtySet: Set<number>, parts: Set<number>, totalVal: number): Record<number, string> => {
const ids = [...parts]
const free = ids.filter(id => !dirtySet.has(id))
if (free.length === 0) return paidMap
const pinnedSum = ids.filter(id => dirtySet.has(id)).reduce((a, id) => a + (parseFloat(paidMap[id]) || 0), 0)
const shares = splitCents(totalVal - pinnedSum, free.length)
const next = { ...paidMap }
free.forEach((id, i) => { next[id] = shares[i] ? String(shares[i]) : '' })
return next
}
const ticketInfo = useMemo(() => {
return calculateTicketShares(ticketItems)
}, [ticketItems])
const totalNum = isTicketMode ? ticketInfo.total : (parseFloat(total) || 0)
const splitSum = [...participants].reduce((sum, id) => sum + (parseFloat(customAmounts[id]) || 0), 0)
const customBalanced = Math.round(splitSum * 100) === Math.round(totalNum * 100)
const each = participants.size > 0 ? totalNum / participants.size : 0
const equalShares = useMemo(() => {
return splitEqualShares(totalNum, [...participants].map(id => ({ user_id: id })), editing?.id || 0)
}, [totalNum, participants, editing])
const placeholderShares = useMemo(() => {
const emptyParts = [...participants].filter(id => !customAmounts[id])
if (emptyParts.length === 0) return {}
const enteredSum = [...participants]
.filter(id => customAmounts[id])
.reduce((sum, id) => sum + (parseFloat(customAmounts[id]) || 0), 0)
const remaining = Math.max(0, totalNum - enteredSum)
return splitEqualShares(remaining, emptyParts.map(id => ({ user_id: id })), editing?.id || 0)
}, [totalNum, participants, customAmounts, editing])
const ticketValid = ticketItems.length > 0 && ticketItems.every(item => item.name.trim().length > 0 && (parseFloat(item.price) || 0) > 0 && item.participants.size > 0)
const valid = name.trim().length > 0 && (
isTicketMode
? ticketValid
: totalNum > 0 && (participants.size === 0 || splitMode === 'equally' || customBalanced)
)
const onTotalChange = (v: string) => {
v = v.replace(',', '.')
setTotal(v)
setPaid(prev => rebalance(prev, dirty, participants, parseFloat(v) || 0))
setTotal(v.replace(',', '.'))
}
const onPaidChange = (id: number, v: string) => {
v = v.replace(',', '.')
const nextDirty = new Set(dirty); nextDirty.add(id)
setDirty(nextDirty)
setPaid(prev => rebalance({ ...prev, [id]: v }, nextDirty, participants, totalNum))
const handleCustomAmountChange = (id: number, val: string) => {
val = val.replace(',', '.')
if (/^\d*\.?\d{0,2}$/.test(val) || val === '') {
setCustomAmounts(prev => ({ ...prev, [id]: val }))
}
}
const handleAddEmptyItem = () => {
setTicketItems(prev => [
...prev,
{
id: String(Date.now() + Math.random()),
name: '',
price: '',
participants: new Set(people.map(p => p.id))
}
])
}
const handleUpdateItemName = (id: string, name: string) => {
setTicketItems(prev => prev.map(item => item.id === id ? { ...item, name } : item))
}
const handleUpdateItemPrice = (id: string, price: string) => {
price = price.replace(',', '.')
if (/^\d*\.?\d{0,2}$/.test(price) || price === '') {
setTicketItems(prev => prev.map(item => item.id === id ? { ...item, price } : item))
}
}
const handleRemoveItem = (id: string) => {
setTicketItems(prev => prev.filter(item => item.id !== id))
}
const handleToggleItemParticipant = (itemId: string, userId: number) => {
setTicketItems(prev => prev.map(item => {
if (item.id === itemId) {
const nextParts = new Set(item.participants)
if (nextParts.has(userId)) nextParts.delete(userId)
else nextParts.add(userId)
return { ...item, participants: nextParts }
}
return item
}))
}
const toggleParticipant = (id: number) => {
const nextParts = new Set(participants), nextDirty = new Set(dirty), nextPaid = { ...paid }
if (nextParts.has(id)) { nextParts.delete(id); nextDirty.delete(id); delete nextPaid[id] }
else nextParts.add(id)
setParticipants(nextParts); setDirty(nextDirty)
setPaid(rebalance(nextPaid, nextDirty, nextParts, totalNum))
const nextParts = new Set(participants)
if (nextParts.has(id)) {
nextParts.delete(id)
setCustomAmounts(prev => {
const copy = { ...prev }
delete copy[id]
return copy
})
} else {
nextParts.add(id)
}
setParticipants(nextParts)
}
const save = async () => {
if (!valid) return
setSaving(true)
const payerList = [...participants]
.map(id => ({ user_id: id, amount: parseFloat(paid[id]) || 0 }))
.filter(p => p.amount > 0)
const payerList = (payerId > 0 && participants.size > 0) ? [{ user_id: payerId, amount: totalNum }] : []
const memberList = [...participants].map(id => ({
user_id: id,
amount: splitMode === 'custom'
? (parseFloat(customAmounts[id]) || 0)
: splitMode === 'ticket'
? (ticketInfo.shares[id] || 0)
: null
}))
const data = {
name: name.trim(), category: cat,
// Store the actual currency the amounts were entered in; conversion to the
// viewer's display currency happens live (real rates), no manual rate.
name: name.trim(),
category: cat,
currency,
payers: payerList, member_ids: [...participants],
payers: payerList,
members: memberList,
member_ids: [...participants],
expense_date: day || null,
// Always record the entered total: the server keeps it as-is for an unfinished
// expense (no payers) and otherwise re-derives it from the payer sum (== total).
total_price: totalNum,
// Link a freshly-created expense to its booking (create-from-booking flow).
note: splitMode === 'ticket' ? 'TICKETJSON:' + JSON.stringify({
items: ticketItems.map(item => ({
name: item.name,
price: item.price,
parts: [...item.participants]
}))
}) : null,
...(!editing && prefill?.reservationId ? { reservation_id: prefill.reservationId } : {}),
}
try {
if (editing) await updateBudgetItem(tripId, editing.id, data)
else await addBudgetItem(tripId, data)
onSaved()
} catch { toast.error(t('common.unknownError')) } finally { setSaving(false) }
} catch {
toast.error(t('common.unknownError'))
} finally {
setSaving(false)
}
}
const inputCls = 'w-full bg-surface-input border border-edge text-content'
@@ -906,10 +1072,11 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
<div>
<label className={labelCls}>{t('costs.totalAmount')}</label>
<div className="bg-surface-input border border-edge" style={{ height: FIELD_H, boxSizing: 'border-box', display: 'flex', alignItems: 'center', borderRadius: 10, padding: '0 12px' }}>
<div className="bg-surface-input border border-edge" style={{ height: FIELD_H, boxSizing: 'border-box', display: 'flex', alignItems: 'center', borderRadius: 10, padding: '0 12px', opacity: isTicketMode ? 0.6 : 1 }}>
<span className="text-content-faint" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))' }}>{sym(currency)}</span>
<input type="text" inputMode="decimal" placeholder="0.00" value={total}
<input type="text" inputMode="decimal" placeholder="0.00" value={isTicketMode ? ticketInfo.total.toFixed(2) : total}
onChange={e => onTotalChange(e.target.value)}
disabled={isTicketMode}
className="text-content" style={{ flex: 1, border: 0, background: 'none', outline: 'none', fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 600, paddingLeft: 6, width: '100%' }} />
</div>
</div>
@@ -954,39 +1121,165 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
<div>
<label className={labelCls}>{t('costs.whoPaid')}</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
{people.map((p, idx) => {
const on = participants.has(p.id)
return (
<div key={p.id} className="bg-surface-secondary border border-edge" style={{ display: 'grid', gridTemplateColumns: '1fr 130px', gap: 10, alignItems: 'center', padding: '8px 11px', borderRadius: 10, opacity: on ? 1 : 0.5 }}>
<button onClick={() => toggleParticipant(p.id)} style={{ display: 'inline-flex', alignItems: 'center', gap: 8, background: 'none', border: 0, cursor: 'pointer', fontFamily: 'inherit', padding: 0, minWidth: 0, textAlign: 'left' }}>
{p.avatar_url
? <img src={p.avatar_url} alt="" style={{ width: 22, height: 22, borderRadius: '50%', objectFit: 'cover', display: 'block', flexShrink: 0, opacity: on ? 1 : 0.45 }} />
: <span style={{ width: 22, height: 22, borderRadius: '50%', background: SPLIT_COLORS[idx % SPLIT_COLORS.length].gradient, color: '#fff', display: 'grid', placeItems: 'center', fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 700, flexShrink: 0, opacity: on ? 1 : 0.45 }}>{(p.id === me ? t('costs.youShort') : p.username.charAt(0)).toUpperCase()}</span>}
<span className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.id === me ? t('costs.you') : p.username}</span>
</button>
{on ? (
<div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 4, borderRadius: 8, padding: '0 10px' }}>
<span className="text-content-faint" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>{sym(currency)}</span>
<input type="text" inputMode="decimal" placeholder="0.00" value={paid[p.id] || ''}
onChange={e => onPaidChange(p.id, e.target.value)}
className="text-content" style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, padding: '8px 0', textAlign: 'right' }} />
<CustomSelect value={String(payerId)} onChange={v => setPayerId(Number(v))}
options={[
{ value: '0', label: t('costs.noOnePaid') || 'Nobody (planning entry)' },
...people.map(p => ({ value: String(p.id), label: p.id === me ? t('costs.you') : p.username }))
]}
style={{ width: '100%' }} />
</div>
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<label className={labelCls}>{t('costs.split') || 'Split'}</label>
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 8, padding: 2 }}>
<button type="button" onClick={() => setSplitMode('equally')}
className={splitMode === 'equally' ? 'bg-surface-card text-content' : 'text-content-muted'}
style={{ padding: '4px 10px', fontSize: 11.5, borderRadius: 6, fontWeight: 600, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>
{t('costs.splitEqually') || 'Equally'}
</button>
<button type="button" onClick={() => setSplitMode('custom')}
className={splitMode === 'custom' ? 'bg-surface-card text-content' : 'text-content-muted'}
style={{ padding: '4px 10px', fontSize: 11.5, borderRadius: 6, fontWeight: 600, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>
{t('costs.splitCustom') || 'Custom'}
</button>
<button type="button" onClick={() => setSplitMode('ticket')}
className={splitMode === 'ticket' ? 'bg-surface-card text-content' : 'text-content-muted'}
style={{ padding: '4px 10px', fontSize: 11.5, borderRadius: 6, fontWeight: 600, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>
{t('costs.splitTicket') || 'Ticket'}
</button>
</div>
</div>
{splitMode === 'ticket' ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{ticketItems.map((item, itemIdx) => (
<div key={item.id} className="bg-surface-secondary border border-edge" style={{ padding: 10, borderRadius: 10, display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input
type="text"
placeholder="Item name"
value={item.name}
onChange={e => handleUpdateItemName(item.id, e.target.value)}
className="bg-surface-input border border-edge text-content"
style={{ flex: 2, padding: '6px 10px', borderRadius: 8, fontSize: 13, border: '1px solid var(--border-color)', outline: 'none' }}
/>
<div className="bg-surface-input border border-edge" style={{ flex: 1, display: 'flex', alignItems: 'center', padding: '0 8px', borderRadius: 8 }}>
<span className="text-content-faint" style={{ fontSize: 12 }}>{sym(currency)}</span>
<input
type="text"
inputMode="decimal"
placeholder="0.00"
value={item.price}
onChange={e => handleUpdateItemPrice(item.id, e.target.value)}
className="text-content"
style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 13, fontWeight: 600, textAlign: 'right', padding: '6px 0' }}
/>
</div>
<button type="button" onClick={() => handleRemoveItem(item.id)} className="text-content-muted" style={{ background: 'none', border: 0, cursor: 'pointer', padding: 4 }}>
<Trash2 size={15} />
</button>
</div>
) : (
<button onClick={() => toggleParticipant(p.id)} className="text-content-faint" style={{ background: 'none', border: 0, cursor: 'pointer', fontFamily: 'inherit', fontSize: 'calc(12px * var(--fs-scale-body, 1))', textAlign: 'right' }}>{t('costs.tapToInclude')}</button>
)}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, alignItems: 'center' }}>
<span className="text-content-faint" style={{ fontSize: 10.5, fontWeight: 600, textTransform: 'uppercase', marginRight: 4 }}>Splitting:</span>
{people.map((p, pIdx) => {
const active = item.participants.has(p.id)
return (
<button
type="button"
key={p.id}
onClick={() => handleToggleItemParticipant(item.id, p.id)}
className={active ? 'bg-surface-card text-content border' : 'bg-surface-secondary text-content-muted border border-edge'}
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '3px 8px', borderRadius: 999, fontSize: 11, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', border: active ? '1px solid var(--text-primary)' : undefined }}
>
{p.avatar_url
? <img src={p.avatar_url} alt="" style={{ width: 14, height: 14, borderRadius: '50%', objectFit: 'cover' }} />
: <span style={{ width: 14, height: 14, borderRadius: '50%', background: SPLIT_COLORS[pIdx % SPLIT_COLORS.length].gradient, color: '#fff', display: 'grid', placeItems: 'center', fontSize: 7, fontWeight: 700 }}>{(p.id === me ? t('costs.youShort') : p.username.charAt(0)).toUpperCase()}</span>}
<span>{p.id === me ? t('costs.you') : p.username}</span>
</button>
)
})}
</div>
</div>
))}
</div>
<button type="button" onClick={handleAddEmptyItem} className="border border-dashed border-edge text-content-muted" style={{ padding: '8px 12px', borderRadius: 10, background: 'none', fontSize: 13, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}>
<Plus size={14} /> Add item
</button>
{ticketItems.length > 0 && (
<div className="bg-surface-secondary border border-edge" style={{ padding: 12, borderRadius: 10 }}>
<div className="text-content" style={{ fontSize: 12, fontWeight: 600, marginBottom: 8 }}>Individual Shares Summary</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{people.map(p => {
const share = ticketInfo.shares[p.id] || 0
return (
<div key={p.id} style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13 }}>
<span className="text-content-muted">{p.id === me ? t('costs.you') : p.username}</span>
<span className="text-content" style={{ fontWeight: 600 }}>{sym(currency)}{share.toFixed(2)}</span>
</div>
)
})}
</div>
</div>
)
})}
</div>
<div style={{ marginTop: 10, fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', display: 'flex', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap' }}>
<span className="text-content-faint">
{participants.size > 0 && t('costs.splitSummary', { count: participants.size, amount: sym(currency) + each.toFixed(2) })}
</span>
{paidEntered
? <span style={{ fontWeight: 600, color: balanced ? '#16a34a' : '#dc2626' }}>{sym(currency)}{paidSum.toFixed(2)} / {sym(currency)}{totalNum.toFixed(2)}</span>
: (totalNum > 0 && <span style={{ color: '#d97706', fontWeight: 600 }}>{t('costs.unfinishedHint')}</span>)}
</div>
)}
</div>
) : (
<>
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
{people.map((p, idx) => {
const on = participants.has(p.id)
return (
<div key={p.id} className="bg-surface-secondary border border-edge" style={{ display: 'grid', gridTemplateColumns: '1fr 130px', gap: 10, alignItems: 'center', padding: '8px 11px', borderRadius: 10, opacity: on ? 1 : 0.5 }}>
<button type="button" onClick={() => toggleParticipant(p.id)} style={{ display: 'inline-flex', alignItems: 'center', gap: 8, background: 'none', border: 0, cursor: 'pointer', fontFamily: 'inherit', padding: 0, minWidth: 0, textAlign: 'left' }}>
{p.avatar_url
? <img src={p.avatar_url} alt="" style={{ width: 22, height: 22, borderRadius: '50%', objectFit: 'cover', display: 'block', flexShrink: 0, opacity: on ? 1 : 0.45 }} />
: <span style={{ width: 22, height: 22, borderRadius: '50%', background: SPLIT_COLORS[idx % SPLIT_COLORS.length].gradient, color: '#fff', display: 'grid', placeItems: 'center', fontSize: 9, fontWeight: 700, flexShrink: 0, opacity: on ? 1 : 0.45 }}>{(p.id === me ? t('costs.youShort') : p.username.charAt(0)).toUpperCase()}</span>}
<span className="text-content" style={{ fontSize: 14, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.id === me ? t('costs.you') : p.username}</span>
{p.is_guest && <GuestBadge size="xs" />}
</button>
{splitMode === 'equally' ? (
on ? (
<span className="text-content" style={{ fontSize: 14, fontWeight: 600, textAlign: 'right', paddingRight: 10 }}>
{sym(currency)}{(equalShares[p.id] || 0).toFixed(2)}
</span>
) : (
<span className="text-content-faint" style={{ fontSize: 12, textAlign: 'right', paddingRight: 10 }}>Excluded</span>
)
) : (
on ? (
<div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 4, borderRadius: 8, padding: '0 10px' }}>
<span className="text-content-faint" style={{ fontSize: 13 }}>{sym(currency)}</span>
<input type="text" inputMode="decimal" placeholder={(placeholderShares[p.id] || 0).toFixed(2)} value={customAmounts[p.id] || ''}
onChange={e => handleCustomAmountChange(p.id, e.target.value)}
className="text-content" style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 14, fontWeight: 600, padding: '8px 0', textAlign: 'right' }} />
</div>
) : (
<button type="button" onClick={() => toggleParticipant(p.id)} className="text-content-faint" style={{ background: 'none', border: 0, cursor: 'pointer', fontFamily: 'inherit', fontSize: 12, textAlign: 'right' }}>{t('costs.tapToInclude')}</button>
)
)}
</div>
)
})}
</div>
<div style={{ marginTop: 10, fontSize: 12.5, display: 'flex', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap' }}>
{splitMode === 'equally' ? (
<span className="text-content-faint">
{participants.size > 0 && t('costs.splitSummary', { count: participants.size, amount: sym(currency) + each.toFixed(2) })}
</span>
) : (
<span style={{ fontWeight: 600, color: customBalanced ? '#16a34a' : '#dc2626' }}>
{customBalanced
? 'Split matches total'
: `Sum of splits: ${sym(currency)}${splitSum.toFixed(2)} of ${sym(currency)}${totalNum.toFixed(2)} (${(totalNum - splitSum) > 0 ? 'under by' : 'over by'} ${sym(currency)}${Math.abs(totalNum - splitSum).toFixed(2)})`}
</span>
)}
</div>
</>
)}
</div>
</div>
</Modal>
@@ -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} />);
+8 -4
View File
@@ -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} />
@@ -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))
@@ -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
)
}
@@ -7,7 +7,7 @@ import type { Place, Reservation, TripFile, Day, AssignmentsMap } from '../../ty
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import { getAuthUrl } from '../../api/authUrl'
import { isImage } from './FileManager.helpers'
import { isImage, isMedia } from './FileManager.helpers'
export interface FileManagerProps {
files?: TripFile[]
@@ -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,
}
}
@@ -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}
+17 -11
View File
@@ -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 {
@@ -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 && (
@@ -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>
)
}
+9 -1
View File
@@ -5,7 +5,7 @@ import { useAuthStore } from '../../store/authStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useAddonStore } from '../../store/addonStore'
import { useTranslation } from '../../i18n'
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass } from 'lucide-react'
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass, BookOpen } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import InAppNotificationBell from './InAppNotificationBell.tsx'
@@ -252,6 +252,14 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
{t('nav.settings')}
</Link>
<Link to="/help" onClick={() => setUserMenuOpen(false)}
className="flex items-center gap-2 px-4 py-2 text-sm transition-colors text-content-secondary"
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
<BookOpen className="w-4 h-4" />
{t('nav.help')}
</Link>
{user.role === 'admin' && (
<Link to="/admin" onClick={() => setUserMenuOpen(false)}
className="flex items-center gap-2 px-4 py-2 text-sm transition-colors text-content-secondary"
@@ -7,16 +7,20 @@ vi.mock('../../sync/mutationQueue', () => ({
mutationQueue: {
pendingCount: vi.fn(),
failedCount: vi.fn(),
conflictCount: vi.fn(),
},
}))
import { mutationQueue } from '../../sync/mutationQueue'
import { _resetNetworkMode } from '../../sync/networkMode'
const pendingCount = mutationQueue.pendingCount as ReturnType<typeof vi.fn>
const failedCount = mutationQueue.failedCount as ReturnType<typeof vi.fn>
const conflictCount = mutationQueue.conflictCount as ReturnType<typeof vi.fn>
afterEach(() => {
vi.clearAllMocks()
_resetNetworkMode()
Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true })
})
@@ -24,15 +28,27 @@ describe('OfflineBanner (B3 surface)', () => {
it('shows the failed pill when failedCount > 0 while online', async () => {
pendingCount.mockResolvedValue(0)
failedCount.mockResolvedValue(2)
conflictCount.mockResolvedValue(0)
render(<OfflineBanner />)
expect(await screen.findByText(/2 changes failed to sync/i)).toBeInTheDocument()
expect(await screen.findByText(/failed to sync: 2/i)).toBeInTheDocument()
})
it('stays hidden when online with nothing pending or failed', async () => {
it('shows the conflict pill when conflicts exist while online', async () => {
pendingCount.mockResolvedValue(0)
failedCount.mockResolvedValue(0)
conflictCount.mockResolvedValue(3)
render(<OfflineBanner />)
expect(await screen.findByText(/conflicts: 3/i)).toBeInTheDocument()
})
it('stays hidden when online with nothing pending, failed or conflicting', async () => {
pendingCount.mockResolvedValue(0)
failedCount.mockResolvedValue(0)
conflictCount.mockResolvedValue(0)
const { container } = render(<OfflineBanner />)
// Give the async poll a tick to resolve.
+40 -38
View File
@@ -1,49 +1,44 @@
/**
* OfflineBanner — connectivity + sync state indicator.
*
* States:
* N failed → red pill "N changes failed to sync" (takes priority)
* offline + N queued → amber pill "Offline · N queued"
* offline + 0 queued → amber pill "Offline"
* online + N pending → blue pill "Syncing N…"
* online + 0 pending → hidden
* Priority (highest first):
* N failed → red pill "Failed to sync: N" (changes were dropped)
* N conflicts → purple pill "Conflicts: N" (need resolving)
* offline → amber pill "Offline" / "Offline mode" / "Offline · N queued"
* online + N → blue pill "Syncing N…"
* online + 0 → hidden
*
* Rendered as a small floating pill anchored to the bottom-center of the
* viewport so it never competes with top navigation or sticky modal
* headers. On mobile it hovers just above the bottom tab bar.
*/
import React, { useState, useEffect } from 'react'
import { WifiOff, RefreshCw, AlertTriangle } from 'lucide-react'
import { WifiOff, RefreshCw, AlertTriangle, GitMerge } from 'lucide-react'
import { mutationQueue } from '../../sync/mutationQueue'
import { useNetworkMode } from '../../hooks/useNetworkMode'
import { useTranslation } from '../../i18n'
const POLL_MS = 3_000
export default function OfflineBanner(): React.ReactElement | null {
const [isOnline, setIsOnline] = useState(navigator.onLine)
const { t } = useTranslation()
const { offline, forced } = useNetworkMode()
const [pendingCount, setPendingCount] = useState(0)
const [failedCount, setFailedCount] = useState(0)
useEffect(() => {
const onOnline = () => setIsOnline(true)
const onOffline = () => setIsOnline(false)
window.addEventListener('online', onOnline)
window.addEventListener('offline', onOffline)
return () => {
window.removeEventListener('online', onOnline)
window.removeEventListener('offline', onOffline)
}
}, [])
const [conflictCount, setConflictCount] = useState(0)
useEffect(() => {
let cancelled = false
async function poll() {
const [n, failed] = await Promise.all([
const [n, failed, conflicts] = await Promise.all([
mutationQueue.pendingCount(),
mutationQueue.failedCount(),
mutationQueue.conflictCount(),
])
if (!cancelled) {
setPendingCount(n)
setFailedCount(failed)
setConflictCount(conflicts)
}
}
poll()
@@ -51,22 +46,34 @@ export default function OfflineBanner(): React.ReactElement | null {
return () => { cancelled = true; clearInterval(id) }
}, [])
const hidden = isOnline && pendingCount === 0 && failedCount === 0
const hidden = !offline && pendingCount === 0 && failedCount === 0 && conflictCount === 0
if (hidden) return null
const offline = !isOnline
// Failed mutations are the most important signal — they mean data was dropped.
// Conflicts come next (they still need a decision), then plain offline status.
const failed = failedCount > 0
const bg = failed ? '#b91c1c' : offline ? '#92400e' : '#1e40af'
const text = '#fff'
const conflict = !failed && conflictCount > 0
const bg = failed ? '#b91c1c' : conflict ? '#6d28d9' : offline ? '#92400e' : '#1e40af'
const label = failed
? `${failedCount} change${failedCount !== 1 ? 's' : ''} failed to sync`
: offline
? pendingCount > 0
? `Offline · ${pendingCount} queued`
: 'Offline'
: `Syncing ${pendingCount}`
let label: string
let icon: React.ReactElement
if (failed) {
label = t('settings.offline.banner.failed', { count: failedCount })
icon = <AlertTriangle size={12} />
} else if (conflict) {
label = t('settings.offline.banner.conflicts', { count: conflictCount })
icon = <GitMerge size={12} />
} else if (offline) {
label = pendingCount > 0
? t('settings.offline.banner.queued', { count: pendingCount })
: forced
? t('settings.offline.banner.forced')
: t('settings.offline.banner.offline')
icon = <WifiOff size={12} />
} else {
label = t('settings.offline.banner.syncing', { count: pendingCount })
icon = <RefreshCw size={12} style={{ animation: 'spin 1s linear infinite' }} />
}
return (
<div
@@ -81,7 +88,7 @@ export default function OfflineBanner(): React.ReactElement | null {
transform: 'translateX(-50%)',
zIndex: 9999,
background: bg,
color: text,
color: '#fff',
display: 'inline-flex',
alignItems: 'center',
gap: 6,
@@ -94,12 +101,7 @@ export default function OfflineBanner(): React.ReactElement | null {
pointerEvents: 'none',
}}
>
{failed
? <AlertTriangle size={12} />
: offline
? <WifiOff size={12} />
: <RefreshCw size={12} style={{ animation: 'spin 1s linear infinite' }} />
}
{icon}
{label}
</div>
)
@@ -244,4 +244,22 @@ describe('MapView', () => {
rerender(<MapView places={places} fitKey={2} />)
expect(mapMock.fitBounds.mock.calls.length).toBeGreaterThan(afterFirst)
})
it('FE-COMP-MAPVIEW-020: a day fit expands to include the route once it arrives (#1128)', async () => {
const L = ((await import('leaflet')).default) as unknown as { latLngBounds: ReturnType<typeof vi.fn> }
const dayPlaces = [
buildMapPlace({ id: 1, lat: 48.0, lng: 2.0 }),
buildMapPlace({ id: 2, lat: 48.1, lng: 2.1 }),
]
// Day selected, route not computed yet → first fit is the two destinations.
const { rerender } = render(<MapView places={dayPlaces} dayPlaces={dayPlaces} route={[]} fitKey={5} />)
const lastBounds = () => { const c = L.latLngBounds.mock.calls; return c[c.length - 1][0] }
expect(lastBounds()).toHaveLength(2)
// The day's route arrives → one-shot re-fit including the 3 route points.
L.latLngBounds.mockClear()
rerender(<MapView places={dayPlaces} dayPlaces={dayPlaces} route={[[[47.9, 1.9], [48.05, 2.05], [48.2, 2.2]]]} fitKey={5} />)
expect(L.latLngBounds).toHaveBeenCalled()
expect(lastBounds()).toHaveLength(5) // 2 destinations + 3 route points
})
})
+33 -8
View File
@@ -212,24 +212,27 @@ function MapController({ center, zoom }: MapControllerProps) {
return null
}
// Fit bounds when places change (fitKey triggers re-fit)
// Fit bounds when places change (fitKey triggers re-fit). On a day selection we
// fit to that day's destinations immediately, then — once the day's route has
// finished computing asynchronously — re-fit once more to include the full route
// polyline, so a route that bulges past its stops stays in view (#1128).
interface BoundsControllerProps {
hasDayDetail?: boolean
places: Place[]
routeCoords: [number, number][]
fitKey: number
paddingOpts: L.FitBoundsOptions
}
function BoundsController({ places, fitKey, paddingOpts, hasDayDetail }: BoundsControllerProps) {
function BoundsController({ places, routeCoords, fitKey, paddingOpts, hasDayDetail }: BoundsControllerProps) {
const map = useMap()
const prevFitKey = useRef(-1)
const awaitingRoute = useRef(false)
useEffect(() => {
if (fitKey === prevFitKey.current) return
prevFitKey.current = fitKey
if (places.length === 0) return
const fitTo = useCallback((coords: [number, number][]) => {
if (coords.length === 0) return
try {
const bounds = L.latLngBounds(places.map(p => [p.lat, p.lng]))
const bounds = L.latLngBounds(coords)
if (bounds.isValid()) {
map.fitBounds(bounds, { ...paddingOpts, maxZoom: 16, animate: true })
if (hasDayDetail) {
@@ -237,8 +240,27 @@ function BoundsController({ places, fitKey, paddingOpts, hasDayDetail }: BoundsC
}
}
} catch {}
}, [map, paddingOpts, hasDayDetail])
// New fitKey (initial trip fit or a day selection): fit to the destinations now
// and arm a one-shot re-fit for when the route arrives.
useEffect(() => {
if (fitKey === prevFitKey.current) return
prevFitKey.current = fitKey
awaitingRoute.current = false
if (places.length === 0) return
fitTo(places.map(p => [p.lat, p.lng] as [number, number]))
awaitingRoute.current = true
}, [fitKey]) // eslint-disable-line react-hooks/exhaustive-deps
// Once the just-selected day's route is ready, expand the fit to include it.
// One-shot per day-fit, so later route-profile toggles don't re-zoom the map.
useEffect(() => {
if (!awaitingRoute.current || routeCoords.length === 0) return
awaitingRoute.current = false
fitTo([...places.map(p => [p.lat, p.lng] as [number, number]), ...routeCoords])
}, [routeCoords]) // eslint-disable-line react-hooks/exhaustive-deps
return null
}
@@ -463,6 +485,9 @@ export const MapView = memo(function MapView({
const thumbRafRef = useRef<number | null>(null)
const placeIds = useMemo(() => places.map(p => p.id).join(','), [places])
// Flattened [lat,lng] points of the selected day's route, so the bounds fit can
// include the full polyline once it has been computed.
const routeCoords = useMemo<[number, number][]>(() => (route || []).flat() as [number, number][], [route])
useEffect(() => {
if (!places || places.length === 0 || !placesPhotosEnabled) return
const cleanups: (() => void)[] = []
@@ -597,7 +622,7 @@ export const MapView = memo(function MapView({
/>
<MapController center={center} zoom={zoom} />
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} fitKey={fitKey} paddingOpts={paddingOpts} hasDayDetail={hasDayDetail} />
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} routeCoords={dayPlaces.length > 0 ? routeCoords : []} fitKey={fitKey} paddingOpts={paddingOpts} hasDayDetail={hasDayDetail} />
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} />
<MapClickHandler onClick={onMapClick} />
<MapContextMenuHandler onContextMenu={onMapContextMenu} />
@@ -1496,4 +1496,35 @@ describe('PackingListPanel', () => {
await waitFor(() => expect(putBody).toMatchObject({ name: 'Tent' }));
expect(posted).toBe(false);
});
// ── Three-tier sharing (#858) ──────────────────────────────────────────────
it('FE-COMP-PACKING-080: the view switch separates the Common pool from My list', async () => {
seedStore(useAuthStore, { user: buildUser({ id: 1 }), isAuthenticated: true });
const items = [
buildPackingItem({ name: 'Group tent', is_private: 0 }),
buildPackingItem({ name: 'My diary', is_private: 1, owner_id: 1 }),
];
render(<PackingListPanel tripId={1} items={items} />);
// Default view = Common pool → only the shared item.
expect(await screen.findByText('Group tent')).toBeInTheDocument();
expect(screen.queryByText('My diary')).not.toBeInTheDocument();
// Switch to "My list" → only the personal item.
await userEvent.click(screen.getByText('My list'));
expect(await screen.findByText('My diary')).toBeInTheDocument();
expect(screen.queryByText('Group tent')).not.toBeInTheDocument();
});
it('FE-COMP-PACKING-081: a shared-to-me item shows the "by <bringer>" badge in My list', async () => {
seedStore(useAuthStore, { user: buildUser({ id: 1 }), isAuthenticated: true });
const items = [
buildPackingItem({ name: 'Power bank', is_private: 1, owner_id: 2, owner_username: 'Bob', recipients: [{ user_id: 1, username: 'me' }] }),
];
render(<PackingListPanel tripId={1} items={items} />);
await userEvent.click(screen.getByText('My list'));
await screen.findByText('Power bank');
// "by Bob" — taken care of by the bringer.
expect(screen.getByText('by Bob')).toBeInTheDocument();
});
});
@@ -1,6 +1,7 @@
import { usePackingList } from './usePackingListPanel'
import type { PackingListPanelProps } from './usePackingListPanel'
import { PackingHeader } from './PackingListPanelHeader'
import { PackingViewTabs } from './PackingListPanelViewTabs'
import { PackingFilterTabs } from './PackingListPanelFilterTabs'
import { PackingList } from './PackingListPanelList'
import { BagSidebar } from './PackingListPanelBagSidebar'
@@ -18,6 +19,9 @@ export default function PackingListPanel(props: PackingListPanelProps) {
{/* ── Header ── */}
<PackingHeader {...S} />
{/* ── View-Switch: Gemeinsam / Meine Liste (#858) ── */}
<PackingViewTabs {...S} />
{/* ── Filter-Tabs ── */}
<PackingFilterTabs {...S} />
@@ -10,6 +10,7 @@ import type { PackingItem, PackingBag } from '../../types'
import { katColor } from './packingListPanel.helpers'
import type { TripMember, CategoryAssignee } from './usePackingListPanel'
import { ArtikelZeile } from './PackingListPanelItemRow'
import GuestBadge from '../shared/GuestBadge'
interface KategorieGruppeProps {
kategorie: string
@@ -27,10 +28,40 @@ interface KategorieGruppeProps {
bags?: PackingBag[]
onCreateBag: (name: string) => Promise<PackingBag | undefined>
canEdit?: boolean
// Drag-to-reorder (#969): the full ordered item list + a persist callback. The
// order is global, so a within-category drag is mapped back onto the full list.
allItems: PackingItem[]
onReorder: (orderedIds: number[]) => void
// Three-tier sharing (#858) — threaded down to each item's share control.
currentUserId?: number
onSetSharing?: (id: number, visibility: 'common' | 'personal' | 'shared', recipientIds: number[]) => void
onClone?: (id: number) => void
onJoin?: (id: number) => void
onLeave?: (id: number, userId: number) => void
}
export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onDeleteItem, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag, canEdit = true }: KategorieGruppeProps) {
export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onDeleteItem, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag, canEdit = true, allItems, onReorder, currentUserId, onSetSharing, onClone, onJoin, onLeave }: KategorieGruppeProps) {
const [offen, setOffen] = useState(true)
const [dragId, setDragId] = useState<number | null>(null)
const [overId, setOverId] = useState<number | null>(null)
const handleReorderDrop = (targetId: number) => {
const from = dragId
setDragId(null); setOverId(null)
if (from == null || from === targetId) return
const catOrder = items.map(i => i.id)
const fi = catOrder.indexOf(from)
const ti = catOrder.indexOf(targetId)
if (fi < 0 || ti < 0) return
catOrder.splice(fi, 1)
catOrder.splice(ti, 0, from)
// Slot the reordered category ids back into the positions this category's
// items occupy in the global list, leaving every other category untouched.
const catIds = new Set(items.map(i => i.id))
let ci = 0
const globalIds = allItems.map(i => (catIds.has(i.id) ? catOrder[ci++] : i.id))
onReorder(globalIds)
}
const [editingName, setEditingName] = useState(false)
const [editKatName, setEditKatName] = useState(kategorie)
const [showMenu, setShowMenu] = useState(false)
@@ -182,7 +213,10 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
}}>
{m.username[0]}
</div>
<span style={{ flex: 1 }}>{m.username}</span>
<span style={{ flex: 1, display: 'flex', alignItems: 'center', gap: 6, minWidth: 0 }}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{m.username}</span>
{m.is_guest && <GuestBadge size="xs" />}
</span>
{isAssigned && <Check size={12} className="text-content-muted" />}
</button>
)
@@ -232,7 +266,16 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
{offen && (
<div style={{ padding: '4px 4px 6px' }}>
{items.map(item => (
<ArtikelZeile key={item.id} item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} onDelete={onDeleteItem} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} canEdit={canEdit} />
<ArtikelZeile key={item.id} item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} onDelete={onDeleteItem} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} canEdit={canEdit}
tripMembers={tripMembers} currentUserId={currentUserId} onSetSharing={onSetSharing} onClone={onClone} onJoin={onJoin} onLeave={onLeave}
drag={canEdit ? {
isDragging: dragId === item.id,
isOver: overId === item.id && dragId !== null && dragId !== item.id,
onStart: (id) => { setDragId(id); setOverId(null) },
onOver: (id) => setOverId(id),
onEnd: () => { setDragId(null); setOverId(null) },
onDrop: handleReorderDrop,
} : undefined} />
))}
{/* Inline add item */}
{canEdit && (showAddItem ? (
@@ -3,12 +3,14 @@ import { useTripStore } from '../../store/tripStore'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import {
CheckSquare, Square, Trash2, Plus, Pencil, Package,
CheckSquare, Square, Trash2, Plus, Pencil, Package, GripVertical, UserRound, Users, HandHelping,
} from 'lucide-react'
import type { PackingItem, PackingBag } from '../../types'
import { katColor } from './packingListPanel.helpers'
import { PACKING_PLACEHOLDER_NAME } from './packingListPanel.constants'
import { QuantityInput } from './PackingListPanelQuantityInput'
import PackingShareControl from './PackingShareControl'
import type { TripMember } from './usePackingListPanel'
interface ArtikelZeileProps {
item: PackingItem
@@ -20,9 +22,25 @@ interface ArtikelZeileProps {
bags?: PackingBag[]
onCreateBag: (name: string) => Promise<PackingBag | undefined>
canEdit?: boolean
// Three-tier sharing (#858): members + handlers for the per-item share control.
tripMembers?: TripMember[]
currentUserId?: number
onSetSharing?: (id: number, visibility: 'common' | 'personal' | 'shared', recipientIds: number[]) => void
onClone?: (id: number) => void
onJoin?: (id: number) => void
onLeave?: (id: number, userId: number) => void
// Drag-to-reorder (#969) — wired by the category group, which owns the order.
drag?: {
isDragging: boolean
isOver: boolean
onStart: (id: number) => void
onOver: (id: number) => void
onEnd: () => void
onDrop: (targetId: number) => void
}
}
export function ArtikelZeile({ item, tripId, categories, onCategoryChange, onDelete, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true }: ArtikelZeileProps) {
export function ArtikelZeile({ item, tripId, categories, onCategoryChange, onDelete, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true, tripMembers = [], currentUserId, onSetSharing, onClone, onJoin, onLeave, drag }: ArtikelZeileProps) {
const isPlaceholder = item.name === PACKING_PLACEHOLDER_NAME
const [editing, setEditing] = useState(false)
const [editName, setEditName] = useState(isPlaceholder ? '' : item.name)
@@ -35,6 +53,14 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange, onDel
const toast = useToast()
const { t } = useTranslation()
// Three-tier sharing display (#858).
const sharedToMe = !!item.is_private && item.owner_id != null && item.owner_id !== currentUserId
const recipients = item.recipients || []
const sharedByMe = !!item.is_private && item.owner_id === currentUserId && recipients.length > 0
const broughtBy = !item.is_private && item.owner_username ? item.owner_username : null
const contributors = item.contributors || []
const canShare = canEdit && !isPlaceholder && !!onSetSharing
const handleToggle = () => togglePackingItem(tripId, item.id, !item.checked)
const handleSaveName = async () => {
@@ -58,18 +84,36 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange, onDel
catch { toast.error(t('common.error')) }
}
const canDrag = canEdit && !isPlaceholder && !!drag
return (
<div
className="group"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => { setHovered(false); setShowCatPicker(false); setShowBagPicker(false) }}
onDragOver={canDrag ? (e => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; drag!.onOver(item.id) }) : undefined}
onDragLeave={canDrag ? (e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) drag!.onOver(-1) }) : undefined}
onDrop={canDrag ? (e => { e.preventDefault(); e.stopPropagation(); drag!.onDrop(item.id) }) : undefined}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '6px 10px', borderRadius: 10, position: 'relative',
background: hovered ? 'var(--bg-secondary)' : 'transparent',
transition: 'background 0.1s',
opacity: drag?.isDragging ? 0.4 : 1,
boxShadow: drag?.isOver ? 'inset 3px 0 0 0 var(--accent)' : 'none',
transition: 'background 0.1s, opacity 0.15s',
}}
>
{canDrag && (
<div
draggable
onDragStart={e => { e.stopPropagation(); e.dataTransfer.effectAllowed = 'move'; drag!.onStart(item.id) }}
onDragEnd={() => drag!.onEnd()}
title=""
style={{ cursor: 'grab', display: 'flex', alignItems: 'center', color: 'var(--text-faint)', flexShrink: 0, opacity: hovered ? 1 : 0.35, transition: 'opacity 0.15s' }}
>
<GripVertical size={13} />
</div>
)}
<button onClick={handleToggle} style={{
flexShrink: 0, background: 'none', border: 'none', cursor: 'pointer', padding: 0, position: 'relative',
width: 18, height: 18,
@@ -114,6 +158,26 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange, onDel
</span>
)}
{/* Sharing badges (#858 three-tier) */}
{!isPlaceholder && sharedToMe && (
<span title={t('packing.takenCareOf', { name: item.owner_username || '' })}
style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0, fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--accent)', background: 'color-mix(in srgb, var(--accent) 12%, transparent)', padding: '1px 7px', borderRadius: 99 }}>
<HandHelping size={10} /> {t('packing.takenCareOf', { name: item.owner_username || '' })}
</span>
)}
{!isPlaceholder && sharedByMe && (
<span title={recipients.map(r => r.username).join(', ')}
style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0, fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-muted)', background: 'var(--bg-tertiary)', padding: '1px 7px', borderRadius: 99 }}>
<UserRound size={10} /> {t('packing.sharedWithCount', { count: recipients.length })}
</span>
)}
{!isPlaceholder && broughtBy && (
<span title={t('packing.broughtBy', { name: broughtBy })}
style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0, fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', padding: '1px 4px' }}>
<Users size={10} /> {broughtBy}{contributors.length > 0 ? ` +${contributors.length}` : ''}
</span>
)}
{/* Quantity */}
{canEdit && <QuantityInput value={item.quantity || 1} onSave={qty => updatePackingItem(tripId, item.id, { quantity: qty })} />}
@@ -245,6 +309,18 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange, onDel
)}
</div>
{canShare && onClone && onJoin && onLeave && (
<PackingShareControl
item={item}
tripMembers={tripMembers}
currentUserId={currentUserId}
onSetSharing={onSetSharing!}
onClone={onClone}
onJoin={onJoin}
onLeave={onLeave}
/>
)}
<button onClick={() => setEditing(true)} title={t('common.rename')} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '3px 4px', borderRadius: 6, display: 'flex', color: 'var(--text-faint)' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-secondary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Pencil size={13} />
@@ -6,7 +6,8 @@ export function PackingList(S: PackingState) {
const {
items, gruppiert, t, tripId, allCategories, handleRenameCategory, handleDeleteCategory, handleDeleteItem,
handleAddItemToCategory, categoryAssignees, tripMembers, handleSetAssignees,
bagTrackingEnabled, bags, handleCreateBagByName, canEdit,
bagTrackingEnabled, bags, handleCreateBagByName, canEdit, reorderPackingItems,
currentUserId, handleSetSharing, handleCloneItem, handleJoinItem, handleLeaveItem,
} = S
return (
<div style={{ flex: 1, overflowY: 'auto', padding: '10px 0 16px' }}>
@@ -40,6 +41,13 @@ export function PackingList(S: PackingState) {
bags={bags}
onCreateBag={handleCreateBagByName}
canEdit={canEdit}
allItems={items}
onReorder={(orderedIds) => reorderPackingItems(tripId, orderedIds)}
currentUserId={currentUserId}
onSetSharing={handleSetSharing}
onClone={handleCloneItem}
onJoin={handleJoinItem}
onLeave={handleLeaveItem}
/>
))}
</div>
@@ -0,0 +1,43 @@
import { Users, UserRound } from 'lucide-react'
import type { PackingState } from './usePackingListPanel'
/**
* Top-level switch between the shared group pool ("Gemeinsam") and the traveler's
* own list ("Meine Liste" — private + items shared to them) — the #858 three-tier
* model. Existing items live in the Common pool, so that stays the default.
*/
export function PackingViewTabs(S: PackingState) {
const { view, setView, t, items } = S
const commonCount = items.filter(i => !i.is_private).length
const personalCount = items.filter(i => !!i.is_private).length
const tab = (id: 'common' | 'personal', icon: React.ReactNode, label: string, count: number) => {
const active = view === id
return (
<button onClick={() => setView(id)}
style={{
display: 'flex', alignItems: 'center', gap: 6, padding: '6px 14px', borderRadius: 999,
border: '1px solid', cursor: 'pointer', fontFamily: 'inherit',
fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 600,
background: active ? 'var(--text-primary)' : 'transparent',
borderColor: active ? 'var(--text-primary)' : 'var(--border-primary)',
color: active ? 'var(--bg-primary)' : 'var(--text-secondary)',
transition: 'all 0.12s',
}}>
{icon}{label}
<span style={{
fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, borderRadius: 99, padding: '0 6px',
background: active ? 'var(--bg-primary)' : 'var(--bg-tertiary)',
color: active ? 'var(--text-primary)' : 'var(--text-faint)',
}}>{count}</span>
</button>
)
}
return (
<div style={{ display: 'flex', gap: 8, padding: '10px 16px 0', flexShrink: 0 }}>
{tab('common', <Users size={14} />, t('packing.viewCommon'), commonCount)}
{tab('personal', <UserRound size={14} />, t('packing.viewPersonal'), personalCount)}
</div>
)
}
@@ -0,0 +1,119 @@
import { useState, useRef, useEffect } from 'react'
import { Users, UserRound, Share2, Check, Copy, HandHelping } from 'lucide-react'
import { useTranslation } from '../../i18n'
import type { PackingItem } from '../../types'
import type { TripMember } from './usePackingListPanel'
interface Props {
item: PackingItem
tripMembers: TripMember[]
currentUserId?: number
onSetSharing: (id: number, visibility: 'common' | 'personal' | 'shared', recipientIds: number[]) => void
onClone: (id: number) => void
onJoin: (id: number) => void
onLeave: (id: number, userId: number) => void
}
/**
* Per-item sharing control for the three-tier packing model (#858). The owner
* (bringer) sets the tier — Common / Personal / Shared with specific people — via
* a dropdown; everyone else can pledge to co-bring a Common item ("I can bring
* that too") or clone it onto their own list.
*/
export default function PackingShareControl({ item, tripMembers, currentUserId, onSetSharing, onClone, onJoin, onLeave }: Props) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const onClick = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) }
document.addEventListener('mousedown', onClick)
return () => document.removeEventListener('mousedown', onClick)
}, [open])
const isCommon = !item.is_private
const isOwner = item.owner_id == null || item.owner_id === currentUserId
const recipientIds = (item.recipients || []).map(r => r.user_id)
const visibility: 'common' | 'personal' | 'shared' = isCommon ? 'common' : recipientIds.length > 0 ? 'shared' : 'personal'
const iAmContributor = (item.contributors || []).some(c => c.user_id === currentUserId)
const others = tripMembers.filter(m => m.id !== item.owner_id && m.id !== currentUserId)
const toggleRecipient = (uid: number) => {
const next = recipientIds.includes(uid) ? recipientIds.filter(x => x !== uid) : [...recipientIds, uid]
onSetSharing(item.id, 'shared', next)
}
const btn = (onClick: () => void, title: string, active: boolean, node: React.ReactNode) => (
<button onClick={onClick} title={title}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '3px 4px', borderRadius: 6, display: 'flex', color: active ? 'var(--accent)' : 'var(--text-faint)' }}
onMouseEnter={e => { if (!active) e.currentTarget.style.color = 'var(--text-secondary)' }}
onMouseLeave={e => { if (!active) e.currentTarget.style.color = 'var(--text-faint)' }}>
{node}
</button>
)
// Non-owner on a Common item: pledge to co-bring + clone to personal list.
if (!isOwner && isCommon) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
{btn(() => (iAmContributor ? onLeave(item.id, currentUserId!) : onJoin(item.id)),
iAmContributor ? t('packing.alsoBringingStop') : t('packing.alsoBring'), iAmContributor, <HandHelping size={14} />)}
{btn(() => onClone(item.id), t('packing.cloneToMine'), false, <Copy size={13} />)}
</div>
)
}
// A recipient of a shared item has no controls (it's the owner's responsibility).
if (!isOwner) return null
return (
<div ref={ref} style={{ position: 'relative' }}>
{btn(() => setOpen(o => !o), t('packing.share'), visibility !== 'common', <Share2 size={14} />)}
{open && (
<div style={{
position: 'absolute', right: 0, top: '100%', marginTop: 4, zIndex: 60,
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4, minWidth: 190,
}}>
<Row icon={<Users size={13} />} label={t('packing.viewCommon')} sub={t('packing.tierCommonHint')} active={visibility === 'common'} onClick={() => { onSetSharing(item.id, 'common', []); setOpen(false) }} />
<Row icon={<UserRound size={13} />} label={t('packing.tierPersonal')} sub={t('packing.tierPersonalHint')} active={visibility === 'personal'} onClick={() => { onSetSharing(item.id, 'personal', []); setOpen(false) }} />
<div style={{ height: 1, background: 'var(--bg-tertiary)', margin: '4px 0' }} />
<div style={{ padding: '4px 10px 2px', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.04em', display: 'flex', alignItems: 'center', gap: 5 }}>
<Share2 size={10} /> {t('packing.tierShared')}
</div>
{others.length === 0 ? (
<div style={{ padding: '4px 10px 6px', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}>{t('packing.noOneToShare')}</div>
) : others.map(m => {
const on = recipientIds.includes(m.id)
return (
<button key={m.id} onClick={() => toggleRecipient(m.id)}
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '6px 10px', borderRadius: 7, border: 'none', cursor: 'pointer', background: 'none', fontFamily: 'inherit', fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-primary)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
<span style={{ width: 18, height: 18, borderRadius: '50%', flexShrink: 0, background: `hsl(${m.username.charCodeAt(0) * 37 % 360}, 55%, 55%)`, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'white', textTransform: 'uppercase' }}>{m.username[0]}</span>
<span style={{ flex: 1, textAlign: 'left', overflow: 'hidden', textOverflow: 'ellipsis' }}>{m.username}</span>
{on && <Check size={13} className="text-content-muted" />}
</button>
)
})}
</div>
)}
</div>
)
}
function Row({ icon, label, sub, active, onClick }: { icon: React.ReactNode; label: string; sub: string; active: boolean; onClick: () => void }) {
return (
<button onClick={onClick}
style={{ display: 'flex', alignItems: 'flex-start', gap: 8, width: '100%', padding: '7px 10px', borderRadius: 7, border: 'none', cursor: 'pointer', background: active ? 'var(--bg-tertiary)' : 'none', fontFamily: 'inherit', textAlign: 'left' }}
onMouseEnter={e => { if (!active) e.currentTarget.style.background = 'var(--bg-tertiary)' }}
onMouseLeave={e => { if (!active) e.currentTarget.style.background = active ? 'var(--bg-tertiary)' : 'none' }}>
<span style={{ color: active ? 'var(--accent)' : 'var(--text-muted)', marginTop: 1 }}>{icon}</span>
<span style={{ flex: 1, minWidth: 0 }}>
<span style={{ display: 'block', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)' }}>{label}</span>
<span style={{ display: 'block', fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}>{sub}</span>
</span>
{active && <Check size={13} className="text-content-muted" style={{ marginTop: 2 }} />}
</button>
)
}
@@ -16,12 +16,14 @@ export interface TripMember {
username: string
avatar?: string | null
avatar_url?: string | null
is_guest?: boolean
}
export interface CategoryAssignee {
user_id: number
username: string
avatar?: string | null
is_guest?: boolean
}
export interface PackingListPanelProps {
@@ -42,13 +44,18 @@ export interface PackingListPanelProps {
*/
export function usePackingList({ tripId, items, openImportSignal = 0, clearCheckedSignal = 0, saveTemplateSignal = 0, inlineHeader = true }: PackingListPanelProps) {
const [filter, setFilter] = useState('alle') // 'alle' | 'offen' | 'erledigt'
// Three-tier sharing (#858): 'common' = the group pool (where existing items
// live — non-breaking), 'personal' = my own list (private + shared-to-me).
const [view, setView] = useState<'common' | 'personal'>('common')
const [addingCategory, setAddingCategory] = useState(false)
const [newCatName, setNewCatName] = useState('')
const { addPackingItem, updatePackingItem, deletePackingItem, togglePackingItem } = useTripStore()
const { addPackingItem, updatePackingItem, deletePackingItem, togglePackingItem, reorderPackingItems,
setPackingItemSharing, clonePackingItem, addPackingContributor, removePackingContributor } = useTripStore()
const can = useCanDo()
const trip = useTripStore((s) => s.trip)
const canEdit = can('packing_edit', trip)
const isAdmin = useAuthStore((s) => s.user?.role === 'admin')
const currentUserId = useAuthStore((s) => s.user?.id)
const toast = useToast()
const { t } = useTranslation()
@@ -59,8 +66,8 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
useEffect(() => {
tripsApi.getMembers(tripId).then(data => {
const all: TripMember[] = []
if (data.owner) all.push({ id: data.owner.id, username: data.owner.username, avatar: data.owner.avatar_url })
if (data.members) all.push(...data.members.map((m: any) => ({ id: m.id, username: m.username, avatar: m.avatar_url })))
if (data.owner) all.push({ id: data.owner.id, username: data.owner.username, avatar: data.owner.avatar_url, is_guest: false })
if (data.members) all.push(...data.members.map((m: any) => ({ id: m.id, username: m.username, avatar: m.avatar_url, is_guest: !!m.is_guest })))
setTripMembers(all)
}).catch(() => {})
packingApi.getCategoryAssignees(tripId).then(data => {
@@ -77,17 +84,24 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
}
}
// Split by the active view (#858): Common = group pool (is_private 0), Personal =
// my own + shared-to-me (is_private 1, already filtered to me by the server).
const viewItems = useMemo(
() => items.filter(i => (view === 'common' ? !i.is_private : !!i.is_private)),
[items, view],
)
const allCategories = useMemo(() => {
const seen: string[] = []
for (const item of items) {
for (const item of viewItems) {
const cat = item.category || t('packing.defaultCategory')
if (!seen.includes(cat)) seen.push(cat)
}
return seen
}, [items, t])
}, [viewItems, t])
const gruppiert = useMemo(() => {
const filtered = items.filter(i => {
const filtered = viewItems.filter(i => {
if (filter === 'offen') return !i.checked
if (filter === 'erledigt') return i.checked
return true
@@ -99,10 +113,10 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
groups[kat].push(item)
}
return groups
}, [items, filter, t])
}, [viewItems, filter, t])
const abgehakt = items.filter(i => i.checked).length
const fortschritt = items.length > 0 ? Math.round((abgehakt / items.length) * 100) : 0
const abgehakt = viewItems.filter(i => i.checked).length
const fortschritt = viewItems.length > 0 ? Math.round((abgehakt / viewItems.length) * 100) : 0
const handleAddItemToCategory = async (category: string, name: string) => {
try {
@@ -115,7 +129,8 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
if (placeholder) {
await updatePackingItem(tripId, placeholder.id, { name })
} else {
await addPackingItem(tripId, { name, category })
// New items inherit the active view's tier: Personal in "my list", Common otherwise.
await addPackingItem(tripId, { name, category, visibility: view === 'personal' ? 'personal' : 'common' } as Parameters<typeof addPackingItem>[1])
}
} catch { toast.error(t('packing.toast.addError')) }
}
@@ -153,7 +168,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
catName += ''
}
try {
await addPackingItem(tripId, { name: '...', category: catName })
await addPackingItem(tripId, { name: '...', category: catName, visibility: view === 'personal' ? 'personal' : 'common' } as Parameters<typeof addPackingItem>[1])
setNewCatName('')
setAddingCategory(false)
} catch { toast.error(t('packing.toast.addError')) }
@@ -339,8 +354,17 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
const font = { fontFamily: "var(--font-system)" }
// ── Three-tier sharing handlers (#858) ──────────────────────────────────────
const handleSetSharing = (id: number, visibility: 'common' | 'personal' | 'shared', recipientIds: number[]) =>
setPackingItemSharing(tripId, id, visibility, recipientIds)
const handleCloneItem = (id: number) => clonePackingItem(tripId, id)
const handleJoinItem = (id: number) => addPackingContributor(tripId, id)
const handleLeaveItem = (id: number, userId: number) => removePackingContributor(tripId, id, userId)
return {
tripId, items, inlineHeader, t, canEdit, isAdmin, font,
view, setView, currentUserId,
handleSetSharing, handleCloneItem, handleJoinItem, handleLeaveItem,
tripId, items, inlineHeader, t, canEdit, isAdmin, font, reorderPackingItems,
filter, setFilter, addingCategory, setAddingCategory, newCatName, setNewCatName,
tripMembers, categoryAssignees, handleSetAssignees, allCategories, gruppiert, abgehakt, fortschritt,
handleAddItemToCategory, handleAddNewCategory, handleRenameCategory, handleDeleteCategory, handleDeleteItem, handleClearChecked,
@@ -909,7 +909,7 @@ describe('DayPlanSidebar', () => {
// ── ICS export click ─────────────────────────────────────────────────
it('FE-PLANNER-DAYPLAN-058: clicking ICS button calls fetch for .ics export', async () => {
it('FE-PLANNER-DAYPLAN-058: ICS menu "Download ICS" calls fetch for .ics export', async () => {
const user = userEvent.setup()
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true,
@@ -919,7 +919,10 @@ describe('DayPlanSidebar', () => {
const createObjURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock')
const revokeObjURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
render(<DayPlanSidebar {...makeDefaultProps()} />)
await user.click(screen.getByText('ICS').closest('button')!)
// The ICS button now opens a hover menu (Download / Subscribe) instead of
// downloading on direct click.
await user.hover(screen.getByText('ICS').closest('button')!)
await user.click(await screen.findByText('Download ICS'))
await waitFor(() => expect(fetchSpy).toHaveBeenCalledWith('/api/trips/1/export.ics', expect.any(Object)))
fetchSpy.mockRestore()
createObjURL.mockRestore()
@@ -1550,14 +1553,14 @@ describe('DayPlanSidebar', () => {
// ── ICS hover tooltip ─────────────────────────────────────────────────────
it('FE-PLANNER-DAYPLAN-090: hovering ICS button shows tooltip', async () => {
it('FE-PLANNER-DAYPLAN-090: hovering ICS button shows the download/subscribe menu', async () => {
const user = userEvent.setup()
render(<DayPlanSidebar {...makeDefaultProps()} />)
const icsBtn = screen.getByRole('button', { name: /ICS/i })
const icsBtn = screen.getByText('ICS').closest('button')!
await user.hover(icsBtn)
await waitFor(() => {
const tooltips = document.querySelectorAll('[style*="pointer-events: none"]')
expect(tooltips.length).toBeGreaterThan(0)
expect(screen.getByText('Download ICS')).toBeInTheDocument()
expect(screen.getByText('Subscribe to calendar')).toBeInTheDocument()
})
})
@@ -1,9 +1,10 @@
import { useState } from 'react'
import { ChevronsDownUp, ChevronsUpDown, FileDown, Undo2, ArrowUpDown } from 'lucide-react'
import React, { useState, useRef } from 'react'
import { ChevronsDownUp, ChevronsUpDown, FileDown, Undo2, ArrowUpDown, CalendarPlus } from 'lucide-react'
import { downloadTripPDF } from '../PDF/TripPDF'
import { DayReorderPopup } from './DayReorderPopup'
import Tooltip from '../shared/Tooltip'
import { useToast } from '../shared/Toast'
import { IcsSubscribeModal } from './IcsSubscribeModal'
import type { Trip, Day, Place, Category, AssignmentsMap, Reservation, DayNote } from '../../types'
interface DayPlanSidebarToolbarProps {
@@ -36,11 +37,35 @@ interface DayPlanSidebarToolbarProps {
export function DayPlanSidebarToolbar({
tripId, trip, days, places, categories, assignments, reservations, dayNotes,
t, locale, toast, pdfHover, setPdfHover, icsHover, setIcsHover,
t, locale, toast, pdfHover, setPdfHover, setIcsHover,
expandedDays, setExpandedDays, onUndo, canUndo, undoHover, setUndoHover, lastActionLabel,
canEditDays, onReorderDays, onAddDay,
}: DayPlanSidebarToolbarProps) {
const [reorderOpen, setReorderOpen] = useState(false)
const [subscribeOpen, setSubscribeOpen] = useState(false)
const icsMenuTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [icsMenuVisible, setIcsMenuVisible] = useState(false)
const showIcsMenu = () => {
if (icsMenuTimeoutRef.current) clearTimeout(icsMenuTimeoutRef.current)
setIcsMenuVisible(true)
setIcsHover(true)
}
const hideIcsMenu = () => {
icsMenuTimeoutRef.current = setTimeout(() => {
setIcsMenuVisible(false)
setIcsHover(false)
}, 120)
}
const menuItemStyle: React.CSSProperties = {
display: 'flex', alignItems: 'center', gap: 7,
width: '100%', padding: '7px 12px', border: 'none',
background: 'transparent', cursor: 'pointer',
fontSize: 11, fontWeight: 500, fontFamily: 'inherit',
color: 'var(--text-primary)', textAlign: 'left',
transition: 'background 0.1s',
}
return (
<div className="border-b border-edge-faint" style={{ padding: '12px 16px', flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 8 }}>
@@ -83,25 +108,12 @@ export function DayPlanSidebarToolbar({
</div>
)}
</div>
<div style={{ position: 'relative', flexShrink: 0 }}>
<div
style={{ position: 'relative', flexShrink: 0 }}
onMouseEnter={showIcsMenu}
onMouseLeave={hideIcsMenu}
>
<button
onClick={async () => {
try {
const res = await fetch(`/api/trips/${tripId}/export.ics`, {
credentials: 'include',
})
if (!res.ok) throw new Error()
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${trip?.title || 'trip'}.ics`
a.click()
URL.revokeObjectURL(url)
} catch { toast.error(t('planner.icsExportFailed')) }
}}
onMouseEnter={() => setIcsHover(true)}
onMouseLeave={() => setIcsHover(false)}
style={{
display: 'flex', alignItems: 'center', gap: 5,
padding: '5px 10px', borderRadius: 8,
@@ -113,19 +125,66 @@ export function DayPlanSidebarToolbar({
<FileDown size={13} strokeWidth={2} />
ICS
</button>
{icsHover && (
<div style={{
position: 'absolute', top: 'calc(100% + 6px)', right: 0,
whiteSpace: 'nowrap', pointerEvents: 'none', zIndex: 200,
background: 'var(--bg-card, white)', color: 'var(--text-primary, #111827)',
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500, padding: '5px 10px',
borderRadius: 8, boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
border: '1px solid var(--border-faint, #e5e7eb)',
}}>
{t('dayplan.icsTooltip')}
{icsMenuVisible && (
<div
onMouseEnter={showIcsMenu}
onMouseLeave={hideIcsMenu}
style={{
position: 'absolute', top: 'calc(100% + 4px)', right: 0,
zIndex: 200, minWidth: 160,
background: 'var(--bg-card, white)',
borderRadius: 8, boxShadow: '0 4px 16px rgba(0,0,0,0.15)',
border: '1px solid var(--border-faint, #e5e7eb)',
overflow: 'hidden',
}}
>
<button
onClick={async () => {
setIcsMenuVisible(false)
setIcsHover(false)
try {
const res = await fetch(`/api/trips/${tripId}/export.ics`, { credentials: 'include' })
if (!res.ok) throw new Error()
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${trip?.title || 'trip'}.ics`
a.click()
URL.revokeObjectURL(url)
} catch { toast.error(t('planner.icsExportFailed')) }
}}
style={menuItemStyle}
onMouseEnter={e => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--bg-hover, #f3f4f6)' }}
onMouseLeave={e => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
>
<FileDown size={12} strokeWidth={2} />
Download ICS
</button>
<button
onClick={() => {
setIcsMenuVisible(false)
setIcsHover(false)
setSubscribeOpen(true)
}}
style={menuItemStyle}
onMouseEnter={e => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--bg-hover, #f3f4f6)' }}
onMouseLeave={e => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
>
<CalendarPlus size={12} strokeWidth={2} />
Subscribe to calendar
</button>
</div>
)}
</div>
{subscribeOpen && (
<IcsSubscribeModal
endpoint={`/api/trips/${tripId}/feed`}
title="Subscribe to calendar"
description="This link stays in sync with your trip automatically. Calendar apps re-fetch it every hour."
onClose={() => setSubscribeOpen(false)}
/>
)}
{(() => {
const allExpanded = days.length > 0 && days.every(d => expandedDays.has(d.id))
const label = allExpanded ? t('dayplan.collapseAll') : t('dayplan.expandAll')
@@ -0,0 +1,178 @@
import { useState, useEffect, useCallback } from 'react'
import { createPortal } from 'react-dom'
import { X, RefreshCw, Calendar, Power } from 'lucide-react'
import { SubscribeLinks } from './SubscribeLinks'
interface IcsSubscribeModalProps {
/** Token endpoint base, e.g. `/api/trips/123/feed` or `/api/feed/user`. */
endpoint: string
title: string
description: string
onClose: () => void
}
// A server that has no APP_URL configured hands back a host-relative path; the
// webcal:// handoff and Google deep link need an absolute URL, so resolve it
// against the current origin as a fallback.
function absolutize(url: string): string {
if (!url) return ''
if (/^https?:\/\//i.test(url)) return url
if (url.startsWith('/')) return window.location.origin + url
return url
}
/**
* Shared subscribe dialog for the per-trip and all-trips ICS feeds. Opening it
* only *reads* the current token — it never mints one silently. The user
* explicitly enables the public link, and can rotate or fully turn it off.
*/
export function IcsSubscribeModal({ endpoint, title, description, onClose }: IcsSubscribeModalProps) {
const tokenUrl = `${endpoint}/token`
const [feedUrl, setFeedUrl] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState(false)
const httpsUrl = feedUrl ? absolutize(feedUrl) : ''
const webcalUrl = httpsUrl ? httpsUrl.replace(/^https?:\/\//, 'webcal://') : ''
const load = useCallback(async () => {
setLoading(true)
try {
const res = await fetch(tokenUrl, { credentials: 'include' })
if (res.ok) {
const data = await res.json() as { feed_url: string | null }
setFeedUrl(data.feed_url)
}
} catch { /* ignore */ }
setLoading(false)
}, [tokenUrl])
useEffect(() => { load() }, [load])
const mutate = async (method: 'POST' | 'PUT' | 'DELETE') => {
setBusy(true)
try {
const res = await fetch(tokenUrl, { method, credentials: 'include' })
if (res.ok) {
const data = await res.json() as { feed_url: string | null }
setFeedUrl(data.feed_url)
}
} catch { /* ignore */ }
setBusy(false)
}
return createPortal(
<div
style={{
position: 'fixed', inset: 0, zIndex: 9999,
background: 'rgba(0,0,0,0.5)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: '16px',
}}
onClick={e => { if (e.target === e.currentTarget) onClose() }}
>
<div style={{
background: 'var(--bg-card, white)',
borderRadius: 14,
padding: '22px 24px',
width: '100%',
maxWidth: 420,
boxShadow: '0 16px 48px rgba(0,0,0,0.22)',
border: '1px solid var(--border-faint)',
color: 'var(--text-primary)',
fontFamily: 'inherit',
position: 'relative',
}}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Calendar size={16} strokeWidth={2} style={{ color: 'var(--accent, #6366f1)' }} />
<span style={{ fontWeight: 600, fontSize: 14 }}>{title}</span>
</div>
<button
onClick={onClose}
style={{
background: 'none', border: 'none', cursor: 'pointer', padding: 4,
color: 'var(--text-muted)', borderRadius: 6, display: 'flex',
}}
>
<X size={15} strokeWidth={2} />
</button>
</div>
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 16, lineHeight: 1.5 }}>
{description}
</p>
{loading ? (
<div style={{ textAlign: 'center', padding: '16px 0', color: 'var(--text-muted)', fontSize: 12 }}>
Loading
</div>
) : !feedUrl ? (
<>
<button
onClick={() => mutate('POST')}
disabled={busy}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
width: '100%', padding: '9px 14px', borderRadius: 9, border: 'none',
background: 'var(--accent, #6366f1)', color: 'var(--accent-text, #fff)',
fontSize: 12, fontWeight: 600, fontFamily: 'inherit',
cursor: busy ? 'default' : 'pointer', opacity: busy ? 0.6 : 1,
}}
>
<Calendar size={14} strokeWidth={2} />
Enable calendar subscription
</button>
<p style={{ fontSize: 10, color: 'var(--text-muted)', marginTop: 8, lineHeight: 1.4 }}>
Creates a secret link anyone with it can read without logging in. You can turn it off anytime.
</p>
</>
) : (
<>
<SubscribeLinks httpsUrl={httpsUrl} webcalUrl={webcalUrl} />
<div style={{ marginTop: 16, paddingTop: 12, borderTop: '1px solid var(--border-faint)', display: 'flex', gap: 8 }}>
<button
onClick={() => mutate('PUT')}
disabled={busy}
style={{
display: 'flex', alignItems: 'center', gap: 6,
background: 'none', border: '1px solid var(--border-primary)',
borderRadius: 7, padding: '5px 10px',
fontSize: 11, color: 'var(--text-muted)',
cursor: busy ? 'default' : 'pointer',
fontFamily: 'inherit', opacity: busy ? 0.6 : 1,
}}
>
<RefreshCw size={11} strokeWidth={2} style={{ animation: busy ? 'spin 0.8s linear infinite' : 'none' }} />
Regenerate
</button>
<button
onClick={() => mutate('DELETE')}
disabled={busy}
style={{
display: 'flex', alignItems: 'center', gap: 6,
background: 'none', border: '1px solid var(--border-primary)',
borderRadius: 7, padding: '5px 10px',
fontSize: 11, color: 'var(--danger, #dc2626)',
cursor: busy ? 'default' : 'pointer',
fontFamily: 'inherit', opacity: busy ? 0.6 : 1,
}}
>
<Power size={11} strokeWidth={2} />
Turn off
</button>
</div>
<p style={{ fontSize: 10, color: 'var(--text-muted)', marginTop: 6, lineHeight: 1.4 }}>
Regenerating creates a new link and invalidates the old one. Turning off disables the link entirely.
</p>
</>
)}
</div>
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
</div>,
document.body
)
}
@@ -5,6 +5,7 @@ import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import { X, Clock, MapPin, ExternalLink, Phone, Euro, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation, Users, Mountain, TrendingUp } from 'lucide-react'
import PlaceAvatar from '../shared/PlaceAvatar'
import GuestBadge from '../shared/GuestBadge'
import { mapsApi } from '../../api/client'
import { useSettingsStore } from '../../store/settingsStore'
import { getCategoryIcon } from '../shared/categoryIcons'
@@ -91,6 +92,7 @@ interface TripMember {
username: string
avatar?: string | null
avatar_url?: string | null
is_guest?: boolean
}
interface PlaceInspectorProps {
@@ -486,7 +488,8 @@ function ParticipantsBox({ tripMembers, participantIds, allJoined, onSetParticip
}}>
{(member.avatar_url || member.avatar) ? <img src={member.avatar_url || `/uploads/avatars/${member.avatar}`} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : member.username?.[0]?.toUpperCase()}
</div>
{member.username}
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' }}>{member.username}</span>
{member.is_guest && <GuestBadge size="xs" />}
</button>
))}
</div>
@@ -0,0 +1,65 @@
import { createPortal } from 'react-dom'
import { X, MapPin } from 'lucide-react'
import { getCategoryIcon } from '../shared/categoryIcons'
import { useTranslation } from '../../i18n'
import type { Category } from '../../types'
interface PlacesBulkCategoryModalProps {
count: number
categories: Category[]
onPick: (categoryId: number | null) => void
onClose: () => void
}
const rowStyle: React.CSSProperties = {
display: 'flex', alignItems: 'center', gap: 9, width: '100%',
padding: '8px 10px', borderRadius: 7, border: 'none', cursor: 'pointer',
fontFamily: 'inherit', fontSize: 'calc(13px * var(--fs-scale-body, 1))', textAlign: 'left',
}
const hoverOn = (e: React.MouseEvent<HTMLButtonElement>) => { e.currentTarget.style.background = 'var(--bg-hover)' }
const hoverOff = (e: React.MouseEvent<HTMLButtonElement>) => { e.currentTarget.style.background = 'transparent' }
/**
* Popup for the Places selection toolbar: pick one category to apply to every
* currently-selected place. Reuses the category swatch styling from the header's
* filter dropdown; clicking a row applies immediately and closes.
*/
export function PlacesBulkCategoryModal({ count, categories, onPick, onClose }: PlacesBulkCategoryModalProps) {
const { t } = useTranslation()
return createPortal(
<div
onClick={e => { if (e.target === e.currentTarget) onClose() }}
style={{ position: 'fixed', inset: 0, zIndex: 9999, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
>
<div className="bg-surface-card text-content" style={{
borderRadius: 14, padding: '18px 20px', width: '100%', maxWidth: 380,
boxShadow: '0 16px 48px rgba(0,0,0,0.22)', border: '1px solid var(--border-faint)', fontFamily: 'inherit',
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 4 }}>
<span style={{ fontWeight: 600, fontSize: 14 }}>{t('places.changeCategory')}</span>
<button onClick={onClose} aria-label={t('common.close')} className="text-content-muted" style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4, display: 'flex' }}>
<X size={15} strokeWidth={2} />
</button>
</div>
<p className="text-content-faint" style={{ fontSize: 12, marginBottom: 12 }}>{t('places.selectionCount', { count })}</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, maxHeight: 300, overflowY: 'auto' }}>
{categories.map(c => {
const CatIcon = getCategoryIcon(c.icon)
return (
<button key={c.id} onClick={() => onPick(c.id)} className="text-content bg-transparent" style={rowStyle} onMouseEnter={hoverOn} onMouseLeave={hoverOff}>
<CatIcon size={14} strokeWidth={2} color={c.color || 'var(--text-muted)'} />
<span style={{ flex: 1 }}>{c.name}</span>
</button>
)
})}
<button onClick={() => onPick(null)} className="text-content-muted bg-transparent" style={{ ...rowStyle, borderTop: categories.length > 0 ? '1px solid var(--border-faint)' : 'none', marginTop: categories.length > 0 ? 2 : 0 }} onMouseEnter={hoverOn} onMouseLeave={hoverOff}>
<MapPin size={14} strokeWidth={2} color="var(--text-faint)" />
<span style={{ flex: 1 }}>{t('places.noCategory')}</span>
</button>
</div>
</div>
</div>,
document.body,
)
}
@@ -8,6 +8,7 @@ import { PlacesSelectionBar } from './PlacesSidebarSelectionBar'
import { PlacesList } from './PlacesSidebarList'
import { MobileDayPickerSheet } from './PlacesSidebarMobileDayPicker'
import { ListImportModal } from './PlacesSidebarListImportModal'
import { PlacesBulkCategoryModal } from './PlacesBulkCategoryModal'
const PlacesSidebar = React.memo(function PlacesSidebar(props: PlacesSidebarProps) {
const S = usePlacesSidebar(props)
@@ -16,6 +17,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar(props: PlacesSidebarProp
selectMode, filtered, t, dayPickerPlace, listImportOpen,
fileImportOpen, setFileImportOpen, sidebarDropFile, setSidebarDropFile, tripId, pushUndo,
ctxMenu, isMobile, pendingDeleteIds, setPendingDeleteIds, onBulkDeleteConfirm,
categories, selectedIds, exitSelectMode, onBulkChangeCategory, categoryPickerOpen, setCategoryPickerOpen,
} = S
return (
<div
@@ -51,6 +53,14 @@ const PlacesSidebar = React.memo(function PlacesSidebar(props: PlacesSidebarProp
initialFile={sidebarDropFile}
/>
<ContextMenu menu={ctxMenu.menu} onClose={ctxMenu.close} />
{categoryPickerOpen && (
<PlacesBulkCategoryModal
count={selectedIds.size}
categories={categories}
onClose={() => setCategoryPickerOpen(false)}
onPick={(catId) => { onBulkChangeCategory?.(Array.from(selectedIds), catId); setCategoryPickerOpen(false); exitSelectMode() }}
/>
)}
{isMobile && (
<ConfirmDialog
isOpen={!!pendingDeleteIds?.length}
@@ -1,9 +1,9 @@
import { Check, Trash2 } from 'lucide-react'
import { Check, Tag, Trash2 } from 'lucide-react'
import Tooltip from '../shared/Tooltip'
import type { SidebarState } from './usePlacesSidebar'
export function PlacesSelectionBar(S: SidebarState) {
const { t, selectedIds, filtered, setSelectedIds, isMobile, setPendingDeleteIds, onBulkDeletePlaces } = S
const { t, selectedIds, filtered, setSelectedIds, isMobile, setPendingDeleteIds, onBulkDeletePlaces, setCategoryPickerOpen } = S
return (
<div style={{
margin: '6px 16px', padding: '5px 8px 5px 10px', borderRadius: 8,
@@ -32,6 +32,23 @@ export function PlacesSelectionBar(S: SidebarState) {
<Check size={13} strokeWidth={2.2} />
</button>
</Tooltip>
<Tooltip label={t('places.changeCategory')} placement="bottom">
<button
onClick={() => { if (selectedIds.size === 0) return; setCategoryPickerOpen(true) }}
disabled={selectedIds.size === 0}
aria-label={t('places.changeCategory')}
className={selectedIds.size > 0 ? 'bg-transparent text-content-muted' : 'bg-transparent text-content-faint'}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
width: 24, height: 24, borderRadius: 6, border: 'none',
cursor: selectedIds.size > 0 ? 'pointer' : 'default', padding: 0,
}}
onMouseEnter={e => { if (selectedIds.size > 0) e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
>
<Tag size={13} strokeWidth={2} />
</button>
</Tooltip>
<Tooltip label={t('places.deleteSelected')} placement="bottom">
<button
onClick={() => {
@@ -86,7 +86,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
const [form, setForm] = useState({
title: '', type: 'other', status: 'pending',
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
notes: '', assignment_id: '' as string | number, accommodation_id: '' as string | number,
notes: '', url: '', assignment_id: '' as string | number, accommodation_id: '' as string | number,
meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '',
hotel_place_id: '' as string | number, hotel_start_day: '' as string | number, hotel_end_day: '' as string | number,
hotel_address: '',
@@ -136,6 +136,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
location: reservation.location || '',
confirmation_number: reservation.confirmation_number || '',
notes: reservation.notes || '',
url: reservation.url || '',
assignment_id: reservation.assignment_id || '',
accommodation_id: reservation.accommodation_id || '',
meta_check_in_time: meta.check_in_time || '',
@@ -164,6 +165,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
location: prefill.location || '',
confirmation_number: prefill.confirmation_number || '',
notes: prefill.notes || '',
url: (prefill as { url?: string }).url || '',
assignment_id: defaultAssignmentId ?? '',
accommodation_id: '',
meta_check_in_time: meta.check_in_time || '',
@@ -180,7 +182,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
setForm({
title: '', type: 'other', status: 'pending',
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
notes: '', assignment_id: defaultAssignmentId ?? '', accommodation_id: '',
notes: '', url: '', assignment_id: defaultAssignmentId ?? '', accommodation_id: '',
meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '',
hotel_place_id: '', hotel_start_day: '', hotel_end_day: '', hotel_address: '',
})
@@ -237,6 +239,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
reservation_end_time: form.type === 'hotel' ? null : (combinedEndTime || null),
location: form.location, confirmation_number: form.confirmation_number,
notes: form.notes,
url: form.url,
assignment_id: (form.type === 'hotel' && !form.accommodation_id) ? null : (form.assignment_id || null),
accommodation_id: form.type === 'hotel' ? (form.accommodation_id || null) : null,
metadata: Object.keys(metadata).length > 0 ? metadata : null,
@@ -591,6 +594,16 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
</>
)}
{/* Link */}
<div>
<label className={labelClass}>{t('reservations.urlLabel')}</label>
<div className="relative">
<Link2 size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-content-muted pointer-events-none" />
<input type="url" value={form.url} onChange={e => set('url', e.target.value)}
placeholder={t('reservations.urlPlaceholder')} className={inputClass} style={{ paddingLeft: 34 }} />
</div>
</div>
{/* Notes */}
<div>
<label className={labelClass}>{t('reservations.notes')}</label>
@@ -361,6 +361,18 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
</div>
)}
{/* Link */}
{r.url && (
<div>
<div className={fieldLabelClass}>{t('reservations.urlLabel')}</div>
<div className={fieldValueClass} style={{ display: 'flex', alignItems: 'center', gap: 6, fontWeight: 400 }}>
<ExternalLink size={13} className="text-content-faint" style={{ flexShrink: 0 }} />
<a href={r.url} target="_blank" rel="noopener noreferrer" className="text-accent hover:underline"
style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.url}</a>
</div>
</div>
)}
{/* Notes */}
{r.notes && (
<div>
@@ -0,0 +1,133 @@
import { useState } from 'react'
import { Copy, Check, CalendarPlus, Calendar } from 'lucide-react'
interface SubscribeLinksProps {
httpsUrl: string
webcalUrl: string
}
/**
* Shared presentation for calendar subscription URLs. Renders one-click
* subscribe actions (Google deep link + webcal handoff) plus a copy fallback.
* Used by both the per-trip and all-trips subscribe modals.
*/
export function SubscribeLinks({ httpsUrl, webcalUrl }: SubscribeLinksProps) {
const [copied, setCopied] = useState<'https' | 'webcal' | null>(null)
// Google Calendar's add-by-URL deep link. The cid must carry the webcal://
// scheme (not https), URL-encoded, and the feed must be served over HTTPS.
const googleUrl = `https://www.google.com/calendar/render?cid=${encodeURIComponent(webcalUrl)}`
const copy = async (url: string, which: 'https' | 'webcal') => {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(url)
} else {
// Fallback for non-secure contexts (plain HTTP) where navigator.clipboard is unavailable
const ta = document.createElement('textarea')
ta.value = url
ta.style.position = 'fixed'
ta.style.left = '-9999px'
document.body.appendChild(ta)
ta.focus()
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
}
setCopied(which)
setTimeout(() => setCopied(null), 2000)
} catch { /* ignore */ }
}
return (
<div>
{/* One-click subscribe actions */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16 }}>
<a
href={googleUrl}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
padding: '9px 14px', borderRadius: 9, textDecoration: 'none',
background: 'var(--accent, #6366f1)', color: 'var(--accent-text, #fff)',
fontSize: 12, fontWeight: 600, fontFamily: 'inherit',
}}
>
<CalendarPlus size={14} strokeWidth={2} />
Add to Google Calendar
</a>
<a
href={webcalUrl}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
padding: '9px 14px', borderRadius: 9, textDecoration: 'none',
background: 'none', border: '1px solid var(--border-primary)',
color: 'var(--text-primary)', fontSize: 12, fontWeight: 600, fontFamily: 'inherit',
}}
>
<Calendar size={14} strokeWidth={2} />
Add to Apple Calendar / Outlook
</a>
</div>
{/* Manual fallback — raw URLs for any other client / "From URL" boxes */}
<details style={{ fontSize: 11, color: 'var(--text-muted)' }}>
<summary style={{ cursor: 'pointer', userSelect: 'none', marginBottom: 8 }}>
Or copy a link manually
</summary>
<UrlRow
label="Google Calendar"
hint="paste into “From URL”"
url={httpsUrl}
copied={copied === 'https'}
onCopy={() => copy(httpsUrl, 'https')}
/>
<UrlRow
label="Apple Calendar / Outlook"
hint="webcal://"
url={webcalUrl}
copied={copied === 'webcal'}
onCopy={() => copy(webcalUrl, 'webcal')}
/>
</details>
</div>
)
}
function UrlRow({ label, hint, url, copied, onCopy }: {
label: string; hint: string; url: string; copied: boolean; onCopy: () => void
}) {
return (
<div style={{ marginBottom: 12 }}>
<div style={{ fontSize: 11, fontWeight: 500, marginBottom: 5, color: 'var(--text-primary)' }}>
{label} <span style={{ color: 'var(--text-muted)', fontWeight: 400 }}> {hint}</span>
</div>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<div style={{
flex: 1, fontSize: 10, fontFamily: 'monospace',
padding: '5px 8px', borderRadius: 6,
border: '1px solid var(--border-faint)',
background: 'var(--bg-subtle, #f9fafb)',
color: 'var(--text-muted)',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{url}
</div>
<button
onClick={onCopy}
title="Copy"
style={{
flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
width: 28, height: 28, borderRadius: 6,
border: '1px solid var(--border-primary)', background: 'none',
cursor: 'pointer', color: copied ? 'var(--accent, #6366f1)' : 'var(--text-muted)',
transition: 'color 0.15s',
}}
>
{copied ? <Check size={12} strokeWidth={2.5} /> : <Copy size={12} strokeWidth={2} />}
</button>
</div>
</div>
)
}
@@ -25,6 +25,7 @@ export interface PlacesSidebarProps {
onDeletePlace: (placeId: number) => void
onBulkDeletePlaces?: (ids: number[]) => void
onBulkDeleteConfirm?: (ids: number[]) => void
onBulkChangeCategory?: (ids: number[], categoryId: number | null) => void
days: Day[]
isMobile: boolean
onCategoryFilterChange?: (categoryIds: Set<string>) => void
@@ -147,6 +148,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
const [selectMode, setSelectMode] = useState(false)
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
const [pendingDeleteIds, setPendingDeleteIds] = useState<number[] | null>(null)
const [categoryPickerOpen, setCategoryPickerOpen] = useState(false)
const exitSelectMode = () => { setSelectMode(false); setSelectedIds(new Set()) }
@@ -258,6 +260,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
availableListImportProviders, hasMultipleListImportProviders, handleListImport,
search, setSearch, filter, setFilter, categoryFilters, setCategoryFiltersLocal,
selectMode, setSelectMode, selectedIds, setSelectedIds, pendingDeleteIds, setPendingDeleteIds,
categoryPickerOpen, setCategoryPickerOpen,
exitSelectMode, toggleSelected, toggleCategoryFilter, dayPickerPlace, setDayPickerPlace,
catDropOpen, setCatDropOpen, mobileShowDays, setMobileShowDays,
hasTracks, plannedIds, filtered, registerPlaceRow, isAssignedToSelectedDay, inDaySet, openContextMenu,
+327 -89
View File
@@ -1,14 +1,30 @@
/**
* Offline settings tab — shows cached trips, storage info, and controls
* to re-sync or clear the offline cache.
* Offline settings tab (#1135) — controls for:
* - Offline mode: a force-offline switch that first downloads everything, then
* routes the app to the cache + mutation queue.
* - Prepare for offline: an awaited, progress-tracked full download.
* - What to store: a map-tiles toggle plus a per-trip on/off.
* - Sync conflicts: a keep-mine / keep-theirs resolver and a default strategy.
* - Cache stats + clear.
*/
import React, { useState, useEffect, useCallback } from 'react'
import { Wifi, RefreshCw, Trash2, Database } from 'lucide-react'
import { RefreshCw, Trash2, Database, CloudOff, Download, Check, GitMerge, Map as MapIcon } from 'lucide-react'
import Section from './Section'
import { offlineDb, clearAll } from '../../db/offlineDb'
import { tripSyncManager } from '../../sync/tripSyncManager'
import ToggleSwitch from './ToggleSwitch'
import { offlineDb, clearAll, clearTripData } from '../../db/offlineDb'
import { tripsApi } from '../../api/client'
import { tripSyncManager, type PrepareProgress } from '../../sync/tripSyncManager'
import { mutationQueue } from '../../sync/mutationQueue'
import type { SyncMeta } from '../../db/offlineDb'
import { clearTileCache } from '../../sync/tilePrefetcher'
import { isEffectivelyOffline } from '../../sync/networkMode'
import {
getOfflinePrefs, setCacheTiles, setConflictStrategy,
isTripOfflineEnabled, setTripOfflineEnabled, onOfflinePrefsChange,
type ConflictStrategy,
} from '../../sync/offlinePrefs'
import { useNetworkMode } from '../../hooks/useNetworkMode'
import { useTranslation } from '../../i18n'
import type { SyncMeta, QueuedMutation } from '../../db/offlineDb'
import type { Trip } from '../../types'
interface CachedTripRow {
@@ -18,24 +34,43 @@ interface CachedTripRow {
fileCount: number
}
function conflictName(m: QueuedMutation): string {
const body = (m.body ?? {}) as { name?: unknown }
const server = (m.conflictServer ?? {}) as { name?: unknown }
return (typeof body.name === 'string' && body.name)
|| (typeof server.name === 'string' && server.name)
|| `#${m.entityId ?? ''}`
}
export default function OfflineTab(): React.ReactElement {
const { t } = useTranslation()
const { offline, forced, setForced } = useNetworkMode()
const [rows, setRows] = useState<CachedTripRow[]>([])
const [allTrips, setAllTrips] = useState<Trip[]>([])
const [pendingCount, setPendingCount] = useState(0)
const [failedCount, setFailedCount] = useState(0)
const [conflicts, setConflicts] = useState<QueuedMutation[]>([])
const [syncing, setSyncing] = useState(false)
const [clearing, setClearing] = useState(false)
const [loading, setLoading] = useState(true)
const [preparing, setPreparing] = useState(false)
const [progress, setProgress] = useState<PrepareProgress | null>(null)
const [prefs, setPrefs] = useState(getOfflinePrefs())
useEffect(() => onOfflinePrefsChange(() => setPrefs(getOfflinePrefs())), [])
const load = useCallback(async () => {
setLoading(true)
try {
const [metas, pending, failed] = await Promise.all([
const [metas, pending, failed, conflictList] = await Promise.all([
offlineDb.syncMeta.toArray(),
mutationQueue.pendingCount(),
mutationQueue.failedCount(),
mutationQueue.conflicts(),
])
setPendingCount(pending)
setFailedCount(failed)
setConflicts(conflictList)
const result: CachedTripRow[] = []
for (const meta of metas) {
@@ -49,6 +84,18 @@ export default function OfflineTab(): React.ReactElement {
}
result.sort((a, b) => (a.trip.start_date ?? '').localeCompare(b.trip.start_date ?? ''))
setRows(result)
// The per-trip storage toggles are driven by the FULL trip list, not just
// the cached ones, so a trip turned off stays visible and re-enableable.
try {
const trips = isEffectivelyOffline()
? await offlineDb.trips.toArray()
: await tripsApi.list().then(r => (r as { trips: Trip[] }).trips).catch(() => offlineDb.trips.toArray())
trips.sort((a, b) => (a.start_date ?? '').localeCompare(b.start_date ?? ''))
setAllTrips(trips)
} catch {
setAllTrips([])
}
} finally {
setLoading(false)
}
@@ -56,6 +103,29 @@ export default function OfflineTab(): React.ReactElement {
useEffect(() => { load() }, [load])
const runPrepare = useCallback(async () => {
setPreparing(true)
setProgress(null)
try {
await tripSyncManager.prepareForOffline(p => setProgress(p))
await load()
} finally {
setPreparing(false)
}
}, [load])
async function handleToggleForce() {
if (!forced) {
// Turning offline mode on: download everything first (while still online),
// then engage so the app has all it needs before the network drops.
if (navigator.onLine) await runPrepare()
setForced(true)
} else {
// Back online: lifting the switch flushes the queue + re-syncs (syncTriggers).
setForced(false)
}
}
async function handleResync() {
setSyncing(true)
try {
@@ -67,7 +137,7 @@ export default function OfflineTab(): React.ReactElement {
}
async function handleClear() {
if (!window.confirm('Clear all offline trip data? You can re-sync anytime while online.')) return
if (!window.confirm(t('settings.offline.clearConfirm'))) return
setClearing(true)
try {
await clearAll()
@@ -77,104 +147,272 @@ export default function OfflineTab(): React.ReactElement {
}
}
async function handleToggleTiles() {
const next = !prefs.cacheTiles
setCacheTiles(next)
// Turning tiles off reclaims the bulk tile storage straight away.
if (!next) await clearTileCache()
}
async function handleToggleTrip(tripId: number) {
const next = !isTripOfflineEnabled(tripId)
setTripOfflineEnabled(tripId, next)
if (!next) {
await clearTripData(tripId)
await load()
} else if (navigator.onLine) {
tripSyncManager.syncAll().then(load).catch(() => {})
}
}
async function resolveConflict(id: string, keepMine: boolean) {
if (keepMine) await mutationQueue.resolveKeepMine(id)
else await mutationQueue.resolveKeepServer(id)
await load()
}
const formatDate = (d: string | null | undefined) =>
d ? new Date(d).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : '—'
const progressLabel = progress
? `${t(`settings.offline.prepare.phase.${progress.phase === 'done' ? 'trips' : progress.phase}`)} · ${progress.current}/${progress.total}`
: ''
return (
<Section title="Offline Cache" icon={Database}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
<div>
{/* Offline mode + prepare */}
<Section title={t('settings.offline.mode.title')} icon={CloudOff}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<Row
label={t('settings.offline.mode.force')}
hint={t('settings.offline.mode.forceHint')}
control={<ToggleSwitch on={forced} onToggle={handleToggleForce} label={t('settings.offline.mode.force')} />}
/>
{forced && (
<p className="text-content-muted" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', margin: 0 }}>
{t('settings.offline.mode.active')}
</p>
)}
{/* Stats row */}
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<Stat label="Cached trips" value={rows.length} />
<Stat label="Pending changes" value={pendingCount} />
{failedCount > 0 && <Stat label="Failed changes" value={failedCount} danger />}
</div>
{/* Actions */}
<div style={{ display: 'flex', gap: 8 }}>
<button
onClick={handleResync}
disabled={syncing || !navigator.onLine}
className="border border-edge bg-surface-secondary text-content"
style={{
display: 'flex', alignItems: 'center', gap: 6, padding: '8px 14px',
borderRadius: 8,
cursor: syncing || !navigator.onLine ? 'not-allowed' : 'pointer',
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, opacity: !navigator.onLine ? 0.5 : 1,
}}
>
<RefreshCw size={14} style={syncing ? { animation: 'spin 1s linear infinite' } : {}} />
{syncing ? 'Syncing…' : 'Re-sync now'}
</button>
<button
onClick={handleClear}
disabled={clearing || rows.length === 0}
className="border border-edge bg-surface-secondary text-[#ef4444]"
style={{
display: 'flex', alignItems: 'center', gap: 6, padding: '8px 14px',
borderRadius: 8,
cursor: clearing || rows.length === 0 ? 'not-allowed' : 'pointer',
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, opacity: rows.length === 0 ? 0.5 : 1,
}}
>
<Trash2 size={14} />
Clear cache
</button>
</div>
{/* Cached trip list */}
{loading ? (
<p className="text-content-muted" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>Loading</p>
) : rows.length === 0 ? (
<p className="text-content-muted" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>
No trips cached yet. Connect to internet to sync.
</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{rows.map(({ trip, meta, placeCount, fileCount }) => (
<div
key={trip.id}
className="border border-edge bg-surface-secondary"
style={{
padding: '10px 14px', borderRadius: 8,
display: 'flex', flexDirection: 'column', gap: 2,
}}
<div style={{ borderTop: '1px solid var(--border-secondary, #e5e7eb)', paddingTop: 16 }}>
<div style={{ fontWeight: 600, fontSize: 'calc(14px * var(--fs-scale-body, 1))', marginBottom: 4 }} className="text-content">
{t('settings.offline.prepare.title')}
</div>
<p className="text-content-muted" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', marginTop: 0, marginBottom: 12 }}>
{t('settings.offline.prepare.hint')}
</p>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<button
onClick={runPrepare}
disabled={preparing || offline}
className="border border-edge bg-surface-secondary text-content"
style={btnStyle(preparing || offline)}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span className="text-content" style={{ fontWeight: 600, fontSize: 'calc(14px * var(--fs-scale-body, 1))' }}>
{trip.title}
</span>
<span className="text-content-muted" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))' }}>
<Wifi size={10} style={{ display: 'inline', marginRight: 3 }} />
{meta.lastSyncedAt
? new Date(meta.lastSyncedAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
: '—'}
</span>
{preparing
? <RefreshCw size={14} style={{ animation: 'spin 1s linear infinite' }} />
: <Download size={14} />}
{preparing ? t('settings.offline.prepare.running') : t('settings.offline.prepare.button')}
</button>
<button
onClick={handleResync}
disabled={syncing || offline}
className="border border-edge bg-surface-secondary text-content"
style={btnStyle(syncing || offline)}
>
<RefreshCw size={14} style={syncing ? { animation: 'spin 1s linear infinite' } : {}} />
{syncing ? t('settings.offline.resyncing') : t('settings.offline.resync')}
</button>
</div>
{preparing && progress && (
<div style={{ marginTop: 12 }}>
<div style={{ height: 6, borderRadius: 3, overflow: 'hidden', background: 'var(--border-primary, #e5e7eb)' }}>
<div style={{
height: '100%', borderRadius: 3, background: 'var(--accent, #4F46E5)',
width: `${progress.total ? Math.round((progress.current / progress.total) * 100) : 100}%`,
transition: 'width 0.2s',
}} />
</div>
<span className="text-content-muted" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>
{formatDate(trip.start_date)} {formatDate(trip.end_date)}
{' · '}
{placeCount} place{placeCount !== 1 ? 's' : ''}
{' · '}
{fileCount} file{fileCount !== 1 ? 's' : ''}
<div className="text-content-muted" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', marginTop: 4 }}>
{progressLabel}{progress.label ? ` · ${progress.label}` : ''}
</div>
</div>
)}
{!preparing && progress?.phase === 'done' && (
<div className="text-content-muted" style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 'calc(12px * var(--fs-scale-body, 1))', marginTop: 10, color: '#10b981' }}>
<Check size={14} /> {t('settings.offline.prepare.done')}
</div>
)}
</div>
</div>
</Section>
{/* Conflicts (only when there are any) */}
{conflicts.length > 0 && (
<Section title={t('settings.offline.conflicts.title')} icon={GitMerge}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<p className="text-content-muted" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', margin: 0 }}>
{t('settings.offline.conflicts.hint')}
</p>
{conflicts.map(c => (
<div key={c.id} className="border border-edge bg-surface-secondary" style={{ padding: '10px 14px', borderRadius: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<span className="text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500 }}>
{t('settings.offline.conflicts.item', { name: conflictName(c) })}
</span>
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={() => resolveConflict(c.id, true)} className="border border-edge bg-surface-card text-content" style={smallBtnStyle()}>
{t('settings.offline.conflicts.keepMine')}
</button>
<button onClick={() => resolveConflict(c.id, false)} className="border border-edge bg-surface-card text-content" style={smallBtnStyle()}>
{t('settings.offline.conflicts.keepServer')}
</button>
</div>
</div>
))}
<Row
label={t('settings.offline.conflicts.strategyTitle')}
control={
<select
value={prefs.conflictStrategy}
onChange={e => setConflictStrategy(e.target.value as ConflictStrategy)}
className="border border-edge bg-surface-secondary text-content"
style={{ padding: '6px 10px', borderRadius: 8, fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}
>
<option value="ask">{t('settings.offline.conflicts.strategy.ask')}</option>
<option value="mine">{t('settings.offline.conflicts.strategy.mine')}</option>
<option value="server">{t('settings.offline.conflicts.strategy.server')}</option>
</select>
}
/>
</div>
)}
</Section>
)}
{/* What to store offline */}
<Section title={t('settings.offline.storage.title')} icon={MapIcon}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<Row
label={t('settings.offline.storage.tiles')}
hint={t('settings.offline.storage.tilesHint')}
control={<ToggleSwitch on={prefs.cacheTiles} onToggle={handleToggleTiles} label={t('settings.offline.storage.tiles')} />}
/>
{allTrips.length > 0 && (
<div style={{ borderTop: '1px solid var(--border-secondary, #e5e7eb)', paddingTop: 16 }}>
<div style={{ fontWeight: 600, fontSize: 'calc(13px * var(--fs-scale-body, 1))', marginBottom: 8 }} className="text-content">
{t('settings.offline.storage.tripsTitle')}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{allTrips.map((trip) => {
const on = isTripOfflineEnabled(trip.id)
return (
<div key={trip.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
<div style={{ minWidth: 0 }}>
<div className="text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{trip.title}
</div>
<div className="text-content-muted" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))' }}>
{on ? t('settings.offline.storage.tripOn') : t('settings.offline.storage.tripOff')}
</div>
</div>
<ToggleSwitch on={on} onToggle={() => handleToggleTrip(trip.id)} label={trip.title} />
</div>
)
})}
</div>
</div>
)}
</div>
</Section>
{/* Cache stats + list + clear */}
<Section title={t('settings.offline.cache.title')} icon={Database}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<Stat label={t('settings.offline.stats.trips')} value={rows.length} />
<Stat label={t('settings.offline.stats.pending')} value={pendingCount} />
{conflicts.length > 0 && <Stat label={t('settings.offline.stats.conflicts')} value={conflicts.length} danger />}
{failedCount > 0 && <Stat label={t('settings.offline.stats.failed')} value={failedCount} danger />}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button
onClick={handleClear}
disabled={clearing || rows.length === 0}
className="border border-edge bg-surface-secondary text-[#ef4444]"
style={btnStyle(clearing || rows.length === 0)}
>
<Trash2 size={14} />
{t('settings.offline.clear')}
</button>
</div>
{loading ? (
<p className="text-content-muted" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>{t('settings.offline.loading')}</p>
) : rows.length === 0 ? (
<p className="text-content-muted" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>
{t('settings.offline.empty')}
</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{rows.map(({ trip, meta, placeCount, fileCount }) => (
<div
key={trip.id}
className="border border-edge bg-surface-secondary"
style={{ padding: '10px 14px', borderRadius: 8, display: 'flex', flexDirection: 'column', gap: 2 }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span className="text-content" style={{ fontWeight: 600, fontSize: 'calc(14px * var(--fs-scale-body, 1))' }}>
{trip.title}
</span>
<span className="text-content-muted" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))' }}>
{meta.lastSyncedAt
? new Date(meta.lastSyncedAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
: '—'}
</span>
</div>
<span className="text-content-muted" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>
{formatDate(trip.start_date)} {formatDate(trip.end_date)}
{' · '}{placeCount}{' · '}{fileCount}
</span>
</div>
))}
</div>
)}
</div>
</Section>
</div>
)
}
function btnStyle(disabled: boolean): React.CSSProperties {
return {
display: 'flex', alignItems: 'center', gap: 6, padding: '8px 14px', borderRadius: 8,
cursor: disabled ? 'not-allowed' : 'pointer',
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, opacity: disabled ? 0.5 : 1,
}
}
function smallBtnStyle(): React.CSSProperties {
return {
padding: '6px 12px', borderRadius: 8, cursor: 'pointer',
fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 500,
}
}
function Row({ label, hint, control }: { label: string; hint?: string; control: React.ReactNode }) {
return (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 16 }}>
<div style={{ minWidth: 0 }}>
<div className="text-content" style={{ fontWeight: 500, fontSize: 'calc(14px * var(--fs-scale-body, 1))' }}>{label}</div>
{hint && <div className="text-content-muted" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', marginTop: 2 }}>{hint}</div>}
</div>
</Section>
<div style={{ flexShrink: 0 }}>{control}</div>
</div>
)
}
function Stat({ label, value, danger }: { label: string; value: number; danger?: boolean }) {
return (
<div className="border border-edge bg-surface-secondary" style={{
padding: '8px 14px', borderRadius: 8,
minWidth: 100,
}}>
<div className="border border-edge bg-surface-secondary" style={{ padding: '8px 14px', borderRadius: 8, minWidth: 100 }}>
<div style={{ fontSize: 'calc(20px * var(--fs-scale-title, 1))', fontWeight: 700, color: danger ? '#ef4444' : undefined }}
className={danger ? undefined : 'text-content'}>{value}</div>
<div className="text-content-muted" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))' }}>{label}</div>
+35 -2
View File
@@ -22,7 +22,7 @@ import TodoRow from './TodoRow'
export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tripId: number; items: TodoItem[]; addItemSignal?: number }) {
// Layout component: state/effects/derived/handlers live in useTodoList.
const {
canEdit, t, formatDate, toggleTodoItem,
canEdit, t, formatDate, toggleTodoItem, reorderTodoItems,
isMobile, filter, setFilter, selectedId, setSelectedId,
isAddingNew, setIsAddingNew, sortByPrio, setSortByPrio,
addingCategory, setAddingCategory, newCategoryName, setNewCategoryName,
@@ -31,6 +31,31 @@ export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tr
addCategory, catCount,
} = useTodoList(tripId, items, addItemSignal)
// Drag-to-reorder (#969). Manual ordering only makes sense when the list isn't
// sorted by priority; a drag within the filtered view is mapped back onto the
// full item order so unfiltered tasks keep their place.
const [dragId, setDragId] = useState<number | null>(null)
const [overId, setOverId] = useState<number | null>(null)
const canReorder = canEdit && !sortByPrio
const handleReorderDrop = (targetId: number) => {
const from = dragId
setDragId(null); setOverId(null)
if (from == null || from === targetId) return
const viewOrder = filtered.map(i => i.id)
const fi = viewOrder.indexOf(from)
const ti = viewOrder.indexOf(targetId)
if (fi < 0 || ti < 0) return
viewOrder.splice(fi, 1)
viewOrder.splice(ti, 0, from)
// Slot the reordered visible ids back into the positions they occupy in the
// global list, leaving every filtered-out task where it was.
const viewIds = new Set(filtered.map(i => i.id))
let vi = 0
const globalIds = items.map(i => (viewIds.has(i.id) ? viewOrder[vi++] : i.id))
reorderTodoItems(tripId, globalIds)
}
// Sidebar filter item
const SidebarItem = ({ id, icon: Icon, label, count, color }: { id: string; icon: any; label: string; count: number; color?: string }) => (
<button onClick={() => setFilter(id as FilterType)}
@@ -189,6 +214,14 @@ export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tr
formatDate={formatDate}
onSelect={(id) => { setSelectedId(id); setIsAddingNew(false) }}
onToggle={(id, checked) => toggleTodoItem(tripId, id, checked)}
drag={canReorder ? {
isDragging: dragId === item.id,
isOver: overId === item.id && dragId !== null && dragId !== item.id,
onStart: (id) => { setDragId(id); setOverId(null) },
onOver: (id) => setOverId(id),
onEnd: () => { setDragId(null); setOverId(null) },
onDrop: handleReorderDrop,
} : undefined}
/>
))
)}
@@ -446,7 +479,7 @@ function DetailPane({ item, tripId, categories, members, onClose }: {
{ value: '', label: t('todo.unassigned'), icon: <User size={14} className="text-content-faint" /> },
...members.map(m => ({
value: String(m.id),
label: m.username,
label: m.is_guest ? `${m.username} · ${t('members.guest')}` : m.username,
icon: m.avatar ? (
<img src={`/uploads/avatars/${m.avatar}`} style={{ width: 18, height: 18, borderRadius: '50%', objectFit: 'cover' as const }} alt="" />
) : (
+31 -3
View File
@@ -1,10 +1,10 @@
import { CheckSquare, Square, ChevronRight, Flag, Calendar } from 'lucide-react'
import { CheckSquare, Square, ChevronRight, Flag, Calendar, GripVertical, UserRound } from 'lucide-react'
import type { TodoItem } from '../../types'
import { katColor, PRIO_CONFIG, type Member } from './todoListModel'
/** A single task row in the todo list. Pure presentation; all behaviour is
* delegated to onSelect/onToggle so TodoListPanel stays a layout component. */
export default function TodoRow({ item, members, categories, today, isSelected, canEdit, formatDate, onSelect, onToggle }: {
export default function TodoRow({ item, members, categories, today, isSelected, canEdit, formatDate, onSelect, onToggle, drag }: {
item: TodoItem
members: Member[]
categories: string[]
@@ -14,24 +14,51 @@ export default function TodoRow({ item, members, categories, today, isSelected,
formatDate: (d: string) => string
onSelect: (id: number | null) => void
onToggle: (id: number, checked: boolean) => void
// Drag-to-reorder (#969); only provided when manual ordering is active.
drag?: {
isDragging: boolean
isOver: boolean
onStart: (id: number) => void
onOver: (id: number) => void
onEnd: () => void
onDrop: (targetId: number) => void
}
}) {
const done = !!item.checked
const assignedUser = members.find(m => m.id === item.assigned_user_id)
const isOverdue = item.due_date && !done && item.due_date < today
const catColor = item.category ? katColor(item.category, categories) : null
const canDrag = canEdit && !!drag
return (
<div key={item.id}
onClick={() => onSelect(isSelected ? null : item.id)}
onDragOver={canDrag ? (e => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; drag!.onOver(item.id) }) : undefined}
onDragLeave={canDrag ? (e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) drag!.onOver(-1) }) : undefined}
onDrop={canDrag ? (e => { e.preventDefault(); e.stopPropagation(); drag!.onDrop(item.id) }) : undefined}
style={{
display: 'flex', alignItems: 'center', gap: 10, padding: '10px 20px',
borderBottom: '1px solid var(--border-faint)', cursor: 'pointer',
background: isSelected ? 'var(--bg-hover)' : 'transparent',
transition: 'background 0.1s',
opacity: drag?.isDragging ? 0.4 : 1,
boxShadow: drag?.isOver ? 'inset 3px 0 0 0 var(--accent)' : 'none',
transition: 'background 0.1s, opacity 0.15s',
}}
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = 'rgba(0,0,0,0.02)' }}
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = 'transparent' }}>
{canDrag && (
<div
draggable
onClick={e => e.stopPropagation()}
onDragStart={e => { e.stopPropagation(); e.dataTransfer.effectAllowed = 'move'; drag!.onStart(item.id) }}
onDragEnd={() => drag!.onEnd()}
style={{ cursor: 'grab', display: 'flex', alignItems: 'center', color: 'var(--text-faint)', flexShrink: 0, marginLeft: -6 }}
>
<GripVertical size={14} />
</div>
)}
{/* Checkbox */}
<button onClick={e => { e.stopPropagation(); if (canEdit) onToggle(item.id, !done) }}
style={{ background: 'none', border: 'none', cursor: canEdit ? 'pointer' : 'default', padding: 0, flexShrink: 0,
@@ -104,6 +131,7 @@ export default function TodoRow({ item, members, categories, today, isSelected,
{assignedUser.username.charAt(0).toUpperCase()}
</span>
)}
{assignedUser.is_guest && <UserRound size={11} style={{ opacity: 0.7 }} />}
{assignedUser.username}
</span>
)}
+1 -1
View File
@@ -21,4 +21,4 @@ export function katColor(kat: string, allCategories: string[]) {
export type FilterType = 'all' | 'my' | 'overdue' | 'done' | string
export interface Member { id: number; username: string; avatar: string | null }
export interface Member { id: number; username: string; avatar: string | null; is_guest?: boolean }
+2 -2
View File
@@ -15,7 +15,7 @@ import type { FilterType, Member } from './todoListModel'
* (TodoRow) and the detail/new panes from this state.
*/
export function useTodoList(tripId: number, items: TodoItem[], addItemSignal: number) {
const { addTodoItem, updateTodoItem, deleteTodoItem, toggleTodoItem } = useTripStore()
const { addTodoItem, updateTodoItem, deleteTodoItem, toggleTodoItem, reorderTodoItems } = useTripStore()
const trip = useTripStore((s) => s.trip)
const can = useCanDo()
const canEdit = can('packing_edit', trip)
@@ -100,7 +100,7 @@ export function useTodoList(tripId: number, items: TodoItem[], addItemSignal: nu
const catCount = (cat: string) => items.filter(i => i.category === cat && !i.checked).length
return {
canEdit, t, formatDate, toggleTodoItem,
canEdit, t, formatDate, toggleTodoItem, reorderTodoItems,
isMobile, filter, setFilter, selectedId, setSelectedId,
isAddingNew, setIsAddingNew, sortByPrio, setSortByPrio,
addingCategory, setAddingCategory, newCategoryName, setNewCategoryName,
@@ -423,4 +423,42 @@ describe('TripMembersModal', () => {
render(<TripMembersModal {...defaultProps} />);
await screen.findByText('All users already have access.');
});
it('FE-COMP-MEMBERS-026: owner sees the guests section and can add a guest (#1362)', async () => {
let createdName: string | null = null;
server.use(
http.post('/api/trips/1/guests', async ({ request }) => {
createdName = ((await request.json()) as { name: string }).name;
return HttpResponse.json({ member: { id: 99, username: createdName, is_guest: true } });
}),
);
render(<TripMembersModal {...defaultProps} />);
// The guests section + add affordance is shown to the owner.
await screen.findByText('Guests');
const input = screen.getByPlaceholderText('Guest name');
await userEvent.type(input, 'Grandpa');
await userEvent.click(screen.getByRole('button', { name: /Add guest/i }));
await waitFor(() => expect(createdName).toBe('Grandpa'));
});
it('FE-COMP-MEMBERS-027: a guest member is shown in the guests section with a Guest badge, not the members list (#1362)', async () => {
server.use(
http.get('/api/trips/1/members', () =>
HttpResponse.json({
owner: { id: ownerUser.id, username: ownerUser.username, avatar_url: null, is_guest: false },
members: [
{ id: 2, username: 'alice', avatar_url: null, is_guest: false },
{ id: 3, username: 'Grandma', avatar_url: null, is_guest: true },
],
current_user_id: ownerUser.id,
})
),
);
render(<TripMembersModal {...defaultProps} />);
await screen.findByText('Grandma');
// The guest carries a "Guest" badge.
expect(screen.getAllByText('Guest').length).toBeGreaterThan(0);
// Access count covers owner + the real member only (2), not the guest.
expect(screen.getByText(/Access \(2/)).toBeInTheDocument();
});
});
@@ -5,7 +5,7 @@ import { useToast } from '../shared/Toast'
import { useAuthStore } from '../../store/authStore'
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import { Crown, UserMinus, UserPlus, Users, LogOut, Link2, Trash2, Copy, Check } from 'lucide-react'
import { Crown, UserMinus, UserPlus, Users, LogOut, Link2, Trash2, Copy, Check, UserRound, Pencil, Plus } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { getApiErrorMessage } from '../../types'
import CustomSelect from '../shared/CustomSelect'
@@ -177,6 +177,11 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
const [selectedUserId, setSelectedUserId] = useState('')
const [adding, setAdding] = useState(false)
const [removingId, setRemovingId] = useState(null)
const [transferringId, setTransferringId] = useState(null)
const [newGuestName, setNewGuestName] = useState('')
const [addingGuest, setAddingGuest] = useState(false)
const [renamingGuestId, setRenamingGuestId] = useState(null)
const [renameValue, setRenameValue] = useState('')
const toast = useToast()
const { user } = useAuthStore()
const { t } = useTranslation()
@@ -227,6 +232,63 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
}
}
const handleTransfer = async (newOwnerId, username) => {
if (!confirm(t('members.confirmTransfer', { name: username }))) return
setTransferringId(newOwnerId)
try {
await tripsApi.transferOwnership(tripId, newOwnerId)
// The current user just dropped from owner to member — reload so the trip
// state and permissions everywhere reflect the new ownership.
onClose()
window.location.reload()
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('members.transferError')))
setTransferringId(null)
}
}
const handleAddGuest = async () => {
const name = newGuestName.trim()
if (!name) return
setAddingGuest(true)
try {
await tripsApi.createGuest(tripId, name)
setNewGuestName('')
await loadMembers()
toast.success(t('members.guestAdded'))
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('members.guestAddError')))
} finally {
setAddingGuest(false)
}
}
const handleRenameGuest = async (userId) => {
const name = renameValue.trim()
if (!name) { setRenamingGuestId(null); return }
try {
await tripsApi.renameGuest(tripId, userId, name)
setRenamingGuestId(null)
await loadMembers()
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('members.guestRenameError')))
}
}
const handleDeleteGuest = async (userId) => {
if (!confirm(t('members.confirmRemoveGuest'))) return
setRemovingId(userId)
try {
await tripsApi.deleteGuest(tripId, userId)
await loadMembers()
toast.success(t('members.guestRemoved'))
} catch {
toast.error(t('members.removeError'))
} finally {
setRemovingId(null)
}
}
const handleRemove = async (userId, isSelf) => {
const msg = isSelf
? t('members.confirmLeave')
@@ -244,18 +306,20 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
}
}
// Users not yet in the trip
// Users not yet in the trip (guests are accountless and never live in the directory)
const existingIds = new Set([
data?.owner?.id,
...(data?.members?.map(m => m.id) || []),
])
const availableUsers = allUsers.filter(u => !existingIds.has(u.id))
const availableUsers = allUsers.filter(u => !existingIds.has(u.id) && !u.is_guest)
const isCurrentOwner = data?.owner?.id === user?.id
const allMembers = data ? [
// Real members (owner + accounts) and guests (#1362) are listed separately.
const realMembers = data ? [
{ ...data.owner, role: 'owner' },
...data.members,
...data.members.filter(m => !m.is_guest),
] : []
const guests = data ? data.members.filter(m => m.is_guest) : []
return (
<Modal isOpen={isOpen} onClose={onClose} title={t('members.shareTrip')} size="3xl">
@@ -315,7 +379,7 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 10 }}>
<Users size={13} className="text-content-faint" />
<span className="text-content-secondary" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600 }}>
{t('members.access')} ({allMembers.length} {allMembers.length === 1 ? t('members.person') : t('members.persons')})
{t('members.access')} ({realMembers.length} {realMembers.length === 1 ? t('members.person') : t('members.persons')})
</span>
</div>
@@ -327,7 +391,7 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{allMembers.map(member => {
{realMembers.map(member => {
const isSelf = member.id === user?.id
const canRemove = isSelf || (canManageMembers && member.role !== 'owner')
return (
@@ -347,6 +411,18 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
)}
</div>
</div>
{isCurrentOwner && member.role !== 'owner' && (
<button
onClick={() => handleTransfer(member.id, member.username)}
disabled={transferringId === member.id}
title={t('members.makeOwner')}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '4px', borderRadius: 6, display: 'flex', color: 'var(--text-faint)', opacity: transferringId === member.id ? 0.4 : 1 }}
onMouseEnter={e => e.currentTarget.style.color = '#d97706'}
onMouseLeave={e => e.currentTarget.style.color = '#9ca3af'}
>
<Crown size={14} />
</button>
)}
{canRemove && (
<button
onClick={() => handleRemove(member.id, isSelf)}
@@ -366,6 +442,97 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
)}
</div>
{/* Guests (#1362) — accountless participants, managed by the owner */}
{(isCurrentOwner || guests.length > 0) && (
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
<UserRound size={13} className="text-content-faint" />
<span className="text-content-secondary" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600 }}>
{t('members.guests')}{guests.length > 0 ? ` (${guests.length})` : ''}
</span>
</div>
<p className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', margin: '0 0 10px', lineHeight: 1.5 }}>{t('members.guestsHint')}</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{guests.map(g => (
<div key={g.id} className="bg-surface-secondary border border-edge-secondary" style={{
display: 'flex', alignItems: 'center', gap: 10, padding: '8px 12px', borderRadius: 10,
}}>
<Avatar username={g.username} avatarUrl={null} />
{renamingGuestId === g.id ? (
<input
autoFocus
value={renameValue}
onChange={e => setRenameValue(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleRenameGuest(g.id); if (e.key === 'Escape') setRenamingGuestId(null) }}
onBlur={() => handleRenameGuest(g.id)}
maxLength={50}
className="bg-surface border border-edge text-content"
style={{ flex: 1, minWidth: 0, fontSize: 'calc(13px * var(--fs-scale-body, 1))', padding: '4px 8px', borderRadius: 8, outline: 'none', fontFamily: 'inherit' }}
/>
) : (
<div style={{ flex: 1, minWidth: 0, display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<span className="text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{g.username}</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-muted)', background: 'var(--bg-tertiary)', padding: '1px 6px', borderRadius: 99 }}>
<UserRound size={9} /> {t('members.guest')}
</span>
</div>
)}
{isCurrentOwner && renamingGuestId !== g.id && (
<>
<button
onClick={() => { setRenamingGuestId(g.id); setRenameValue(g.username) }}
title={t('common.rename')}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '4px', borderRadius: 6, display: 'flex', color: 'var(--text-faint)' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-secondary)'}
onMouseLeave={e => e.currentTarget.style.color = '#9ca3af'}
>
<Pencil size={13} />
</button>
<button
onClick={() => handleDeleteGuest(g.id)}
disabled={removingId === g.id}
title={t('members.removeAccess')}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '4px', borderRadius: 6, display: 'flex', color: 'var(--text-faint)', opacity: removingId === g.id ? 0.4 : 1 }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
onMouseLeave={e => e.currentTarget.style.color = '#9ca3af'}
>
<Trash2 size={13} />
</button>
</>
)}
</div>
))}
</div>
{isCurrentOwner && (
<div style={{ display: 'flex', gap: 8, marginTop: guests.length > 0 ? 8 : 0 }}>
<input
value={newGuestName}
onChange={e => setNewGuestName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleAddGuest() }}
placeholder={t('members.guestNamePlaceholder')}
maxLength={50}
className="bg-surface border border-edge text-content"
style={{ flex: 1, minWidth: 0, fontSize: 'calc(13px * var(--fs-scale-body, 1))', padding: '8px 10px', borderRadius: 10, outline: 'none', fontFamily: 'inherit' }}
/>
<button
onClick={handleAddGuest}
disabled={addingGuest || !newGuestName.trim()}
style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '8px 14px',
background: 'var(--bg-tertiary)', color: 'var(--text-primary)', border: '1px solid var(--border-primary)', borderRadius: 10,
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: addingGuest || !newGuestName.trim() ? 'default' : 'pointer',
fontFamily: 'inherit', opacity: addingGuest || !newGuestName.trim() ? 0.4 : 1, flexShrink: 0,
}}
>
<Plus size={13} /> {addingGuest ? '…' : t('members.addGuest')}
</button>
</div>
)}
</div>
)}
</div>
{/* Right column: Share Link */}
@@ -0,0 +1,24 @@
import { UserRound } from 'lucide-react'
import { useTranslation } from '../../i18n'
/**
* Small "Guest" pill (#1362) shown next to a member's name in assignment pickers
* so it's clear the person is an accountless guest. Purely presentational.
*/
export default function GuestBadge({ size = 'sm' }: { size?: 'sm' | 'xs' }) {
const { t } = useTranslation()
const fs = size === 'xs' ? 9 : 10
return (
<span
title={t('members.guestsHint')}
style={{
display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0,
fontSize: `calc(${fs}px * var(--fs-scale-caption, 1))`, fontWeight: 600,
color: 'var(--text-muted)', background: 'var(--bg-tertiary)',
padding: '1px 6px', borderRadius: 99,
}}
>
<UserRound size={fs - 1} /> {t('members.guest')}
</span>
)
}
+31 -3
View File
@@ -8,7 +8,10 @@ export interface CachedTripMember extends TripMember {
// ── Queue + sync types ────────────────────────────────────────────────────────
export type MutationStatus = 'pending' | 'syncing' | 'failed';
// 'conflict' is terminal-until-resolved: the server rejected the replay because
// the entity changed underneath the offline edit (#1135 ask 3). It is surfaced
// to the user for a keep-mine / keep-theirs decision rather than dropped.
export type MutationStatus = 'pending' | 'syncing' | 'failed' | 'conflict';
export interface QueuedMutation {
/** UUID — also used as X-Idempotency-Key sent to the server */
@@ -33,6 +36,21 @@ export interface QueuedMutation {
* mutation queue rewrites to the real server id once the dependent CREATE flushes.
*/
tempEntityId?: number;
/**
* Optimistic-concurrency token: the entity's `updated_at` at the moment the
* offline edit was made. Sent as `X-Base-Updated-At` on replay so the server
* can reject the write (409) if someone else changed the entity in the
* meantime. Absent for creates and for resources without a token.
*/
baseUpdatedAt?: string | null;
/**
* Set when the replay came back 409: the server's current version of the
* entity, kept so the conflict resolver can show "theirs" beside "mine"
* (which is reconstructed from `body`). Only present while status==='conflict'.
*/
conflictServer?: unknown;
/** When the conflict was detected (for ordering / display). */
conflictAt?: number;
}
export interface SyncMeta {
@@ -348,7 +366,16 @@ export async function enforceBlobBudget(
// ── Eviction / cleanup ────────────────────────────────────────────────────────
/** Delete all cached data for one trip (eviction or explicit clear). */
/**
* Delete one trip's cached READ data (eviction, per-trip opt-out). The offline
* write queue is deliberately preserved except for already-dropped 'failed' rows:
* a trip can be evicted for being stale, or turned off in the storage settings,
* while it still holds unsynced offline edits (pending/syncing) or unresolved
* conflicts those must survive so the user's work is not silently lost (#1135).
* The replay only needs the queued REST request, not the cached entities, and a
* successful flush re-adds the canonical row. The full "Clear cache" wipe goes
* through clearAll(), which intentionally drops everything.
*/
export async function clearTripData(tripId: number): Promise<void> {
await offlineDb.transaction(
'rw',
@@ -376,7 +403,8 @@ export async function clearTripData(tripId: number): Promise<void> {
await offlineDb.tripFiles.where('trip_id').equals(tripId).delete();
await offlineDb.accommodations.where('trip_id').equals(tripId).delete();
await offlineDb.tripMembers.where('tripId').equals(tripId).delete();
await offlineDb.mutationQueue.where('tripId').equals(tripId).delete();
// Keep pending/syncing/conflict mutations — only purge dead 'failed' rows.
await offlineDb.mutationQueue.where('tripId').equals(tripId).and(m => m.status === 'failed').delete();
await offlineDb.syncMeta.where('tripId').equals(tripId).delete();
await offlineDb.blobCache.where('tripId').equals(tripId).delete();
},
+21
View File
@@ -0,0 +1,21 @@
import { useSyncExternalStore } from 'react'
import {
isEffectivelyOffline,
isForcedOffline,
setForcedOffline,
onNetworkModeChange,
} from '../sync/networkMode'
/**
* React binding for the global network mode. Re-renders when the browser goes
* online/offline or the user toggles force-offline.
*
* offline the effective offline state (real disconnection OR forced)
* forced whether the user has the force-offline switch on
* setForced flip the force-offline switch
*/
export function useNetworkMode(): { offline: boolean; forced: boolean; setForced: (v: boolean) => void } {
const offline = useSyncExternalStore(onNetworkModeChange, isEffectivelyOffline, () => true)
const forced = useSyncExternalStore(onNetworkModeChange, isForcedOffline, () => false)
return { offline, forced, setForced: setForcedOffline }
}
+1
View File
@@ -38,6 +38,7 @@ const localeLoaders: Record<SupportedLanguageCode, () => Promise<{ default: Tran
uk: () => import('@trek/shared/i18n/uk'),
gr: () => import('@trek/shared/i18n/gr'),
sv: () => import('@trek/shared/i18n/sv'),
vi: () => import('@trek/shared/i18n/vi'),
}
// Re-export pure helpers that live in shared so downstream consumers can import them
+42 -7
View File
@@ -16,8 +16,9 @@ import {
import {
Plus, Edit2, Trash2, Archive, Copy, ArrowRight, MapPin,
Plane, Hotel, Utensils, Clock, RefreshCw, ArrowRightLeft, Calendar,
LayoutGrid, List, Ticket, X,
LayoutGrid, List, Ticket, X, CalendarPlus,
} from 'lucide-react'
import { IcsSubscribeModal } from '../components/Planner/IcsSubscribeModal'
import { formatTime, splitReservationDateTime } from '../utils/formatters'
import { convertDistance, getDistanceUnitLabel } from '../utils/units'
import { useSettingsStore } from '../store/settingsStore'
@@ -36,17 +37,33 @@ const GRADIENTS = [
]
function tripGradient(id: number): string { return GRADIENTS[id % GRADIENTS.length] }
// Day + short month for the boarding pass / cards, e.g. { d: '10', m: 'Sep' }.
function splitDate(dateStr: string | null | undefined, locale: string): { d: string; m: string } | null {
// Day + short month for the boarding pass / cards, plus the year — but only
// when it isn't the current year (this year's trips stay clutter-free), e.g.
// { d: '10', m: 'Sep', y: '' } now vs { …, y: '2024' } for an older trip.
function splitDate(dateStr: string | null | undefined, locale: string): { d: string; m: string; y: string } | null {
if (!dateStr) return null
const date = new Date(dateStr + 'T00:00:00Z')
if (isNaN(date.getTime())) return null // malformed date — render a dash, never crash
const otherYear = date.getUTCFullYear() !== new Date().getUTCFullYear()
return {
d: date.toLocaleDateString(locale, { day: 'numeric', timeZone: 'UTC' }),
m: date.toLocaleDateString(locale, { month: 'short', timeZone: 'UTC' }),
y: otherYear ? date.toLocaleDateString(locale, { year: 'numeric', timeZone: 'UTC' }) : '',
}
}
// Localized date for the cards. The year is included only when it isn't the
// current year, and order/punctuation follow the locale (EN "Sep 10, 2026",
// DE "10. Sep 2026" — vs a plain "Sep 10" this year), never a hard-coded layout.
function fullDate(dateStr: string | null | undefined, locale: string): string | null {
if (!dateStr) return null
const date = new Date(dateStr + 'T00:00:00Z')
if (isNaN(date.getTime())) return null
const opts: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'short', timeZone: 'UTC' }
if (date.getUTCFullYear() !== new Date().getUTCFullYear()) opts.year = 'numeric'
return date.toLocaleDateString(locale, opts)
}
function buddyColor(seed: number): string {
const pairs = [
['#6366f1', '#8b5cf6'], ['#10b981', '#059669'], ['#f59e0b', '#d97706'],
@@ -91,6 +108,7 @@ export default function DashboardPage(): React.ReactElement {
showForm, setShowForm, editingTrip, setEditingTrip,
deleteTrip, setDeleteTrip, copyTrip, setCopyTrip, setTrips,
handleCreate, handleUpdate, confirmDelete, handleArchive, handleUnarchive, confirmCopy,
allSubOpen, setAllSubOpen,
} = useDashboard()
// Per-device dashboard widget visibility (from the appearance config).
@@ -149,11 +167,28 @@ export default function DashboardPage(): React.ReactElement {
<button className={tripFilter === 'archive' ? 'on' : ''} onClick={() => setTripFilter('archive')}>{t('dashboard.archived')}</button>
<button className={tripFilter === 'completed' ? 'on' : ''} onClick={() => setTripFilter('completed')}>{t('dashboard.mobile.completed')}</button>
</div>
<button
className="tool-action"
aria-label="Subscribe to all trips calendar"
title="Subscribe to all trips"
onClick={() => setAllSubOpen(true)}
style={{ width: 38, height: 38, borderRadius: 11 }}
>
<CalendarPlus size={17} />
</button>
<button className="tool-action" aria-label={t('dashboard.aria.toggleView')} onClick={toggleViewMode} style={{ width: 38, height: 38, borderRadius: 11 }}>
{viewMode === 'grid' ? <List size={17} /> : <LayoutGrid size={17} />}
</button>
</div>
</div>
{allSubOpen && (
<IcsSubscribeModal
endpoint="/api/feed/user"
title="Subscribe to all trips"
description="One calendar feed for all your active trips, kept in sync automatically. Excludes archived trips and trips that ended more than 90 days ago."
onClose={() => setAllSubOpen(false)}
/>
)}
{gridTrips.length === 0 && tripFilter === 'planned' && !isLoading && !loadError && (
<div className="trips-empty">
@@ -303,10 +338,10 @@ function BoardingPassHero({ trip, bundle, locale, onOpen, onEdit, onCopy, onArch
<div className="pass-cell dates-combined">
<div className="pass-label">{t('dashboard.hero.tripDates')}</div>
<div className="dates-row">
{start ? <div className="date-block"><div className="date-num mono">{start.d}</div><div className="date-month">{start.m}</div></div>
{start ? <div className="date-block"><div className="date-num mono">{start.d}</div><div className="date-month">{start.m}{start.y ? ` ${start.y}` : ''}</div></div>
: <div className="date-block"><div className="date-num"></div></div>}
<div className="date-arrow"><ArrowRight /></div>
{end ? <div className="date-block"><div className="date-num mono">{end.d}</div><div className="date-month">{end.m}</div></div>
{end ? <div className="date-block"><div className="date-num mono">{end.d}</div><div className="date-month">{end.m}{end.y ? ` ${end.y}` : ''}</div></div>
: <div className="date-block"><div className="date-num"></div></div>}
</div>
</div>
@@ -511,9 +546,9 @@ function TripCard({ trip, locale, onOpen, onEdit, onCopy, onArchive, onDelete }:
<div className="trip-dates">
{start && end ? (
<>
<span className="date-num">{start.m} {start.d}</span>
<span className="date-num">{fullDate(trip.start_date, locale)}</span>
<span className="date-arrow"><ArrowRight size={11} /></span>
<span className="date-num">{end.m} {end.d}</span>
<span className="date-num">{fullDate(trip.end_date, locale)}</span>
</>
) : <span>{t('dashboard.hero.noDates')}</span>}
</div>
+188
View File
@@ -0,0 +1,188 @@
import { Link } from 'react-router-dom'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { Search, ChevronRight, Loader2, AlertCircle, BookOpen, PanelLeft, X } from 'lucide-react'
import PageShell from '../components/Layout/PageShell'
import { useTranslation } from '../i18n'
import { useHelp } from './help/useHelp'
export default function HelpPage() {
const { t } = useTranslation()
const { page, loading, pageError, query, setQuery, navOpen, setNavOpen, contentRef, activeSlug, filtered } =
useHelp()
const nav = (
<nav className="flex flex-col gap-5">
<div className="relative">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-content-faint" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t('help.search')}
className="w-full bg-surface-tertiary text-content rounded-lg pl-9 pr-3 py-2 text-[13px] outline-none border border-transparent focus:border-edge"
/>
</div>
{filtered.map((section) => (
<div key={section.title}>
{section.title && (
<h3 className="text-[10px] font-semibold tracking-[0.1em] uppercase text-content-faint mb-1.5 px-2">
{section.title}
</h3>
)}
<div className="flex flex-col">
{section.pages.map((p) => {
const active = p.slug === activeSlug
return (
<Link
key={p.slug}
to={`/help/${p.slug}`}
className={`flex items-center gap-1.5 px-2 py-1.5 rounded-lg text-[13px] transition-colors ${
active
? 'bg-accent-subtle text-accent font-semibold'
: 'text-content-secondary hover:bg-surface-hover'
}`}
>
{active && <ChevronRight size={13} className="shrink-0" />}
<span className={active ? '' : 'pl-[18px]'}>{p.title}</span>
</Link>
)
})}
</div>
</div>
))}
{!filtered.length && <p className="text-[12px] text-content-faint px-2">{t('help.noResults')}</p>}
</nav>
)
return (
<PageShell className="bg-surface-secondary" navOffset="var(--nav-h, 56px)">
<div className="max-w-[1600px] mx-auto px-4 lg:px-10 py-6 flex gap-10">
{/* Desktop sidebar */}
<aside className="hidden lg:block w-[260px] shrink-0">
<div className="sticky top-[calc(var(--nav-h,56px)+24px)] max-h-[calc(100vh-var(--nav-h,56px)-48px)] overflow-y-auto pr-1">
<div className="flex items-center gap-2 mb-4 px-2">
<BookOpen size={16} className="text-accent" />
<span className="text-[14px] font-bold text-content">{t('help.title')}</span>
</div>
{nav}
</div>
</aside>
{/* Content */}
<main className="flex-1 min-w-0" ref={contentRef}>
{/* Mobile nav toggle */}
<button
onClick={() => setNavOpen(true)}
className="lg:hidden inline-flex items-center gap-2 mb-4 px-3 py-2 rounded-lg bg-surface-card border border-edge text-[13px] font-medium text-content"
>
<PanelLeft size={15} /> {t('help.contents')}
</button>
{loading ? (
<div className="flex items-center justify-center py-24 text-content-faint">
<Loader2 size={22} className="animate-spin" />
</div>
) : pageError ? (
<div className="flex flex-col items-center justify-center gap-2 py-24 text-center">
<AlertCircle size={28} className="text-content-faint" />
<p className="text-[14px] font-semibold text-content">{t('help.errorTitle')}</p>
<p className="text-[13px] text-content-faint max-w-sm">{t('help.errorBody')}</p>
</div>
) : page ? (
<article className="wiki-prose max-w-[1040px]">
<WikiContent markdown={page.markdown} />
</article>
) : null}
</main>
</div>
{/* Mobile sidebar drawer */}
{navOpen && (
<div className="lg:hidden fixed inset-0 z-[120]" onClick={() => setNavOpen(false)}>
<div className="absolute inset-0 bg-black/40" />
<div
className="absolute left-0 top-0 bottom-0 w-[280px] bg-surface-card p-5 overflow-y-auto shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-4">
<span className="text-[14px] font-bold text-content flex items-center gap-2">
<BookOpen size={16} className="text-accent" /> {t('help.title')}
</span>
<button onClick={() => setNavOpen(false)} className="text-content-faint">
<X size={18} />
</button>
</div>
{nav}
</div>
</div>
)}
</PageShell>
)
}
/** Markdown renderer with TREK-styled elements and SPA-internal links. */
function WikiContent({ markdown }: { markdown: string }) {
return (
<Markdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({ children }) => (
<h1 className="text-[26px] font-bold text-content mt-1 mb-4 leading-tight">{children}</h1>
),
h2: ({ children }) => (
<h2 className="text-[19px] font-bold text-content mt-8 mb-3 pb-1.5 border-b border-edge-secondary">
{children}
</h2>
),
h3: ({ children }) => <h3 className="text-[15.5px] font-semibold text-content mt-6 mb-2">{children}</h3>,
h4: ({ children }) => <h4 className="text-[14px] font-semibold text-content mt-5 mb-2">{children}</h4>,
p: ({ children }) => <p className="text-[14px] text-content-secondary leading-[1.7] my-3">{children}</p>,
ul: ({ children }) => <ul className="list-disc pl-5 my-3 space-y-1.5 text-[14px] text-content-secondary">{children}</ul>,
ol: ({ children }) => <ol className="list-decimal pl-5 my-3 space-y-1.5 text-[14px] text-content-secondary">{children}</ol>,
li: ({ children }) => <li className="leading-[1.6]">{children}</li>,
a: ({ href, children }) => {
const url = href ?? ''
if (url.startsWith('#')) return <a href={url} className="text-accent hover:underline">{children}</a>
if (url.startsWith('/')) return <Link to={url} className="text-accent hover:underline font-medium">{children}</Link>
return (
<a href={url} target="_blank" rel="noopener noreferrer" className="text-accent hover:underline font-medium">
{children}
</a>
)
},
img: ({ src, alt }) => (
<img src={typeof src === 'string' ? src : ''} alt={alt} loading="lazy" className="rounded-lg border border-edge my-4 max-w-full" />
),
code: ({ className, children }) => {
const isBlock = (className ?? '').includes('language-')
if (isBlock) return <code className={className}>{children}</code>
return <code className="bg-surface-tertiary text-content rounded px-1.5 py-0.5 text-[12.5px] font-mono">{children}</code>
},
pre: ({ children }) => (
<pre className="bg-surface-tertiary text-content rounded-xl p-4 my-4 overflow-x-auto text-[12.5px] font-mono leading-relaxed border border-edge-secondary">
{children}
</pre>
),
blockquote: ({ children }) => (
<blockquote className="border-l-3 border-accent bg-accent-subtle/40 rounded-r-lg px-4 py-1 my-4 text-content-secondary">
{children}
</blockquote>
),
table: ({ children }) => (
<div className="overflow-x-auto my-4">
<table className="w-full text-[13px] border-collapse">{children}</table>
</div>
),
th: ({ children }) => (
<th className="text-left font-semibold text-content border border-edge-secondary px-3 py-2 bg-surface-tertiary">
{children}
</th>
),
td: ({ children }) => <td className="text-content-secondary border border-edge-secondary px-3 py-2">{children}</td>,
hr: () => <hr className="my-6 border-edge-secondary" />,
}}
>
{markdown}
</Markdown>
)
}
+2 -2
View File
@@ -1813,7 +1813,7 @@ describe('JourneyDetailPage', () => {
expect(uploadBtn).toBeTruthy();
// Verify the hidden file input exists in the gallery view
const fileInput = document.querySelector('input[type="file"][accept="image/*"]') as HTMLInputElement;
const fileInput = document.querySelector('input[type="file"][accept="image/*,video/*"]') as HTMLInputElement;
expect(fileInput).toBeTruthy();
});
});
@@ -3314,7 +3314,7 @@ describe('JourneyDetailPage', () => {
});
// Find the hidden file input in the gallery view
const fileInput = document.querySelector('input[type="file"][accept="image/*"][multiple]') as HTMLInputElement;
const fileInput = document.querySelector('input[type="file"][accept="image/*,video/*"][multiple]') as HTMLInputElement;
expect(fileInput).toBeTruthy();
// Simulate file selection
+4 -4
View File
@@ -95,7 +95,7 @@ export default function JourneyDetailPage() {
onClose={() => setViewingEntry(null)}
onEdit={() => { setViewingEntry(null); setEditingEntry(viewingEntry); }}
onDelete={() => { setViewingEntry(null); setDeleteTarget(viewingEntry); }}
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })}
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id, mediaType: p.media_type })), index: idx })}
/>
)}
@@ -384,7 +384,7 @@ export default function JourneyDetailPage() {
readOnly={!canEditEntries}
onEdit={() => setEditingEntry(entry)}
onDelete={() => setDeleteTarget(entry)}
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })}
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id, mediaType: p.media_type })), index: idx })}
/>
)}
</div>
@@ -408,7 +408,7 @@ export default function JourneyDetailPage() {
journeyId={current.id}
userId={useAuthStore.getState().user?.id || 0}
trips={current.trips}
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption ?? null, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })}
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption ?? null, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id, mediaType: p.media_type })), index: idx })}
onRefresh={() => loadJourney(Number(id))}
/>
</div>
@@ -538,7 +538,7 @@ export default function JourneyDetailPage() {
{/* Lightbox */}
{lightbox && (
<PhotoLightbox
photos={lightbox.photos.map(p => ({ id: p.id.toString(), src: p.src, caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id }))}
photos={lightbox.photos.map(p => ({ id: p.id.toString(), src: p.src, caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id, mediaType: p.mediaType }))}
startIndex={lightbox.index}
onClose={() => setLightbox(null)}
/>
+12 -4
View File
@@ -1,7 +1,7 @@
import { useTranslation, SUPPORTED_LANGUAGES } from '../i18n'
import { useSettingsStore } from '../store/settingsStore'
import {
List, Grid, MapPin, Camera, BookOpen, Image, Clock,
List, Grid, MapPin, Camera, BookOpen, Image, Clock, Play,
Laugh, Smile, Meh, Frown,
Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake,
ThumbsUp, ThumbsDown,
@@ -123,7 +123,7 @@ export default function JourneyPublicPage() {
const prosArr = entry.pros_cons?.pros ?? []
const consArr = entry.pros_cons?.cons ?? []
const hasProscons = prosArr.length > 0 || consArr.length > 0
const lightboxPhotos = photos.map(p => ({ id: String(p.id), src: photoUrl(p, token!, 'original'), caption: p.caption }))
const lightboxPhotos = photos.map(p => ({ id: String(p.id), src: photoUrl(p, token!, 'original'), caption: p.caption, mediaType: (p as any).media_type }))
const isActive = activeEntryId === String(entry.id)
return (
@@ -296,10 +296,17 @@ export default function JourneyPublicPage() {
{allPhotos.map((photo, idx) => (
<div
key={photo.id}
className="aspect-square rounded-lg overflow-hidden cursor-pointer"
onClick={() => setLightbox({ photos: allPhotos.map(p => ({ id: String(p.id), src: photoUrl(p, token!, 'original'), caption: p.caption })), index: idx })}
className="relative aspect-square rounded-lg overflow-hidden cursor-pointer"
onClick={() => setLightbox({ photos: allPhotos.map(p => ({ id: String(p.id), src: photoUrl(p, token!, 'original'), caption: p.caption, mediaType: (p as any).media_type })), index: idx })}
>
<img src={photoUrl(photo, token!, 'thumbnail')} className="w-full h-full object-cover hover:scale-105 transition-transform" alt="" loading="lazy" />
{(photo as any).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>
)}
</div>
))}
</div>
@@ -513,6 +520,7 @@ export default function JourneyPublicPage() {
id: String(p.id),
src: photoUrl(p as any, token!, 'original'),
caption: (p as any).caption ?? null,
mediaType: (p as any).media_type,
})),
index: idx,
})}
+12 -1
View File
@@ -11,7 +11,7 @@ export default function LoginPage(): React.ReactElement {
navigate,
mode, setMode,
username, setUsername, email, setEmail, password, setPassword, rememberMe, setRememberMe, showPassword, setShowPassword,
isLoading, error, setError, appConfig, inviteToken,
isLoading, error, setError, insecureCookie, appConfig, inviteToken,
langDropdownOpen, setLangDropdownOpen, setLanguageLocal,
showTakeoff, mfaStep, setMfaStep, mfaToken, setMfaToken, mfaCode, setMfaCode,
passwordChangeStep, newPassword, setNewPassword, confirmPassword, setConfirmPassword,
@@ -447,6 +447,17 @@ export default function LoginPage(): React.ReactElement {
</div>
)}
{insecureCookie && (
<div style={{ padding: '12px 14px', background: '#fffbeb', border: '1px solid #fde68a', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', color: '#92400e' }}>
<div style={{ fontWeight: 700, marginBottom: 4 }}>{t('login.insecureCookie.title')}</div>
<div style={{ lineHeight: 1.55 }}>{t('login.insecureCookie.body')}</div>
<a href="https://github.com/mauriceboe/TREK/wiki/Troubleshooting" target="_blank" rel="noopener noreferrer"
style={{ display: 'inline-block', marginTop: 6, fontWeight: 600, color: '#b45309', textDecoration: 'underline' }}>
{t('login.insecureCookie.link')}
</a>
</div>
)}
{passwordChangeStep && (
<>
<div style={{ padding: '10px 14px', background: '#fefce8', border: '1px solid #fde68a', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', color: '#92400e' }}>
+3 -2
View File
@@ -203,7 +203,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
expandedDayIds, setExpandedDayIds, mapPlaces,
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, openAddPlaceFromPoi,
handleSavePlace, openPlaceEditor, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces,
handleSavePlace, openPlaceEditor, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces, confirmChangeCategory,
handleAssignToDay, handleRemoveAssignment, handleReorder, handleReorderDays, handleAddDay, handleUpdateDayTitle,
handleSaveReservation, handleSaveTransport, handleDeleteReservation,
selectedPlace, dayOrderMap, dayPlaces,
@@ -468,6 +468,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
onEditPlace={(place) => openPlaceEditor(place)}
onDeletePlace={(placeId) => handleDeletePlace(placeId)}
onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)}
onBulkChangeCategory={(ids, catId) => confirmChangeCategory(ids, catId)}
onCategoryFilterChange={setMapCategoryFilter}
onPlacesFilterChange={setMapPlacesFilter}
pushUndo={pushUndo}
@@ -610,7 +611,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
<div style={{ flex: 1, overflow: 'auto' }}>
{mobileSidebarOpen === 'left'
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded />
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { openPlaceEditor(place); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { openPlaceEditor(place); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} onBulkChangeCategory={(ids, catId) => confirmChangeCategory(ids, catId)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
}
</div>
</div>
@@ -33,6 +33,7 @@ export function useDashboard() {
const [deleteTrip, setDeleteTrip] = useState<DashboardTrip | null>(null)
const [copyTrip, setCopyTrip] = useState<DashboardTrip | null>(null)
const [tripFilter, setTripFilter] = useState<'planned' | 'archive' | 'completed'>('planned')
const [allSubOpen, setAllSubOpen] = useState<boolean>(false)
const [loadError, setLoadError] = useState<boolean>(false)
const [stats, setStats] = useState<TravelStats | null>(null)
@@ -192,6 +193,7 @@ export function useDashboard() {
tripFilter, setTripFilter, viewMode, toggleViewMode,
showForm, setShowForm, editingTrip, setEditingTrip,
deleteTrip, setDeleteTrip, copyTrip, setCopyTrip, setTrips,
allSubOpen, setAllSubOpen,
// actions
handleCreate, handleUpdate, confirmDelete, handleArchive, handleUnarchive, confirmCopy,
}
+56
View File
@@ -0,0 +1,56 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useParams } from 'react-router-dom'
import { helpApi, type HelpNavSection, type HelpPageData } from '../../api/client'
/** State + data loading for the in-app help wiki (see PATTERN.md). */
export function useHelp() {
const { slug } = useParams<{ slug: string }>()
const [sections, setSections] = useState<HelpNavSection[]>([])
const [page, setPage] = useState<HelpPageData | null>(null)
const [loading, setLoading] = useState(true)
const [pageError, setPageError] = useState(false)
const [query, setQuery] = useState('')
const [navOpen, setNavOpen] = useState(false)
const contentRef = useRef<HTMLDivElement>(null)
useEffect(() => {
helpApi.index().then((d) => setSections(d.sections)).catch(() => setSections([]))
}, [])
const homeSlug = sections[0]?.pages[0]?.slug ?? 'Home'
const activeSlug = slug ?? homeSlug
useEffect(() => {
let alive = true
setLoading(true)
setPageError(false)
helpApi
.page(activeSlug)
.then((p) => {
if (!alive) return
setPage(p)
setLoading(false)
})
.catch(() => {
if (!alive) return
setPageError(true)
setLoading(false)
})
contentRef.current?.scrollTo?.({ top: 0 })
window.scrollTo?.({ top: 0 })
setNavOpen(false)
return () => {
alive = false
}
}, [activeSlug])
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
if (!q) return sections
return sections
.map((s) => ({ ...s, pages: s.pages.filter((p) => p.title.toLowerCase().includes(q)) }))
.filter((s) => s.pages.length > 0)
}, [sections, query])
return { page, loading, pageError, query, setQuery, navOpen, setNavOpen, contentRef, activeSlug, filtered }
}
@@ -39,7 +39,7 @@ export function useJourneyDetail() {
const feedRef = useRef<HTMLDivElement>(null)
const [viewingEntry, setViewingEntry] = useState<JourneyEntry | null>(null)
const [editingEntry, setEditingEntry] = useState<JourneyEntry | null>(null)
const [lightbox, setLightbox] = useState<{ photos: { id: number; src: string; caption?: string | null; provider?: string; asset_id?: string | null; owner_id?: number | null }[]; index: number } | null>(null)
const [lightbox, setLightbox] = useState<{ photos: { id: number; src: string; caption?: string | null; provider?: string; asset_id?: string | null; owner_id?: number | null; mediaType?: string | null }[]; index: number } | null>(null)
const [deleteTarget, setDeleteTarget] = useState<JourneyEntry | null>(null)
const [showInvite, setShowInvite] = useState(false)
const [showAddTrip, setShowAddTrip] = useState(false)
@@ -23,7 +23,7 @@ export function useJourneyPublic() {
const [error, setError] = useState(false)
const isMobile = useIsMobile()
const [view, setView] = useState<'timeline' | 'gallery' | 'map'>('timeline')
const [lightbox, setLightbox] = useState<{ photos: { id: string; src: string; caption?: string | null }[]; index: number } | null>(null)
const [lightbox, setLightbox] = useState<{ photos: { id: string; src: string; caption?: string | null; mediaType?: string | null }[]; index: number } | null>(null)
const [showLangPicker, setShowLangPicker] = useState(false)
const locale = useSettingsStore(s => s.settings.language) || 'en'
const mapRef = useRef<JourneyMapHandle>(null)
+12 -1
View File
@@ -41,6 +41,9 @@ export function useLogin() {
const [showPassword, setShowPassword] = useState<boolean>(false)
const [isLoading, setIsLoading] = useState<boolean>(false)
const [error, setError] = useState<string>('')
// Set when the server signals it just issued a Secure cookie over plain HTTP —
// the browser drops it, so we explain the fix instead of a bare 401 later.
const [insecureCookie, setInsecureCookie] = useState(false)
const [appConfig, setAppConfig] = useState<AppConfig | null>(null)
const [inviteToken, setInviteToken] = useState<string>('')
const [inviteValid, setInviteValid] = useState<boolean>(false)
@@ -225,6 +228,7 @@ export function useLogin() {
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>): Promise<void> => {
e.preventDefault()
setError('')
setInsecureCookie(false)
setIsLoading(true)
try {
if (passwordChangeStep) {
@@ -260,6 +264,13 @@ export function useLogin() {
await register(username, email, password, inviteToken || undefined)
} else {
const result = await login(email, password, rememberMe)
if ((result as { insecureCookie?: boolean }).insecureCookie) {
// Credentials were correct, but the secure cookie won't survive plain
// HTTP — proceeding would just dead-end on "Access token required".
setInsecureCookie(true)
setIsLoading(false)
return
}
if ('mfa_required' in result && result.mfa_required && 'mfa_token' in result) {
setMfaToken(result.mfa_token)
setMfaStep(true)
@@ -291,7 +302,7 @@ export function useLogin() {
navigate,
mode, setMode,
username, setUsername, email, setEmail, password, setPassword, rememberMe, setRememberMe, showPassword, setShowPassword,
isLoading, error, setError, appConfig, inviteToken,
isLoading, error, setError, insecureCookie, appConfig, inviteToken,
langDropdownOpen, setLangDropdownOpen, setLanguageLocal,
showTakeoff, mfaStep, setMfaStep, mfaToken, setMfaToken, mfaCode, setMfaCode,
passwordChangeStep, newPassword, setNewPassword, confirmPassword, setConfirmPassword,
+29 -2
View File
@@ -12,6 +12,7 @@ import { parsedItemToDraft, isTransportItem, type BookingReviewDraft } from '../
import type { BookingImportPreviewItem } from '@trek/shared'
import { accommodationRepo } from '../../repo/accommodationRepo'
import { offlineDb, getImportFiles, deleteImportFiles } from '../../db/offlineDb'
import { isEffectivelyOffline } from '../../sync/networkMode'
import { useBackgroundTasksStore } from '../../store/backgroundTasksStore'
import { useAuthStore } from '../../store/authStore'
import { useResizablePanels } from '../../hooks/useResizablePanels'
@@ -254,7 +255,7 @@ export function useTripPlanner() {
if (tripId) {
tripActions.loadTrip(tripId).catch(() => { toast.error(t('trip.toast.loadError')); navigate('/dashboard') })
loadAccommodations()
if (!navigator.onLine) {
if (isEffectivelyOffline()) {
offlineDb.tripMembers.where('tripId').equals(Number(tripId)).toArray()
.then(rows => setTripMembers(rows))
.catch(() => {})
@@ -525,6 +526,32 @@ export function useTripPlanner() {
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
}, [deletePlaceIds, tripId, toast, selectedPlaceId, selectedDayId, updateRouteForDay, pushUndo])
const confirmChangeCategory = useCallback(async (ids: number[], categoryId: number | null) => {
if (!ids.length) return
const state = useTripStore.getState()
// Capture each place's prior category so undo can restore them per group.
const captured = state.places.filter(p => ids.includes(p.id)).map(p => ({ id: p.id, prev: p.category_id ?? null }))
try {
await tripActions.updatePlacesMany(tripId, ids, { category_id: categoryId })
toast.success(t('places.categoryChanged', { count: ids.length }))
if (captured.length > 0) {
pushUndo(t('undo.changeCategory'), async () => {
// Group the captured ids by their prior category so each set is restored
// in one call ('null' key = previously uncategorized). Map is shadowed by
// the lucide icon import in this file, so use a plain object.
const byPrev: Record<string, number[]> = {}
for (const { id, prev } of captured) {
const key = prev === null ? 'null' : String(prev)
;(byPrev[key] ??= []).push(id)
}
for (const [key, group] of Object.entries(byPrev)) {
await tripActions.updatePlacesMany(tripId, group, { category_id: key === 'null' ? null : Number(key) })
}
})
}
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
}, [tripId, toast, pushUndo])
const handleAssignToDay = useCallback(async (placeId: number, dayId?: number, position?: number) => {
const target = dayId || selectedDayId
if (!target) { toast.error(t('trip.toast.selectDay')); return }
@@ -841,7 +868,7 @@ export function useTripPlanner() {
expandedDayIds, setExpandedDayIds, mapPlaces,
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, openAddPlaceFromPoi,
handleSavePlace, openPlaceEditor, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces,
handleSavePlace, openPlaceEditor, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces, confirmChangeCategory,
handleAssignToDay, handleRemoveAssignment, handleReorder, handleReorderDays, handleAddDay, handleUpdateDayTitle,
handleSaveReservation, handleSaveTransport, handleDeleteReservation,
selectedPlace, dayOrderMap, dayPlaces,
+5 -3
View File
@@ -1,6 +1,7 @@
import { packingApi } from '../api/client'
import { offlineDb, upsertPackingItems } from '../db/offlineDb'
import { mutationQueue, generateUUID, nextTempId } from '../sync/mutationQueue'
import { isEffectivelyOffline } from '../sync/networkMode'
import { onlineThenCache } from './withOfflineFallback'
import type { PackingItem } from '../types'
@@ -20,7 +21,7 @@ export const packingRepo = {
},
async create(tripId: number | string, data: Record<string, unknown> & { name: string }): Promise<{ item: PackingItem }> {
if (!navigator.onLine) {
if (isEffectivelyOffline()) {
const tempId = nextTempId()
const tempItem: PackingItem = {
...(data as Partial<PackingItem>),
@@ -48,7 +49,7 @@ export const packingRepo = {
},
async update(tripId: number | string, id: number, data: Record<string, unknown>): Promise<{ item: PackingItem }> {
if (!navigator.onLine) {
if (isEffectivelyOffline()) {
const existing = await offlineDb.packingItems.get(id)
const optimistic: PackingItem = { ...(existing ?? {} as PackingItem), ...(data as Partial<PackingItem>), id }
await offlineDb.packingItems.put(optimistic)
@@ -62,6 +63,7 @@ export const packingRepo = {
body: data,
resource: 'packingItems',
entityId: id,
baseUpdatedAt: existing?.updated_at ?? null,
...(isTemp ? { tempEntityId: id } : {}),
})
return { item: optimistic }
@@ -72,7 +74,7 @@ export const packingRepo = {
},
async delete(tripId: number | string, id: number): Promise<unknown> {
if (!navigator.onLine) {
if (isEffectivelyOffline()) {
await offlineDb.packingItems.delete(id)
const mutId = generateUUID()
const isTemp = id < 0
+34 -4
View File
@@ -1,6 +1,7 @@
import { placesApi } from '../api/client'
import { offlineDb, upsertPlaces } from '../db/offlineDb'
import { mutationQueue, generateUUID, nextTempId } from '../sync/mutationQueue'
import { isEffectivelyOffline } from '../sync/networkMode'
import { onlineThenCache } from './withOfflineFallback'
import type { Place } from '../types'
@@ -20,7 +21,7 @@ export const placeRepo = {
},
async create(tripId: number | string, data: Record<string, unknown> & { name: string }): Promise<{ place: Place }> {
if (!navigator.onLine) {
if (isEffectivelyOffline()) {
const tempId = nextTempId()
const tempPlace: Place = {
...(data as Partial<Place>),
@@ -47,7 +48,7 @@ export const placeRepo = {
},
async update(tripId: number | string, id: number | string, data: Record<string, unknown>): Promise<{ place: Place }> {
if (!navigator.onLine) {
if (isEffectivelyOffline()) {
const existing = await offlineDb.places.get(Number(id))
const optimistic: Place = { ...(existing ?? {} as Place), ...(data as Partial<Place>), id: Number(id) }
await offlineDb.places.put(optimistic)
@@ -61,6 +62,7 @@ export const placeRepo = {
body: data,
resource: 'places',
entityId: Number(id),
baseUpdatedAt: existing?.updated_at ?? null,
...(isTemp ? { tempEntityId: Number(id) } : {}),
})
return { place: optimistic }
@@ -71,7 +73,7 @@ export const placeRepo = {
},
async delete(tripId: number | string, id: number | string): Promise<unknown> {
if (!navigator.onLine) {
if (isEffectivelyOffline()) {
await offlineDb.places.delete(Number(id))
const mutId = generateUUID()
const isTemp = Number(id) < 0
@@ -93,7 +95,7 @@ export const placeRepo = {
},
async deleteMany(tripId: number | string, ids: number[]): Promise<unknown> {
if (!navigator.onLine) {
if (isEffectivelyOffline()) {
await offlineDb.places.bulkDelete(ids)
for (const id of ids) {
const mutId = generateUUID()
@@ -115,4 +117,32 @@ export const placeRepo = {
await offlineDb.places.bulkDelete(ids)
return result
},
async updateMany(tripId: number | string, ids: number[], data: Record<string, unknown>): Promise<{ updated: number[]; count: number }> {
if (isEffectivelyOffline()) {
// Offline fans out one queued PUT per id (mirrors deleteMany's DELETE fan-out).
for (const id of ids) {
const existing = await offlineDb.places.get(id)
if (existing) await offlineDb.places.put({ ...existing, ...(data as Partial<Place>) })
const mutId = generateUUID()
const isTemp = id < 0
await mutationQueue.enqueue({
id: mutId,
tripId: Number(tripId),
method: 'PUT',
url: isTemp ? `/trips/${tripId}/places/{id}` : `/trips/${tripId}/places/${id}`,
body: data,
resource: 'places',
entityId: id,
baseUpdatedAt: existing?.updated_at ?? null,
...(isTemp ? { tempEntityId: id } : {}),
})
}
return { updated: ids, count: ids.length }
}
const result = await placesApi.bulkUpdate(tripId, ids, data as Parameters<typeof placesApi.bulkUpdate>[2])
const cached = await offlineDb.places.bulkGet(ids)
await offlineDb.places.bulkPut(cached.filter(Boolean).map(p => ({ ...(p as Place), ...(data as Partial<Place>) })))
return result
},
}
+8 -6
View File
@@ -1,3 +1,5 @@
import { isEffectivelyOffline } from '../sync/networkMode'
/**
* True when an error means the request never reached the server a network-level
* failure (offline, captive portal, proxy auth wall, dropped connection, CORS).
@@ -22,11 +24,11 @@ function isNetworkError(err: unknown): boolean {
* connection (H2). Rather than surfacing that (which blanks the trip even
* though a good cached copy exists), we fall back to the cache.
*
* We intentionally gate only on `navigator.onLine`, NOT the connectivity probe:
* the probe is a coarse global flag, and a single failed health check would
* otherwise force every read to the (possibly empty) cache even when the request
* itself would succeed. The network-error catch below covers the captive-portal
* case the probe was meant to.
* We gate on the effective offline state (real `navigator.onLine` OR the user's
* force-offline override), NOT the connectivity probe: the probe is a coarse
* global flag, and a single failed health check would otherwise force every read
* to the (possibly empty) cache even when the request itself would succeed. The
* network-error catch below covers the captive-portal case the probe was meant to.
*
* A genuine HTTP error (404/403/500 the server responded) is NOT swallowed: it
* is rethrown so callers can set error state, navigate away, etc.
@@ -38,7 +40,7 @@ export async function onlineThenCache<T>(
onlineFn: () => Promise<T>,
cacheFn: () => Promise<T>,
): Promise<T> {
if (!navigator.onLine) return cacheFn()
if (isEffectivelyOffline()) return cacheFn()
try {
return await onlineFn()
} catch (err) {
+20 -2
View File
@@ -1,6 +1,7 @@
import { create } from 'zustand'
import { journeyApi } from '../api/client'
import { uploadFilesResilient, type ResilientResult, type UploadProgress } from '../utils/uploadQueue'
import { captureVideoPoster, isVideoFile } from '../utils/videoPoster'
export interface Journey {
id: number
@@ -56,6 +57,9 @@ export interface JourneyPhoto {
thumbnail_path?: string | null
width?: number | null
height?: number | null
// 'image' (default) or 'video' (#823)
media_type?: string | null
duration_ms?: number | null
}
export interface GalleryPhoto {
@@ -74,6 +78,9 @@ export interface GalleryPhoto {
thumbnail_path?: string | null
width?: number | null
height?: number | null
// 'image' (default) or 'video' (#823)
media_type?: string | null
duration_ms?: number | null
}
export interface JourneyTrip {
@@ -270,8 +277,19 @@ export const useJourneyStore = create<JourneyState>((set, get) => ({
files,
async (file, opts) => {
const fd = new FormData()
fd.append('photos', file)
const data = await journeyApi.uploadGalleryPhotos(journeyId, fd, opts)
let data: { photos?: GalleryPhoto[] }
if (isVideoFile(file)) {
// Video: grab a poster frame + duration in the browser, then upload the
// raw video + poster (#823). No server-side transcoding.
const { poster, durationMs } = await captureVideoPoster(file)
fd.append('video', file)
if (poster) fd.append('poster', poster, 'poster.jpg')
if (durationMs != null) fd.append('duration_ms', String(durationMs))
data = await journeyApi.uploadGalleryVideo(journeyId, fd, opts)
} else {
fd.append('photos', file)
data = await journeyApi.uploadGalleryPhotos(journeyId, fd, opts)
}
const photos: GalleryPhoto[] = data.photos || []
set(s => {
if (!s.current || s.current.id !== journeyId) return s
+1 -1
View File
@@ -52,7 +52,7 @@ describe('budgetSlice', () => {
HttpResponse.json({ error: 'Validation failed' }, { status: 422 })
)
);
await expect(useTripStore.getState().addBudgetItem(1, {})).rejects.toThrow();
await expect(useTripStore.getState().addBudgetItem(1, { name: 'x' })).rejects.toThrow();
});
it('FE-STORE-BUDGET-005: updateBudgetItem replaces item in store', async () => {
+4 -4
View File
@@ -12,8 +12,8 @@ type GetState = StoreApi<TripStoreState>['getState']
export interface BudgetSlice {
loadBudgetItems: (tripId: number | string) => Promise<void>
addBudgetItem: (tripId: number | string, data: Partial<BudgetItem>) => Promise<BudgetItem>
updateBudgetItem: (tripId: number | string, id: number, data: Partial<BudgetItem>) => Promise<BudgetItem>
addBudgetItem: (tripId: number | string, data: BudgetCreateItemRequest) => Promise<BudgetItem>
updateBudgetItem: (tripId: number | string, id: number, data: BudgetUpdateItemRequest) => Promise<BudgetItem>
deleteBudgetItem: (tripId: number | string, id: number) => Promise<void>
setBudgetItemMembers: (tripId: number | string, itemId: number, userIds: number[]) => Promise<{ members: BudgetItemMember[]; item: BudgetItem }>
toggleBudgetMemberPaid: (tripId: number | string, itemId: number, userId: number, paid: boolean) => Promise<void>
@@ -33,7 +33,7 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice =>
addBudgetItem: async (tripId, data) => {
try {
const result = await budgetApi.create(tripId, data as BudgetCreateItemRequest)
const result = await budgetApi.create(tripId, data)
set(state => ({ budgetItems: [...state.budgetItems, result.item] }))
return result.item
} catch (err: unknown) {
@@ -43,7 +43,7 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice =>
updateBudgetItem: async (tripId, id, data) => {
try {
const result = await budgetApi.update(tripId, id, data as BudgetUpdateItemRequest)
const result = await budgetApi.update(tripId, id, data)
set(state => ({
budgetItems: state.budgetItems.map(item => item.id === id ? result.item : item)
}))
@@ -0,0 +1,48 @@
// FE-STORE-PACKING-001 to FE-STORE-PACKING-002 (reorder, #969)
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildPackingItem } from '../../../tests/helpers/factories';
import { useTripStore } from '../tripStore';
beforeEach(() => {
resetAllStores();
server.resetHandlers();
});
describe('packingSlice', () => {
it('FE-STORE-PACKING-001: reorderPackingItems reorders optimistically and reindexes sort_order', async () => {
const a = buildPackingItem({ id: 1, trip_id: 1, sort_order: 0 });
const b = buildPackingItem({ id: 2, trip_id: 1, sort_order: 1 });
seedStore(useTripStore, { packingItems: [a, b] });
server.use(
http.put('/api/trips/1/packing/reorder', () =>
HttpResponse.json({ success: true })
)
);
await useTripStore.getState().reorderPackingItems(1, [2, 1]);
const items = useTripStore.getState().packingItems;
expect(items[0].id).toBe(2);
expect(items[0].sort_order).toBe(0);
expect(items[1].id).toBe(1);
expect(items[1].sort_order).toBe(1);
});
it('FE-STORE-PACKING-002: reorderPackingItems rolls back to previous order on API error', async () => {
const a = buildPackingItem({ id: 1, trip_id: 1, sort_order: 0 });
const b = buildPackingItem({ id: 2, trip_id: 1, sort_order: 1 });
seedStore(useTripStore, { packingItems: [a, b] });
server.use(
http.put('/api/trips/1/packing/reorder', () =>
HttpResponse.json({ error: 'error' }, { status: 500 })
)
);
await useTripStore.getState().reorderPackingItems(1, [2, 1]);
// After failure the original order is restored
const items = useTripStore.getState().packingItems;
expect(items[0].id).toBe(1);
expect(items[1].id).toBe(2);
});
});
+65
View File
@@ -1,4 +1,5 @@
import { packingRepo } from '../../repo/packingRepo'
import { packingApi } from '../../api/client'
import type { StoreApi } from 'zustand'
import type { TripStoreState } from '../tripStore'
import type { PackingItem } from '../../types'
@@ -13,6 +14,12 @@ export interface PackingSlice {
updatePackingItem: (tripId: number | string, id: number, data: Partial<PackingItem>) => Promise<PackingItem>
deletePackingItem: (tripId: number | string, id: number) => Promise<void>
togglePackingItem: (tripId: number | string, id: number, checked: boolean) => Promise<void>
reorderPackingItems: (tripId: number | string, orderedIds: number[]) => Promise<void>
// Three-tier sharing (#858)
setPackingItemSharing: (tripId: number | string, id: number, visibility: 'common' | 'personal' | 'shared', recipientIds: number[]) => Promise<void>
clonePackingItem: (tripId: number | string, id: number) => Promise<void>
addPackingContributor: (tripId: number | string, id: number) => Promise<void>
removePackingContributor: (tripId: number | string, id: number, userId: number) => Promise<void>
}
export const createPackingSlice = (set: SetState, get: GetState): PackingSlice => ({
@@ -68,4 +75,62 @@ export const createPackingSlice = (set: SetState, get: GetState): PackingSlice =
notify(getApiErrorMessage(err, 'Error updating item'), 'error')
}
},
reorderPackingItems: async (tripId, orderedIds) => {
const prev = get().packingItems
// Optimistic reorder: rebuild the array in the requested order, reindexing
// sort_order; any items not in orderedIds keep their place at the end.
set(state => {
const byId = new Map(state.packingItems.map(i => [i.id, i]))
const reordered = orderedIds
.map((id, idx): PackingItem | null => { const item = byId.get(id); return item ? { ...item, sort_order: idx } : null })
.filter((i): i is PackingItem => i !== null)
const remaining = state.packingItems.filter(i => !orderedIds.includes(i.id))
return { packingItems: [...reordered, ...remaining] }
})
try {
await packingApi.reorder(tripId, orderedIds)
} catch (err: unknown) {
set({ packingItems: prev })
notify(getApiErrorMessage(err, 'Error reordering items'), 'error')
}
},
// ── Three-tier sharing (#858) ──────────────────────────────────────────────
setPackingItemSharing: async (tripId, id, visibility, recipientIds) => {
try {
const result = await packingApi.setSharing(tripId, id, { visibility, recipient_ids: recipientIds })
set(state => ({ packingItems: state.packingItems.map(i => i.id === id ? result.item : i) }))
} catch (err: unknown) {
notify(getApiErrorMessage(err, 'Error updating sharing'), 'error')
throw err
}
},
clonePackingItem: async (tripId, id) => {
try {
const result = await packingApi.clone(tripId, id)
set(state => (state.packingItems.some(i => i.id === result.item.id) ? {} : { packingItems: [...state.packingItems, result.item] }))
} catch (err: unknown) {
notify(getApiErrorMessage(err, 'Error copying item'), 'error')
}
},
addPackingContributor: async (tripId, id) => {
try {
const result = await packingApi.addContributor(tripId, id)
set(state => ({ packingItems: state.packingItems.map(i => i.id === id ? result.item : i) }))
} catch (err: unknown) {
notify(getApiErrorMessage(err, 'Error joining item'), 'error')
}
},
removePackingContributor: async (tripId, id, userId) => {
try {
const result = await packingApi.removeContributor(tripId, id, userId)
set(state => ({ packingItems: state.packingItems.map(i => i.id === id ? result.item : i) }))
} catch (err: unknown) {
notify(getApiErrorMessage(err, 'Error leaving item'), 'error')
}
},
})
+30
View File
@@ -13,6 +13,7 @@ export interface PlacesSlice {
updatePlace: (tripId: number | string, placeId: number, placeData: Partial<Place>) => Promise<Place>
deletePlace: (tripId: number | string, placeId: number) => Promise<void>
deletePlacesMany: (tripId: number | string, placeIds: number[]) => Promise<void>
updatePlacesMany: (tripId: number | string, placeIds: number[], patch: Partial<Place>) => Promise<void>
}
export const createPlacesSlice = (set: SetState, get: GetState): PlacesSlice => ({
@@ -105,4 +106,33 @@ export const createPlacesSlice = (set: SetState, get: GetState): PlacesSlice =>
throw new Error(getApiErrorMessage(err, 'Error deleting places'))
}
},
updatePlacesMany: async (tripId, placeIds, patch) => {
if (placeIds.length === 0) return
try {
await placeRepo.updateMany(tripId, placeIds, patch as Record<string, unknown>)
const idSet = new Set(placeIds)
set(state => {
// Patch both the place pool and the embedded place on each day assignment
// (preserving the assignment's own place_time/end_time) so itinerary cards
// reflect the change immediately, like single updatePlace does.
const updatedAssignments = { ...state.assignments }
let changed = false
for (const [dayId, items] of Object.entries(state.assignments)) {
if (items.some((a: Assignment) => a.place?.id != null && idSet.has(a.place.id))) {
updatedAssignments[dayId] = items.map((a: Assignment) =>
a.place?.id != null && idSet.has(a.place.id) ? { ...a, place: { ...a.place, ...patch } } : a
)
changed = true
}
}
return {
places: state.places.map(p => idSet.has(p.id) ? { ...p, ...patch } : p),
...(changed ? { assignments: updatedAssignments } : {}),
}
})
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, 'Error updating places'))
}
},
})
+48
View File
@@ -0,0 +1,48 @@
// FE-STORE-TODO-001 to FE-STORE-TODO-002 (reorder, #969)
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildTodoItem } from '../../../tests/helpers/factories';
import { useTripStore } from '../tripStore';
beforeEach(() => {
resetAllStores();
server.resetHandlers();
});
describe('todoSlice', () => {
it('FE-STORE-TODO-001: reorderTodoItems reorders optimistically and reindexes sort_order', async () => {
const a = buildTodoItem({ id: 1, trip_id: 1, sort_order: 0 });
const b = buildTodoItem({ id: 2, trip_id: 1, sort_order: 1 });
seedStore(useTripStore, { todoItems: [a, b] });
server.use(
http.put('/api/trips/1/todo/reorder', () =>
HttpResponse.json({ success: true })
)
);
await useTripStore.getState().reorderTodoItems(1, [2, 1]);
const items = useTripStore.getState().todoItems;
expect(items[0].id).toBe(2);
expect(items[0].sort_order).toBe(0);
expect(items[1].id).toBe(1);
expect(items[1].sort_order).toBe(1);
});
it('FE-STORE-TODO-002: reorderTodoItems rolls back to previous order on API error', async () => {
const a = buildTodoItem({ id: 1, trip_id: 1, sort_order: 0 });
const b = buildTodoItem({ id: 2, trip_id: 1, sort_order: 1 });
seedStore(useTripStore, { todoItems: [a, b] });
server.use(
http.put('/api/trips/1/todo/reorder', () =>
HttpResponse.json({ error: 'error' }, { status: 500 })
)
);
await useTripStore.getState().reorderTodoItems(1, [2, 1]);
// After failure the original order is restored
const items = useTripStore.getState().todoItems;
expect(items[0].id).toBe(1);
expect(items[1].id).toBe(2);
});
});
+19
View File
@@ -14,6 +14,7 @@ export interface TodoSlice {
updateTodoItem: (tripId: number | string, id: number, data: TodoUpdateItemRequest) => Promise<TodoItem>
deleteTodoItem: (tripId: number | string, id: number) => Promise<void>
toggleTodoItem: (tripId: number | string, id: number, checked: boolean) => Promise<void>
reorderTodoItems: (tripId: number | string, orderedIds: number[]) => Promise<void>
}
export const createTodoSlice = (set: SetState, get: GetState): TodoSlice => ({
@@ -69,4 +70,22 @@ export const createTodoSlice = (set: SetState, get: GetState): TodoSlice => ({
notify(getApiErrorMessage(err, 'Error updating todo'), 'error')
}
},
reorderTodoItems: async (tripId, orderedIds) => {
const prev = get().todoItems
set(state => {
const byId = new Map(state.todoItems.map(i => [i.id, i]))
const reordered = orderedIds
.map((id, idx): TodoItem | null => { const item = byId.get(id); return item ? { ...item, sort_order: idx } : null })
.filter((i): i is TodoItem => i !== null)
const remaining = state.todoItems.filter(i => !orderedIds.includes(i.id))
return { todoItems: [...reordered, ...remaining] }
})
try {
await todoApi.reorder(tripId, orderedIds)
} catch (err: unknown) {
set({ todoItems: prev })
notify(getApiErrorMessage(err, 'Error reordering todos'), 'error')
}
},
})
+3 -2
View File
@@ -10,6 +10,7 @@ import { todoRepo } from '../repo/todoRepo'
import { budgetRepo } from '../repo/budgetRepo'
import { reservationRepo } from '../repo/reservationRepo'
import { fileRepo } from '../repo/fileRepo'
import { isEffectivelyOnline } from '../sync/networkMode'
import { createPlacesSlice } from './slices/placesSlice'
import { createAssignmentsSlice } from './slices/assignmentsSlice'
import { createDaysSlice } from './slices/daysSlice'
@@ -128,10 +129,10 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
budgetRepo.list(tripId).catch(() => ({ items: [] as BudgetItem[] })),
reservationRepo.list(tripId).catch(() => ({ reservations: [] as Reservation[] })),
fileRepo.list(tripId).catch(() => ({ files: [] as TripFile[] })),
navigator.onLine
isEffectivelyOnline()
? tagsApi.list().catch(() => offlineDb.tags.toArray().then(tags => ({ tags })))
: offlineDb.tags.toArray().then(tags => ({ tags })),
navigator.onLine
isEffectivelyOnline()
? categoriesApi.list().catch(() => offlineDb.categories.toArray().then(categories => ({ categories })))
: offlineDb.categories.toArray().then(categories => ({ categories })),
])
+135 -2
View File
@@ -8,6 +8,8 @@
import { offlineDb } from '../db/offlineDb'
import { apiClient } from '../api/client'
import { isAuthed } from './authGate'
import { isEffectivelyOffline } from './networkMode'
import { getOfflinePrefs } from './offlinePrefs'
import type { QueuedMutation } from '../db/offlineDb'
import type { Table } from 'dexie'
@@ -62,6 +64,22 @@ function isRetryableStatus(status: number | undefined): boolean {
return status === 401 || status === 408 || status === 425 || status === 429
}
/** Pull the server's current entity out of a 409 response body ({ server: {...} }). */
function extractConflictServer(err: unknown): unknown {
const data = (err as { response?: { data?: unknown } })?.response?.data
if (data && typeof data === 'object' && 'server' in data) {
return (data as { server: unknown }).server
}
return null
}
/** Write a server entity into its Dexie table (used when "theirs" wins a conflict). */
async function applyServerEntity(mutation: QueuedMutation, server: unknown): Promise<void> {
if (!mutation.resource || !server || typeof server !== 'object' || !('id' in server)) return
const table = getTable(mutation.resource)
if (table) await table.put(server)
}
export const mutationQueue = {
/**
* Add a mutation to the queue.
@@ -89,12 +107,19 @@ export const mutationQueue = {
* 4xx responses are marked failed and skipped.
*/
async flush(): Promise<void> {
if (_flushing || !navigator.onLine || !isAuthed()) return
if (_flushing || isEffectivelyOffline() || !isAuthed()) return
_flushing = true
// tempId → realId learned during this flush, so a dependent edit/delete
// queued against an offline-created entity (still holding the negative id)
// can be rewritten to the server id before it is replayed.
const idMap = new Map<number, number>()
// resource:entityId → freshest updated_at applied during this flush. A second
// queued edit of the same entity must send THIS token, not the stale one its
// snapshot was loaded with, or it would 409 against our own first edit (#1135).
const tokenMap = new Map<string, string>()
// Set when a conflict auto-resolved as "mine wins": the mutation is re-queued
// without its base token, so one more pass overwrites the server cleanly.
let needsRetry = false
try {
const pending = await offlineDb.mutationQueue
.where('status')
@@ -128,11 +153,20 @@ export const mutationQueue = {
}
try {
// Send the optimistic-concurrency token when we have one so the server
// can reject a stale overwrite (409). Absent header => unconditional
// write (back-compat with servers / resources that don't check it).
// A newer token learned earlier in THIS flush (an earlier edit of the
// same entity) overrides the snapshot's stale base.
const headers: Record<string, string> = { 'X-Idempotency-Key': mutation.id }
const tokenKey = mutation.resource !== undefined && reqEntityId !== undefined ? `${mutation.resource}:${reqEntityId}` : undefined
const baseToken = (tokenKey && tokenMap.get(tokenKey)) || mutation.baseUpdatedAt
if (baseToken) headers['X-Base-Updated-At'] = baseToken
const response = await apiClient.request({
method: mutation.method,
url: reqUrl,
data: mutation.body,
headers: { 'X-Idempotency-Key': mutation.id },
headers,
})
// Apply canonical server response to Dexie
@@ -161,6 +195,29 @@ export const mutationQueue = {
})
}
await table.put(entity)
// Advance the base-version token of any other queued edits to the
// same entity to the value we just wrote. Without this, a second
// offline edit of the same place/item still carries the pre-flush
// token and would 409 against our OWN just-applied first edit —
// self-conflicting and risking loss of the later edit (#1135).
const newToken = (entity as { updated_at?: unknown }).updated_at
if (typeof newToken === 'string') {
// In-memory: consulted when the sibling is replayed later in this
// same flush (its snapshot still holds the stale base).
if (mutation.resource) tokenMap.set(`${mutation.resource}:${realId}`, newToken)
// Durable: survives a flush boundary / reload if the sibling is
// not reached this pass.
await offlineDb.mutationQueue
.where('tripId')
.equals(mutation.tripId)
.filter(m =>
m.id !== mutation.id &&
m.resource === mutation.resource &&
m.entityId === realId &&
(m.status === 'pending' || m.status === 'syncing'),
)
.modify(m => { m.baseUpdatedAt = newToken })
}
}
}
} else if (mutation.method === 'DELETE' && mutation.resource && reqEntityId !== undefined) {
@@ -172,6 +229,37 @@ export const mutationQueue = {
await offlineDb.mutationQueue.delete(mutation.id)
} catch (err: unknown) {
const httpStatus = (err as { response?: { status: number } })?.response?.status
// 409 = the entity changed on the server since this offline edit was
// made. This is NOT a dropped change like other 4xx — resolve it per
// the user's strategy instead of failing it. Deliberately scoped to
// edits: an offline DELETE is "delete wins" by design (no CAS on the
// delete path), so it never reaches here. See the wiki Offline doc.
if (httpStatus === 409 && mutation.method !== 'DELETE') {
const server = extractConflictServer(err)
const strategy = getOfflinePrefs().conflictStrategy
if (strategy === 'server') {
// Theirs wins: adopt the server's version locally, drop our write.
await applyServerEntity(mutation, server)
await offlineDb.mutationQueue.delete(mutation.id)
} else if (strategy === 'mine') {
// Mine wins: re-queue without the base token so the next pass
// overwrites unconditionally.
await offlineDb.mutationQueue.update(mutation.id, {
status: 'pending', baseUpdatedAt: null, conflictServer: undefined,
attempts: mutation.attempts + 1, lastError: null,
})
needsRetry = true
} else {
// Ask: park it as a conflict for the user to resolve.
await offlineDb.mutationQueue.update(mutation.id, {
status: 'conflict', conflictServer: server ?? null, conflictAt: Date.now(),
attempts: mutation.attempts + 1, lastError: 'conflict',
})
}
continue
}
const isTerminal =
httpStatus !== undefined && httpStatus >= 400 && httpStatus < 500 && !isRetryableStatus(httpStatus)
if (isTerminal) {
@@ -200,6 +288,12 @@ export const mutationQueue = {
} finally {
_flushing = false
}
// A "mine wins" auto-resolution dropped its base token; one more pass now
// overwrites the server unconditionally. Bounded: the retried write carries
// no token, so it cannot 409 for the same reason.
if (needsRetry && !isEffectivelyOffline()) {
await this.flush()
}
},
/**
@@ -237,6 +331,45 @@ export const mutationQueue = {
.count()
},
/** Count unresolved sync conflicts (offline edits the server rejected as stale). */
async conflictCount(): Promise<number> {
return offlineDb.mutationQueue
.where('status')
.equals('conflict')
.count()
},
/** All unresolved conflicts, newest first, optionally scoped to one trip. */
async conflicts(tripId?: number): Promise<QueuedMutation[]> {
const all = await offlineDb.mutationQueue.where('status').equals('conflict').toArray()
const scoped = tripId === undefined ? all : all.filter(m => m.tripId === tripId)
return scoped.sort((a, b) => (b.conflictAt ?? 0) - (a.conflictAt ?? 0))
},
/**
* Resolve a conflict by keeping the local (offline) edit: re-queue it without
* the base token so the next flush overwrites the server unconditionally.
*/
async resolveKeepMine(id: string): Promise<void> {
const m = await offlineDb.mutationQueue.get(id)
if (!m || m.status !== 'conflict') return
await offlineDb.mutationQueue.update(id, {
status: 'pending', baseUpdatedAt: null, conflictServer: undefined, conflictAt: undefined, lastError: null,
})
await this.flush()
},
/**
* Resolve a conflict by keeping the server's version: adopt it into the local
* cache and drop the queued write.
*/
async resolveKeepServer(id: string): Promise<void> {
const m = await offlineDb.mutationQueue.get(id)
if (!m || m.status !== 'conflict') return
await applyServerEntity(m, m.conflictServer)
await offlineDb.mutationQueue.delete(id)
},
/** Reset internal flushing flag and timestamp counters — useful in tests. */
_resetFlushing(): void {
_flushing = false
+99
View File
@@ -0,0 +1,99 @@
/**
* Network mode the single source of truth for whether the app should behave
* as if it were offline right now.
*
* Two inputs combine here:
* - the real browser state (`navigator.onLine`)
* - a user-controlled "force offline" override (the Settings Offline toggle)
*
* The repo layer, the mutation queue and the sync triggers all gate on
* `isEffectivelyOffline()` instead of reading `navigator.onLine` directly, so a
* forced-offline session routes every read to the Dexie cache and every write to
* the mutation queue exactly as a genuine disconnection would. The override is
* persisted so it survives a reload (a user who forced offline before boarding a
* plane stays offline after the PWA is relaunched).
*
* Forcing offline does NOT pretend the network is gone for everything: it is the
* caller's job (Settings Offline) to pre-download first and only then flip the
* switch. See tripSyncManager.prepareForOffline().
*/
const STORAGE_KEY = 'trek_forced_offline'
let _forced = readPersisted()
const listeners = new Set<() => void>()
function readPersisted(): boolean {
try {
return typeof localStorage !== 'undefined' && localStorage.getItem(STORAGE_KEY) === '1'
} catch {
return false
}
}
function persist(v: boolean): void {
try {
if (v) localStorage.setItem(STORAGE_KEY, '1')
else localStorage.removeItem(STORAGE_KEY)
} catch {
/* private mode / quota — the in-memory flag still governs this session */
}
}
function notify(): void {
listeners.forEach(fn => {
try { fn() } catch { /* a listener throwing must not break the others */ }
})
}
/** True when the user has manually forced the app into offline mode. */
export function isForcedOffline(): boolean {
return _forced
}
/** Flip the manual force-offline override and notify subscribers. */
export function setForcedOffline(v: boolean): void {
if (_forced === v) return
_forced = v
persist(v)
notify()
}
/**
* True when the app should treat itself as offline: either the browser is
* genuinely offline OR the user forced offline mode. This is the flag the
* offline read/write paths must gate on.
*/
export function isEffectivelyOffline(): boolean {
return _forced || !navigator.onLine
}
/** Convenience inverse of {@link isEffectivelyOffline}. */
export function isEffectivelyOnline(): boolean {
return !isEffectivelyOffline()
}
/**
* Subscribe to network-mode changes (force-offline toggled, or the browser's own
* online/offline events). Returns an unsubscribe function. Registers the global
* browser listeners lazily on first subscription.
*/
export function onNetworkModeChange(fn: () => void): () => void {
ensureBrowserListeners()
listeners.add(fn)
return () => listeners.delete(fn)
}
let _browserListenersBound = false
function ensureBrowserListeners(): void {
if (_browserListenersBound || typeof window === 'undefined') return
_browserListenersBound = true
window.addEventListener('online', notify)
window.addEventListener('offline', notify)
}
/** Reset state — test helper only. */
export function _resetNetworkMode(): void {
_forced = false
listeners.clear()
}
+101
View File
@@ -0,0 +1,101 @@
/**
* Offline preferences device-local choices about WHAT gets stored offline and
* HOW sync conflicts are resolved (discussion #1135, asks 2 and 3).
*
* These live in localStorage rather than the server user-settings because they
* are inherently per-device: how much storage a phone should spend on map tiles,
* or which trips to keep on this particular device, has nothing to do with the
* account and everything to do with the hardware in the user's hand.
*
* cacheTiles global on/off for pre-downloading map tiles. Off keeps
* the cache to trip data + documents only ("not the whole
* world map"). See tripSyncManager / clearTileCache.
* disabledTripIds trips the user explicitly excluded from offline storage.
* Everything else that is date-eligible is cached.
* conflictStrategy what to do when an offline edit collides with a newer
* server change: 'ask' surfaces a per-conflict picker,
* 'mine'/'server' resolve automatically.
*/
export type ConflictStrategy = 'ask' | 'mine' | 'server'
export interface OfflinePrefs {
cacheTiles: boolean
disabledTripIds: number[]
conflictStrategy: ConflictStrategy
}
const STORAGE_KEY = 'trek_offline_prefs'
const DEFAULTS: OfflinePrefs = {
cacheTiles: true,
disabledTripIds: [],
conflictStrategy: 'ask',
}
let _prefs: OfflinePrefs = read()
const listeners = new Set<() => void>()
function read(): OfflinePrefs {
try {
const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(STORAGE_KEY) : null
if (!raw) return { ...DEFAULTS }
const parsed = JSON.parse(raw) as Partial<OfflinePrefs>
return {
cacheTiles: typeof parsed.cacheTiles === 'boolean' ? parsed.cacheTiles : DEFAULTS.cacheTiles,
disabledTripIds: Array.isArray(parsed.disabledTripIds) ? parsed.disabledTripIds.filter(n => typeof n === 'number') : [],
conflictStrategy: parsed.conflictStrategy === 'mine' || parsed.conflictStrategy === 'server' ? parsed.conflictStrategy : 'ask',
}
} catch {
return { ...DEFAULTS }
}
}
function write(next: OfflinePrefs): void {
_prefs = next
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(next)) } catch { /* best-effort */ }
listeners.forEach(fn => { try { fn() } catch { /* isolate listeners */ } })
}
/** Current snapshot (a copy — callers must not mutate it in place). */
export function getOfflinePrefs(): OfflinePrefs {
return { ..._prefs, disabledTripIds: [..._prefs.disabledTripIds] }
}
export function setCacheTiles(on: boolean): void {
if (_prefs.cacheTiles === on) return
write({ ..._prefs, cacheTiles: on })
}
export function setConflictStrategy(strategy: ConflictStrategy): void {
if (_prefs.conflictStrategy === strategy) return
write({ ..._prefs, conflictStrategy: strategy })
}
/** True when this trip should be cached offline (i.e. not explicitly disabled). */
export function isTripOfflineEnabled(tripId: number): boolean {
return !_prefs.disabledTripIds.includes(tripId)
}
/** Turn offline storage for a single trip on or off. */
export function setTripOfflineEnabled(tripId: number, on: boolean): void {
const has = _prefs.disabledTripIds.includes(tripId)
if (on && !has) return
if (!on && has) return
const disabledTripIds = on
? _prefs.disabledTripIds.filter(id => id !== tripId)
: [..._prefs.disabledTripIds, tripId]
write({ ..._prefs, disabledTripIds })
}
/** Subscribe to preference changes. Returns an unsubscribe function. */
export function onOfflinePrefsChange(fn: () => void): () => void {
listeners.add(fn)
return () => listeners.delete(fn)
}
/** Reset to defaults — test helper only. */
export function _resetOfflinePrefs(): void {
_prefs = { ...DEFAULTS }
listeners.clear()
}
+30 -2
View File
@@ -14,12 +14,15 @@
*/
import { mutationQueue } from './mutationQueue'
import { tripSyncManager } from './tripSyncManager'
import { isEffectivelyOnline, onNetworkModeChange } from './networkMode'
import { setPreReconnectHook, setRefetchCallback, getActiveTrips } from '../api/websocket'
import { useTripStore } from '../store/tripStore'
const PERIODIC_MS = 30_000
let _intervalId: ReturnType<typeof setInterval> | null = null
let _unsubscribeNetworkMode: (() => void) | null = null
let _wasEffectivelyOnline = isEffectivelyOnline()
let _registered = false
/** Pull the latest server state for every open trip into the Zustand store. */
@@ -36,6 +39,11 @@ function rehydrateActiveTrips() {
* edits made while we were offline appear without navigating away.
*/
function onOnline() {
// A real browser reconnect must NOT override a user-forced offline session:
// syncAll would re-seed Dexie from the server and wipe un-flushed optimistic
// edits from the cache/UI. Stay put until the user lifts the switch (which
// routes through onNetworkMode → here with the force flag already cleared).
if (!isEffectivelyOnline()) return
mutationQueue.flush()
.catch(console.error)
.finally(() => {
@@ -46,18 +54,30 @@ function onOnline() {
/** Tab became visible — flush only; don't trigger a potentially expensive syncAll. */
function onVisibility() {
if (!document.hidden && navigator.onLine) {
if (!document.hidden && isEffectivelyOnline()) {
mutationQueue.flush().catch(console.error)
}
}
/** Periodic heartbeat — drain any lingering pending mutations. */
function onPeriodic() {
if (navigator.onLine) {
if (isEffectivelyOnline()) {
mutationQueue.flush().catch(console.error)
}
}
/**
* The force-offline toggle (or a browser online/offline event) changed the
* effective network mode. Coming back online whether the network returned or
* the user lifted the force-offline switch behaves like a real reconnection:
* flush queued writes, then re-seed and re-hydrate.
*/
function onNetworkMode() {
const nowOnline = isEffectivelyOnline()
if (nowOnline && !_wasEffectivelyOnline) onOnline()
_wasEffectivelyOnline = nowOnline
}
export function registerSyncTriggers(): void {
if (_registered) return
_registered = true
@@ -73,6 +93,10 @@ export function registerSyncTriggers(): void {
window.addEventListener('online', onOnline)
document.addEventListener('visibilitychange', onVisibility)
// React to the force-offline toggle (and browser online/offline) so lifting
// the switch immediately flushes + re-seeds like a real reconnection.
_wasEffectivelyOnline = isEffectivelyOnline()
_unsubscribeNetworkMode = onNetworkModeChange(onNetworkMode)
_intervalId = setInterval(onPeriodic, PERIODIC_MS)
}
@@ -84,6 +108,10 @@ export function unregisterSyncTriggers(): void {
setRefetchCallback(null)
window.removeEventListener('online', onOnline)
document.removeEventListener('visibilitychange', onVisibility)
if (_unsubscribeNetworkMode) {
_unsubscribeNetworkMode()
_unsubscribeNetworkMode = null
}
if (_intervalId !== null) {
clearInterval(_intervalId)
_intervalId = null
+25 -3
View File
@@ -143,11 +143,16 @@ export async function prefetchTiles(
tileUrlTemplate: string,
minZoom = 10,
maxZoom = 16,
awaitAll = false,
): Promise<number> {
if (!navigator.onLine) return 0
if (!('serviceWorker' in navigator) || !navigator.serviceWorker.controller) return 0
let fetched = 0
// When awaitAll is set (the "prepare for offline" path), we wait for every tile
// request to settle so the caller's progress bar only completes once the tiles
// are actually downloaded into the SW cache — not merely dispatched.
const inflight: Promise<unknown>[] = []
for (let z = minZoom; z <= maxZoom; z++) {
const minX = lngToTileX(bbox.minLng, z)
@@ -161,16 +166,32 @@ export async function prefetchTiles(
for (let x = minX; x <= maxX; x++) {
for (let y = minY; y <= maxY; y++) {
const url = buildTileUrl(tileUrlTemplate, z, x, y)
// Fire-and-forget: SW CacheFirst handler stores the response
fetch(url, { mode: 'no-cors' }).catch(() => {})
// SW CacheFirst handler stores the response. Fire-and-forget unless the
// caller asked to await completion.
const p = fetch(url, { mode: 'no-cors' }).catch(() => {})
if (awaitAll) inflight.push(p)
fetched++
}
}
}
if (awaitAll && inflight.length) await Promise.allSettled(inflight)
return fetched
}
/**
* Drop the pre-downloaded map-tile cache. Called when the user turns off
* "store map tiles offline" (#1135 ask 2) so the bulk tile storage the real
* "whole world map" concern is reclaimed immediately.
*/
export async function clearTileCache(): Promise<void> {
try {
if (typeof caches !== 'undefined') await caches.delete('map-tiles')
} catch {
/* Cache Storage unavailable (no SW / private mode) — nothing to clear */
}
}
/**
* Full pipeline: compute bbox guard prefetch update syncMeta.
* Designed to be called fire-and-forget from tripSyncManager.
@@ -179,6 +200,7 @@ export async function prefetchTilesForTrip(
tripId: number,
places: Place[],
tileUrlTemplate?: string,
awaitAll = false,
): Promise<void> {
const template = tileUrlTemplate || DEFAULT_TILE_URL
const bbox = computeBbox(places)
@@ -194,7 +216,7 @@ export async function prefetchTilesForTrip(
// tile providers that don't send CORS headers. To stop the browser evicting
// these tiles under the inflated quota, we request persistent storage at app
// init instead (sync/persistentStorage.ts).
const fetched = await prefetchTiles(bbox, template)
const fetched = await prefetchTiles(bbox, template, 10, 16, awaitAll)
// Update syncMeta with bbox and tile count
const meta = await offlineDb.syncMeta.get(tripId)
+101 -11
View File
@@ -31,6 +31,7 @@ import {
} from '../db/offlineDb'
import { prefetchTilesForTrip } from './tilePrefetcher'
import { isAuthed } from './authGate'
import { getOfflinePrefs, isTripOfflineEnabled } from './offlinePrefs'
import { useSettingsStore } from '../store/settingsStore'
import type { Trip, Day, Place, PackingItem, TodoItem, BudgetItem, Reservation, TripFile, Accommodation, TripMember } from '../types'
@@ -71,6 +72,12 @@ function isPhoto(file: TripFile): boolean {
return file.mime_type.startsWith('image/')
}
// Videos can be hundreds of MB — never prefetch them into the bounded offline
// blob cache, or a single clip would evict the trip's real documents (#823).
function isVideo(file: TripFile): boolean {
return file.mime_type.startsWith('video/')
}
// ── Core logic ────────────────────────────────────────────────────────────────
/** Fetch bundle + write all entities for one trip into Dexie. */
@@ -98,7 +105,7 @@ async function syncTrip(tripId: number): Promise<void> {
/** Cache non-photo file blobs for a trip. Fire-and-forget safe. */
async function cacheFilesForTrip(files: TripFile[]): Promise<void> {
const nonPhotos = files.filter(f => f.url && !isPhoto(f))
const nonPhotos = files.filter(f => f.url && !isPhoto(f) && !isVideo(f))
let cached = 0
for (const file of nonPhotos) {
@@ -130,26 +137,46 @@ async function cacheFilesForTrip(files: TripFile[]): Promise<void> {
// ── Public API ────────────────────────────────────────────────────────────────
/** Progress callback payload for a {@link tripSyncManager.prepareForOffline} run. */
export interface PrepareProgress {
/** Current stage. 'done' fires once at the end. */
phase: 'trips' | 'files' | 'tiles' | 'done'
/** 1-based index of the trip currently processed in this phase. */
current: number
/** Total trips to process in this phase. */
total: number
/** Name of the trip currently processed (for the UI). */
label?: string
}
let _syncing = false
/**
* Decide which trips to cache and which to drop, honouring both the date rule
* and the user's per-trip offline choices (#1135 ask 2). Returns the trips to
* sync; clears Dexie for stale or user-disabled trips as a side effect.
*/
async function reconcileTrips(trips: Trip[]): Promise<Trip[]> {
const stale = trips.filter(isStale)
// Trips the user turned off explicitly are evicted regardless of date.
const disabled = trips.filter(t => !isTripOfflineEnabled(t.id))
await Promise.all([...stale, ...disabled].map(t => clearTripData(t.id).catch(console.error)))
return trips.filter(t => shouldCache(t) && isTripOfflineEnabled(t.id))
}
export const tripSyncManager = {
/**
* Sync all cache-eligible trips.
* Evicts stale trips. Caches file blobs in the background.
* No-ops when offline.
* Evicts stale and user-disabled trips. Caches file blobs + map tiles in the
* background. No-ops when offline.
*/
async syncAll(): Promise<void> {
if (_syncing || !navigator.onLine || !isAuthed()) return
_syncing = true
try {
const { trips } = await tripsApi.list() as { trips: Trip[] }
const toSync = await reconcileTrips(trips)
// Evict stale trips first
const stale = trips.filter(isStale)
await Promise.all(stale.map(t => clearTripData(t.id).catch(console.error)))
// Sync eligible trips
const toSync = trips.filter(shouldCache)
for (const trip of toSync) {
try {
await syncTrip(trip.id)
@@ -163,19 +190,82 @@ export const tripSyncManager = {
categoriesApi.list().then(d => upsertCategories(d.categories)).catch(() => {})
// Cache file blobs + map tiles in background (don't block syncAll)
const cacheTiles = getOfflinePrefs().cacheTiles
const tileUrl = useSettingsStore.getState().settings.map_tile_url || undefined
for (const trip of toSync) {
const files = await offlineDb.tripFiles.where('trip_id').equals(trip.id).toArray()
cacheFilesForTrip(files).catch(console.error)
const places = await offlineDb.places.where('trip_id').equals(trip.id).toArray()
prefetchTilesForTrip(trip.id, places, tileUrl).catch(console.error)
if (cacheTiles) {
const places = await offlineDb.places.where('trip_id').equals(trip.id).toArray()
prefetchTilesForTrip(trip.id, places, tileUrl).catch(console.error)
}
}
} finally {
_syncing = false
}
},
/**
* "Prepare for offline" (#1135 ask 1): a fully-awaited sync the user runs while
* still online so everything they need is guaranteed on-device before they go
* offline. Unlike syncAll, this AWAITS file-blob and map-tile downloads and
* reports progress, so the UI can show a real completion state instead of
* resolving the moment the requests are merely dispatched.
*
* Returns the number of trips prepared.
*/
async prepareForOffline(onProgress?: (p: PrepareProgress) => void): Promise<number> {
if (_syncing || !navigator.onLine || !isAuthed()) return 0
_syncing = true
try {
const { trips } = await tripsApi.list() as { trips: Trip[] }
const toSync = await reconcileTrips(trips)
const total = toSync.length
// 1) Trip bundles (structured data).
let i = 0
for (const trip of toSync) {
onProgress?.({ phase: 'trips', current: ++i, total, label: trip.title })
try {
await syncTrip(trip.id)
} catch (err) {
console.error(`[tripSync] prepare failed for trip ${trip.id}:`, err)
}
}
// Global user data (tags + categories) — awaited here.
await Promise.all([
tagsApi.list().then(d => upsertTags(d.tags)).catch(() => {}),
categoriesApi.list().then(d => upsertCategories(d.categories)).catch(() => {}),
])
// 2) File blobs — awaited so "prepared" really means downloaded.
i = 0
for (const trip of toSync) {
onProgress?.({ phase: 'files', current: ++i, total, label: trip.title })
const files = await offlineDb.tripFiles.where('trip_id').equals(trip.id).toArray()
await cacheFilesForTrip(files).catch(console.error)
}
// 3) Map tiles — awaited, and only when the user opted to store them.
if (getOfflinePrefs().cacheTiles) {
const tileUrl = useSettingsStore.getState().settings.map_tile_url || undefined
i = 0
for (const trip of toSync) {
onProgress?.({ phase: 'tiles', current: ++i, total, label: trip.title })
const places = await offlineDb.places.where('trip_id').equals(trip.id).toArray()
await prefetchTilesForTrip(trip.id, places, tileUrl, true).catch(console.error)
}
}
onProgress?.({ phase: 'done', current: total, total })
return total
} finally {
_syncing = false
}
},
/** Reset syncing flag — useful in tests. */
_resetSyncing(): void {
_syncing = false
+2 -1
View File
@@ -1,4 +1,5 @@
import { getCachedBlob } from '../db/offlineDb'
import { isEffectivelyOffline } from '../sync/networkMode'
// MIME types safe to open inline (will not execute script in any browser).
// Everything else (text/html, image/svg+xml, text/javascript, …) is forced to
@@ -51,7 +52,7 @@ function isIosStandalone(): boolean {
*/
async function getFileBlob(url: string): Promise<Blob> {
assertRelativeUrl(url)
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
if (typeof navigator !== 'undefined' && isEffectivelyOffline()) {
const cached = await getCachedBlob(url)
if (cached) return cached
throw new Error('File not available offline')
+5 -1
View File
@@ -93,11 +93,15 @@ export function formatMoney(
export function formatDate(dateStr: string | null | undefined, locale: string, timeZone?: string): string | null {
if (!dateStr) return null
const date = new Date(dateStr + 'T00:00:00Z')
const opts: Intl.DateTimeFormatOptions = {
weekday: 'short', day: 'numeric', month: 'short',
timeZone: timeZone || 'UTC',
}
return new Date(dateStr + 'T00:00:00Z').toLocaleDateString(locale, opts)
// Show the year only when it isn't the current year, so this year's dates stay
// compact while older/future ones are unambiguous.
if (date.getUTCFullYear() !== new Date().getUTCFullYear()) opts.year = 'numeric'
return date.toLocaleDateString(locale, opts)
}
export function formatTime(timeStr: string | null | undefined, locale: string, timeFormat: string): string {
+57
View File
@@ -0,0 +1,57 @@
/**
* Capture a poster frame and duration from a video file entirely in the browser
* (#823). This avoids any server-side transcoding: the picked video is decoded by
* the browser, a frame is drawn to a canvas and exported as a JPEG that is
* uploaded alongside the video and stored as its thumbnail.
*
* Resolves with a null poster (and best-effort duration) if anything fails the
* caller still uploads the video; the gallery just shows a placeholder tile.
*/
export async function captureVideoPoster(file: File): Promise<{ poster: Blob | null; durationMs: number | null }> {
return new Promise((resolve) => {
if (typeof document === 'undefined') { resolve({ poster: null, durationMs: null }); return }
const url = URL.createObjectURL(file)
const video = document.createElement('video')
video.preload = 'metadata'
video.muted = true
video.playsInline = true
video.src = url
let settled = false
const finish = (poster: Blob | null, durationMs: number | null) => {
if (settled) return
settled = true
URL.revokeObjectURL(url)
resolve({ poster, durationMs })
}
// Don't hang forever on a codec the browser can't decode.
const timer = setTimeout(() => finish(null, null), 10_000)
video.onerror = () => { clearTimeout(timer); finish(null, null) }
video.onloadedmetadata = () => {
const durationMs = Number.isFinite(video.duration) ? Math.round(video.duration * 1000) : null
// Seek slightly in to dodge an all-black first frame.
const target = Math.min(0.1, (video.duration || 1) / 2)
video.onseeked = () => {
clearTimeout(timer)
try {
const canvas = document.createElement('canvas')
canvas.width = video.videoWidth || 640
canvas.height = video.videoHeight || 360
const ctx = canvas.getContext('2d')
if (!ctx) return finish(null, durationMs)
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
canvas.toBlob((blob) => finish(blob, durationMs), 'image/jpeg', 0.8)
} catch {
finish(null, durationMs)
}
}
try { video.currentTime = target } catch { clearTimeout(timer); finish(null, durationMs) }
}
})
}
/** True for a File the user picked that should go through the video upload path. */
export function isVideoFile(file: File): boolean {
return typeof file.type === 'string' && file.type.startsWith('video/')
}
+16
View File
@@ -319,6 +319,22 @@ describe('offlineDb — clearTripData', () => {
expect(await offlineDb.days.where('trip_id').equals(2).count()).toBe(1);
expect(await offlineDb.blobCache.get('/api/files/2/download')).toBeDefined();
});
it('preserves unsynced (pending/conflict) writes but drops dead failed ones (#1135)', async () => {
await upsertTrip(makeTrip(1));
await offlineDb.mutationQueue.bulkPut([
{ id: 'p1', tripId: 1, method: 'PUT', url: '/trips/1/places/10', body: { name: 'X' }, createdAt: 1, status: 'pending', attempts: 0, lastError: null, resource: 'places', entityId: 10 },
{ id: 'c1', tripId: 1, method: 'PUT', url: '/trips/1/places/11', body: { name: 'Y' }, createdAt: 2, status: 'conflict', attempts: 1, lastError: 'conflict', resource: 'places', entityId: 11 },
{ id: 'f1', tripId: 1, method: 'PUT', url: '/trips/1/places/12', body: { name: 'Z' }, createdAt: 3, status: 'failed', attempts: 1, lastError: 'boom', resource: 'places', entityId: 12 },
]);
await clearTripData(1);
// The trip's cached read data is gone, but the unsynced work survives.
expect(await offlineDb.mutationQueue.get('p1')).toBeDefined();
expect(await offlineDb.mutationQueue.get('c1')).toBeDefined();
expect(await offlineDb.mutationQueue.get('f1')).toBeUndefined();
});
});
describe('offlineDb — clearAll', () => {
+2 -1
View File
@@ -91,7 +91,7 @@ describe('isRtlLanguage', () => {
describe('SUPPORTED_LANGUAGES', () => {
it('FE-COMP-I18N-009: contains expected entries with value/label shape', () => {
expect(Array.isArray(SUPPORTED_LANGUAGES)).toBe(true)
expect(SUPPORTED_LANGUAGES).toHaveLength(21)
expect(SUPPORTED_LANGUAGES).toHaveLength(22)
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'en', label: 'English' }))
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'tr', label: 'Türkçe' }))
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'ja', label: '日本語' }))
@@ -99,6 +99,7 @@ describe('SUPPORTED_LANGUAGES', () => {
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'uk', label: 'Українська' }))
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'sv', label: 'Svenska' }))
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'ar', label: 'العربية' }))
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'vi', label: 'Tiếng Việt' }))
})
})
@@ -91,6 +91,35 @@ describe('placesSlice', () => {
});
});
describe('updatePlacesMany', () => {
it('FE-PLACES-008: applies the patch to every listed place and cascades to assignments', async () => {
const a = buildPlace({ id: 10, trip_id: 1, category_id: 1 });
const b = buildPlace({ id: 20, trip_id: 1, category_id: 1 });
const c = buildPlace({ id: 30, trip_id: 1, category_id: 9 });
const assignment = buildAssignment({ id: 100, day_id: 1, place: a });
seedStore(useTripStore, { places: [a, b, c], assignments: { '1': [assignment] } });
server.use(
http.post('/api/trips/1/places/bulk-update', () => HttpResponse.json({ updated: [10, 20], count: 2 })),
);
await useTripStore.getState().updatePlacesMany(1, [10, 20], { category_id: 5 });
const places = useTripStore.getState().places;
expect(places.find(p => p.id === 10)?.category_id).toBe(5);
expect(places.find(p => p.id === 20)?.category_id).toBe(5);
expect(places.find(p => p.id === 30)?.category_id).toBe(9); // untouched
expect(useTripStore.getState().assignments['1'][0].place.category_id).toBe(5); // cascaded
});
it('FE-PLACES-009: no-ops on an empty id list without calling the API', async () => {
const a = buildPlace({ id: 10, trip_id: 1, category_id: 1 });
seedStore(useTripStore, { places: [a] });
await useTripStore.getState().updatePlacesMany(1, [], { category_id: 5 });
expect(useTripStore.getState().places[0].category_id).toBe(1);
});
});
describe('deletePlace', () => {
it('FE-PLACES-005: deletePlace removes place from places array', async () => {
const place1 = buildPlace({ id: 10, trip_id: 1 });
@@ -0,0 +1,193 @@
/**
* mutationQueue conflict tests (#1135) 409 handling + resolution.
*
* Covers: X-Base-Updated-At header, 'ask' parks a conflict, keep-mine re-sends
* unconditionally, keep-theirs adopts the server entity, and the 'mine'/'server'
* auto-strategies.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import 'fake-indexeddb/auto'
import { server } from '../../helpers/msw/server'
import { http, HttpResponse } from 'msw'
import { setAuthed } from '../../../src/sync/authGate'
import { mutationQueue, generateUUID } from '../../../src/sync/mutationQueue'
import { offlineDb, clearAll } from '../../../src/db/offlineDb'
import { _resetNetworkMode } from '../../../src/sync/networkMode'
import { _resetOfflinePrefs, setConflictStrategy } from '../../../src/sync/offlinePrefs'
import { buildPlace } from '../../helpers/factories'
beforeEach(async () => {
await clearAll()
mutationQueue._resetFlushing()
_resetNetworkMode()
_resetOfflinePrefs()
setAuthed(true)
Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true })
})
afterEach(() => {
vi.restoreAllMocks()
setAuthed(false)
})
const BASE = '2026-01-01 00:00:00'
function enqueueConflictingPut(id: string, baseUpdatedAt: string | null = BASE) {
return mutationQueue.enqueue({
id, tripId: 1, method: 'PUT', url: '/trips/1/places/42',
body: { name: 'Mine' }, resource: 'places', entityId: 42, baseUpdatedAt,
})
}
/** Server that 409s when the base token is sent, and 200s once it isn't. */
function conflictThenAcceptHandler(serverName = 'Theirs') {
server.use(
http.put('/api/trips/1/places/42', ({ request }) => {
if (request.headers.get('X-Base-Updated-At')) {
return HttpResponse.json({ error: 'conflict', server: buildPlace({ trip_id: 1, id: 42, name: serverName }) }, { status: 409 })
}
return HttpResponse.json({ place: buildPlace({ trip_id: 1, id: 42, name: 'Mine' }) })
}),
)
}
describe('mutationQueue — base-version header', () => {
it('sends X-Base-Updated-At when the mutation carries a base version', async () => {
let captured: string | null = null
server.use(http.put('/api/trips/1/places/42', ({ request }) => {
captured = request.headers.get('X-Base-Updated-At')
return HttpResponse.json({ place: buildPlace({ trip_id: 1, id: 42 }) })
}))
await enqueueConflictingPut(generateUUID())
await mutationQueue.flush()
expect(captured).toBe(BASE)
})
it('omits the header when there is no base version', async () => {
let hadHeader = true
server.use(http.put('/api/trips/1/places/42', ({ request }) => {
hadHeader = request.headers.has('X-Base-Updated-At')
return HttpResponse.json({ place: buildPlace({ trip_id: 1, id: 42 }) })
}))
await enqueueConflictingPut(generateUUID(), null)
await mutationQueue.flush()
expect(hadHeader).toBe(false)
})
})
describe('mutationQueue — 409 with strategy "ask"', () => {
it('parks the mutation as a conflict carrying the server version', async () => {
const id = generateUUID()
server.use(http.put('/api/trips/1/places/42', () =>
HttpResponse.json({ error: 'conflict', server: buildPlace({ trip_id: 1, id: 42, name: 'Theirs' }) }, { status: 409 })))
await enqueueConflictingPut(id)
await mutationQueue.flush()
const m = await offlineDb.mutationQueue.get(id)
expect(m!.status).toBe('conflict')
expect((m!.conflictServer as { name: string }).name).toBe('Theirs')
expect(await mutationQueue.conflictCount()).toBe(1)
expect(await mutationQueue.failedCount()).toBe(0)
})
it('does not count a conflict as pending and is skipped by later flushes', async () => {
const id = generateUUID()
server.use(http.put('/api/trips/1/places/42', () =>
HttpResponse.json({ error: 'conflict', server: buildPlace({ trip_id: 1, id: 42 }) }, { status: 409 })))
await enqueueConflictingPut(id)
await mutationQueue.flush()
expect(await mutationQueue.pendingCount()).toBe(0)
// A second flush must not touch the parked conflict.
await mutationQueue.flush()
expect((await offlineDb.mutationQueue.get(id))!.status).toBe('conflict')
})
})
describe('mutationQueue — conflict resolution', () => {
it('keep-mine re-sends without the base token and clears the conflict', async () => {
const id = generateUUID()
conflictThenAcceptHandler()
await enqueueConflictingPut(id)
await mutationQueue.flush()
expect((await offlineDb.mutationQueue.get(id))!.status).toBe('conflict')
await mutationQueue.resolveKeepMine(id)
expect(await offlineDb.mutationQueue.get(id)).toBeUndefined()
expect((await offlineDb.places.get(42))!.name).toBe('Mine')
expect(await mutationQueue.conflictCount()).toBe(0)
})
it('keep-theirs adopts the server entity and drops the queued write', async () => {
const id = generateUUID()
server.use(http.put('/api/trips/1/places/42', () =>
HttpResponse.json({ error: 'conflict', server: buildPlace({ trip_id: 1, id: 42, name: 'Theirs' }) }, { status: 409 })))
await enqueueConflictingPut(id)
await mutationQueue.flush()
await mutationQueue.resolveKeepServer(id)
expect(await offlineDb.mutationQueue.get(id)).toBeUndefined()
expect((await offlineDb.places.get(42))!.name).toBe('Theirs')
})
})
describe('mutationQueue — chained edits to the same entity', () => {
it('do NOT self-conflict: the new token is propagated to the next queued edit (#1135)', async () => {
// A server that does real compare-and-swap on X-Base-Updated-At and bumps
// the token on each accepted write.
let token = 'T0'
let serverPlace = { ...buildPlace({ trip_id: 1, id: 42, name: 'A' }), notes: 'orig', updated_at: token } as Record<string, unknown>
server.use(http.put('/api/trips/1/places/42', async ({ request }) => {
const base = request.headers.get('X-Base-Updated-At')
if (base !== token) return HttpResponse.json({ error: 'conflict', server: serverPlace }, { status: 409 })
const body = await request.json() as Record<string, unknown>
token = token === 'T0' ? 'T1' : 'T2'
serverPlace = { ...serverPlace, ...body, updated_at: token }
return HttpResponse.json({ place: serverPlace })
}))
await offlineDb.places.put({ ...(serverPlace as object) } as never)
// Two offline edits to different fields of place 42, both based on T0.
await mutationQueue.enqueue({ id: 'm1', tripId: 1, method: 'PUT', url: '/trips/1/places/42', body: { name: 'B' }, resource: 'places', entityId: 42, baseUpdatedAt: 'T0' })
await mutationQueue.enqueue({ id: 'm2', tripId: 1, method: 'PUT', url: '/trips/1/places/42', body: { notes: 'edited' }, resource: 'places', entityId: 42, baseUpdatedAt: 'T0' })
await mutationQueue.flush()
expect(await mutationQueue.conflictCount()).toBe(0)
expect(await offlineDb.mutationQueue.count()).toBe(0)
const final = await offlineDb.places.get(42) as unknown as { name: string; notes: string }
expect(final.name).toBe('B')
expect(final.notes).toBe('edited')
})
})
describe('mutationQueue — auto strategies', () => {
it('"server" adopts the server version automatically', async () => {
setConflictStrategy('server')
const id = generateUUID()
server.use(http.put('/api/trips/1/places/42', () =>
HttpResponse.json({ error: 'conflict', server: buildPlace({ trip_id: 1, id: 42, name: 'Theirs' }) }, { status: 409 })))
await enqueueConflictingPut(id)
await mutationQueue.flush()
expect(await offlineDb.mutationQueue.get(id)).toBeUndefined()
expect((await offlineDb.places.get(42))!.name).toBe('Theirs')
expect(await mutationQueue.conflictCount()).toBe(0)
})
it('"mine" re-sends unconditionally and wins', async () => {
setConflictStrategy('mine')
const id = generateUUID()
conflictThenAcceptHandler()
await enqueueConflictingPut(id)
await mutationQueue.flush()
expect(await offlineDb.mutationQueue.get(id)).toBeUndefined()
expect((await offlineDb.places.get(42))!.name).toBe('Mine')
expect(await mutationQueue.conflictCount()).toBe(0)
})
})
@@ -0,0 +1,56 @@
/**
* networkMode unit tests the force-offline override + effective offline state.
*/
import { describe, it, expect, beforeEach } from 'vitest'
import {
isEffectivelyOffline, isEffectivelyOnline, isForcedOffline,
setForcedOffline, onNetworkModeChange, _resetNetworkMode,
} from '../../../src/sync/networkMode'
beforeEach(() => {
_resetNetworkMode()
try { localStorage.removeItem('trek_forced_offline') } catch { /* ignore */ }
Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true })
})
describe('networkMode', () => {
it('is online by default', () => {
expect(isForcedOffline()).toBe(false)
expect(isEffectivelyOffline()).toBe(false)
expect(isEffectivelyOnline()).toBe(true)
})
it('forced offline overrides a real online connection', () => {
setForcedOffline(true)
expect(isForcedOffline()).toBe(true)
expect(isEffectivelyOffline()).toBe(true)
expect(isEffectivelyOnline()).toBe(false)
})
it('reports offline when the browser is offline even without the force flag', () => {
Object.defineProperty(navigator, 'onLine', { value: false, writable: true, configurable: true })
expect(isForcedOffline()).toBe(false)
expect(isEffectivelyOffline()).toBe(true)
})
it('notifies subscribers on change, ignores no-op sets, and stops after unsubscribe', () => {
let count = 0
const unsub = onNetworkModeChange(() => { count++ })
setForcedOffline(true)
expect(count).toBe(1)
setForcedOffline(true) // same value → no notification
expect(count).toBe(1)
setForcedOffline(false)
expect(count).toBe(2)
unsub()
setForcedOffline(true)
expect(count).toBe(2)
})
it('persists the forced flag to localStorage', () => {
setForcedOffline(true)
expect(localStorage.getItem('trek_forced_offline')).toBe('1')
setForcedOffline(false)
expect(localStorage.getItem('trek_forced_offline')).toBeNull()
})
})
@@ -0,0 +1,60 @@
/**
* offlinePrefs unit tests device-local "what to store offline" + conflict strategy.
*/
import { describe, it, expect, beforeEach } from 'vitest'
import {
getOfflinePrefs, setCacheTiles, setConflictStrategy,
isTripOfflineEnabled, setTripOfflineEnabled, onOfflinePrefsChange, _resetOfflinePrefs,
} from '../../../src/sync/offlinePrefs'
beforeEach(() => {
_resetOfflinePrefs()
try { localStorage.removeItem('trek_offline_prefs') } catch { /* ignore */ }
})
describe('offlinePrefs', () => {
it('defaults to tiles on, no disabled trips, ask strategy', () => {
const p = getOfflinePrefs()
expect(p.cacheTiles).toBe(true)
expect(p.disabledTripIds).toEqual([])
expect(p.conflictStrategy).toBe('ask')
expect(isTripOfflineEnabled(5)).toBe(true)
})
it('toggles tile caching and persists it', () => {
setCacheTiles(false)
expect(getOfflinePrefs().cacheTiles).toBe(false)
expect(JSON.parse(localStorage.getItem('trek_offline_prefs')!).cacheTiles).toBe(false)
})
it('disables and re-enables a single trip', () => {
setTripOfflineEnabled(7, false)
expect(isTripOfflineEnabled(7)).toBe(false)
expect(getOfflinePrefs().disabledTripIds).toContain(7)
setTripOfflineEnabled(7, true)
expect(isTripOfflineEnabled(7)).toBe(true)
expect(getOfflinePrefs().disabledTripIds).not.toContain(7)
})
it('does not duplicate a trip id when disabled twice', () => {
setTripOfflineEnabled(3, false)
setTripOfflineEnabled(3, false)
expect(getOfflinePrefs().disabledTripIds.filter(id => id === 3)).toHaveLength(1)
})
it('sets the conflict strategy', () => {
setConflictStrategy('mine')
expect(getOfflinePrefs().conflictStrategy).toBe('mine')
})
it('notifies subscribers and stops after unsubscribe', () => {
let n = 0
const unsub = onOfflinePrefsChange(() => { n++ })
setCacheTiles(false)
expect(n).toBe(1)
unsub()
setCacheTiles(true)
expect(n).toBe(1)
})
})
@@ -0,0 +1,20 @@
/**
* videoPoster unit tests (#823). The poster-capture path needs a real <video>
* decoder + canvas, which jsdom does not provide, so we cover the pure file-type
* gate here; poster capture is exercised manually / in the browser.
*/
import { describe, it, expect } from 'vitest'
import { isVideoFile } from '../../../src/utils/videoPoster'
describe('isVideoFile', () => {
it('is true for a video MIME type', () => {
expect(isVideoFile(new File([], 'clip.mp4', { type: 'video/mp4' }))).toBe(true)
expect(isVideoFile(new File([], 'clip.webm', { type: 'video/webm' }))).toBe(true)
})
it('is false for images and other files', () => {
expect(isVideoFile(new File([], 'photo.jpg', { type: 'image/jpeg' }))).toBe(false)
expect(isVideoFile(new File([], 'doc.pdf', { type: 'application/pdf' }))).toBe(false)
expect(isVideoFile(new File([], 'noext', { type: '' }))).toBe(false)
})
})
+49
View File
@@ -40,6 +40,7 @@
"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",
@@ -9439,6 +9440,17 @@
"dev": true,
"license": "MIT"
},
"node_modules/core-js": {
"version": "3.49.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
"hasInstallScript": true,
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/core-js-compat": {
"version": "3.49.0",
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz",
@@ -9564,6 +9576,12 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/custom-event-polyfill": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/custom-event-polyfill/-/custom-event-polyfill-1.0.7.tgz",
"integrity": "sha512-TDDkd5DkaZxZFM8p+1I3yAlvM3rSr1wbrOliG4yJiwinMZN8z/iGL7BTlDkrJcYTmgUSb4ywVCc3ZaUtOtC76w==",
"license": "MIT"
},
"node_modules/data-urls": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
@@ -13484,6 +13502,12 @@
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
}
},
"node_modules/loadjs": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/loadjs/-/loadjs-4.3.0.tgz",
"integrity": "sha512-vNX4ZZLJBeDEOBvdr2v/F+0aN5oMuPu7JTqrMwp+DtgK+AryOlpy6Xtm2/HpNr+azEa828oQjOtWsB6iDtSfSQ==",
"license": "MIT"
},
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -15769,6 +15793,19 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/plyr": {
"version": "3.8.4",
"resolved": "https://registry.npmjs.org/plyr/-/plyr-3.8.4.tgz",
"integrity": "sha512-DrzLbK9Wol3zeiuZCleD9aUOl0KAaBHR9H6WVVVYPZ4Ya+LYxUFTgSF1jooHcMQCv96Ws96wCaZzIoP3bES8pQ==",
"license": "MIT",
"dependencies": {
"core-js": "^3.45.1",
"custom-event-polyfill": "^1.0.7",
"loadjs": "^4.3.0",
"rangetouch": "^2.0.1",
"url-polyfill": "^1.1.13"
}
},
"node_modules/png-js": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/png-js/-/png-js-2.0.0.tgz",
@@ -16562,6 +16599,12 @@
"node": ">= 0.6"
}
},
"node_modules/rangetouch": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/rangetouch/-/rangetouch-2.0.1.tgz",
"integrity": "sha512-sln+pNSc8NGaHoLzwNBssFSf/rSYkqeBXzX1AtJlkJiUaVSJSbRAWJk+4omsXkN+EJalzkZhWQ3th1m0FpR5xA==",
"license": "MIT"
},
"node_modules/raw-body": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
@@ -19703,6 +19746,12 @@
"punycode": "^2.1.0"
}
},
"node_modules/url-polyfill": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/url-polyfill/-/url-polyfill-1.1.14.tgz",
"integrity": "sha512-p4f3TTAG6ADVF3mwbXw7hGw+QJyw5CnNGvYh5fCuQQZIiuKUswqcznyV3pGDP9j0TSmC4UvRKm8kl1QsX1diiQ==",
"license": "MIT"
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+109
View File
@@ -3071,6 +3071,115 @@ function runMigrations(db: Database.Database): void {
if (!err.message?.includes('duplicate column name')) throw err;
}
},
() => {
try {
db.exec('ALTER TABLE budget_item_members ADD COLUMN amount REAL');
} catch (err: any) {
if (!err.message?.includes('duplicate column name')) throw err;
}
},
// Calendar feed tokens — subscribable ICS links for per-trip and all-trips feeds
() => {
try {
db.exec('ALTER TABLE trips ADD COLUMN feed_token TEXT');
} catch (err: any) {
if (!err.message?.includes('duplicate column name')) throw err;
}
try {
db.exec('ALTER TABLE users ADD COLUMN feed_token TEXT');
} catch (err: any) {
if (!err.message?.includes('duplicate column name')) throw err;
}
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_trips_feed_token ON trips(feed_token) WHERE feed_token IS NOT NULL');
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_users_feed_token ON users(feed_token) WHERE feed_token IS NOT NULL');
},
// Optimistic-concurrency token for offline conflict detection (#1135).
// packing_items had only created_at, so an offline edit could not be checked
// against a concurrent server change. SQLite forbids a non-constant DEFAULT on
// ALTER ADD COLUMN, so add it nullable and backfill from created_at; new rows
// set it explicitly (packingService). Additive: a request without the
// X-Base-Updated-At header keeps the old last-write-wins behaviour.
() => {
try {
db.exec('ALTER TABLE packing_items ADD COLUMN updated_at DATETIME');
} catch (err: any) {
if (!err.message?.includes('duplicate column name')) throw err;
}
db.exec('UPDATE packing_items SET updated_at = COALESCE(updated_at, created_at, CURRENT_TIMESTAMP) WHERE updated_at IS NULL');
},
// Video support (#823): the trek_photos registry held only images. media_type
// discriminates image vs video so the gallery, lightbox and provider proxy can
// branch; duration_ms is optional metadata for the player. Additive — existing
// rows default to 'image'.
() => {
for (const stmt of [
"ALTER TABLE trek_photos ADD COLUMN media_type TEXT NOT NULL DEFAULT 'image'",
'ALTER TABLE trek_photos ADD COLUMN duration_ms INTEGER',
]) {
try {
db.exec(stmt);
} catch (err: any) {
if (!err.message?.includes('duplicate column name')) throw err;
}
}
},
// Dedicated booking URL (#935) — users previously stuffed links into notes.
// Additive nullable TEXT; existing rows default to NULL.
() => {
try {
db.exec('ALTER TABLE reservations ADD COLUMN url TEXT');
} catch (err: any) {
if (!err.message?.includes('duplicate column name')) throw err;
}
},
// Private packing items (#858): an item can be hidden from other trip members.
// is_private toggles the visibility; owner_id records who it belongs to so the
// listing can show it only to them. owner_id is NULL on legacy rows (shared).
() => {
for (const stmt of [
'ALTER TABLE packing_items ADD COLUMN is_private INTEGER NOT NULL DEFAULT 0',
'ALTER TABLE packing_items ADD COLUMN owner_id INTEGER REFERENCES users(id) ON DELETE SET NULL',
]) {
try {
db.exec(stmt);
} catch (err: any) {
if (!err.message?.includes('duplicate column name')) throw err;
}
}
},
// Guest members (#1362): people added to a trip without an account. A guest is a
// users row flagged is_guest=1 (no usable credentials) joined into trip_members,
// so it's assignable everywhere a member is — but must never authenticate or show
// up in the global user directory. The flag is the discriminator for those guards.
() => {
try {
db.exec('ALTER TABLE users ADD COLUMN is_guest INTEGER NOT NULL DEFAULT 0');
} catch (err: any) {
if (!err.message?.includes('duplicate column name')) throw err;
}
},
// Three-tier packing sharing (#858 follow-up): an item is Common (is_private=0,
// every existing item — non-breaking), Personal (is_private=1, owner only) or
// Shared-with-people (is_private=1 + recipient rows). owner_id is the "bringer".
// Contributors are extra people who said "I can bring that too" on a Common item
// (status 'pending' until the owner accepts).
() => {
db.exec(`
CREATE TABLE IF NOT EXISTS packing_item_recipients (
item_id INTEGER NOT NULL REFERENCES packing_items(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
PRIMARY KEY (item_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_packing_item_recipients_user ON packing_item_recipients(user_id);
CREATE TABLE IF NOT EXISTS packing_item_contributors (
item_id INTEGER NOT NULL REFERENCES packing_items(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'accepted',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (item_id, user_id)
);
`);
},
];
if (currentVersion < migrations.length) {

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