Compare commits

...

26 Commits

Author SHA1 Message Date
github-actions[bot] ec1ed60117 chore: bump version to 3.0.2 [skip ci] 2026-04-22 19:25:28 +00:00
Julien G. ed4c21eade Merge pull request #835 from mauriceboe/fix/oidc-issuer-trailing-slash
fix(oidc): normalize discovery doc issuer before trailing slash comparison
2026-04-22 21:25:15 +02:00
jubnl 9093948ff6 test(systemNotices): exclude v3 upgrade notices from login_count-only tests
Tests that expect an empty notice list were using first_seen_version='0.0.0'
(DB default), which matches the existingUserBeforeVersion('3.0.0') condition
now that the app is at 3.0.1. Set first_seen_version='3.0.0' so only the
firstLogin condition controls visibility in these tests.
2026-04-22 21:19:04 +02:00
jubnl 2cea4d73aa fix(oidc): normalize discovery doc issuer before comparison
Trailing slash in doc.issuer (e.g. Authentik) caused a mismatch against
the already-normalized configured issuer, breaking OIDC login entirely.

Closes #834
2026-04-22 21:14:29 +02:00
github-actions[bot] a2a6f52e6e chore: bump version to 3.0.1 [skip ci] 2026-04-22 17:58:18 +00:00
Maurice 0978b40b6d Merge pull request #832 from mauriceboe/fix/reservations-day-id-mismatch
fix(reservations): restore correct day assignment for non-transport bookings
2026-04-22 19:58:03 +02:00
Maurice 6155b6dc86 fix(reservations): restore correct day assignment for non-transport bookings
v3.0.0 switched the planner from rendering reservations by
reservation_time to rendering them by day_id (commit 3f61e1c), but
migration 110 only backfilled day_id for transport types. Tours,
restaurants, events and 'other' bookings kept whatever day_id was
stored in the DB — often the trip's first day, from older code paths
that defaulted it there — so after the upgrade those rows all show
up on day 1 regardless of their actual reservation_time.

- Migration 122: for every non-hotel reservation, null out any
  day_id / end_day_id that does not match the reservation's time,
  then backfill it from reservation_time / reservation_end_time.
  Idempotent; leaves already-correct rows alone.
- reservationService.createReservation / updateReservation now
  derive day_id / end_day_id from reservation_time /
  reservation_end_time when the client didn't send one explicitly,
  so the mismatch cannot reappear on new or edited bookings.
  Hotels are skipped because they store their date range on the
  linked day_accommodation.
2026-04-22 19:47:22 +02:00
jubnl 314486325e fix: resolve dead wiki links across install and config pages 2026-04-22 19:21:53 +02:00
github-actions[bot] 523bca3a20 chore: bump version to 3.0.0 [skip ci] 2026-04-22 16:59:12 +00:00
Maurice d5be528d4b Merge pull request #758 from mauriceboe/dev
V3.0.0
2026-04-22 18:58:23 +02:00
Julien G. 3ada075b1a Merge pull request #831 from mauriceboe/fix/transport-modal-price-budget-fields
fix: restore Price and Budget Category fields in Edit Transport dialog
2026-04-22 18:55:53 +02:00
jubnl afce302b59 fix: restore price and budget category fields in TransportModal 2026-04-22 18:50:42 +02:00
Maurice 8e8433fa9d docs: align Home.md + README addon list + Tags/Photo-Providers wording with dev state
- Home.md: addon list (9 real addons), MCP numbers (150+ tools, 30 resources, 27 scopes), admin-seeding text
- README.md: expand addon list from 5 to 9 (Lists/Budget/Documents/Naver/MCP in, Dashboard widgets out)
- Photo-Providers.md: 'Memories addon' -> photo provider toggles under Journey
- Admin-Addons.md: Journey works without photo providers; they are optional sub-toggles
- Tags-and-Categories.md: add Personal Tags section (user-scoped, MCP-only for now)
2026-04-22 18:22:22 +02:00
Maurice ff42fa0b8c docs: sync README with current dev state
- MCP: 80+ tools/27 resources -> 150+ tools/30 resources
- MCP: 24 -> 27 OAuth scopes
- i18n: 14 -> 15 languages
- admin seeding on first boot (not first-to-register)
- nginx: client_max_body_size 50m -> 500m, add proxy_read_timeout 86400 on /ws
2026-04-22 18:10:27 +02:00
jubnl ccea7f7a65 fix: restore map share toggle and fix public journey horizontal scroll
Re-adds the share_map permission toggle to the journey share settings UI so
owners can control whether the map is visible on the public share page.
Fixes horizontal scrollbar on the public journey page caused by decorative
hero circles with negative offsets overflowing the viewport.
2026-04-22 17:05:15 +02:00
jubnl 45a5b4e588 fix: remove obsolete map share toggle and make public desktop entries openable
Map permission is always enabled on new links (share always includes map).
Removed the toggle from the share settings UI since the map is now always
part of the combined timeline+map view with no standalone value in toggling it.

Desktop entry cards on the public share page now open MobileEntryView on click,
matching the mobile behaviour added in #826.
2026-04-22 16:33:04 +02:00
jubnl 82cce365f7 fix: validate image-only uploads and respect allowed_file_types setting for journey photos
Add fileFilter to the journey photo multer config (shared by entry photo
upload and gallery upload routes):
- Rejects any non-image MIME type (including SVG which carries XSS risk)
- Checks the extension against the admin-configured allowed_file_types setting
  (same getAllowedExtensions() used by the trip file upload route)
- Returns HTTP 400 with a descriptive message on rejection

Also fix the global error handler to return err.message for 4xx responses
instead of the generic 'Internal server error', so fileFilter rejections
produce a readable error on the client.
2026-04-22 16:16:35 +02:00
jubnl ed7e2badca fix: catch sharp errors in ensureLocalThumbnail and fall back to original
Sharp throws on unsupported formats (HEIC, corrupt files, etc.) and the
error was propagated outside the try/catch, crashing the server. Moved the
mkdir + sharp pipeline inside the catch block so any failure returns null
and streamPhoto falls through to serving the original file.
2026-04-22 16:11:38 +02:00
jubnl ba7b99fb7d fix: update backend tests and service bugs for gallery 1-to-N schema
updatePhoto: write sort_order to journey_entry_photos (junction) not journey_photos,
since JP_SELECT reads jep.sort_order — updating the gallery row had no visible effect.

deletePhoto: include id in return value so callers that check deleted.id still work.

Tests updated for new schema:
- journeyShareService: insertJourneyPhoto helper now inserts into journey_photos
  (keyed by journey_id) + journey_entry_photos junction instead of the old
  entry_id-keyed table
- SVC-081: deleteEntry cascades junction rows (journey_entry_photos), not gallery
  rows (journey_photos); assert junction is gone, gallery is preserved
- SVC-086: syncTripPhotos now populates the gallery directly — no [Trip Photos]
  wrapper entry; assert journey_photos gallery row instead
- INT-028: error message updated to 'journey_photo_id required'
2026-04-22 16:05:18 +02:00
jubnl 71aa8f8051 feat: journey gallery 1-to-N model with M:N entry-photo junction table
Replaces the old model where journey_photos was keyed per-entry with a
per-journey gallery table (one row per unique photo per journey) and a new
junction table journey_entry_photos that links gallery photos to entries.

Key changes:
- Migration 121: renames old journey_photos to journey_photos_old, creates the
  new gallery table + junction table, backfills both from existing data, drops
  the backup, removes synthetic 'Gallery' / '[Trip Photos]' wrapper entries
- journeyService: rewrites photo helpers (JP_SELECT/JOIN now joins via
  journey_entry_photos → journey_photos → trek_photos); adds uploadGalleryPhotos,
  addProviderPhotoToGallery, unlinkPhotoFromEntry, deleteGalleryPhoto; simplifies
  deletePhoto and linkPhotoToEntry against the new schema; syncTripPhotos inserts
  directly into the gallery instead of a wrapper entry
- journeyShareService: updates public photo and asset validation queries to join
  through the gallery table instead of entry_id; getPublicJourney now returns a
  dedicated gallery array alongside per-entry photos
- journey routes: adds gallery upload, provider-photo, and delete endpoints
  (POST/DELETE /:id/gallery/*); adds unlink-from-entry route
  (DELETE /entries/:entryId/photos/:journeyPhotoId); updates link-photo to
  accept journey_photo_id with a backwards-compat photo_id alias
- types: adds GalleryPhoto interface
- client api: adds uploadGalleryPhotos, addProviderPhotosToGallery, unlinkPhoto,
  deleteGalleryPhoto; updates linkPhoto param name to journeyPhotoId
- journeyStore: adds GalleryPhoto type, gallery field on JourneyDetail,
  uploadGalleryPhotos / unlinkPhoto / deleteGalleryPhoto store actions
- JourneyDetailPage + tests: updated to work with the new gallery model
2026-04-22 15:58:31 +02:00
jubnl 7c9e945b8c fix: serve real thumbnails for local photos instead of full-resolution originals (#822)
Add thumbnailService that lazy-generates a WebP thumbnail (800px max, q80) on
first GET /api/photos/:id/thumbnail request using sharp. The generated file is
stored at uploads/journey/thumbs/<sha1>.webp and the path is persisted to
trek_photos.thumbnail_path so subsequent requests are served directly from disk.
Also populates width/height as a side-effect.

streamPhoto now branches on kind for local file_path rows — thumbnail requests
use the stored/generated thumb path; original requests (and fallback when thumb
generation fails) continue to serve the full file. Remote providers (Immich,
Synology) are unaffected.
2026-04-22 15:56:34 +02:00
jubnl f6b3931bc4 fix: mobile public share — remove map tab (#828), cap timeline width (#827), wire entry click (#826)
- #828: exclude 'map' from availableViews on mobile; MobileMapTimeline already
  shows combined map+timeline so the standalone map tab is redundant
- #827: cap timeline feed column at xl:max-w-[50%] on ≥1280px viewports so the
  map aside is not dwarfed on wide monitors; applies to both desktop two-column
  layouts (JourneyPublicPage)
- #826: wire MobileMapTimeline onEntryClick to setViewingEntry; render
  MobileEntryView with readOnly + public photo URL builder so photos load via
  the share token endpoint; add publicPhotoUrl prop to MobileEntryView so
  photo URLs are routable for both authenticated and public-share contexts
2026-04-22 15:56:20 +02:00
Maurice 9e3041305c docs: remove badge icons + Roadmap board->view 2026-04-22 00:00:46 +02:00
Maurice 78fc557143 docs: remove icons from badges 2026-04-22 00:00:27 +02:00
Maurice 8a2fec8de0 docs: shorten badge labels (Demo/Try, Discord/Join, Ko-fi/Support, BMAC/Support) 2026-04-21 23:58:49 +02:00
Maurice e109dc0b51 docs: subtitle onto its own line under the logo + Ko-fi/BMAC badges
- <br /> between the TREK logo and the subtitle picture so the
  subtitle sits below the logo instead of rendering next to it.
- New badge row with Ko-fi and Buy Me a Coffee in the same
  for-the-badge style as Live Demo / Docker / Discord / Roadmap.
2026-04-21 23:39:54 +02:00
42 changed files with 1611 additions and 417 deletions
+1 -1
View File
@@ -23,4 +23,4 @@ jobs:
- name: Publish to GitHub wiki - name: Publish to GitHub wiki
uses: Andrew-Chen-Wang/github-wiki-action@v5 uses: Andrew-Chen-Wang/github-wiki-action@v5
with: with:
strategy: init strategy: clone
+24 -12
View File
@@ -6,6 +6,8 @@
<img src="docs/logo-trek-dark.gif" alt="TREK" height="96" /> <img src="docs/logo-trek-dark.gif" alt="TREK" height="96" />
</picture> </picture>
<br />
<picture> <picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/subtitle-light.png" /> <source media="(prefers-color-scheme: dark)" srcset="docs/subtitle-light.png" />
<source media="(prefers-color-scheme: light)" srcset="docs/subtitle-dark.png" /> <source media="(prefers-color-scheme: light)" srcset="docs/subtitle-dark.png" />
@@ -16,13 +18,17 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
<br /> <br />
<a href="https://demo-nomad.pakulat.org"><img alt="Live Demo" src="https://img.shields.io/badge/Live_Demo-try_it_now-111827?style=for-the-badge" /></a> <a href="https://demo-nomad.pakulat.org"><img alt="Demo" src="https://img.shields.io/badge/Demo-try-111827?style=for-the-badge" /></a>
&nbsp; &nbsp;
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=for-the-badge&logo=docker&logoColor=white" /></a> <a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=for-the-badge" /></a>
&nbsp; &nbsp;
<a href="https://discord.gg/NhZBDSd4qW"><img alt="Discord" src="https://img.shields.io/badge/Discord-community-5865F2?style=for-the-badge&logo=discord&logoColor=white" /></a> <a href="https://discord.gg/NhZBDSd4qW"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?style=for-the-badge" /></a>
&nbsp; &nbsp;
<a href="https://kanban.pakulat.org/shared/I4wxF6inOOMB0C6hH6kQm3efyNxFjwyI"><img alt="Roadmap" src="https://img.shields.io/badge/Roadmap-board-0EA5E9?style=for-the-badge&logo=trello&logoColor=white" /></a> <a href="https://kanban.pakulat.org/shared/I4wxF6inOOMB0C6hH6kQm3efyNxFjwyI"><img alt="Roadmap" src="https://img.shields.io/badge/Roadmap-view-0EA5E9?style=for-the-badge" /></a>
<br />
<a href="https://ko-fi.com/mauriceboe"><img alt="Ko-fi" src="https://img.shields.io/badge/Ko--fi-support-FF5E5B?style=for-the-badge" /></a>
&nbsp;
<a href="https://www.buymeacoffee.com/mauriceboe"><img alt="BMAC" src="https://img.shields.io/badge/BMAC-support-FFDD00?style=for-the-badge" /></a>
<br /> <br />
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-AGPL_v3-6B7280?style=flat-square" /></a> <a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-AGPL_v3-6B7280?style=flat-square" /></a>
<a href="https://github.com/mauriceboe/TREK/releases"><img alt="Latest Release" src="https://img.shields.io/github/v/release/mauriceboe/TREK?include_prereleases&style=flat-square&color=6B7280" /></a> <a href="https://github.com/mauriceboe/TREK/releases"><img alt="Latest Release" src="https://img.shields.io/github/v/release/mauriceboe/TREK?include_prereleases&style=flat-square&color=6B7280" /></a>
@@ -121,19 +127,23 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
#### 🧩 Addons (admin-toggleable) #### 🧩 Addons (admin-toggleable)
- **Lists** — packing lists + to-dos with templates, member assignments, optional bag tracking
- **Budget** — expense tracker with splits, pie chart, multi-currency
- **Documents** — file attachments on trips, places, and reservations
- **Collab** — chat, notes, polls, day-by-day attendance
- **Vacay** — personal vacation planner with calendar, 100+ country holidays, carry-over tracking - **Vacay** — personal vacation planner with calendar, 100+ country holidays, carry-over tracking
- **Atlas** — world map of visited countries, bucket list, travel stats, streak tracking, liquid-glass UI - **Atlas** — world map of visited countries, bucket list, travel stats, streak tracking, liquid-glass UI
- **Collab** — chat, notes, polls, day-by-day attendance - **Journey** — magazine-style travel journal with entries, photos (Immich/Synology), maps, moods
- **Journey** — magazine-style travel journal with entries, photos, maps, moods - **Naver List Import** — one-click import from shared Naver Maps lists
- **Dashboard widgets** — currency converter and timezone clocks - **MCP** — expose TREK to AI assistants via OAuth 2.1
</td> </td>
<td width="50%" valign="top"> <td width="50%" valign="top">
#### 🤖 AI / MCP #### 🤖 AI / MCP
- **Built-in MCP server** — OAuth 2.1 authenticated. 80+ tools, 27 resources - **Built-in MCP server** — OAuth 2.1 authenticated. 150+ tools, 30 resources
- **Granular scopes** — 24 OAuth scopes across 13 permission groups - **Granular scopes** — 27 OAuth scopes across 13 permission groups
- **Full automation** — AI can create trips, plan days, build packing lists, manage budgets, mark countries visited - **Full automation** — AI can create trips, plan days, build packing lists, manage budgets, mark countries visited
- **Pre-built prompts** — `trip-summary`, `packing-list`, `budget-overview` - **Pre-built prompts** — `trip-summary`, `packing-list`, `budget-overview`
- **Addon-aware** — exposes Atlas, Collab, Vacay when those addons are on - **Addon-aware** — exposes Atlas, Collab, Vacay when those addons are on
@@ -146,7 +156,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
#### ⚙️ Admin & customisation #### ⚙️ Admin & customisation
- **Dashboard views** — card grid or compact list · **Dark mode** — full theme with matching status bar - **Dashboard views** — card grid or compact list · **Dark mode** — full theme with matching status bar
- **14 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID - **15 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID
- **Admin panel** — users, invites, packing templates, categories, addons, API keys, backups, GitHub history - **Admin panel** — users, invites, packing templates, categories, addons, API keys, backups, GitHub history
- **Auto-backups** — scheduled with configurable retention · **Units** — °C/°F, 12h/24h, map tile sources, default coordinates - **Auto-backups** — scheduled with configurable retention · **Units** — °C/°F, 12h/24h, map tile sources, default coordinates
@@ -166,7 +176,7 @@ ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d -p 3000:3000 \
-v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek -v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek
``` ```
Open `http://localhost:3000`. The first user to register becomes admin. Open `http://localhost:3000`. On first boot TREK seeds an admin account — if you set `ADMIN_EMAIL`/`ADMIN_PASSWORD` those are used, otherwise the credentials are printed to the container log (`docker logs trek`).
<div align="center"> <div align="center">
@@ -332,7 +342,8 @@ server {
ssl_certificate /etc/ssl/fullchain.pem; ssl_certificate /etc/ssl/fullchain.pem;
ssl_certificate_key /etc/ssl/privkey.pem; ssl_certificate_key /etc/ssl/privkey.pem;
client_max_body_size 50m; # 500 MB covers backup-restore uploads (capped at 500 MB server-side).
client_max_body_size 500m;
location / { location / {
proxy_pass http://localhost:3000; proxy_pass http://localhost:3000;
@@ -349,6 +360,7 @@ server {
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade"; proxy_set_header Connection "upgrade";
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_read_timeout 86400;
} }
} }
``` ```
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2 apiVersion: v2
name: trek name: trek
version: 2.9.14 version: 3.0.2
description: Minimal Helm chart for TREK app description: Minimal Helm chart for TREK app
appVersion: "2.9.14" appVersion: "3.0.2"
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "trek-client", "name": "trek-client",
"version": "2.9.14", "version": "3.0.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "trek-client", "name": "trek-client",
"version": "2.9.14", "version": "3.0.2",
"dependencies": { "dependencies": {
"@react-pdf/renderer": "^4.3.2", "@react-pdf/renderer": "^4.3.2",
"axios": "^1.6.7", "axios": "^1.6.7",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "trek-client", "name": "trek-client",
"version": "2.9.14", "version": "3.0.2",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+5 -1
View File
@@ -356,9 +356,13 @@ export const journeyApi = {
// Photos // Photos
uploadPhotos: (entryId: number, formData: FormData) => apiClient.post(`/journeys/entries/${entryId}/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data), uploadPhotos: (entryId: number, formData: FormData) => apiClient.post(`/journeys/entries/${entryId}/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
uploadGalleryPhotos: (journeyId: number, formData: FormData) => apiClient.post(`/journeys/${journeyId}/gallery/photos`, formData, { headers: { 'Content-Type': undefined as any } }).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 } : {}) }).then(r => r.data),
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data), addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string) => 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) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
linkPhoto: (entryId: number, photoId: number) => apiClient.post(`/journeys/entries/${entryId}/link-photo`, { photo_id: photoId }).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),
updatePhoto: (photoId: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/photos/${photoId}`, data).then(r => r.data), updatePhoto: (photoId: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/photos/${photoId}`, data).then(r => r.data),
deletePhoto: (photoId: number) => apiClient.delete(`/journeys/photos/${photoId}`).then(r => r.data), deletePhoto: (photoId: number) => apiClient.delete(`/journeys/photos/${photoId}`).then(r => r.data),
@@ -25,20 +25,22 @@ const WEATHER_CONFIG: Record<string, { icon: typeof Sun; label: string }> = {
cold: { icon: Snowflake, label: 'Cold' }, cold: { icon: Snowflake, label: 'Cold' },
} }
function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'original'): string { function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'original', builder?: (id: number) => string): string {
if (builder) return builder(p.photo_id)
return `/api/photos/${p.photo_id}/${size}` return `/api/photos/${p.photo_id}/${size}`
} }
interface Props { interface Props {
entry: JourneyEntry entry: JourneyEntry
readOnly?: boolean readOnly?: boolean
publicPhotoUrl?: (photoId: number) => string
onClose: () => void onClose: () => void
onEdit: () => void onEdit: () => void
onDelete: () => void onDelete: () => void
onPhotoClick: (photos: JourneyPhoto[], index: number) => void onPhotoClick: (photos: JourneyPhoto[], index: number) => void
} }
export default function MobileEntryView({ entry, readOnly, onClose, onEdit, onDelete, onPhotoClick }: Props) { export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClose, onEdit, onDelete, onPhotoClick }: Props) {
const photos = entry.photos || [] const photos = entry.photos || []
const mood = entry.mood ? MOOD_CONFIG[entry.mood] : null const mood = entry.mood ? MOOD_CONFIG[entry.mood] : null
const weather = entry.weather ? WEATHER_CONFIG[entry.weather] : null const weather = entry.weather ? WEATHER_CONFIG[entry.weather] : null
@@ -85,7 +87,7 @@ export default function MobileEntryView({ entry, readOnly, onClose, onEdit, onDe
{photos.length > 0 && ( {photos.length > 0 && (
<div className="relative"> <div className="relative">
<img <img
src={photoUrl(photos[0])} src={photoUrl(photos[0], 'original', publicPhotoUrl)}
alt="" alt=""
className="w-full max-h-[50vh] object-cover cursor-pointer" className="w-full max-h-[50vh] object-cover cursor-pointer"
onClick={() => onPhotoClick(photos, 0)} onClick={() => onPhotoClick(photos, 0)}
@@ -102,7 +104,7 @@ export default function MobileEntryView({ entry, readOnly, onClose, onEdit, onDe
{photos.map((p, i) => ( {photos.map((p, i) => (
<img <img
key={p.id || i} key={p.id || i}
src={photoUrl(p, 'thumbnail')} src={photoUrl(p, 'thumbnail', publicPhotoUrl)}
alt="" alt=""
className="w-16 h-16 rounded-lg object-cover flex-shrink-0 cursor-pointer hover:ring-2 ring-zinc-900/30 dark:ring-white/30 transition-all" className="w-16 h-16 rounded-lg object-cover flex-shrink-0 cursor-pointer hover:ring-2 ring-zinc-900/30 dark:ring-white/30 transition-all"
onClick={() => onPhotoClick(photos, i)} onClick={() => onPhotoClick(photos, i)}
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react' import { useState, useEffect, useMemo } from 'react'
import { Plane, Train, Car, Ship } from 'lucide-react' import { Plane, Train, Car, Ship } from 'lucide-react'
import Modal from '../shared/Modal' import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect' import CustomSelect from '../shared/CustomSelect'
@@ -7,6 +7,8 @@ import AirportSelect, { type Airport } from './AirportSelect'
import LocationSelect, { type LocationPoint } from './LocationSelect' import LocationSelect, { type LocationPoint } from './LocationSelect'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import { useTripStore } from '../../store/tripStore'
import { useAddonStore } from '../../store/addonStore'
import { formatDate } from '../../utils/formatters' import { formatDate } from '../../utils/formatters'
import type { Day, Reservation, ReservationEndpoint } from '../../types' import type { Day, Reservation, ReservationEndpoint } from '../../types'
@@ -75,6 +77,8 @@ const defaultForm = {
arrival_time: '', arrival_time: '',
confirmation_number: '', confirmation_number: '',
notes: '', notes: '',
price: '',
budget_category: '',
meta_airline: '', meta_airline: '',
meta_flight_number: '', meta_flight_number: '',
meta_train_number: '', meta_train_number: '',
@@ -94,6 +98,13 @@ interface TransportModalProps {
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId }: TransportModalProps) { export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId }: TransportModalProps) {
const { t, locale } = useTranslation() const { t, locale } = useTranslation()
const toast = useToast() const toast = useToast()
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
const budgetItems = useTripStore(s => s.budgetItems)
const budgetCategories = useMemo(() => {
const cats = new Set<string>()
budgetItems.forEach(i => { if (i.category) cats.add(i.category) })
return Array.from(cats).sort()
}, [budgetItems])
const [form, setForm] = useState({ ...defaultForm }) const [form, setForm] = useState({ ...defaultForm })
const [isSaving, setIsSaving] = useState(false) const [isSaving, setIsSaving] = useState(false)
const [fromPick, setFromPick] = useState<EndpointPick>({}) const [fromPick, setFromPick] = useState<EndpointPick>({})
@@ -126,6 +137,8 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
meta_train_number: meta.train_number || '', meta_train_number: meta.train_number || '',
meta_platform: meta.platform || '', meta_platform: meta.platform || '',
meta_seat: meta.seat || '', meta_seat: meta.seat || '',
price: meta.price || '',
budget_category: (meta.budget_category && budgetItems.some(i => i.category === meta.budget_category)) ? meta.budget_category : '',
}) })
if (type === 'flight') { if (type === 'flight') {
setFromPick({ airport: airportFromEndpoint(from) || undefined }) setFromPick({ airport: airportFromEndpoint(from) || undefined })
@@ -139,7 +152,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
setFromPick({}) setFromPick({})
setToPick({}) setToPick({})
} }
}, [isOpen, reservation, selectedDayId]) }, [isOpen, reservation, selectedDayId, budgetItems])
const set = (field: string, value: any) => setForm(prev => ({ ...prev, [field]: value })) const set = (field: string, value: any) => setForm(prev => ({ ...prev, [field]: value }))
@@ -173,6 +186,10 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
if (form.meta_platform) metadata.platform = form.meta_platform if (form.meta_platform) metadata.platform = form.meta_platform
if (form.meta_seat) metadata.seat = form.meta_seat if (form.meta_seat) metadata.seat = form.meta_seat
} }
if (isBudgetEnabled) {
if (form.price) metadata.price = form.price
if (form.budget_category) metadata.budget_category = form.budget_category
}
const startDate = startDay?.date ?? null const startDate = startDay?.date ?? null
const endDate = (endDay ?? startDay)?.date ?? null const endDate = (endDay ?? startDay)?.date ?? null
@@ -200,6 +217,11 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
endpoints, endpoints,
needs_review: false, needs_review: false,
} }
if (isBudgetEnabled) {
(payload as any).create_budget_entry = form.price && parseFloat(form.price) > 0
? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' }
: { total_price: 0 }
}
await onSave(payload) await onSave(payload)
} catch (err: unknown) { } catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('common.unknownError')) toast.error(err instanceof Error ? err.message : t('common.unknownError'))
@@ -422,6 +444,40 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
style={{ ...inputStyle, resize: 'none', lineHeight: 1.5 }} /> style={{ ...inputStyle, resize: 'none', lineHeight: 1.5 }} />
</div> </div>
{/* Price + Budget Category */}
{isBudgetEnabled && (
<>
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.price')}</label>
<input type="text" inputMode="decimal" value={form.price}
onChange={e => { const v = e.target.value; if (v === '' || /^\d*[.,]?\d{0,2}$/.test(v)) set('price', v.replace(',', '.')) }}
onPaste={e => { e.preventDefault(); let txt = e.clipboardData.getData('text').trim().replace(/[^\d.,-]/g, ''); const lc = txt.lastIndexOf(','), ld = txt.lastIndexOf('.'), dp = Math.max(lc, ld); if (dp > -1) { txt = txt.substring(0, dp).replace(/[.,]/g, '') + '.' + txt.substring(dp + 1) } else { txt = txt.replace(/[.,]/g, '') } set('price', txt) }}
placeholder="0.00"
style={inputStyle} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.budgetCategory')}</label>
<CustomSelect
value={form.budget_category}
onChange={v => set('budget_category', v)}
options={[
{ value: '', label: t('reservations.budgetCategoryAuto') },
...budgetCategories.map(c => ({ value: c, label: c })),
]}
placeholder={t('reservations.budgetCategoryAuto')}
size="sm"
/>
</div>
</div>
{form.price && parseFloat(form.price) > 0 && (
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: -4 }}>
{t('reservations.budgetHint')}
</div>
)}
</>
)}
</form> </form>
</Modal> </Modal>
) )
+38 -24
View File
@@ -177,6 +177,24 @@ const mockJourneyDetail = {
}, },
], ],
stats: { entries: 2, photos: 1, places: 2 }, stats: { entries: 2, photos: 1, places: 2 },
gallery: [
{
id: 100,
journey_id: 1,
photo_id: 100,
provider: 'local',
file_path: 'photos/test.jpg',
asset_id: null,
owner_id: null,
thumbnail_path: null,
caption: 'Colosseum',
sort_order: 0,
width: 800,
height: 600,
shared: 1,
created_at: now,
},
],
}; };
// ── MSW Handlers ───────────────────────────────────────────────────────────── // ── MSW Handlers ─────────────────────────────────────────────────────────────
@@ -1724,13 +1742,14 @@ describe('JourneyDetailPage', () => {
it('renders the empty gallery state when journey has no photos', async () => { it('renders the empty gallery state when journey has no photos', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
// Override with entries that have no photos // Override with entries that have no photos and empty gallery
const emptyEntry = { const emptyEntry = {
...mockJourneyDetail.entries[0], ...mockJourneyDetail.entries[0],
photos: [], photos: [],
}; };
setupDefaultHandlers({ setupDefaultHandlers({
entries: [emptyEntry], entries: [emptyEntry],
gallery: [],
stats: { entries: 1, photos: 0, places: 1 }, stats: { entries: 1, photos: 0, places: 1 },
}); });
@@ -1981,10 +2000,9 @@ describe('JourneyDetailPage', () => {
expect(screen.getByText(/1 photos/i)).toBeInTheDocument(); expect(screen.getByText(/1 photos/i)).toBeInTheDocument();
}); });
// The entry date '2026-03-15' is shown as a formatted overlay on each gallery photo // Gallery photos render in a grid; each photo has a group container
// The component uses toLocaleDateString which produces "Mar 15, 2026" in en-US const photos = document.querySelectorAll('[class*="aspect-square"]');
const dateOverlay = document.querySelector('[class*="opacity-0"]'); expect(photos.length).toBeGreaterThanOrEqual(1);
expect(dateOverlay).toBeTruthy();
}); });
}); });
@@ -2022,6 +2040,11 @@ describe('JourneyDetailPage', () => {
setupDefaultHandlers({ setupDefaultHandlers({
entries: [immichEntry, mockJourneyDetail.entries[1]], entries: [immichEntry, mockJourneyDetail.entries[1]],
stats: { entries: 2, photos: 1, places: 2 }, stats: { entries: 2, photos: 1, places: 2 },
gallery: [{
id: 200, journey_id: 1, photo_id: 200, provider: 'immich', file_path: null,
asset_id: 'asset-123', owner_id: 1, thumbnail_path: null,
caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now,
}],
}); });
render(<JourneyDetailPage />); render(<JourneyDetailPage />);
@@ -2056,6 +2079,11 @@ describe('JourneyDetailPage', () => {
setupDefaultHandlers({ setupDefaultHandlers({
entries: [synologyEntry, mockJourneyDetail.entries[1]], entries: [synologyEntry, mockJourneyDetail.entries[1]],
stats: { entries: 2, photos: 1, places: 2 }, stats: { entries: 2, photos: 1, places: 2 },
gallery: [{
id: 201, journey_id: 1, photo_id: 201, provider: 'synology', file_path: null,
asset_id: 'syn-456', owner_id: 1, thumbnail_path: null,
caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now,
}],
}); });
render(<JourneyDetailPage />); render(<JourneyDetailPage />);
@@ -3265,25 +3293,14 @@ describe('JourneyDetailPage', () => {
// ── FE-PAGE-JOURNEYDETAIL-141 ────────────────────────────────────────── // ── FE-PAGE-JOURNEYDETAIL-141 ──────────────────────────────────────────
describe('FE-PAGE-JOURNEYDETAIL-141: Gallery upload triggers file input and calls API', () => { describe('FE-PAGE-JOURNEYDETAIL-141: Gallery upload triggers file input and calls API', () => {
it('uploading files in gallery creates an entry and uploads photos', async () => { it('uploading files in gallery calls gallery upload API', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
let createCalled = false;
let uploadCalled = false; let uploadCalled = false;
server.use( server.use(
http.post('/api/journeys/1/entries', () => { http.post('/api/journeys/1/gallery/photos', () => {
createCalled = true;
return HttpResponse.json({
id: 99, journey_id: 1, author_id: 1, type: 'entry',
entry_date: '2026-04-11', title: 'Gallery', story: null, location_name: null,
location_lat: null, location_lng: null, mood: null, weather: null,
tags: [], pros_cons: null, visibility: 'private', sort_order: 0,
entry_time: null, photos: [], created_at: now, updated_at: now,
});
}),
http.post('/api/journeys/entries/99/photos', () => {
uploadCalled = true; uploadCalled = true;
return HttpResponse.json([]); return HttpResponse.json({ photos: [] });
}), }),
); );
@@ -3304,9 +3321,6 @@ describe('JourneyDetailPage', () => {
const testFile = new File(['fake-content'], 'test-photo.jpg', { type: 'image/jpeg' }); const testFile = new File(['fake-content'], 'test-photo.jpg', { type: 'image/jpeg' });
await user.upload(fileInput, testFile); await user.upload(fileInput, testFile);
await waitFor(() => {
expect(createCalled).toBe(true);
});
await waitFor(() => { await waitFor(() => {
expect(uploadCalled).toBe(true); expect(uploadCalled).toBe(true);
}); });
@@ -3320,9 +3334,9 @@ describe('JourneyDetailPage', () => {
let deleteCalled = false; let deleteCalled = false;
server.use( server.use(
http.delete('/api/journeys/photos/100', () => { http.delete('/api/journeys/1/gallery/100', () => {
deleteCalled = true; deleteCalled = true;
return HttpResponse.json({ success: true }); return new HttpResponse(null, { status: 204 });
}), }),
); );
+44 -75
View File
@@ -27,7 +27,7 @@ import {
import MobileMapTimeline from '../components/Journey/MobileMapTimeline' import MobileMapTimeline from '../components/Journey/MobileMapTimeline'
import MobileEntryView from '../components/Journey/MobileEntryView' import MobileEntryView from '../components/Journey/MobileEntryView'
import { useIsMobile } from '../hooks/useIsMobile' import { useIsMobile } from '../hooks/useIsMobile'
import type { JourneyEntry, JourneyPhoto, JourneyDetail } from '../store/journeyStore' import type { JourneyEntry, JourneyPhoto, GalleryPhoto, JourneyTrip, JourneyDetail } from '../store/journeyStore'
import { computeJourneyLifecycle } from '../utils/journeyLifecycle' import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
const GRADIENTS = [ const GRADIENTS = [
@@ -80,7 +80,7 @@ function formatDate(d: string, locale?: string): { weekday: string; month: strin
} }
} }
function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'thumbnail'): string { function photoUrl(p: { photo_id: number }, size: 'thumbnail' | 'original' = 'thumbnail'): string {
return `/api/photos/${p.photo_id}/${size}` return `/api/photos/${p.photo_id}/${size}`
} }
@@ -341,7 +341,7 @@ export default function JourneyDetailPage() {
) )
} }
const timelineEntries = current.entries.filter(e => e.title !== 'Gallery' && e.title !== '[Trip Photos]' && (!hideSkeletons || e.type !== 'skeleton')) const timelineEntries = current.entries.filter(e => (!hideSkeletons || e.type !== 'skeleton'))
const dayGroups = groupByDate(timelineEntries) const dayGroups = groupByDate(timelineEntries)
const sortedDates = [...dayGroups.keys()].sort() const sortedDates = [...dayGroups.keys()].sort()
@@ -458,7 +458,7 @@ export default function JourneyDetailPage() {
className={ className={
isMobile isMobile
? '' ? ''
: 'flex-1 overflow-y-auto journey-feed-scroll' : 'flex-1 xl:max-w-[50%] overflow-y-auto journey-feed-scroll'
} }
> >
<div className={isMobile ? '' : 'w-full px-8 py-6'}> <div className={isMobile ? '' : 'w-full px-8 py-6'}>
@@ -693,10 +693,11 @@ export default function JourneyDetailPage() {
> >
<GalleryView <GalleryView
entries={current.entries} entries={current.entries}
gallery={current.gallery || []}
journeyId={current.id} journeyId={current.id}
userId={useAuthStore.getState().user?.id || 0} userId={useAuthStore.getState().user?.id || 0}
trips={current.trips} trips={current.trips}
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 ?? null, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })}
onRefresh={() => loadJourney(Number(id))} onRefresh={() => loadJourney(Number(id))}
/> />
</div> </div>
@@ -733,7 +734,7 @@ export default function JourneyDetailPage() {
entry={editingEntry} entry={editingEntry}
journeyId={current.id} journeyId={current.id}
tripDates={tripDates} tripDates={tripDates}
galleryPhotos={current.entries.flatMap(e => e.photos || [])} galleryPhotos={current.gallery || []}
onClose={() => setEditingEntry(null)} onClose={() => setEditingEntry(null)}
onSave={async (data) => { onSave={async (data) => {
let entryId = editingEntry.id let entryId = editingEntry.id
@@ -971,12 +972,13 @@ function MapView({ entries, mapEntries, sortedDates, activeLocationId, fullMapRe
// ── Gallery View ────────────────────────────────────────────────────────── // ── Gallery View ──────────────────────────────────────────────────────────
function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefresh }: { function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick, onRefresh }: {
entries: JourneyEntry[] entries: JourneyEntry[]
gallery: GalleryPhoto[]
journeyId: number journeyId: number
userId: number userId: number
trips: JourneyTrip[] trips: JourneyTrip[]
onPhotoClick: (photos: JourneyPhoto[], index: number) => void onPhotoClick: (photos: GalleryPhoto[], index: number) => void
onRefresh: () => void onRefresh: () => void
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
@@ -1009,19 +1011,7 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
})() })()
}, []) }, [])
const allPhotos: { photo: JourneyPhoto; entry: JourneyEntry }[] = [] const allPhotos = gallery
const seenPhotoIds = new Map<number, number>() // photo_id → index in allPhotos
for (const e of entries) {
for (const p of e.photos) {
const existing = seenPhotoIds.get(p.photo_id)
if (existing === undefined) {
seenPhotoIds.set(p.photo_id, allPhotos.length)
allPhotos.push({ photo: p, entry: e })
} else if (e.title === 'Gallery' && allPhotos[existing].entry.title !== 'Gallery') {
allPhotos[existing] = { photo: p, entry: e }
}
}
}
const entriesWithContent = entries.filter(e => e.type !== 'skeleton' || e.title) const entriesWithContent = entries.filter(e => e.type !== 'skeleton' || e.title)
@@ -1037,22 +1027,9 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
if (!files?.length) return if (!files?.length) return
setGalleryUploading(true) setGalleryUploading(true)
try { try {
// find existing "Gallery" entry or create one. The stored title is the
// literal 'Gallery' (server-side checks look for this exact string) —
// do not send a translated label here.
let galleryEntry = entries.find(e => e.title === 'Gallery' && e.type === 'entry')
let entryId = galleryEntry?.id
if (!entryId) {
const entry = await journeyApi.createEntry(journeyId, {
title: 'Gallery',
entry_date: new Date().toISOString().split('T')[0],
type: 'entry',
})
entryId = entry.id
}
const formData = new FormData() const formData = new FormData()
for (const f of files) formData.append('photos', f) for (const f of files) formData.append('photos', f)
await journeyApi.uploadPhotos(entryId, formData) await journeyApi.uploadGalleryPhotos(journeyId, formData)
toast.success(t('journey.photosUploaded', { count: files.length })) toast.success(t('journey.photosUploaded', { count: files.length }))
onRefresh() onRefresh()
} catch { } catch {
@@ -1063,25 +1040,24 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
e.target.value = '' e.target.value = ''
} }
const handleDeletePhoto = async (photoId: number) => { const handleDeletePhoto = async (galleryPhotoId: number) => {
const store = useJourneyStore.getState() const store = useJourneyStore.getState()
if (!store.current) return if (!store.current) return
const target = store.current.entries.flatMap(e => e.photos).find(p => p.id === photoId)
if (!target) return
const siblingIds = store.current.entries.flatMap(e => e.photos).filter(p => p.photo_id === target.photo_id).map(p => p.id)
// Optimistic update — remove every row with this photo_id // Optimistic update — remove from gallery and all entry photo lists
const updated = { useJourneyStore.setState({
...store.current, current: {
entries: store.current.entries.map(e => ({ ...store.current,
...e, gallery: (store.current.gallery || []).filter(p => p.id !== galleryPhotoId),
photos: e.photos.filter(p => p.photo_id !== target.photo_id), entries: store.current.entries.map(e => ({
})).filter(e => e.type !== 'entry' || e.title !== 'Gallery' || e.photos.length > 0 || e.story), ...e,
} photos: e.photos.filter(p => p.id !== galleryPhotoId),
useJourneyStore.setState({ current: updated }) })),
},
})
try { try {
await Promise.all(siblingIds.map(id => journeyApi.deletePhoto(id))) await journeyApi.deleteGalleryPhoto(journeyId, galleryPhotoId)
} catch { } catch {
toast.error(t('common.error')) toast.error(t('common.error'))
onRefresh() onRefresh()
@@ -1132,11 +1108,11 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
</div> </div>
) : ( ) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5 pb-24 md:pb-6"> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5 pb-24 md:pb-6">
{allPhotos.map(({ photo, entry }, i) => ( {allPhotos.map((photo, i) => (
<div <div
key={photo.id} key={photo.id}
className="relative aspect-square rounded-lg overflow-hidden cursor-pointer group" className="relative aspect-square rounded-lg overflow-hidden cursor-pointer group"
onClick={() => onPhotoClick(allPhotos.map(a => a.photo), i)} onClick={() => onPhotoClick(allPhotos, i)}
> >
<img <img
src={photoUrl(photo, 'thumbnail')} src={photoUrl(photo, 'thumbnail')}
@@ -1165,11 +1141,6 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
<p className="text-[10px] text-white truncate">{photo.caption}</p> <p className="text-[10px] text-white truncate">{photo.caption}</p>
</div> </div>
)} )}
<div className="absolute bottom-1.5 left-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
<span className="text-[9px] font-medium px-1.5 py-0.5 rounded-full bg-black/50 backdrop-blur text-white">
{new Date(entry.entry_date + 'T12:00:00').toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' })}
</span>
</div>
</div> </div>
))} ))}
</div> </div>
@@ -1182,25 +1153,19 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
userId={userId} userId={userId}
entries={entriesWithContent} entries={entriesWithContent}
trips={trips} trips={trips}
existingAssetIds={new Set(entries.flatMap(e => (e.photos || []).filter(p => p.asset_id).map(p => p.asset_id!)))} existingAssetIds={new Set(gallery.filter(p => p.asset_id).map(p => p.asset_id!))}
onClose={() => setShowPicker(false)} onClose={() => setShowPicker(false)}
onAdd={async (groups, entryId) => { onAdd={async (groups, entryId) => {
let targetId = entryId
if (!targetId) {
try {
const entry = await journeyApi.createEntry(journeyId, {
title: 'Gallery',
entry_date: new Date().toISOString().split('T')[0],
type: 'entry',
})
targetId = entry.id
} catch { return }
}
let added = 0 let added = 0
for (const group of groups) { for (const group of groups) {
try { try {
const result = await journeyApi.addProviderPhotos(targetId, pickerProvider!, group.assetIds, undefined, group.passphrase) if (entryId) {
added += result.added || 0 const result = await journeyApi.addProviderPhotos(entryId, pickerProvider!, group.assetIds, undefined, group.passphrase)
added += result.added || 0
} else {
const result = await journeyApi.addProviderPhotosToGallery(journeyId, pickerProvider!, group.assetIds, group.passphrase)
added += result.added || 0
}
} catch {} } catch {}
} }
if (added > 0) { if (added > 0) {
@@ -2201,7 +2166,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
entry: JourneyEntry entry: JourneyEntry
journeyId: number journeyId: number
tripDates: Set<string> tripDates: Set<string>
galleryPhotos: JourneyPhoto[] galleryPhotos: GalleryPhoto[]
onClose: () => void onClose: () => void
onSave: (data: Record<string, unknown>) => Promise<number> onSave: (data: Record<string, unknown>) => Promise<number>
onUploadPhotos: (entryId: number, formData: FormData) => Promise<JourneyPhoto[]> onUploadPhotos: (entryId: number, formData: FormData) => Promise<JourneyPhoto[]>
@@ -2227,7 +2192,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
const [cons, setCons] = useState<string[]>(entry.pros_cons?.cons?.length ? entry.pros_cons.cons : ['']) const [cons, setCons] = useState<string[]>(entry.pros_cons?.cons?.length ? entry.pros_cons.cons : [''])
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
const [photos, setPhotos] = useState<JourneyPhoto[]>(entry.photos || []) const [photos, setPhotos] = useState<(JourneyPhoto | GalleryPhoto)[]>(entry.photos || [])
const [pendingFiles, setPendingFiles] = useState<File[]>([]) const [pendingFiles, setPendingFiles] = useState<File[]>([])
const [pendingLinkIds, setPendingLinkIds] = useState<number[]>([]) const [pendingLinkIds, setPendingLinkIds] = useState<number[]>([])
const [showGalleryPick, setShowGalleryPick] = useState(false) const [showGalleryPick, setShowGalleryPick] = useState(false)
@@ -2254,8 +2219,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
pendingLinkIds.length > 0 pendingLinkIds.length > 0
) )
const uniqueGalleryPhotos = Array.from(new Map(galleryPhotos.map(gp => [gp.photo_id, gp])).values()) const availableGalleryPhotos = galleryPhotos.filter(gp => !photos.some(p => p.id === gp.id))
const availableGalleryPhotos = uniqueGalleryPhotos.filter(gp => !photos.some(p => p.photo_id === gp.photo_id))
const handleClose = () => { const handleClose = () => {
if (isDirty && !window.confirm(t('journey.editor.discardChangesConfirm'))) return if (isDirty && !window.confirm(t('journey.editor.discardChangesConfirm'))) return
@@ -2421,8 +2385,13 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
<button <button
onClick={async (e) => { onClick={async (e) => {
e.stopPropagation() e.stopPropagation()
await journeyApi.deletePhoto(p.id)
setPhotos(prev => prev.filter(x => x.id !== p.id)) setPhotos(prev => prev.filter(x => x.id !== p.id))
if (entry.id > 0) {
// unlink from entry; gallery row is preserved
try { await journeyApi.unlinkPhoto(entry.id, p.id) } catch {}
} else {
setPendingLinkIds(prev => prev.filter(id => id !== p.id))
}
}} }}
className="absolute top-1 right-1 w-5 h-5 rounded-full bg-black/60 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity" className="absolute top-1 right-1 w-5 h-5 rounded-full bg-black/60 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
> >
@@ -56,6 +56,21 @@ vi.mock('../components/Journey/PhotoLightbox', () => ({
), ),
})); }));
vi.mock('../components/Journey/MobileMapTimeline', () => ({
default: ({ onEntryClick }: any) => (
<div data-testid="mobile-map-timeline">
<button onClick={() => onEntryClick({ id: 10, title: 'Shibuya Crossing', story: 'The most famous crossing in the world.', entry_date: '2026-03-15', entry_time: '14:00', location_name: 'Shibuya, Tokyo', photos: [] })}>
Open Entry
</button>
</div>
),
}));
const mockIsMobile = { value: false };
vi.mock('../hooks/useIsMobile', () => ({
useIsMobile: () => mockIsMobile.value,
}));
import JourneyPublicPage from './JourneyPublicPage'; import JourneyPublicPage from './JourneyPublicPage';
// ── Fixtures ───────────────────────────────────────────────────────────────── // ── Fixtures ─────────────────────────────────────────────────────────────────
@@ -106,6 +121,9 @@ const mockJourneyData = {
share_gallery: true, share_gallery: true,
share_map: true, share_map: true,
}, },
gallery: [
{ id: 100, journey_id: 1, photo_id: 100, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/temple.jpg', caption: 'Temple entrance', shared: 1, sort_order: 0, created_at: 0 },
],
stats: { stats: {
entries: 2, entries: 2,
photos: 1, photos: 1,
@@ -136,6 +154,7 @@ function setup404() {
beforeEach(() => { beforeEach(() => {
resetAllStores(); resetAllStores();
vi.clearAllMocks(); vi.clearAllMocks();
mockIsMobile.value = false;
}); });
// ── Tests ──────────────────────────────────────────────────────────────────── // ── Tests ────────────────────────────────────────────────────────────────────
@@ -340,6 +359,11 @@ describe('JourneyPublicPage', () => {
], ],
}, },
], ],
gallery: [
{ id: 200, journey_id: 1, photo_id: 200, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/a.jpg', caption: 'Photo A', shared: 1, sort_order: 0, created_at: 0 },
{ id: 201, journey_id: 1, photo_id: 201, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/b.jpg', caption: 'Photo B', shared: 1, sort_order: 1, created_at: 0 },
{ id: 202, journey_id: 1, photo_id: 202, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/c.jpg', caption: 'Photo C', shared: 1, sort_order: 2, created_at: 0 },
],
stats: { entries: 1, photos: 3, places: 0 }, stats: { entries: 1, photos: 3, places: 0 },
}; };
@@ -391,6 +415,40 @@ describe('JourneyPublicPage', () => {
expect(statsContainer!.textContent).toContain('7'); expect(statsContainer!.textContent).toContain('7');
}); });
// FE-PAGE-PUBLICJOURNEY-019 — bug #828
it('FE-PAGE-PUBLICJOURNEY-019: mobile public share does not show standalone Map tab', async () => {
mockIsMobile.value = true;
setupSuccess();
render(<JourneyPublicPage />);
await waitFor(() => {
expect(screen.getByText('Tokyo 2026')).toBeInTheDocument();
});
const buttons = screen.getAllByRole('button');
const mapBtn = buttons.find(btn => btn.textContent && /^map$/i.test(btn.textContent.trim()));
expect(mapBtn).toBeUndefined();
});
// FE-PAGE-PUBLICJOURNEY-020 — bug #826
it('FE-PAGE-PUBLICJOURNEY-020: mobile public share opens entry details on card click', async () => {
const user = userEvent.setup();
mockIsMobile.value = true;
setupSuccess();
render(<JourneyPublicPage />);
await waitFor(() => {
expect(screen.getByText('Tokyo 2026')).toBeInTheDocument();
});
// The MobileMapTimeline mock fires onEntryClick when "Open Entry" is clicked
const openBtn = screen.getByText('Open Entry');
await user.click(openBtn);
// MobileEntryView should slide in with the entry title
await waitFor(() => {
expect(screen.getByText('Shibuya Crossing')).toBeInTheDocument();
});
});
// FE-PAGE-PUBLICJOURNEY-016 // FE-PAGE-PUBLICJOURNEY-016
it('FE-PAGE-PUBLICJOURNEY-016: language picker opens and switches language', async () => { it('FE-PAGE-PUBLICJOURNEY-016: language picker opens and switches language', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
+44 -13
View File
@@ -14,6 +14,7 @@ import type { JourneyMapHandle } from '../components/Journey/JourneyMap'
import JournalBody from '../components/Journey/JournalBody' import JournalBody from '../components/Journey/JournalBody'
import PhotoLightbox from '../components/Journey/PhotoLightbox' import PhotoLightbox from '../components/Journey/PhotoLightbox'
import MobileMapTimeline from '../components/Journey/MobileMapTimeline' import MobileMapTimeline from '../components/Journey/MobileMapTimeline'
import MobileEntryView from '../components/Journey/MobileEntryView'
import { useIsMobile } from '../hooks/useIsMobile' import { useIsMobile } from '../hooks/useIsMobile'
import { formatLocationName } from '../utils/formatters' import { formatLocationName } from '../utils/formatters'
import { DAY_COLORS } from '../components/Journey/dayColors' import { DAY_COLORS } from '../components/Journey/dayColors'
@@ -44,6 +45,17 @@ interface PublicPhoto {
caption?: string | null caption?: string | null
} }
interface PublicGalleryPhoto {
id: number
journey_id: number
photo_id: number
provider?: string
asset_id?: string | null
owner_id?: number | null
file_path?: string | null
caption?: string | null
}
const MOOD_CONFIG: Record<string, { icon: typeof Smile; label: string; bg: string; text: string }> = { const MOOD_CONFIG: Record<string, { icon: typeof Smile; label: string; bg: string; text: string }> = {
amazing: { icon: Laugh, label: 'Amazing', bg: 'bg-pink-50 dark:bg-pink-900/20', text: 'text-pink-600 dark:text-pink-400' }, amazing: { icon: Laugh, label: 'Amazing', bg: 'bg-pink-50 dark:bg-pink-900/20', text: 'text-pink-600 dark:text-pink-400' },
good: { icon: Smile, label: 'Good', bg: 'bg-amber-50 dark:bg-amber-900/20', text: 'text-amber-600 dark:text-amber-400' }, good: { icon: Smile, label: 'Good', bg: 'bg-amber-50 dark:bg-amber-900/20', text: 'text-amber-600 dark:text-amber-400' },
@@ -60,7 +72,7 @@ const WEATHER_CONFIG: Record<string, { icon: typeof Sun; label: string }> = {
cold: { icon: Snowflake, label: 'Cold' }, cold: { icon: Snowflake, label: 'Cold' },
} }
function photoUrl(p: PublicPhoto, shareToken: string, kind: 'thumbnail' | 'original' = 'original'): string { function photoUrl(p: { photo_id: number }, shareToken: string, kind: 'thumbnail' | 'original' = 'original'): string {
return `/api/public/journey/${shareToken}/photos/${p.photo_id}/${kind}` return `/api/public/journey/${shareToken}/photos/${p.photo_id}/${kind}`
} }
@@ -96,6 +108,7 @@ export default function JourneyPublicPage() {
const locale = useSettingsStore(s => s.settings.language) || 'en' const locale = useSettingsStore(s => s.settings.language) || 'en'
const mapRef = useRef<JourneyMapHandle>(null) const mapRef = useRef<JourneyMapHandle>(null)
const [activeEntryId, setActiveEntryId] = useState<string | null>(null) const [activeEntryId, setActiveEntryId] = useState<string | null>(null)
const [viewingEntry, setViewingEntry] = useState<PublicEntry | null>(null)
const handleMarkerClick = useCallback((entryId: string) => { const handleMarkerClick = useCallback((entryId: string) => {
setActiveEntryId(entryId) setActiveEntryId(entryId)
@@ -113,21 +126,19 @@ export default function JourneyPublicPage() {
}, [token]) }, [token])
const entries = (data?.entries || []) as PublicEntry[] const entries = (data?.entries || []) as PublicEntry[]
const gallery = (data?.gallery || []) as PublicGalleryPhoto[]
const perms = data?.permissions || {} const perms = data?.permissions || {}
const journey = data?.journey || {} const journey = data?.journey || {}
const stats = data?.stats || {} const stats = data?.stats || {}
const timelineEntries = useMemo( const timelineEntries = useMemo(() => entries, [entries])
() => entries.filter(e => e.title !== '[Trip Photos]' && e.title !== 'Gallery'),
[entries],
)
const groupedEntries = useMemo(() => groupByDate(timelineEntries), [timelineEntries]) const groupedEntries = useMemo(() => groupByDate(timelineEntries), [timelineEntries])
const sortedDates = useMemo(() => [...groupedEntries.keys()].sort(), [groupedEntries]) const sortedDates = useMemo(() => [...groupedEntries.keys()].sort(), [groupedEntries])
const mapEntries = useMemo( const mapEntries = useMemo(
() => timelineEntries.filter(e => e.location_lat && e.location_lng), () => timelineEntries.filter(e => e.location_lat && e.location_lng),
[timelineEntries], [timelineEntries],
) )
const allPhotos = useMemo(() => entries.flatMap(e => (e.photos || []).map(p => ({ photo: p, entry: e }))), [entries]) const allPhotos = gallery
// Map entries with day color/label for colored markers. // Map entries with day color/label for colored markers.
// dayIdx is derived from sortedDates (ALL timeline dates) so marker colors // dayIdx is derived from sortedDates (ALL timeline dates) so marker colors
@@ -189,7 +200,7 @@ export default function JourneyPublicPage() {
const availableViews = [ const availableViews = [
perms.share_timeline && { id: 'timeline' as const, icon: List, label: t('journey.share.timeline') }, perms.share_timeline && { id: 'timeline' as const, icon: List, label: t('journey.share.timeline') },
perms.share_gallery && { id: 'gallery' as const, icon: Grid, label: t('journey.share.gallery') }, perms.share_gallery && { id: 'gallery' as const, icon: Grid, label: t('journey.share.gallery') },
!desktopTwoColumn && perms.share_map && { id: 'map' as const, icon: MapPin, label: t('journey.share.map') }, !desktopTwoColumn && !isMobile && perms.share_map && { id: 'map' as const, icon: MapPin, label: t('journey.share.map') },
].filter(Boolean) as { id: 'timeline' | 'gallery' | 'map'; icon: any; label: string }[] ].filter(Boolean) as { id: 'timeline' | 'gallery' | 'map'; icon: any; label: string }[]
// Shared timeline renderer used in both layout modes // Shared timeline renderer used in both layout modes
@@ -306,7 +317,7 @@ export default function JourneyPublicPage() {
)} )}
{/* Content */} {/* Content */}
<div className="px-5 pt-4 pb-5"> <div className="px-5 pt-4 pb-5 cursor-pointer" onClick={() => setViewingEntry(entry)}>
{/* Title (only when no single photo — photo has it in overlay) */} {/* Title (only when no single photo — photo has it in overlay) */}
{photos.length !== 1 && entry.title && ( {photos.length !== 1 && entry.title && (
<h3 className="text-[16px] font-semibold text-zinc-900 dark:text-white tracking-tight leading-snug mb-2">{entry.title}</h3> <h3 className="text-[16px] font-semibold text-zinc-900 dark:text-white tracking-tight leading-snug mb-2">{entry.title}</h3>
@@ -402,11 +413,11 @@ export default function JourneyPublicPage() {
// Shared gallery renderer // Shared gallery renderer
const renderGallery = () => ( const renderGallery = () => (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5"> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5">
{allPhotos.map(({ photo }, idx) => ( {allPhotos.map((photo, idx) => (
<div <div
key={photo.id} key={photo.id}
className="aspect-square rounded-lg overflow-hidden cursor-pointer" className="aspect-square rounded-lg overflow-hidden cursor-pointer"
onClick={() => setLightbox({ photos: allPhotos.map(({ photo: p }) => ({ id: String(p.id), src: photoUrl(p, token!, 'original'), caption: p.caption })), index: idx })} onClick={() => setLightbox({ photos: allPhotos.map(p => ({ id: String(p.id), src: photoUrl(p, token!, 'original'), caption: p.caption })), index: idx })}
> >
<img src={photoUrl(photo, token!, 'thumbnail')} className="w-full h-full object-cover hover:scale-105 transition-transform" alt="" loading="lazy" /> <img src={photoUrl(photo, token!, 'thumbnail')} className="w-full h-full object-cover hover:scale-105 transition-transform" alt="" loading="lazy" />
</div> </div>
@@ -437,7 +448,7 @@ export default function JourneyPublicPage() {
return ( return (
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950"> <div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
{/* Hero */} {/* Hero */}
<div className="relative text-center text-white" style={{ background: 'linear-gradient(135deg, #000 0%, #0f172a 50%, #1e293b 100%)', padding: '32px 20px 28px' }}> <div className="relative text-center text-white" style={{ background: 'linear-gradient(135deg, #000 0%, #0f172a 50%, #1e293b 100%)', padding: '32px 20px 28px', overflow: 'hidden' }}>
{journey.cover_image && ( {journey.cover_image && (
<div style={{ position: 'absolute', inset: 0, backgroundImage: `url(/uploads/${journey.cover_image})`, backgroundSize: 'cover', backgroundPosition: 'center', opacity: 0.15 }} /> <div style={{ position: 'absolute', inset: 0, backgroundImage: `url(/uploads/${journey.cover_image})`, backgroundSize: 'cover', backgroundPosition: 'center', opacity: 0.15 }} />
)} )}
@@ -499,7 +510,7 @@ export default function JourneyPublicPage() {
// ── Desktop two-column: scrollable timeline feed + sticky map ────────── // ── Desktop two-column: scrollable timeline feed + sticky map ──────────
<div className="max-w-[1440px] mx-auto flex" style={{ alignItems: 'flex-start' }}> <div className="max-w-[1440px] mx-auto flex" style={{ alignItems: 'flex-start' }}>
{/* Left: feed */} {/* Left: feed */}
<div className="flex-1 min-w-0 px-8 py-6"> <div className="flex-1 xl:max-w-[50%] min-w-0 px-8 py-6">
{renderTabs(availableViews)} {renderTabs(availableViews)}
{view === 'timeline' && perms.share_timeline && renderTimeline()} {view === 'timeline' && perms.share_timeline && renderTimeline()}
{view === 'gallery' && perms.share_gallery && renderGallery()} {view === 'gallery' && perms.share_gallery && renderGallery()}
@@ -563,7 +574,7 @@ export default function JourneyPublicPage() {
mapEntries={sidebarMapItems as any} mapEntries={sidebarMapItems as any}
dark={document.documentElement.classList.contains('dark')} dark={document.documentElement.classList.contains('dark')}
readOnly readOnly
onEntryClick={() => {}} onEntryClick={(entry) => setViewingEntry(entry as any)}
publicPhotoUrl={(photoId) => `/api/public/journey/${token}/photos/${photoId}/original`} publicPhotoUrl={(photoId) => `/api/public/journey/${token}/photos/${photoId}/original`}
carouselBottom="calc(env(safe-area-inset-bottom, 16px) + 8px)" carouselBottom="calc(env(safe-area-inset-bottom, 16px) + 8px)"
/> />
@@ -607,6 +618,26 @@ export default function JourneyPublicPage() {
onClose={() => setLightbox(null)} onClose={() => setLightbox(null)}
/> />
)} )}
{/* Mobile entry detail view (public share) */}
{viewingEntry && (
<MobileEntryView
entry={viewingEntry as any}
readOnly
publicPhotoUrl={(photoId) => `/api/public/journey/${token}/photos/${photoId}/original`}
onClose={() => setViewingEntry(null)}
onEdit={() => {}}
onDelete={() => {}}
onPhotoClick={(photos, idx) => setLightbox({
photos: photos.map(p => ({
id: String(p.id),
src: photoUrl(p as any, token!, 'original'),
caption: (p as any).caption ?? null,
})),
index: idx,
})}
/>
)}
</div> </div>
) )
} }
+65
View File
@@ -57,6 +57,24 @@ export interface JourneyPhoto {
height?: number | null height?: number | null
} }
export interface GalleryPhoto {
id: number
journey_id: number
photo_id: number
caption?: string | null
shared: number
sort_order: number
created_at: number
// Joined from trek_photos for display
provider?: string
asset_id?: string | null
owner_id?: number | null
file_path?: string | null
thumbnail_path?: string | null
width?: number | null
height?: number | null
}
export interface JourneyTrip { export interface JourneyTrip {
trip_id: number trip_id: number
added_at: number added_at: number
@@ -79,6 +97,7 @@ export interface JourneyContributor {
export interface JourneyDetail extends Journey { export interface JourneyDetail extends Journey {
entries: JourneyEntry[] entries: JourneyEntry[]
gallery: GalleryPhoto[]
trips: JourneyTrip[] trips: JourneyTrip[]
contributors: JourneyContributor[] contributors: JourneyContributor[]
stats: { entries: number; photos: number; places: number } stats: { entries: number; photos: number; places: number }
@@ -103,6 +122,9 @@ interface JourneyState {
reorderEntries: (journeyId: number, orderedIds: number[]) => Promise<void> reorderEntries: (journeyId: number, orderedIds: number[]) => Promise<void>
uploadPhotos: (entryId: number, formData: FormData) => Promise<JourneyPhoto[]> uploadPhotos: (entryId: number, formData: FormData) => Promise<JourneyPhoto[]>
uploadGalleryPhotos: (journeyId: number, formData: FormData) => Promise<GalleryPhoto[]>
unlinkPhoto: (entryId: number, journeyPhotoId: number) => Promise<void>
deleteGalleryPhoto: (journeyId: number, journeyPhotoId: number) => Promise<void>
deletePhoto: (photoId: number) => Promise<void> deletePhoto: (photoId: number) => Promise<void>
clear: () => void clear: () => void
@@ -228,12 +250,55 @@ export const useJourneyStore = create<JourneyState>((set, get) => ({
entries: s.current.entries.map(e => entries: s.current.entries.map(e =>
e.id === entryId ? { ...e, photos: [...(e.photos || []), ...photos] } : e e.id === entryId ? { ...e, photos: [...(e.photos || []), ...photos] } : e
), ),
gallery: [...(s.current.gallery || []), ...(data.gallery || [])],
}, },
} }
}) })
return photos return photos
}, },
uploadGalleryPhotos: async (journeyId, formData) => {
const data = await journeyApi.uploadGalleryPhotos(journeyId, formData)
const photos: GalleryPhoto[] = data.photos || []
set(s => {
if (!s.current || s.current.id !== journeyId) return s
return { current: { ...s.current, gallery: [...(s.current.gallery || []), ...photos] } }
})
return photos
},
unlinkPhoto: async (entryId, journeyPhotoId) => {
await journeyApi.unlinkPhoto(entryId, journeyPhotoId)
set(s => {
if (!s.current) return s
return {
current: {
...s.current,
entries: s.current.entries.map(e =>
e.id === entryId ? { ...e, photos: (e.photos || []).filter(p => p.id !== journeyPhotoId) } : e
),
},
}
})
},
deleteGalleryPhoto: async (journeyId, journeyPhotoId) => {
await journeyApi.deleteGalleryPhoto(journeyId, journeyPhotoId)
set(s => {
if (!s.current) return s
return {
current: {
...s.current,
gallery: (s.current.gallery || []).filter(p => p.id !== journeyPhotoId),
entries: s.current.entries.map(e => ({
...e,
photos: (e.photos || []).filter(p => p.id !== journeyPhotoId),
})),
},
}
})
},
deletePhoto: async (photoId) => { deletePhoto: async (photoId) => {
await journeyApi.deletePhoto(photoId) await journeyApi.deletePhoto(photoId)
set(s => { set(s => {
+577 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "trek-server", "name": "trek-server",
"version": "2.9.14", "version": "3.0.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "trek-server", "name": "trek-server",
"version": "2.9.14", "version": "3.0.2",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.28.0", "@modelcontextprotocol/sdk": "^1.28.0",
"archiver": "^6.0.1", "archiver": "^6.0.1",
@@ -25,6 +25,7 @@
"otplib": "^12.0.1", "otplib": "^12.0.1",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"semver": "^7.7.4", "semver": "^7.7.4",
"sharp": "^0.34.5",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "^6.0.2", "typescript": "^6.0.2",
"undici": "^7.0.0", "undici": "^7.0.0",
@@ -132,6 +133,16 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.27.7", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
@@ -560,6 +571,519 @@
"hono": "^4" "hono": "^4"
} }
}, },
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.2.4"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.2.4"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.2.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.7.0"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@isaacs/cliui": { "node_modules/@isaacs/cliui": {
"version": "8.0.2", "version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -5083,6 +5607,50 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/sharp": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
"semver": "^7.7.3"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.34.5",
"@img/sharp-darwin-x64": "0.34.5",
"@img/sharp-libvips-darwin-arm64": "1.2.4",
"@img/sharp-libvips-darwin-x64": "1.2.4",
"@img/sharp-libvips-linux-arm": "1.2.4",
"@img/sharp-libvips-linux-arm64": "1.2.4",
"@img/sharp-libvips-linux-ppc64": "1.2.4",
"@img/sharp-libvips-linux-riscv64": "1.2.4",
"@img/sharp-libvips-linux-s390x": "1.2.4",
"@img/sharp-libvips-linux-x64": "1.2.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
"@img/sharp-linux-arm": "0.34.5",
"@img/sharp-linux-arm64": "0.34.5",
"@img/sharp-linux-ppc64": "0.34.5",
"@img/sharp-linux-riscv64": "0.34.5",
"@img/sharp-linux-s390x": "0.34.5",
"@img/sharp-linux-x64": "0.34.5",
"@img/sharp-linuxmusl-arm64": "0.34.5",
"@img/sharp-linuxmusl-x64": "0.34.5",
"@img/sharp-wasm32": "0.34.5",
"@img/sharp-win32-arm64": "0.34.5",
"@img/sharp-win32-ia32": "0.34.5",
"@img/sharp-win32-x64": "0.34.5"
}
},
"node_modules/shebang-command": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -5766,6 +6334,13 @@
"nodetouch": "bin/nodetouch.js" "nodetouch": "bin/nodetouch.js"
} }
}, },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"optional": true
},
"node_modules/tsx": { "node_modules/tsx": {
"version": "4.21.0", "version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "trek-server", "name": "trek-server",
"version": "2.9.14", "version": "3.0.2",
"main": "src/index.ts", "main": "src/index.ts",
"scripts": { "scripts": {
"start": "node --import tsx src/index.ts", "start": "node --import tsx src/index.ts",
@@ -30,6 +30,7 @@
"otplib": "^12.0.1", "otplib": "^12.0.1",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"semver": "^7.7.4", "semver": "^7.7.4",
"sharp": "^0.34.5",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "^6.0.2", "typescript": "^6.0.2",
"undici": "^7.0.0", "undici": "^7.0.0",
+4 -2
View File
@@ -372,8 +372,10 @@ export function createApp(): express.Application {
} else { } else {
console.error('Unhandled error:', err); console.error('Unhandled error:', err);
} }
const status = err.statusCode || 500; const status = err.statusCode || err.status || 500;
res.status(status).json({ error: 'Internal server error' }); // Expose the message for client errors (4xx); keep 'Internal server error' for 5xx.
const message = status < 500 ? err.message : 'Internal server error';
res.status(status).json({ error: message });
}); });
return app; return app;
+161
View File
@@ -1946,6 +1946,167 @@ function runMigrations(db: Database.Database): void {
) )
`); `);
}, },
// Migration 121: Journey gallery refactor — decouple photo ownership from
// entries. journey_photos becomes a per-journey gallery (one row per unique
// photo per journey). A new junction table journey_entry_photos links
// gallery photos to the entries that reference them, allowing the same
// photo to appear in multiple entries without duplication. Synthetic
// wrapper entries ('Gallery', '[Trip Photos]') created by the old model
// are removed — the gallery table replaces them.
() => {
const hasOld = db.prepare(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'journey_photos'"
).get();
const hasBackup = db.prepare(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'journey_photos_old'"
).get();
if (hasOld && !hasBackup) {
db.exec('ALTER TABLE journey_photos RENAME TO journey_photos_old');
}
db.exec(`
CREATE TABLE IF NOT EXISTS journey_photos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
journey_id INTEGER NOT NULL REFERENCES journeys(id) ON DELETE CASCADE,
photo_id INTEGER NOT NULL REFERENCES trek_photos(id) ON DELETE CASCADE,
caption TEXT,
shared INTEGER DEFAULT 0,
sort_order INTEGER DEFAULT 0,
provider TEXT,
asset_id TEXT,
owner_id INTEGER,
created_at INTEGER NOT NULL,
UNIQUE(journey_id, photo_id)
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS journey_entry_photos (
entry_id INTEGER NOT NULL REFERENCES journey_entries(id) ON DELETE CASCADE,
journey_photo_id INTEGER NOT NULL REFERENCES journey_photos(id) ON DELETE CASCADE,
sort_order INTEGER DEFAULT 0,
created_at INTEGER NOT NULL,
PRIMARY KEY(entry_id, journey_photo_id)
)
`);
if (hasOld || hasBackup) {
// Backfill gallery: deduplicate by (journey_id, photo_id), keeping
// the earliest row (MIN(id) = earliest created_at on AUTOINCREMENT).
db.exec(`
INSERT OR IGNORE INTO journey_photos
(journey_id, photo_id, caption, shared, sort_order, created_at)
SELECT
je.journey_id,
jpo.photo_id,
jpo.caption,
jpo.shared,
jpo.sort_order,
jpo.created_at
FROM journey_photos_old jpo
JOIN journey_entries je ON je.id = jpo.entry_id
WHERE jpo.id IN (
SELECT MIN(jpo2.id)
FROM journey_photos_old jpo2
JOIN journey_entries je2 ON je2.id = jpo2.entry_id
GROUP BY je2.journey_id, jpo2.photo_id
)
`);
// Backfill junction: one row per (entry_id, photo_id), resolved to
// the new gallery ids.
db.exec(`
INSERT OR IGNORE INTO journey_entry_photos
(entry_id, journey_photo_id, sort_order, created_at)
SELECT
jpo.entry_id,
jp.id,
jpo.sort_order,
jpo.created_at
FROM journey_photos_old jpo
JOIN journey_entries je ON je.id = jpo.entry_id
JOIN journey_photos jp
ON jp.journey_id = je.journey_id
AND jp.photo_id = jpo.photo_id
`);
db.exec('DROP TABLE journey_photos_old');
}
// Remove synthetic wrapper entries replaced by the gallery model.
// ON DELETE CASCADE on journey_entry_photos cleans up junction rows.
db.prepare(
"DELETE FROM journey_entries WHERE title IN ('Gallery', '[Trip Photos]')"
).run();
db.exec('CREATE INDEX IF NOT EXISTS idx_journey_photos_journey ON journey_photos(journey_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_journey_entry_photos_entry ON journey_entry_photos(entry_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_journey_entry_photos_photo ON journey_entry_photos(journey_photo_id)');
},
// Migration 122: Correct stale day_id / end_day_id on non-transport
// reservations. Migration 110 only backfilled transport types; tours,
// restaurants, events and "other" bookings kept a stale day_id from
// older code paths that often defaulted to the first day of the trip.
// Starting with v3.0.0 the planner renders reservations by day_id
// instead of reservation_time, so those stale rows show up on the
// wrong day. This migration nulls out day_id / end_day_id values that
// don't match the reservation's time and then backfills them from
// reservation_time / reservation_end_time.
() => {
db.exec(`
UPDATE reservations
SET day_id = NULL
WHERE reservation_time IS NOT NULL
AND day_id IS NOT NULL
AND type != 'hotel'
AND NOT EXISTS (
SELECT 1 FROM days d
WHERE d.id = reservations.day_id
AND d.date = substr(reservations.reservation_time, 1, 10)
)
`);
db.exec(`
UPDATE reservations
SET end_day_id = NULL
WHERE reservation_end_time IS NOT NULL
AND end_day_id IS NOT NULL
AND type != 'hotel'
AND NOT EXISTS (
SELECT 1 FROM days d
WHERE d.id = reservations.end_day_id
AND d.date = substr(reservations.reservation_end_time, 1, 10)
)
`);
db.exec(`
UPDATE reservations
SET day_id = (
SELECT d.id FROM days d
WHERE d.trip_id = reservations.trip_id
AND d.date = substr(reservations.reservation_time, 1, 10)
LIMIT 1
)
WHERE type != 'hotel'
AND reservation_time IS NOT NULL
AND day_id IS NULL
`);
db.exec(`
UPDATE reservations
SET end_day_id = (
SELECT d.id FROM days d
WHERE d.trip_id = reservations.trip_id
AND d.date = substr(reservations.reservation_end_time, 1, 10)
LIMIT 1
)
WHERE type != 'hotel'
AND reservation_end_time IS NOT NULL
AND end_day_id IS NULL
AND substr(reservations.reservation_end_time, 1, 10)
!= substr(reservations.reservation_time, 1, 10)
`);
},
]; ];
if (currentVersion < migrations.length) { if (currentVersion < migrations.length) {
+83 -24
View File
@@ -9,6 +9,7 @@ import * as svc from '../services/journeyService';
import { db } from '../db/database'; import { db } from '../db/database';
import { createOrUpdateJourneyShareLink, getJourneyShareLink, deleteJourneyShareLink, getPublicJourney } from '../services/journeyShareService'; import { createOrUpdateJourneyShareLink, getJourneyShareLink, deleteJourneyShareLink, getPublicJourney } from '../services/journeyShareService';
import { uploadToImmich } from '../services/memories/immichService'; import { uploadToImmich } from '../services/memories/immichService';
import { getAllowedExtensions } from '../services/fileService';
const router = express.Router(); const router = express.Router();
@@ -25,9 +26,26 @@ const storage = multer.diskStorage({
}, },
}); });
const imageFilter: multer.Options['fileFilter'] = (_req, file, cb) => {
if (!file.mimetype.startsWith('image/') || file.mimetype.includes('svg')) {
const err: Error & { statusCode?: number } = new Error('Only image files are allowed');
err.statusCode = 400;
return cb(err);
}
const ext = path.extname(file.originalname).toLowerCase().replace('.', '');
const allowed = getAllowedExtensions().split(',').map(e => e.trim().toLowerCase());
if (!allowed.includes('*') && !allowed.includes(ext)) {
const err: Error & { statusCode?: number } = new Error(`File type .${ext} is not allowed`);
err.statusCode = 400;
return cb(err);
}
cb(null, true);
};
const upload = multer({ const upload = multer({
storage, storage,
limits: { fileSize: 20 * 1024 * 1024 }, limits: { fileSize: 20 * 1024 * 1024 },
fileFilter: imageFilter,
}); });
// ── Static prefix routes (MUST come before /:id) ───────────────────────── // ── Static prefix routes (MUST come before /:id) ─────────────────────────
@@ -104,10 +122,11 @@ router.post('/entries/:entryId/photos', authenticate, upload.array('photos', 10)
try { try {
const immichId = await uploadToImmich(authReq.user.id, relativePath, file.originalname); const immichId = await uploadToImmich(authReq.user.id, relativePath, file.originalname);
if (immichId) { if (immichId) {
// photo.id is now the gallery photo id (journey_photos.id)
svc.setPhotoProvider(photo.id, 'immich', immichId, authReq.user.id); svc.setPhotoProvider(photo.id, 'immich', immichId, authReq.user.id);
photo.provider = 'immich' as any; (photo as any).provider = 'immich';
photo.asset_id = immichId; (photo as any).asset_id = immichId;
photo.owner_id = authReq.user.id; (photo as any).owner_id = authReq.user.id;
} }
} catch {} } catch {}
} }
@@ -141,16 +160,25 @@ router.post('/entries/:entryId/provider-photos', authenticate, (req: Request, re
res.status(201).json(photo); res.status(201).json(photo);
}); });
// Link an existing photo to a (different) entry // Link a gallery photo to an entry
router.post('/entries/:entryId/link-photo', authenticate, (req: Request, res: Response) => { router.post('/entries/:entryId/link-photo', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest; const authReq = req as AuthRequest;
const { photo_id } = req.body || {}; // Accept journey_photo_id (new) or photo_id (legacy alias) for backwards compat
if (!photo_id) return res.status(400).json({ error: 'photo_id required' }); const journeyPhotoId = (req.body || {}).journey_photo_id ?? (req.body || {}).photo_id;
const result = svc.linkPhotoToEntry(Number(req.params.entryId), Number(photo_id), authReq.user.id); if (!journeyPhotoId) return res.status(400).json({ error: 'journey_photo_id required' });
const result = svc.linkPhotoToEntry(Number(req.params.entryId), Number(journeyPhotoId), authReq.user.id);
if (!result) return res.status(403).json({ error: 'Not allowed' }); if (!result) return res.status(403).json({ error: 'Not allowed' });
res.status(201).json(result); res.status(201).json(result);
}); });
// Unlink a photo from a specific entry (gallery row is preserved)
router.delete('/entries/:entryId/photos/:journeyPhotoId', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const ok = svc.unlinkPhotoFromEntry(Number(req.params.entryId), Number(req.params.journeyPhotoId), authReq.user.id);
if (!ok) return res.status(404).json({ error: 'Not found or not allowed' });
res.status(204).end();
});
router.patch('/photos/:photoId', authenticate, (req: Request, res: Response) => { router.patch('/photos/:photoId', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest; const authReq = req as AuthRequest;
const result = svc.updatePhoto(Number(req.params.photoId), authReq.user.id, req.body || {}); const result = svc.updatePhoto(Number(req.params.photoId), authReq.user.id, req.body || {});
@@ -158,34 +186,65 @@ router.patch('/photos/:photoId', authenticate, (req: Request, res: Response) =>
res.json(result); res.json(result);
}); });
// Hard-delete: removes gallery row + cascades to all entry links + deletes file if unreferenced
router.delete('/photos/:photoId', authenticate, async (req: Request, res: Response) => { router.delete('/photos/:photoId', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest; const authReq = req as AuthRequest;
const photo = svc.deletePhoto(Number(req.params.photoId), authReq.user.id); const photo = svc.deletePhoto(Number(req.params.photoId), authReq.user.id);
if (!photo) return res.status(404).json({ error: 'Photo not found' }); if (!photo) return res.status(404).json({ error: 'Photo not found' });
// delete local file
if (photo.file_path) { if (photo.file_path) {
const fullPath = path.join(__dirname, '../../uploads', photo.file_path); const fullPath = path.join(__dirname, '../../uploads', photo.file_path);
try { fs.unlinkSync(fullPath); } catch {} try { fs.unlinkSync(fullPath); } catch {}
} }
// only delete from Immich if the photo was UPLOADED through TREK (has local file)
// photos imported from Immich (no file_path) are just references — don't touch Immich
if (photo.provider === 'immich' && photo.asset_id && photo.file_path) {
try {
const { getImmichCredentials } = await import('../services/memories/immichService');
const creds = getImmichCredentials(authReq.user.id);
if (creds) {
const { safeFetch } = await import('../utils/ssrfGuard');
await safeFetch(`${creds.immich_url}/api/assets`, {
method: 'DELETE',
headers: { 'x-api-key': creds.immich_api_key, 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: [photo.asset_id] }),
});
}
} catch {}
}
res.json({ success: true }); res.json({ success: true });
}); });
// ── Gallery (prefix /:id/gallery — before /:id) ──────────────────────────
// Upload photos directly to the journey gallery (no entry association)
router.post('/:id/gallery/photos', authenticate, upload.array('photos', 20), async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const files = req.files as Express.Multer.File[];
if (!files?.length) return res.status(400).json({ error: 'No files uploaded' });
const filePaths = files.map(f => ({ path: `journey/${f.filename}` }));
const photos = svc.uploadGalleryPhotos(Number(req.params.id), authReq.user.id, filePaths);
if (!photos.length) return res.status(403).json({ error: 'Not allowed' });
res.status(201).json({ photos });
});
// Add provider photos to gallery only (no entry link)
router.post('/:id/gallery/provider-photos', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { provider, asset_id, asset_ids, passphrase } = req.body || {};
const pp = passphrase && typeof passphrase === 'string' ? passphrase : undefined;
if (Array.isArray(asset_ids) && provider) {
const added: any[] = [];
for (const id of asset_ids) {
const photo = svc.addProviderPhotoToGallery(Number(req.params.id), authReq.user.id, provider, String(id), undefined, pp);
if (photo) added.push(photo);
}
return res.status(201).json({ photos: added, added: added.length });
}
if (!provider || !asset_id) return res.status(400).json({ error: 'provider and asset_id required' });
const photo = svc.addProviderPhotoToGallery(Number(req.params.id), authReq.user.id, provider, asset_id, undefined, pp);
if (!photo) return res.status(403).json({ error: 'Not allowed or duplicate' });
res.status(201).json(photo);
});
// Hard-delete a gallery photo (removes from all entries)
router.delete('/:id/gallery/:journeyPhotoId', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const photo = svc.deleteGalleryPhoto(Number(req.params.journeyPhotoId), authReq.user.id);
if (!photo) return res.status(404).json({ error: 'Photo not found or not allowed' });
if (photo.file_path) {
const fullPath = path.join(__dirname, '../../uploads', photo.file_path);
try { fs.unlinkSync(fullPath); } catch {}
}
res.status(204).end();
});
// ── Journeys /:id (parameterized routes AFTER static prefixes) ─────────── // ── Journeys /:id (parameterized routes AFTER static prefixes) ───────────
router.get('/:id', authenticate, (req: Request, res: Response) => { router.get('/:id', authenticate, (req: Request, res: Response) => {
+151 -149
View File
@@ -7,12 +7,22 @@ function ts(): number {
return Date.now(); return Date.now();
} }
// Joined SELECT for journey_photos + trek_photos — returns fields matching JourneyPhoto interface // Per-entry photo view: join journey_entry_photos → journey_photos (gallery) → trek_photos.
// id = gp.id (gallery photo id) — used by clients for linkPhoto/updatePhoto/unlink/delete.
const JP_SELECT = ` const JP_SELECT = `
jp.id, jp.entry_id, jp.photo_id, jp.caption, jp.sort_order, jp.shared, jp.created_at, gp.id, jep.entry_id, gp.photo_id, gp.caption, jep.sort_order, gp.shared, gp.created_at,
tkp.provider, tkp.asset_id, tkp.owner_id, tkp.file_path, tkp.thumbnail_path, tkp.width, tkp.height tp.provider, tp.asset_id, tp.owner_id, tp.file_path, tp.thumbnail_path, tp.width, tp.height
`; `;
const JP_JOIN = 'journey_photos jp JOIN trek_photos tkp ON tkp.id = jp.photo_id'; const JP_JOIN = `journey_entry_photos jep
JOIN journey_photos gp ON gp.id = jep.journey_photo_id
JOIN trek_photos tp ON tp.id = gp.photo_id`;
// Per-journey gallery view: journey_photos → trek_photos (no entry context).
const GALLERY_SELECT = `
gp.id, gp.journey_id, gp.photo_id, gp.caption, gp.shared, gp.sort_order, gp.created_at,
tp.provider, tp.asset_id, tp.owner_id, tp.file_path, tp.thumbnail_path, tp.width, tp.height
`;
const GALLERY_JOIN = 'journey_photos gp JOIN trek_photos tp ON tp.id = gp.photo_id';
function broadcastJourneyEvent(journeyId: number, event: string, data: Record<string, unknown>, excludeSocketId?: string | number) { function broadcastJourneyEvent(journeyId: number, event: string, data: Record<string, unknown>, excludeSocketId?: string | number) {
const contributors = db.prepare( const contributors = db.prepare(
@@ -58,7 +68,7 @@ export function listJourneys(userId: number) {
return db.prepare(` return db.prepare(`
SELECT DISTINCT j.*, SELECT DISTINCT j.*,
(SELECT COUNT(*) FROM journey_entries je WHERE je.journey_id = j.id AND je.type != 'skeleton') as entry_count, (SELECT COUNT(*) FROM journey_entries je WHERE je.journey_id = j.id AND je.type != 'skeleton') as entry_count,
(SELECT COUNT(DISTINCT jp.photo_id) FROM journey_photos jp JOIN journey_entries je2 ON jp.entry_id = je2.id WHERE je2.journey_id = j.id) as photo_count, (SELECT COUNT(*) FROM journey_photos jp WHERE jp.journey_id = j.id) as photo_count,
(SELECT COUNT(DISTINCT je3.location_name) FROM journey_entries je3 WHERE je3.journey_id = j.id AND je3.location_name IS NOT NULL AND je3.location_name != '') as place_count, (SELECT COUNT(DISTINCT je3.location_name) FROM journey_entries je3 WHERE je3.journey_id = j.id AND je3.location_name IS NOT NULL AND je3.location_name != '') as place_count,
(SELECT MIN(t.start_date) FROM journey_trips jt JOIN trips t ON jt.trip_id = t.id WHERE jt.journey_id = j.id) as trip_date_min, (SELECT MIN(t.start_date) FROM journey_trips jt JOIN trips t ON jt.trip_id = t.id WHERE jt.journey_id = j.id) as trip_date_min,
(SELECT MAX(t.end_date) FROM journey_trips jt JOIN trips t ON jt.trip_id = t.id WHERE jt.journey_id = j.id) as trip_date_max (SELECT MAX(t.end_date) FROM journey_trips jt JOIN trips t ON jt.trip_id = t.id WHERE jt.journey_id = j.id) as trip_date_max
@@ -114,7 +124,7 @@ export function getJourneyFull(journeyId: number, userId: number) {
).all(journeyId) as JourneyEntry[]; ).all(journeyId) as JourneyEntry[];
const photos = db.prepare( const photos = db.prepare(
`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.entry_id IN (SELECT id FROM journey_entries WHERE journey_id = ?) ORDER BY jp.sort_order ASC` `SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jep.entry_id IN (SELECT id FROM journey_entries WHERE journey_id = ?) ORDER BY jep.sort_order ASC`
).all(journeyId) as JourneyPhoto[]; ).all(journeyId) as JourneyPhoto[];
// group photos by entry // group photos by entry
@@ -123,12 +133,11 @@ export function getJourneyFull(journeyId: number, userId: number) {
(photosByEntry[p.entry_id] ||= []).push(p); (photosByEntry[p.entry_id] ||= []).push(p);
} }
const gallery = db.prepare(
`SELECT ${GALLERY_SELECT} FROM ${GALLERY_JOIN} WHERE gp.journey_id = ? ORDER BY gp.sort_order ASC, gp.id ASC`
).all(journeyId);
const enrichedEntries = entries const enrichedEntries = entries
.filter(e => {
// hide empty Gallery entries (no photos, no story)
if (e.title === 'Gallery' && !e.story && !(photosByEntry[e.id]?.length)) return false;
return true;
})
.map(e => ({ .map(e => ({
...e, ...e,
tags: e.tags ? JSON.parse(e.tags) : [], tags: e.tags ? JSON.parse(e.tags) : [],
@@ -160,7 +169,7 @@ export function getJourneyFull(journeyId: number, userId: number) {
// stats // stats
const entryCount = entries.filter(e => e.type === 'entry').length; const entryCount = entries.filter(e => e.type === 'entry').length;
const photoCount = new Set(photos.map(p => p.photo_id)).size; const photoCount = (gallery as any[]).length;
const places = [...new Set(entries.map(e => e.location_name).filter(Boolean))]; const places = [...new Set(entries.map(e => e.location_name).filter(Boolean))];
const userPrefs = db.prepare( const userPrefs = db.prepare(
@@ -183,6 +192,7 @@ export function getJourneyFull(journeyId: number, userId: number) {
return { return {
...journey, ...journey,
entries: enrichedEntries, entries: enrichedEntries,
gallery,
trips, trips,
contributors, contributors,
stats: { entries: entryCount, photos: photoCount, places: places.length }, stats: { entries: entryCount, photos: photoCount, places: places.length },
@@ -315,46 +325,22 @@ export function syncTripPlaces(journeyId: number, tripId: number, authorId: numb
} }
} }
// import trip_photos into journey when a trip is linked // import trip_photos into journey gallery when a trip is linked
function syncTripPhotos(journeyId: number, tripId: number) { function syncTripPhotos(journeyId: number, tripId: number) {
const tripPhotos = db.prepare( const tripPhotos = db.prepare(
'SELECT tp.photo_id, tp.user_id, tp.shared FROM trip_photos tp WHERE tp.trip_id = ?' 'SELECT tp.photo_id, tp.shared FROM trip_photos tp WHERE tp.trip_id = ?'
).all(tripId) as { photo_id: number; user_id: number; shared: number }[]; ).all(tripId) as { photo_id: number; shared: number }[];
if (!tripPhotos.length) return; if (!tripPhotos.length) return;
const now = ts(); const now = ts();
const maxOrderRow = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE journey_id = ?').get(journeyId) as { m: number | null };
let nextOrder = (maxOrderRow?.m ?? -1) + 1;
// find or create a "Photos" entry for this trip's photos
let photoEntry = db.prepare(`
SELECT id FROM journey_entries
WHERE journey_id = ? AND source_trip_id = ? AND title = '[Trip Photos]' AND type = 'entry'
`).get(journeyId, tripId) as { id: number } | undefined;
if (!photoEntry) {
const trip = db.prepare('SELECT start_date FROM trips WHERE id = ?').get(tripId) as { start_date: string } | undefined;
const entryDate = trip?.start_date || new Date().toISOString().split('T')[0];
const owner = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(journeyId) as { user_id: number };
const res = db.prepare(`
INSERT INTO journey_entries (journey_id, source_trip_id, author_id, type, title, entry_date, sort_order, created_at, updated_at)
VALUES (?, ?, ?, 'entry', '[Trip Photos]', ?, 999, ?, ?)
`).run(journeyId, tripId, owner.user_id, entryDate, now, now);
photoEntry = { id: Number(res.lastInsertRowid) };
}
// import each trip photo, skip duplicates (by photo_id)
for (const tp of tripPhotos) { for (const tp of tripPhotos) {
const exists = db.prepare(
'SELECT 1 FROM journey_photos WHERE entry_id = ? AND photo_id = ?'
).get(photoEntry.id, tp.photo_id);
if (exists) continue;
const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE entry_id = ?').get(photoEntry.id) as { m: number | null };
db.prepare(` db.prepare(`
INSERT INTO journey_photos (entry_id, photo_id, shared, sort_order, created_at) INSERT OR IGNORE INTO journey_photos (journey_id, photo_id, shared, sort_order, created_at)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
`).run(photoEntry.id, tp.photo_id, tp.shared, (maxOrder?.m ?? -1) + 1, now); `).run(journeyId, tp.photo_id, tp.shared, nextOrder++, now);
} }
} }
@@ -444,7 +430,7 @@ export function onPlaceDeleted(placeId: number) {
for (const entry of entries) { for (const entry of entries) {
if (entry.type === 'skeleton') { if (entry.type === 'skeleton') {
// no content: just delete // no content: just delete
const hasPhotos = db.prepare('SELECT 1 FROM journey_photos WHERE entry_id = ?').get(entry.id); const hasPhotos = db.prepare('SELECT 1 FROM journey_entry_photos WHERE entry_id = ?').get(entry.id);
if (!hasPhotos && !entry.story) { if (!hasPhotos && !entry.story) {
db.prepare('DELETE FROM journey_entries WHERE id = ?').run(entry.id); db.prepare('DELETE FROM journey_entries WHERE id = ?').run(entry.id);
continue; continue;
@@ -469,7 +455,7 @@ export function listEntries(journeyId: number, userId: number) {
).all(journeyId) as JourneyEntry[]; ).all(journeyId) as JourneyEntry[];
const photos = db.prepare( const photos = db.prepare(
`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.entry_id IN (SELECT id FROM journey_entries WHERE journey_id = ?) ORDER BY jp.sort_order ASC` `SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jep.entry_id IN (SELECT id FROM journey_entries WHERE journey_id = ?) ORDER BY jep.sort_order ASC`
).all(journeyId) as JourneyPhoto[]; ).all(journeyId) as JourneyPhoto[];
const photosByEntry: Record<number, JourneyPhoto[]> = {}; const photosByEntry: Record<number, JourneyPhoto[]> = {};
@@ -628,9 +614,6 @@ export function deleteEntry(entryId: number, userId: number, sid?: string): bool
if (!entry) return false; if (!entry) return false;
if (!canEdit(entry.journey_id, userId)) return false; if (!canEdit(entry.journey_id, userId)) return false;
// delete photos along with the entry — no more orphan Gallery entries
db.prepare('DELETE FROM journey_photos WHERE entry_id = ?').run(entryId);
if (entry.source_trip_id && entry.source_place_id && entry.type !== 'skeleton') { if (entry.source_trip_id && entry.source_place_id && entry.type !== 'skeleton') {
// Revert filled entry back to skeleton instead of deleting // Revert filled entry back to skeleton instead of deleting
db.prepare(` db.prepare(`
@@ -645,12 +628,6 @@ export function deleteEntry(entryId: number, userId: number, sid?: string): bool
broadcastJourneyEvent(entry.journey_id, 'journey:entry:deleted', { entryId }, sid); broadcastJourneyEvent(entry.journey_id, 'journey:entry:deleted', { entryId }, sid);
} }
// clean up any empty Gallery entries in this journey
db.prepare(`
DELETE FROM journey_entries WHERE journey_id = ? AND title = 'Gallery'
AND id NOT IN (SELECT DISTINCT entry_id FROM journey_photos)
`).run(entry.journey_id);
return true; return true;
} }
@@ -664,23 +641,40 @@ function promoteSkeletonIfNeeded(entry: JourneyEntry): void {
db.prepare('UPDATE journey_entries SET type = ?, updated_at = ? WHERE id = ?').run('entry', ts(), entry.id); db.prepare('UPDATE journey_entries SET type = ?, updated_at = ? WHERE id = ?').run('entry', ts(), entry.id);
} }
// Ensure a trek_photo_id is in the journey gallery; return its gallery row id.
function ensureInGallery(journeyId: number, trekPhotoId: number, caption?: string, shared?: number): number {
const now = ts();
const maxOrderRow = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE journey_id = ?').get(journeyId) as { m: number | null };
db.prepare(`
INSERT OR IGNORE INTO journey_photos (journey_id, photo_id, caption, shared, sort_order, created_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(journeyId, trekPhotoId, caption || null, shared ?? 0, (maxOrderRow?.m ?? -1) + 1, now);
const row = db.prepare('SELECT id FROM journey_photos WHERE journey_id = ? AND photo_id = ?').get(journeyId, trekPhotoId) as { id: number };
return row.id;
}
// Link a gallery photo to an entry (idempotent). Returns the junction JP_SELECT row.
function linkGalleryPhotoToEntry(galleryId: number, entryId: number): JourneyPhoto | null {
const now = ts();
const maxOrderRow = db.prepare('SELECT MAX(sort_order) as m FROM journey_entry_photos WHERE entry_id = ?').get(entryId) as { m: number | null };
db.prepare(`
INSERT OR IGNORE INTO journey_entry_photos (entry_id, journey_photo_id, sort_order, created_at)
VALUES (?, ?, ?, ?)
`).run(entryId, galleryId, (maxOrderRow?.m ?? -1) + 1, now);
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jep.entry_id = ? AND jep.journey_photo_id = ?`)
.get(entryId, galleryId) as JourneyPhoto | null;
}
export function addPhoto(entryId: number, userId: number, filePath: string, thumbnailPath?: string, caption?: string): JourneyPhoto | null { export function addPhoto(entryId: number, userId: number, filePath: string, thumbnailPath?: string, caption?: string): JourneyPhoto | null {
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined; const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
if (!entry) return null; if (!entry) return null;
if (!canEdit(entry.journey_id, userId)) return null; if (!canEdit(entry.journey_id, userId)) return null;
const trekPhotoId = getOrCreateLocalTrekPhoto(filePath, thumbnailPath); const trekPhotoId = getOrCreateLocalTrekPhoto(filePath, thumbnailPath);
const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE entry_id = ?').get(entryId) as { m: number | null }; const galleryId = db.transaction(() => ensureInGallery(entry.journey_id, trekPhotoId, caption))();
const now = ts(); const result = linkGalleryPhotoToEntry(galleryId, entryId);
const res = db.prepare(`
INSERT INTO journey_photos (entry_id, photo_id, caption, sort_order, created_at)
VALUES (?, ?, ?, ?, ?)
`).run(entryId, trekPhotoId, caption || null, (maxOrder?.m ?? -1) + 1, now);
promoteSkeletonIfNeeded(entry); promoteSkeletonIfNeeded(entry);
return result;
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(Number(res.lastInsertRowid)) as JourneyPhoto;
} }
export function addProviderPhoto(entryId: number, userId: number, provider: string, assetId: string, caption?: string, passphrase?: string): JourneyPhoto | null { export function addProviderPhoto(entryId: number, userId: number, provider: string, assetId: string, caption?: string, passphrase?: string): JourneyPhoto | null {
@@ -690,119 +684,127 @@ export function addProviderPhoto(entryId: number, userId: number, provider: stri
const trekPhotoId = getOrCreateTrekPhoto(provider, assetId, userId, passphrase); const trekPhotoId = getOrCreateTrekPhoto(provider, assetId, userId, passphrase);
// skip if already added // skip if this photo is already linked to this entry
const exists = db.prepare('SELECT 1 FROM journey_photos WHERE entry_id = ? AND photo_id = ?').get(entryId, trekPhotoId); const alreadyLinked = db.prepare(`
if (exists) return null; SELECT 1 FROM journey_entry_photos jep
JOIN journey_photos gp ON gp.id = jep.journey_photo_id
const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE entry_id = ?').get(entryId) as { m: number | null }; WHERE jep.entry_id = ? AND gp.photo_id = ?
const now = ts(); `).get(entryId, trekPhotoId);
if (alreadyLinked) return null;
const res = db.prepare(`
INSERT INTO journey_photos (entry_id, photo_id, caption, sort_order, created_at)
VALUES (?, ?, ?, ?, ?)
`).run(entryId, trekPhotoId, caption || null, (maxOrder?.m ?? -1) + 1, now);
const galleryId = db.transaction(() => ensureInGallery(entry.journey_id, trekPhotoId, caption))();
const result = linkGalleryPhotoToEntry(galleryId, entryId);
promoteSkeletonIfNeeded(entry); promoteSkeletonIfNeeded(entry);
return result;
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(Number(res.lastInsertRowid)) as JourneyPhoto;
} }
export function linkPhotoToEntry(entryId: number, photoId: number, userId: number): JourneyPhoto | null { // Link a gallery photo (by its journey_photos.id) to an entry — idempotent.
export function linkPhotoToEntry(entryId: number, journeyPhotoId: number, userId: number): JourneyPhoto | null {
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined; const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
if (!entry) return null; if (!entry) return null;
if (!canEdit(entry.journey_id, userId)) return null; if (!canEdit(entry.journey_id, userId)) return null;
const source = db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(photoId) as JourneyPhoto | undefined; // Verify the gallery photo belongs to this journey
if (!source) return null; const galleryRow = db.prepare('SELECT id, journey_id FROM journey_photos WHERE id = ?').get(journeyPhotoId) as { id: number; journey_id: number } | undefined;
if (!galleryRow || galleryRow.journey_id !== entry.journey_id) return null;
if (source.entry_id === entryId) return source;
const oldEntry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(source.entry_id) as JourneyEntry | undefined;
const sourceIsGallery = oldEntry?.title === 'Gallery';
// skip if target already has this photo (by trek_photo_id)
const dupe = db.prepare('SELECT id FROM journey_photos WHERE entry_id = ? AND photo_id = ?').get(entryId, source.photo_id) as { id: number } | undefined;
if (dupe) return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(dupe.id) as JourneyPhoto;
const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE entry_id = ?').get(entryId) as { m: number | null };
let resultId: number;
if (sourceIsGallery) {
// Copy so the photo stays in the gallery even after being used in an entry.
const res = db.prepare(`
INSERT INTO journey_photos (entry_id, photo_id, caption, sort_order, created_at)
VALUES (?, ?, ?, ?, ?)
`).run(entryId, source.photo_id, source.caption || null, (maxOrder?.m ?? -1) + 1, ts());
resultId = Number(res.lastInsertRowid);
} else {
// Non-gallery source: keep existing move behavior.
db.prepare('UPDATE journey_photos SET entry_id = ? WHERE id = ?').run(entryId, photoId);
resultId = photoId;
}
const result = linkGalleryPhotoToEntry(galleryRow.id, entryId);
promoteSkeletonIfNeeded(entry); promoteSkeletonIfNeeded(entry);
return result;
}
// If we moved out of a Gallery entry (shouldn't happen with the guard above, // Upload photos to the journey gallery only (no entry association).
// but kept for any legacy data), clean up the Gallery wrapper if emptied. export function uploadGalleryPhotos(journeyId: number, userId: number, filePaths: { path: string; thumbnail?: string }[]): JourneyPhoto[] {
if (!sourceIsGallery && oldEntry && oldEntry.title === 'Gallery') { if (!canEdit(journeyId, userId)) return [];
const remaining = db.prepare('SELECT COUNT(*) as c FROM journey_photos WHERE entry_id = ?').get(source.entry_id) as { c: number }; const results: any[] = [];
if (remaining.c === 0) { const now = ts();
db.prepare('DELETE FROM journey_entries WHERE id = ?').run(source.entry_id); const maxOrderRow = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE journey_id = ?').get(journeyId) as { m: number | null };
} let nextOrder = (maxOrderRow?.m ?? -1) + 1;
for (const f of filePaths) {
const trekPhotoId = getOrCreateLocalTrekPhoto(f.path, f.thumbnail);
db.prepare(`
INSERT OR IGNORE INTO journey_photos (journey_id, photo_id, shared, sort_order, created_at)
VALUES (?, ?, 0, ?, ?)
`).run(journeyId, trekPhotoId, nextOrder++, now);
const row = db.prepare(`SELECT ${GALLERY_SELECT} FROM ${GALLERY_JOIN} WHERE gp.journey_id = ? AND gp.photo_id = ?`).get(journeyId, trekPhotoId);
if (row) results.push(row);
} }
return results;
}
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(resultId) as JourneyPhoto; // Add a provider photo to the gallery only (no entry link).
export function addProviderPhotoToGallery(journeyId: number, userId: number, provider: string, assetId: string, caption?: string, passphrase?: string): any | null {
if (!canEdit(journeyId, userId)) return null;
const trekPhotoId = getOrCreateTrekPhoto(provider, assetId, userId, passphrase);
const galleryId = db.transaction(() => ensureInGallery(journeyId, trekPhotoId, caption))();
return db.prepare(`SELECT ${GALLERY_SELECT} FROM ${GALLERY_JOIN} WHERE gp.id = ?`).get(galleryId) ?? null;
}
// Unlink a photo from a specific entry; gallery row is preserved.
export function unlinkPhotoFromEntry(entryId: number, journeyPhotoId: number, userId: number): boolean {
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
if (!entry) return false;
if (!canEdit(entry.journey_id, userId)) return false;
const result = db.prepare('DELETE FROM journey_entry_photos WHERE entry_id = ? AND journey_photo_id = ?').run(entryId, journeyPhotoId);
return result.changes > 0;
}
// Hard-delete a gallery photo (removes from all entries and the gallery).
export function deleteGalleryPhoto(journeyPhotoId: number, userId: number): { photo_id: number; file_path?: string | null } | null {
const row = db.prepare('SELECT * FROM journey_photos WHERE id = ?').get(journeyPhotoId) as { id: number; journey_id: number; photo_id: number } | undefined;
if (!row) return null;
if (!canEdit(row.journey_id, userId)) return null;
const trekRow = db.prepare('SELECT file_path, provider FROM trek_photos WHERE id = ?').get(row.photo_id) as { file_path?: string; provider?: string } | undefined;
// cascade on journey_entry_photos.journey_photo_id handles junction cleanup
db.prepare('DELETE FROM journey_photos WHERE id = ?').run(journeyPhotoId);
deleteTrekPhotoIfOrphan(row.photo_id);
return { photo_id: row.photo_id, file_path: trekRow?.file_path ?? null };
} }
export function setPhotoProvider(photoId: number, provider: string, assetId: string, ownerId: number) { export function setPhotoProvider(photoId: number, provider: string, assetId: string, ownerId: number) {
// Get the trek_photo_id from the journey_photo, then update the central registry // photoId = journey_photos.id (gallery row); look up the trek_photo_id
const jp = db.prepare('SELECT photo_id FROM journey_photos WHERE id = ?').get(photoId) as { photo_id: number } | undefined; const jp = db.prepare('SELECT photo_id FROM journey_photos WHERE id = ?').get(photoId) as { photo_id: number } | undefined;
if (!jp) return; if (!jp) return;
setTrekPhotoProvider(jp.photo_id, provider, assetId, ownerId); setTrekPhotoProvider(jp.photo_id, provider, assetId, ownerId);
// also denorm on gallery row for fast reads
db.prepare('UPDATE journey_photos SET provider = ?, asset_id = ?, owner_id = ? WHERE id = ?').run(provider, assetId, ownerId, photoId);
} }
export function updatePhoto(photoId: number, userId: number, data: { caption?: string; sort_order?: number }): JourneyPhoto | null { export function updatePhoto(photoId: number, userId: number, data: { caption?: string; sort_order?: number }): JourneyPhoto | null {
const photo = db.prepare(` // photoId = journey_photos.id (gallery row)
SELECT ${JP_SELECT}, je.journey_id FROM ${JP_JOIN} const row = db.prepare('SELECT id, journey_id FROM journey_photos WHERE id = ?').get(photoId) as { id: number; journey_id: number } | undefined;
JOIN journey_entries je ON jp.entry_id = je.id if (!row) return null;
WHERE jp.id = ? if (!canEdit(row.journey_id, userId)) return null;
`).get(photoId) as (JourneyPhoto & { journey_id: number }) | undefined;
if (!photo) return null;
if (!canEdit(photo.journey_id, userId)) return null;
const fields: string[] = []; // caption lives on the gallery row; sort_order lives on the junction table
const values: unknown[] = []; // (JP_SELECT reads jep.sort_order, so updating journey_photos.sort_order
if (data.caption !== undefined) { fields.push('caption = ?'); values.push(data.caption); } // would not be reflected in the returned row).
if (data.sort_order !== undefined) { fields.push('sort_order = ?'); values.push(data.sort_order); } if (data.caption !== undefined) {
if (!fields.length) return photo; db.prepare('UPDATE journey_photos SET caption = ? WHERE id = ?').run(data.caption, photoId);
}
values.push(photoId); if (data.sort_order !== undefined) {
db.prepare(`UPDATE journey_photos SET ${fields.join(', ')} WHERE id = ?`).run(...values); db.prepare('UPDATE journey_entry_photos SET sort_order = ? WHERE journey_photo_id = ?').run(data.sort_order, photoId);
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(photoId) as JourneyPhoto; }
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE gp.id = ? LIMIT 1`).get(photoId) as JourneyPhoto | null;
} }
export function deletePhoto(photoId: number, userId: number): (JourneyPhoto & { journey_id: number }) | null { // deletePhoto: hard-delete (backwards compat name used by old route).
const photo = db.prepare(` export function deletePhoto(photoId: number, userId: number): { id: number; photo_id: number; file_path?: string | null; journey_id: number } | null {
SELECT ${JP_SELECT}, je.journey_id FROM ${JP_JOIN} const row = db.prepare('SELECT id, journey_id, photo_id FROM journey_photos WHERE id = ?').get(photoId) as { id: number; journey_id: number; photo_id: number } | undefined;
JOIN journey_entries je ON jp.entry_id = je.id if (!row) return null;
WHERE jp.id = ? if (!canEdit(row.journey_id, userId)) return null;
`).get(photoId) as (JourneyPhoto & { journey_id: number }) | undefined;
if (!photo) return null; const trekRow = db.prepare('SELECT file_path, provider FROM trek_photos WHERE id = ?').get(row.photo_id) as { file_path?: string; provider?: string } | undefined;
if (!canEdit(photo.journey_id, userId)) return null;
db.prepare('DELETE FROM journey_photos WHERE id = ?').run(photoId); db.prepare('DELETE FROM journey_photos WHERE id = ?').run(photoId);
deleteTrekPhotoIfOrphan(photo.photo_id); deleteTrekPhotoIfOrphan(row.photo_id);
// clean up empty Gallery entries left behind return { id: row.id, photo_id: row.photo_id, file_path: trekRow?.file_path ?? null, journey_id: row.journey_id };
const remaining = db.prepare('SELECT 1 FROM journey_photos WHERE entry_id = ?').get(photo.entry_id);
if (!remaining) {
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(photo.entry_id) as JourneyEntry | undefined;
if (entry && entry.title === 'Gallery' && !entry.story) {
db.prepare('DELETE FROM journey_entries WHERE id = ?').run(photo.entry_id);
}
}
return photo;
} }
// ── Contributors ───────────────────────────────────────────────────────── // ── Contributors ─────────────────────────────────────────────────────────
+24 -21
View File
@@ -66,11 +66,10 @@ export function validateShareTokenForPhoto(token: string, photoId: number): { jo
const row = db.prepare('SELECT journey_id FROM journey_share_tokens WHERE token = ?').get(token) as any; const row = db.prepare('SELECT journey_id FROM journey_share_tokens WHERE token = ?').get(token) as any;
if (!row) return null; if (!row) return null;
const photo = db.prepare(` const photo = db.prepare(`
SELECT jp.photo_id, tkp.owner_id, je.journey_id SELECT gp.photo_id, tkp.owner_id, gp.journey_id
FROM journey_photos jp FROM journey_photos gp
JOIN trek_photos tkp ON tkp.id = jp.photo_id JOIN trek_photos tkp ON tkp.id = gp.photo_id
JOIN journey_entries je ON jp.entry_id = je.id WHERE gp.photo_id = ? AND gp.journey_id = ?
WHERE jp.photo_id = ? AND je.journey_id = ?
`).get(photoId, row.journey_id) as any; `).get(photoId, row.journey_id) as any;
if (!photo) return null; if (!photo) return null;
const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(row.journey_id) as any; const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(row.journey_id) as any;
@@ -81,10 +80,9 @@ export function validateShareTokenForAsset(token: string, assetId: string): { ow
const row = db.prepare('SELECT journey_id FROM journey_share_tokens WHERE token = ?').get(token) as any; const row = db.prepare('SELECT journey_id FROM journey_share_tokens WHERE token = ?').get(token) as any;
if (!row) return null; if (!row) return null;
const photo = db.prepare(` const photo = db.prepare(`
SELECT tkp.owner_id FROM journey_photos jp SELECT tkp.owner_id FROM journey_photos gp
JOIN trek_photos tkp ON tkp.id = jp.photo_id JOIN trek_photos tkp ON tkp.id = gp.photo_id
JOIN journey_entries je ON jp.entry_id = je.id WHERE tkp.asset_id = ? AND gp.journey_id = ?
WHERE tkp.asset_id = ? AND je.journey_id = ?
`).get(assetId, row.journey_id) as any; `).get(assetId, row.journey_id) as any;
if (!photo) { if (!photo) {
const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(row.journey_id) as any; const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(row.journey_id) as any;
@@ -108,13 +106,13 @@ export function getPublicJourney(token: string) {
`).all(row.journey_id) as any[]; `).all(row.journey_id) as any[];
const photos = db.prepare(` const photos = db.prepare(`
SELECT jp.id, jp.entry_id, jp.photo_id, jp.caption, jp.sort_order, jp.shared, jp.created_at, SELECT gp.id, jep.entry_id, gp.photo_id, gp.caption, jep.sort_order, gp.shared, gp.created_at,
tkp.provider, tkp.asset_id, tkp.owner_id, tkp.file_path, tkp.thumbnail_path, tkp.width, tkp.height tkp.provider, tkp.asset_id, tkp.owner_id, tkp.file_path, tkp.thumbnail_path, tkp.width, tkp.height
FROM journey_photos jp FROM journey_entry_photos jep
JOIN trek_photos tkp ON tkp.id = jp.photo_id JOIN journey_photos gp ON gp.id = jep.journey_photo_id
JOIN journey_entries je ON jp.entry_id = je.id JOIN trek_photos tkp ON tkp.id = gp.photo_id
WHERE je.journey_id = ? WHERE gp.journey_id = ?
ORDER BY jp.sort_order ORDER BY jep.sort_order
`).all(row.journey_id) as any[]; `).all(row.journey_id) as any[];
const photosByEntry: Record<number, any[]> = {}; const photosByEntry: Record<number, any[]> = {};
@@ -122,12 +120,16 @@ export function getPublicJourney(token: string) {
(photosByEntry[p.entry_id] ||= []).push(p); (photosByEntry[p.entry_id] ||= []).push(p);
} }
const gallery = db.prepare(`
SELECT gp.id, gp.journey_id, gp.photo_id, gp.caption, gp.shared, gp.sort_order, gp.created_at,
tkp.provider, tkp.asset_id, tkp.owner_id, tkp.file_path, tkp.thumbnail_path, tkp.width, tkp.height
FROM journey_photos gp
JOIN trek_photos tkp ON tkp.id = gp.photo_id
WHERE gp.journey_id = ?
ORDER BY gp.sort_order
`).all(row.journey_id) as any[];
const enrichedEntries = entries const enrichedEntries = entries
.filter(e => {
// hide empty Gallery entries (no photos, no story)
if (e.title === 'Gallery' && !e.story && !(photosByEntry[e.id]?.length)) return false;
return true;
})
.map(e => ({ .map(e => ({
...e, ...e,
tags: e.tags ? JSON.parse(e.tags) : [], tags: e.tags ? JSON.parse(e.tags) : [],
@@ -138,7 +140,7 @@ export function getPublicJourney(token: string) {
// Stats // Stats
const stats = { const stats = {
entries: entries.length, entries: entries.length,
photos: photos.length, photos: gallery.length,
places: new Set(entries.filter(e => e.location_name).map(e => e.location_name)).size, places: new Set(entries.filter(e => e.location_name).map(e => e.location_name)).size,
}; };
@@ -150,6 +152,7 @@ export function getPublicJourney(token: string) {
status: journey.status, status: journey.status,
}, },
entries: enrichedEntries, entries: enrichedEntries,
gallery,
stats, stats,
permissions: { permissions: {
share_timeline: !!row.share_timeline, share_timeline: !!row.share_timeline,
+8 -10
View File
@@ -129,15 +129,14 @@ export function canAccessUserPhoto(requestingUserId: number, ownerUserId: number
// Journey photos use tripId=0 — check journey_photos + journey_contributors // Journey photos use tripId=0 — check journey_photos + journey_contributors
if (tripId === '0') { if (tripId === '0') {
const journeyPhoto = db.prepare(` const journeyPhoto = db.prepare(`
SELECT jp.entry_id, je.journey_id SELECT gp.journey_id
FROM journey_photos jp FROM journey_photos gp
JOIN trek_photos tkp ON tkp.id = jp.photo_id JOIN trek_photos tkp ON tkp.id = gp.photo_id
JOIN journey_entries je ON je.id = jp.entry_id
WHERE tkp.asset_id = ? WHERE tkp.asset_id = ?
AND tkp.provider = ? AND tkp.provider = ?
AND tkp.owner_id = ? AND tkp.owner_id = ?
LIMIT 1 LIMIT 1
`).get(assetId, provider, ownerUserId) as { entry_id: number; journey_id: number } | undefined; `).get(assetId, provider, ownerUserId) as { journey_id: number } | undefined;
if (!journeyPhoto) return false; if (!journeyPhoto) return false;
const access = db.prepare(` const access = db.prepare(`
@@ -194,13 +193,12 @@ export function canAccessTrekPhoto(requestingUserId: number, trekPhotoId: number
// Check journey_photos — is this photo in a journey the user can access? // Check journey_photos — is this photo in a journey the user can access?
const journeyAccess = db.prepare(` const journeyAccess = db.prepare(`
SELECT 1 FROM journey_photos jp SELECT 1 FROM journey_photos gp
JOIN journey_entries je ON je.id = jp.entry_id WHERE gp.photo_id = ?
WHERE jp.photo_id = ?
AND EXISTS ( AND EXISTS (
SELECT 1 FROM journeys j WHERE j.id = je.journey_id AND j.user_id = ? SELECT 1 FROM journeys j WHERE j.id = gp.journey_id AND j.user_id = ?
UNION ALL UNION ALL
SELECT 1 FROM journey_contributors jc WHERE jc.journey_id = je.journey_id AND jc.user_id = ? SELECT 1 FROM journey_contributors jc WHERE jc.journey_id = gp.journey_id AND jc.user_id = ?
) )
LIMIT 1 LIMIT 1
`).get(trekPhotoId, requestingUserId, requestingUserId); `).get(trekPhotoId, requestingUserId, requestingUserId);
@@ -9,6 +9,7 @@ import type { ServiceResult, AssetInfo } from './helpersService';
import { fail, success } from './helpersService'; import { fail, success } from './helpersService';
import { encrypt_api_key, decrypt_api_key } from '../apiKeyCrypto'; import { encrypt_api_key, decrypt_api_key } from '../apiKeyCrypto';
import * as photoCache from './trekPhotoCache'; import * as photoCache from './trekPhotoCache';
import { ensureLocalThumbnail } from './thumbnailService';
// ── Lookup / Register ──────────────────────────────────────────────────── // ── Lookup / Register ────────────────────────────────────────────────────
@@ -101,7 +102,31 @@ export async function streamPhoto(
} }
if (photo.file_path) { if (photo.file_path) {
const localPath = path.join(__dirname, '../../../uploads', photo.file_path); const uploadsRoot = path.join(__dirname, '../../../uploads');
if (kind === 'thumbnail') {
let thumbRel = photo.thumbnail_path ?? null;
if (!thumbRel) {
const result = await ensureLocalThumbnail(uploadsRoot, photo.file_path);
if (result) {
thumbRel = result.thumbnailRelPath;
db.prepare(
'UPDATE trek_photos SET thumbnail_path = ?, width = COALESCE(width, ?), height = COALESCE(height, ?) WHERE id = ?'
).run(thumbRel, result.width, result.height, photo.id);
}
}
if (thumbRel) {
const thumbAbs = path.join(uploadsRoot, thumbRel);
if (fs.existsSync(thumbAbs)) {
res.set('Cache-Control', 'public, max-age=86400, immutable');
res.sendFile(thumbAbs);
return;
}
}
// Fall through to original if thumbnail unavailable.
}
const localPath = path.join(uploadsRoot, photo.file_path);
if (fs.existsSync(localPath)) { if (fs.existsSync(localPath)) {
res.set('Cache-Control', 'public, max-age=86400'); res.set('Cache-Control', 'public, max-age=86400');
res.sendFile(localPath); res.sendFile(localPath);
@@ -0,0 +1,43 @@
import sharp from 'sharp'
import path from 'path'
import fs from 'fs/promises'
import crypto from 'crypto'
const THUMB_MAX = 800
const THUMB_QUALITY = 80
export async function ensureLocalThumbnail(
uploadsRoot: string,
originalRelPath: string,
): Promise<{ thumbnailRelPath: string; width: number; height: number } | null> {
const originalAbs = path.join(uploadsRoot, originalRelPath)
try { await fs.access(originalAbs) } catch { return null }
// Deterministic name so concurrent requests don't race on the same photo.
const hash = crypto.createHash('sha1').update(originalRelPath).digest('hex').slice(0, 16)
const thumbRel = `journey/thumbs/${hash}.webp`
const thumbAbs = path.join(uploadsRoot, thumbRel)
try {
const [srcStat, dstStat] = await Promise.all([
fs.stat(originalAbs),
fs.stat(thumbAbs).catch(() => null),
])
if (dstStat && dstStat.mtimeMs >= srcStat.mtimeMs) {
const meta = await sharp(thumbAbs).metadata()
return { thumbnailRelPath: thumbRel, width: meta.width ?? 0, height: meta.height ?? 0 }
}
await fs.mkdir(path.dirname(thumbAbs), { recursive: true })
await sharp(originalAbs)
.rotate()
.resize({ width: THUMB_MAX, height: THUMB_MAX, fit: 'inside', withoutEnlargement: true })
.webp({ quality: THUMB_QUALITY })
.toFile(thumbAbs)
const meta = await sharp(thumbAbs).metadata()
return { thumbnailRelPath: thumbRel, width: meta.width ?? 0, height: meta.height ?? 0 }
} catch {
// Unsupported format, corrupt file, etc. — fall back to original in caller.
return null
}
}
+1 -1
View File
@@ -143,7 +143,7 @@ export async function discover(issuer: string, discoveryUrl?: string | null): Pr
// Validate that the discovery doc's issuer matches the operator-configured // Validate that the discovery doc's issuer matches the operator-configured
// one. A MITM or compromised doc could otherwise supply a crafted issuer // one. A MITM or compromised doc could otherwise supply a crafted issuer
// that passes jwt.verify() because we used doc.issuer as the expected value. // that passes jwt.verify() because we used doc.issuer as the expected value.
if (doc.issuer && doc.issuer !== issuer) { if (doc.issuer && doc.issuer.replace(/\/+$/, '') !== issuer) {
throw new Error(`OIDC discovery issuer mismatch: expected "${issuer}", got "${doc.issuer}"`); throw new Error(`OIDC discovery issuer mismatch: expected "${issuer}", got "${doc.issuer}"`);
} }
doc._issuer = url; doc._issuer = url;
+67 -7
View File
@@ -43,6 +43,24 @@ function loadEndpoints(reservationId: number): ReservationEndpoint[] {
).all(reservationId) as ReservationEndpoint[]; ).all(reservationId) as ReservationEndpoint[];
} }
// Resolve the day row whose date matches the date portion of an ISO-ish
// timestamp. Used to keep `day_id` / `end_day_id` in sync with
// `reservation_time` / `reservation_end_time` so non-transport bookings
// (tours, restaurants, events, ...) end up on the right day in the UI,
// which now filters by day_id instead of reservation_time.
function resolveDayIdFromTime(
tripId: string | number,
time: string | null | undefined,
): number | null {
if (!time) return null;
const datePart = time.slice(0, 10);
if (!/^\d{4}-\d{2}-\d{2}$/.test(datePart)) return null;
const row = db
.prepare('SELECT id FROM days WHERE trip_id = ? AND date = ? LIMIT 1')
.get(tripId, datePart) as { id: number } | undefined;
return row?.id ?? null;
}
const saveEndpoints = db.transaction((reservationId: number, endpoints: EndpointInput[]) => { const saveEndpoints = db.transaction((reservationId: number, endpoints: EndpointInput[]) => {
db.prepare('DELETE FROM reservation_endpoints WHERE reservation_id = ?').run(reservationId); db.prepare('DELETE FROM reservation_endpoints WHERE reservation_id = ?').run(reservationId);
const insert = db.prepare(` const insert = db.prepare(`
@@ -160,13 +178,26 @@ export function createReservation(tripId: string | number, data: CreateReservati
} }
} }
// Derive day_id / end_day_id from reservation_time when the client
// didn't explicitly set them (non-hotel bookings only — hotels store
// their date range on the linked day_accommodation).
const resolvedType = type || 'other';
let resolvedDayId: number | null = day_id ?? null;
if (resolvedDayId == null && resolvedType !== 'hotel' && reservation_time) {
resolvedDayId = resolveDayIdFromTime(tripId, reservation_time);
}
let resolvedEndDayId: number | null = end_day_id ?? null;
if (resolvedEndDayId == null && resolvedType !== 'hotel' && reservation_end_time) {
resolvedEndDayId = resolveDayIdFromTime(tripId, reservation_end_time);
}
const result = db.prepare(` const result = db.prepare(`
INSERT INTO reservations (trip_id, day_id, end_day_id, place_id, assignment_id, title, reservation_time, reservation_end_time, location, confirmation_number, notes, status, type, accommodation_id, metadata, needs_review) INSERT INTO reservations (trip_id, day_id, end_day_id, place_id, assignment_id, title, reservation_time, reservation_end_time, location, confirmation_number, notes, status, type, accommodation_id, metadata, needs_review)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run( `).run(
tripId, tripId,
day_id || null, resolvedDayId,
end_day_id ?? null, resolvedEndDayId,
place_id || null, place_id || null,
assignment_id || null, assignment_id || null,
title, title,
@@ -176,7 +207,7 @@ export function createReservation(tripId: string | number, data: CreateReservati
confirmation_number || null, confirmation_number || null,
notes || null, notes || null,
status || 'pending', status || 'pending',
type || 'other', resolvedType,
resolvedAccommodationId, resolvedAccommodationId,
metadata ? JSON.stringify(metadata) : null, metadata ? JSON.stringify(metadata) : null,
needs_review ? 1 : 0 needs_review ? 1 : 0
@@ -290,6 +321,35 @@ export function updateReservation(id: string | number, tripId: string | number,
} }
} }
const resolvedType = (type ?? current.type) || 'other';
const nextReservationTime = resolvedType === 'hotel'
? null
: (reservation_time !== undefined ? (reservation_time || null) : current.reservation_time);
const nextReservationEndTime = resolvedType === 'hotel'
? null
: (reservation_end_time !== undefined ? (reservation_end_time || null) : current.reservation_end_time);
// day_id / end_day_id: honour an explicit value from the client,
// otherwise derive from the (possibly updated) reservation_time so the
// planner renders the booking on the correct day.
let nextDayId: number | null;
if (day_id !== undefined) {
nextDayId = day_id || null;
} else if (reservation_time !== undefined && resolvedType !== 'hotel') {
nextDayId = resolveDayIdFromTime(tripId, nextReservationTime);
} else {
nextDayId = current.day_id ?? null;
}
let nextEndDayId: number | null;
if (end_day_id !== undefined) {
nextEndDayId = end_day_id ?? null;
} else if (reservation_end_time !== undefined && resolvedType !== 'hotel') {
nextEndDayId = resolveDayIdFromTime(tripId, nextReservationEndTime);
} else {
nextEndDayId = (current as any).end_day_id ?? null;
}
db.prepare(` db.prepare(`
UPDATE reservations SET UPDATE reservations SET
title = COALESCE(?, title), title = COALESCE(?, title),
@@ -310,13 +370,13 @@ export function updateReservation(id: string | number, tripId: string | number,
WHERE id = ? WHERE id = ?
`).run( `).run(
title || null, title || null,
(type ?? current.type) === 'hotel' ? null : (reservation_time !== undefined ? (reservation_time || null) : current.reservation_time), nextReservationTime,
(type ?? current.type) === 'hotel' ? null : (reservation_end_time !== undefined ? (reservation_end_time || null) : current.reservation_end_time), nextReservationEndTime,
location !== undefined ? (location || null) : current.location, location !== undefined ? (location || null) : current.location,
confirmation_number !== undefined ? (confirmation_number || null) : current.confirmation_number, confirmation_number !== undefined ? (confirmation_number || null) : current.confirmation_number,
notes !== undefined ? (notes || null) : current.notes, notes !== undefined ? (notes || null) : current.notes,
day_id !== undefined ? (day_id || null) : current.day_id, nextDayId,
end_day_id !== undefined ? (end_day_id ?? null) : (current as any).end_day_id ?? null, nextEndDayId,
place_id !== undefined ? (place_id || null) : current.place_id, place_id !== undefined ? (place_id || null) : current.place_id,
assignment_id !== undefined ? (assignment_id || null) : current.assignment_id, assignment_id !== undefined ? (assignment_id || null) : current.assignment_id,
status || null, status || null,
+18
View File
@@ -389,6 +389,24 @@ export interface JourneyPhoto {
height?: number | null; height?: number | null;
} }
export interface GalleryPhoto {
id: number;
journey_id: number;
photo_id: number;
caption?: string | null;
shared: number;
sort_order: number;
created_at: number;
// Joined from trek_photos for API responses
provider?: string;
asset_id?: string | null;
owner_id?: number | null;
file_path?: string | null;
thumbnail_path?: string | null;
width?: number | null;
height?: number | null;
}
export interface JourneyTrip { export interface JourneyTrip {
journey_id: number; journey_id: number;
trip_id: number; trip_id: number;
+1 -1
View File
@@ -649,7 +649,7 @@ describe('Link photo to entry', () => {
.send({}); .send({});
expect(res.status).toBe(400); expect(res.status).toBe(400);
expect(res.body.error).toBe('photo_id required'); expect(res.body.error).toBe('journey_photo_id required');
}); });
}); });
@@ -84,8 +84,9 @@ describe('GET /api/system-notices/active', () => {
it('returns empty array for non-first-login user with no applicable notices', async () => { it('returns empty array for non-first-login user with no applicable notices', async () => {
const { user } = createUser(testDb); const { user } = createUser(testDb);
// login_count > 1 means firstLogin condition does not match for any notice // login_count > 1 means firstLogin condition does not match for any notice;
testDb.prepare('UPDATE users SET login_count = 5 WHERE id = ?').run(user.id); // first_seen_version >= 3.0.0 means existingUserBeforeVersion('3.0.0') also does not match
testDb.prepare('UPDATE users SET login_count = 5, first_seen_version = ? WHERE id = ?').run('3.0.0', user.id);
const res = await request(app) const res = await request(app)
.get('/api/system-notices/active') .get('/api/system-notices/active')
.set('Cookie', authCookie(user.id)); .set('Cookie', authCookie(user.id));
@@ -122,7 +123,7 @@ describe('GET /api/system-notices/active', () => {
SYSTEM_NOTICES.push(TEST_NOTICE); SYSTEM_NOTICES.push(TEST_NOTICE);
try { try {
const { user } = createUser(testDb); const { user } = createUser(testDb);
testDb.prepare('UPDATE users SET login_count = 5 WHERE id = ?').run(user.id); testDb.prepare('UPDATE users SET login_count = 5, first_seen_version = ? WHERE id = ?').run('3.0.0', user.id);
const res = await request(app) const res = await request(app)
.get('/api/system-notices/active') .get('/api/system-notices/active')
@@ -1325,9 +1325,10 @@ describe('Edge cases', () => {
const result = deleteEntry(entry.id, user.id); const result = deleteEntry(entry.id, user.id);
expect(result).toBe(true); expect(result).toBe(true);
// Photo should be deleted with the entry // Junction row must be gone (ON DELETE CASCADE from journey_entries).
const deletedPhoto = testDb.prepare('SELECT * FROM journey_photos WHERE id = ?').get(photo!.id) as any; // Gallery row (journey_photos) is preserved — photo may belong to other entries.
expect(deletedPhoto).toBeUndefined(); const junctionRow = testDb.prepare('SELECT * FROM journey_entry_photos WHERE entry_id = ?').get(entry.id) as any;
expect(junctionRow).toBeUndefined();
}); });
it('JOURNEY-SVC-082: updateJourney can set cover_gradient', () => { it('JOURNEY-SVC-082: updateJourney can set cover_gradient', () => {
@@ -1395,17 +1396,12 @@ describe('Edge cases', () => {
addTripToJourney(journey.id, trip.id, user.id); addTripToJourney(journey.id, trip.id, user.id);
// Should have a [Trip Photos] entry with the imported photo // Trip photos now go straight into the journey gallery (no wrapper entry).
const photoEntry = testDb.prepare(
"SELECT * FROM journey_entries WHERE journey_id = ? AND title = '[Trip Photos]'"
).get(journey.id) as any;
expect(photoEntry).toBeDefined();
const photos = testDb.prepare(` const photos = testDb.prepare(`
SELECT jp.*, tkp.asset_id FROM journey_photos jp SELECT jp.*, tkp.asset_id FROM journey_photos jp
JOIN trek_photos tkp ON tkp.id = jp.photo_id JOIN trek_photos tkp ON tkp.id = jp.photo_id
WHERE jp.entry_id = ? WHERE jp.journey_id = ?
`).all(photoEntry.id); `).all(journey.id);
expect(photos.length).toBe(1); expect(photos.length).toBe(1);
expect((photos[0] as any).asset_id).toBe('immich-photo-1'); expect((photos[0] as any).asset_id).toBe('immich-photo-1');
}); });
@@ -58,7 +58,7 @@ afterAll(() => {
// -- Helpers ------------------------------------------------------------------ // -- Helpers ------------------------------------------------------------------
/** Insert a trek_photos + journey_photos row and return the trek_photos id (used as photoId in public URLs). */ /** Insert a trek_photos + journey_photos (gallery) + journey_entry_photos row and return the trek_photos id (used as photoId in public URLs). */
function insertJourneyPhoto( function insertJourneyPhoto(
entryId: number, entryId: number,
opts: { filePath?: string; assetId?: string; ownerId?: number } = {} opts: { filePath?: string; assetId?: string; ownerId?: number } = {}
@@ -70,10 +70,24 @@ function insertJourneyPhoto(
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
`).run(provider, opts.assetId ?? null, filePath, opts.ownerId ?? null, Date.now()); `).run(provider, opts.assetId ?? null, filePath, opts.ownerId ?? null, Date.now());
const trekId = trekResult.lastInsertRowid as number; const trekId = trekResult.lastInsertRowid as number;
// Look up journey_id from entry so gallery row is keyed to the journey (not entry).
const entryRow = testDb.prepare('SELECT journey_id FROM journey_entries WHERE id = ?').get(entryId) as { journey_id: number };
const journeyId = entryRow.journey_id;
const now = Date.now();
testDb.prepare(` testDb.prepare(`
INSERT INTO journey_photos (entry_id, photo_id, caption, sort_order, created_at) INSERT OR IGNORE INTO journey_photos (journey_id, photo_id, caption, sort_order, created_at)
VALUES (?, ?, NULL, 0, ?) VALUES (?, ?, NULL, 0, ?)
`).run(entryId, trekId, Date.now()); `).run(journeyId, trekId, now);
const galleryRow = testDb.prepare('SELECT id FROM journey_photos WHERE journey_id = ? AND photo_id = ?').get(journeyId, trekId) as { id: number };
testDb.prepare(`
INSERT OR IGNORE INTO journey_entry_photos (entry_id, journey_photo_id, sort_order, created_at)
VALUES (?, ?, 0, ?)
`).run(entryId, galleryRow.id, now);
// Return trek_photos.id — this is p.photo_id in the public API response // Return trek_photos.id — this is p.photo_id in the public API response
// and the value the client sends to /api/public/journey/:token/photos/:photoId/:kind // and the value the client sends to /api/public/journey/:token/photos/:photoId/:kind
return trekId; return trekId;
+1 -1
View File
@@ -59,7 +59,7 @@ If a toggle fails (e.g., network error), it rolls back to its previous state.
Some addons require credentials or environment variables before they are functional: Some addons require credentials or environment variables before they are functional:
- **Journey**requires photo provider credentials (Immich or Synology Photos) configured per-user in their personal Settings. See [Photo-Providers](Photo-Providers). - **Journey**works without any external integration. To embed photos from Immich or Synology Photos, enable the corresponding photo-provider toggle listed under Journey, then configure credentials per-user in **Settings → Integrations**. See [Photo-Providers](Photo-Providers).
- **MCP** — requires `APP_URL` to be set so OAuth redirect URIs resolve correctly. - **MCP** — requires `APP_URL` to be set so OAuth redirect URIs resolve correctly.
## Related pages ## Related pages
+7 -7
View File
@@ -48,7 +48,7 @@ Verified in `server/src/config.ts` (line 107):
## HTTPS / Proxy ## HTTPS / Proxy
These three variables work together behind a TLS-terminating reverse proxy. See [Reverse-Proxy] for the full explanation. These three variables work together behind a TLS-terminating reverse proxy. See [Reverse-Proxy](Reverse-Proxy) for the full explanation.
| Variable | Description | Default | | Variable | Description | Default |
|---|---|---| |---|---|---|
@@ -62,7 +62,7 @@ These three variables work together behind a TLS-terminating reverse proxy. See
## OIDC / SSO ## OIDC / SSO
For setup instructions, see [OIDC-SSO]. For setup instructions, see [OIDC-SSO](OIDC-SSO).
| Variable | Description | Default | | Variable | Description | Default |
|---|---|---| |---|---|---|
@@ -110,7 +110,7 @@ Both variables must be set together. If either is omitted, the account is create
## MCP ## MCP
For setup instructions, see [MCP-Overview]. For setup instructions, see [MCP-Overview](MCP-Overview).
| Variable | Description | Default | | Variable | Description | Default |
|---|---|---| |---|---|---|
@@ -129,7 +129,7 @@ For setup instructions, see [MCP-Overview].
## Related Pages ## Related Pages
- [Reverse-Proxy] — HTTPS proxy setup and the `FORCE_HTTPS` / `TRUST_PROXY` / `COOKIE_SECURE` trio - [Reverse-Proxy](Reverse-Proxy) — HTTPS proxy setup and the `FORCE_HTTPS` / `TRUST_PROXY` / `COOKIE_SECURE` trio
- [OIDC-SSO] — complete OIDC configuration guide - [OIDC-SSO](OIDC-SSO) — complete OIDC configuration guide
- [MCP-Overview] — MCP server setup and rate limiting - [MCP-Overview](MCP-Overview) — MCP server setup and rate limiting
- [Encryption-Key-Rotation] — rotating the `ENCRYPTION_KEY` without losing data - [Encryption-Key-Rotation](Encryption-Key-Rotation) — rotating the `ENCRYPTION_KEY` without losing data
+13 -7
View File
@@ -30,17 +30,23 @@ TREK is a self-hosted, real-time collaborative travel planner licensed under AGP
- **Public Share Links** — share a read-only view of any trip - **Public Share Links** — share a read-only view of any trip
### Addons _(admin-toggleable)_ ### Addons _(admin-toggleable)_
- **Lists** — packing lists and to-dos with templates, member assignments, optional bag tracking
- **Budget Planner** — expense tracker with category breakdown, splits, multi-currency
- **Documents** — file manager for trips, places, and reservations
- **Collab** — group chat, shared notes, polls, day-by-day attendance
- **Vacay** — personal vacation day planner with calendar view, public holidays, and carry-over tracking - **Vacay** — personal vacation day planner with calendar view, public holidays, and carry-over tracking
- **Atlas** — interactive world map, bucket list, travel stats, continent breakdown - **Atlas** — interactive world map, bucket list, travel stats, continent breakdown
- **Journey** — travel journal linking entries to trips, with contributor roles - **Journey** magazine-style travel journal with entries, photos (via Immich/Synology Photos), maps, and moods
- **Memories** — photo-focused trip memories - **Naver List Import** — import places from shared Naver Maps lists
- **Collab**group chat, shared notes, polls, and activity sign-ups - **MCP**expose TREK to AI assistants via the Model Context Protocol (OAuth 2.1)
- **Dashboard Widgets** — currency converter and timezone clock, toggled per user
> Dashboard widgets (currency converter and timezone clock) are per-user preferences, not an admin-toggleable addon — see [Dashboard-Widgets](Dashboard-Widgets).
### AI / MCP Integration ### AI / MCP Integration
- **MCP Server** — built-in Model Context Protocol server with OAuth 2.1 authentication - **MCP Server** — built-in Model Context Protocol server with OAuth 2.1 authentication
- **80+ Tools** — create trips, plan itineraries, manage budgets, send messages, and more - **150+ Tools** — create trips, plan itineraries, manage budgets, send messages, and more
- **24 OAuth Scopes** — granular permissions across 13 permission groups - **30 Resources** — read-only `trek://` URIs for trips, days, places, budget, packing, journeys, and more
- **27 OAuth Scopes** — granular permissions across 13 permission groups
- **Pre-built Prompts**`trip-summary`, `packing-list`, and `budget-overview` context loaders - **Pre-built Prompts**`trip-summary`, `packing-list`, and `budget-overview` context loaders
### Admin ### Admin
@@ -48,7 +54,7 @@ TREK is a self-hosted, real-time collaborative travel planner licensed under AGP
- Addon management, API key storage, scheduled auto-backups - Addon management, API key storage, scheduled auto-backups
- System notices for onboarding and announcements - System notices for onboarding and announcements
> **Admin:** Most configuration lives in the Admin Panel. The first user to register becomes the admin automatically. > **Admin:** Most configuration lives in the Admin Panel. On first boot TREK seeds an admin account automatically — credentials come from `ADMIN_EMAIL` / `ADMIN_PASSWORD` if set, otherwise a random password is printed to the container log.
## Get Started ## Get Started
+5 -5
View File
@@ -93,7 +93,7 @@ ALLOWED_ORIGINS=https://trek.example.com
APP_URL=https://trek.example.com APP_URL=https://trek.example.com
``` ```
Uncomment and fill in the OIDC, initial setup, or MCP variables as needed. For a full description of every variable, see [Environment-Variables]. Uncomment and fill in the OIDC, initial setup, or MCP variables as needed. For a full description of every variable, see [Environment-Variables](Environment-Variables).
## Start TREK ## Start TREK
@@ -111,10 +111,10 @@ docker compose logs -f
This compose file is designed for deployments where a reverse proxy (nginx, Caddy, Traefik) terminates TLS in front of TREK. To enable HTTPS redirects and secure cookies, uncomment `FORCE_HTTPS=true` and `TRUST_PROXY=1`. This compose file is designed for deployments where a reverse proxy (nginx, Caddy, Traefik) terminates TLS in front of TREK. To enable HTTPS redirects and secure cookies, uncomment `FORCE_HTTPS=true` and `TRUST_PROXY=1`.
See [Reverse-Proxy] for complete proxy configuration examples. See [Reverse-Proxy](Reverse-Proxy) for complete proxy configuration examples.
## Next Steps ## Next Steps
- [Environment-Variables] — full variable reference - [Environment-Variables](Environment-Variables) — full variable reference
- [Reverse-Proxy] — HTTPS configuration - [Reverse-Proxy](Reverse-Proxy) — HTTPS configuration
- [Updating] — how to pull a new image - [Updating](Updating) — how to pull a new image
+6 -6
View File
@@ -32,7 +32,7 @@ Pass additional `-e` flags for timezone and CORS/email link support:
-e ALLOWED_ORIGINS=https://trek.example.com \ -e ALLOWED_ORIGINS=https://trek.example.com \
``` ```
See [Environment-Variables] for the full list. See [Environment-Variables](Environment-Variables) for the full list.
## Volume Reference ## Volume Reference
@@ -66,11 +66,11 @@ docker logs trek
## Limitations of `docker run` ## Limitations of `docker run`
A bare `docker run` command has no built-in secret management and is harder to reproduce after a system reboot. For production, see [Install-Docker-Compose], which adds security hardening (`read_only`, `cap_drop`, `cap_add`, `no-new-privileges`, `tmpfs`) and makes it easy to manage environment variables through a `.env` file. A bare `docker run` command has no built-in secret management and is harder to reproduce after a system reboot. For production, see [Install-Docker-Compose](Install-Docker-Compose), which adds security hardening (`read_only`, `cap_drop`, `cap_add`, `no-new-privileges`, `tmpfs`) and makes it easy to manage environment variables through a `.env` file.
## Next Steps ## Next Steps
- [Reverse-Proxy] — HTTPS is required for PWA install and the `trek_session` cookie `secure` flag - [Reverse-Proxy](Reverse-Proxy) — HTTPS is required for PWA install and the `trek_session` cookie `secure` flag
- [Install-Docker-Compose] — recommended for production - [Install-Docker-Compose](Install-Docker-Compose) — recommended for production
- [Environment-Variables] — full list of configurable variables - [Environment-Variables](Environment-Variables) — full list of configurable variables
- [Updating] — how to pull a new image without losing data - [Updating](Updating) — how to pull a new image without losing data
+2 -2
View File
@@ -191,5 +191,5 @@ See the [`charts/README.md`](https://github.com/mauriceboe/TREK/blob/main/charts
## Next Steps ## Next Steps
- [Environment-Variables] — full variable reference - [Environment-Variables](Environment-Variables) — full variable reference
- [Reverse-Proxy] — proxy configuration for non-Kubernetes deployments - [Reverse-Proxy](Reverse-Proxy) — proxy configuration for non-Kubernetes deployments
+2 -2
View File
@@ -69,5 +69,5 @@ On first boot, TREK automatically creates an admin account. The credentials are
## Next Steps ## Next Steps
- [Environment-Variables] — complete variable reference - [Environment-Variables](Environment-Variables) — complete variable reference
- [Updating] — how to pull a new image on Unraid - [Updating](Updating) — how to pull a new image on Unraid
+1 -1
View File
@@ -2,7 +2,7 @@
TREK can browse your personal photo library on Immich or Synology Photos and attach selected photos to trips. TREK never copies the original files — it stores only a reference (provider name + asset ID) and proxies all image streams through its own server, so your provider credentials are never sent to the browser. TREK can browse your personal photo library on Immich or Synology Photos and attach selected photos to trips. TREK never copies the original files — it stores only a reference (provider name + asset ID) and proxies all image streams through its own server, so your provider credentials are never sent to the browser.
> **Admin:** Two things must be enabled for photo providers to appear in Settings: the **Memories addon** and the **individual photo provider** (Immich or Synology Photos). Both are toggled separately in **Admin → Addons**. See [Admin-Addons](Admin-Addons). If your provider is on a local or private network, the server must be configured to allow internal network access. See [Internal-Network-Access](Internal-Network-Access). > **Admin:** Enable at least one photo provider (Immich or Synology Photos) in **Admin → Addons** photo provider toggles appear as sub-items under the **Journey** addon. Once a provider is on, a Photo Providers section appears in each user's **Settings → Integrations**. If your provider runs on a local or private network, the server must be configured to allow internal network access. See [Admin-Addons](Admin-Addons) and [Internal-Network-Access](Internal-Network-Access).
--- ---
+4 -4
View File
@@ -60,7 +60,7 @@ You will be prompted to change the password on first login.
## Next Steps ## Next Steps
- [Install-Docker-Compose] — production setup with security hardening - [Install-Docker-Compose](Install-Docker-Compose) — production setup with security hardening
- [Reverse-Proxy] — put TREK behind HTTPS (required for PWA install and secure cookies) - [Reverse-Proxy](Reverse-Proxy) — put TREK behind HTTPS (required for PWA install and secure cookies)
- [Environment-Variables] — full configuration reference - [Environment-Variables](Environment-Variables) — full configuration reference
- [Admin-Panel-Overview] — explore what the admin panel can do - [Admin-Panel-Overview](Admin-Panel-Overview) — explore what the admin panel can do
+3 -3
View File
@@ -98,9 +98,9 @@ Four variables control how TREK behaves behind a proxy. They work as a group:
If you access TREK directly on `http://<host>:3000` without a proxy, leave `FORCE_HTTPS` unset and do not set `TRUST_PROXY`. If you access TREK directly on `http://<host>:3000` without a proxy, leave `FORCE_HTTPS` unset and do not set `TRUST_PROXY`.
See [Environment-Variables] for full documentation of these and all other variables. See [Environment-Variables](Environment-Variables) for full documentation of these and all other variables.
## Next Steps ## Next Steps
- [Environment-Variables] — full variable reference including OIDC - [Environment-Variables](Environment-Variables) — full variable reference including OIDC
- [Install-Docker-Compose] — production compose file with proxy-ready env vars - [Install-Docker-Compose](Install-Docker-Compose) — production compose file with proxy-ready env vars
+20 -1
View File
@@ -1,7 +1,9 @@
# Tags and Categories # Tags and Categories
TREK has a labeling system: **Global Place Categories** (admin-managed, shared across all users). TREK has two independent labelling systems for places:
- **Global Place Categories** — admin-managed, shared across every user on the instance (e.g. `Restaurant`, `Museum`).
- **Personal Tags** — user-scoped, private labels (e.g. `hidden gem`, `kid-friendly`).
<!-- TODO: screenshot: tag list on place detail --> <!-- TODO: screenshot: tag list on place detail -->
@@ -24,6 +26,23 @@ Categories appear in:
> **Admin:** Create and manage categories in [Admin-Categories](Admin-Categories). Only admins can create, edit, or delete categories. All users can read them. > **Admin:** Create and manage categories in [Admin-Categories](Admin-Categories). Only admins can create, edit, or delete categories. All users can read them.
## Personal Tags
Tags are private labels owned by each user. They attach to individual places via a many-to-many relationship (`place_tags` table), so the same tag can be applied to as many places as you like, and a single place can carry multiple tags.
**Fields per tag:**
- **Name** — free-form text.
- **Color** — hex value displayed alongside the tag name. Default: `#10b981` (emerald).
Tags are scoped to their creator — other trip members do not see your tags, and different users can create tags with identical names without conflict. Deleting a tag automatically removes it from every place it was attached to.
### Where to manage them
At the moment tags are exposed primarily through the MCP API — AI assistants connected to your instance can list, create, update, and delete tags (`list_tags`, `create_tag`, `update_tag`, `delete_tag`) and attach them to places through the place endpoints. A dedicated web UI for tag management is not yet available; the filter `tag` parameter on the places API / MCP resource does support filtering places by a tag ID once one exists.
> **AI / MCP:** See [MCP-Tools-and-Resources](MCP-Tools-and-Resources) for the full tag tool list.
## When to use which ## When to use which
| Use case | Use | | Use case | Use |
+5 -5
View File
@@ -4,7 +4,7 @@ How to update TREK to a newer version without losing data.
## Before You Update ## Before You Update
Back up your data first. Go to Admin Panel → Backups and create a manual backup, or copy your `./data` and `./uploads` directories to a safe location. See [Backups] for details. Back up your data first. Go to Admin Panel → Backups and create a manual backup, or copy your `./data` and `./uploads` directories to a safe location. See [Backups](Backups) for details.
## Docker Compose (Recommended) ## Docker Compose (Recommended)
@@ -42,7 +42,7 @@ TREK runs any pending database migrations automatically at startup. No manual mi
If you are upgrading from a version that predates the dedicated `ENCRYPTION_KEY` (i.e. you have no `ENCRYPTION_KEY` environment variable set), TREK automatically falls back to `./data/.jwt_secret` on startup and immediately promotes it to `./data/.encryption_key`. No manual steps are required — the transition is handled at first boot after the upgrade. If you are upgrading from a version that predates the dedicated `ENCRYPTION_KEY` (i.e. you have no `ENCRYPTION_KEY` environment variable set), TREK automatically falls back to `./data/.jwt_secret` on startup and immediately promotes it to `./data/.encryption_key`. No manual steps are required — the transition is handled at first boot after the upgrade.
If you want to rotate to a new key at any point (not required for a normal update), see [Encryption-Key-Rotation] for the full procedure. If you want to rotate to a new key at any point (not required for a normal update), see [Encryption-Key-Rotation](Encryption-Key-Rotation) for the full procedure.
## Unraid ## Unraid
@@ -50,6 +50,6 @@ In the Unraid Docker tab, click the TREK container and select **Update**. Unraid
## Next Steps ## Next Steps
- [Backups] — schedule automatic backups so you always have a restore point before updates - [Backups](Backups) — schedule automatic backups so you always have a restore point before updates
- [Encryption-Key-Rotation] — if you need to rotate or migrate the encryption key - [Encryption-Key-Rotation](Encryption-Key-Rotation) — if you need to rotate or migrate the encryption key
- [Install-Docker-Compose] — switch to Compose for easier future updates - [Install-Docker-Compose](Install-Docker-Compose) — switch to Compose for easier future updates