mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
Compare commits
36 Commits
v3.0.0-pre.58
...
v3.0.7
| Author | SHA1 | Date | |
|---|---|---|---|
| ae0e59d9f1 | |||
| 50bb7573fd | |||
| b852317c84 | |||
| 4436b6f673 | |||
| 311647fd46 | |||
| 28dbd86d03 | |||
| 842d9760df | |||
| 58218ff5f6 | |||
| 83be5fc92a | |||
| 7798d2a3fd | |||
| ec1ed60117 | |||
| ed4c21eade | |||
| 9093948ff6 | |||
| 2cea4d73aa | |||
| a2a6f52e6e | |||
| 0978b40b6d | |||
| 6155b6dc86 | |||
| 314486325e | |||
| 523bca3a20 | |||
| d5be528d4b | |||
| 3ada075b1a | |||
| afce302b59 | |||
| 8e8433fa9d | |||
| ff42fa0b8c | |||
| ccea7f7a65 | |||
| 45a5b4e588 | |||
| 82cce365f7 | |||
| ed7e2badca | |||
| ba7b99fb7d | |||
| 71aa8f8051 | |||
| 7c9e945b8c | |||
| f6b3931bc4 | |||
| 9e3041305c | |||
| 78fc557143 | |||
| 8a2fec8de0 | |||
| e109dc0b51 |
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
<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>
|
||||||
|
|
||||||
<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>
|
||||||
|
|
||||||
<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>
|
||||||
|
|
||||||
|
<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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
apiVersion: v2
|
apiVersion: v2
|
||||||
name: trek
|
name: trek
|
||||||
version: 2.9.14
|
version: 3.0.7
|
||||||
description: Minimal Helm chart for TREK app
|
description: Minimal Helm chart for TREK app
|
||||||
appVersion: "2.9.14"
|
appVersion: "3.0.7"
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "trek-client",
|
"name": "trek-client",
|
||||||
"version": "2.9.14",
|
"version": "3.0.7",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "trek-client",
|
"name": "trek-client",
|
||||||
"version": "2.9.14",
|
"version": "3.0.7",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-pdf/renderer": "^4.3.2",
|
"@react-pdf/renderer": "^4.3.2",
|
||||||
"axios": "^1.6.7",
|
"axios": "^1.6.7",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "trek-client",
|
"name": "trek-client",
|
||||||
"version": "2.9.14",
|
"version": "3.0.7",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ const transportReservation = {
|
|||||||
id: 400,
|
id: 400,
|
||||||
title: 'Flight to Rome',
|
title: 'Flight to Rome',
|
||||||
type: 'flight',
|
type: 'flight',
|
||||||
|
day_id: 10,
|
||||||
reservation_time: '2025-06-01T14:30:00',
|
reservation_time: '2025-06-01T14:30:00',
|
||||||
confirmation_number: 'ABC123',
|
confirmation_number: 'ABC123',
|
||||||
metadata: JSON.stringify({ airline: 'Air Italia', flight_number: 'AI123', departure_airport: 'CDG', arrival_airport: 'FCO' }),
|
metadata: JSON.stringify({ airline: 'Air Italia', flight_number: 'AI123', departure_airport: 'CDG', arrival_airport: 'FCO' }),
|
||||||
|
|||||||
@@ -140,23 +140,58 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
|||||||
const totalCost = Object.values(assignments || {})
|
const totalCost = Object.values(assignments || {})
|
||||||
.flatMap(a => a).reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0)
|
.flatMap(a => a).reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0)
|
||||||
|
|
||||||
|
// Span helpers for multi-day transport (mirrors DayPlanSidebar logic)
|
||||||
|
const pdfGetDayOrder = (d: Day) => d.day_number
|
||||||
|
const pdfGetSpanPhase = (r: any, dayId: number): 'single' | 'start' | 'middle' | 'end' => {
|
||||||
|
const startId = r.day_id
|
||||||
|
const endId = r.end_day_id ?? startId
|
||||||
|
if (!startId || startId === endId) return 'single'
|
||||||
|
if (dayId === startId) return 'start'
|
||||||
|
if (dayId === endId) return 'end'
|
||||||
|
return 'middle'
|
||||||
|
}
|
||||||
|
const pdfGetDisplayTime = (r: any, dayId: number): string | null => {
|
||||||
|
const phase = pdfGetSpanPhase(r, dayId)
|
||||||
|
if (phase === 'end') return r.reservation_end_time || null
|
||||||
|
if (phase === 'middle') return null
|
||||||
|
return r.reservation_time || null
|
||||||
|
}
|
||||||
|
const pdfGetSpanLabel = (r: any, phase: string): string | null => {
|
||||||
|
if (phase === 'single') return null
|
||||||
|
if (r.type === 'flight') return tr(`reservations.span.${phase === 'start' ? 'departure' : phase === 'end' ? 'arrival' : 'inTransit'}`)
|
||||||
|
if (r.type === 'car') return tr(`reservations.span.${phase === 'start' ? 'pickup' : phase === 'end' ? 'return' : 'active'}`)
|
||||||
|
return tr(`reservations.span.${phase === 'start' ? 'start' : phase === 'end' ? 'end' : 'ongoing'}`)
|
||||||
|
}
|
||||||
|
const pdfGetTransportForDay = (dayId: number) => (reservations || []).filter(r => {
|
||||||
|
if (r.type === 'hotel') return false
|
||||||
|
const startId = r.day_id
|
||||||
|
const endId = r.end_day_id ?? startId
|
||||||
|
if (startId == null) return false
|
||||||
|
if (endId !== startId) {
|
||||||
|
const startDay = sorted.find(d => d.id === startId)
|
||||||
|
const endDay = sorted.find(d => d.id === endId)
|
||||||
|
const thisDay = sorted.find(d => d.id === dayId)
|
||||||
|
if (!startDay || !endDay || !thisDay) return false
|
||||||
|
return pdfGetDayOrder(thisDay) >= pdfGetDayOrder(startDay) && pdfGetDayOrder(thisDay) <= pdfGetDayOrder(endDay)
|
||||||
|
}
|
||||||
|
return startId === dayId
|
||||||
|
})
|
||||||
|
|
||||||
// Build day HTML
|
// Build day HTML
|
||||||
const daysHtml = sorted.map((day, di) => {
|
const daysHtml = sorted.map((day, di) => {
|
||||||
const assigned = assignments[String(day.id)] || []
|
const assigned = assignments[String(day.id)] || []
|
||||||
const notes = (dayNotes || []).filter(n => n.day_id === day.id)
|
const notes = (dayNotes || []).filter(n => n.day_id === day.id)
|
||||||
const cost = dayCost(assignments, day.id, loc)
|
const cost = dayCost(assignments, day.id, loc)
|
||||||
|
|
||||||
// Reservations for this day (hotel rendered via accommodations block)
|
// Reservations for this day (hotel rendered via accommodations block; car middle-phase rendered in sidebar header only)
|
||||||
const dayReservations = (reservations || []).filter(r => {
|
const dayReservations = pdfGetTransportForDay(day.id)
|
||||||
if (!r.reservation_time || r.type === 'hotel') return false
|
.filter(r => !(r.type === 'car' && pdfGetSpanPhase(r, day.id) === 'middle'))
|
||||||
return day.date && r.reservation_time.split('T')[0] === day.date
|
|
||||||
})
|
|
||||||
|
|
||||||
const merged = []
|
const merged = []
|
||||||
assigned.forEach(a => merged.push({ type: 'place', k: a.order_index ?? a.sort_order ?? 0, data: a }))
|
assigned.forEach(a => merged.push({ type: 'place', k: a.order_index ?? a.sort_order ?? 0, data: a }))
|
||||||
notes.forEach(n => merged.push({ type: 'note', k: n.sort_order ?? 0, data: n }))
|
notes.forEach(n => merged.push({ type: 'note', k: n.sort_order ?? 0, data: n }))
|
||||||
dayReservations.forEach(r => {
|
dayReservations.forEach(r => {
|
||||||
const pos = r.day_plan_position ?? (merged.length > 0 ? Math.max(...merged.map(m => m.k)) + 0.5 : 0.5)
|
const pos = r.day_positions?.[day.id] ?? r.day_positions?.[String(day.id)] ?? r.day_plan_position ?? (merged.length > 0 ? Math.max(...merged.map(m => m.k)) + 0.5 : 0.5)
|
||||||
merged.push({ type: 'reservation', k: pos, data: r })
|
merged.push({ type: 'reservation', k: pos, data: r })
|
||||||
})
|
})
|
||||||
merged.sort((a, b) => a.k - b.k)
|
merged.sort((a, b) => a.k - b.k)
|
||||||
@@ -177,13 +212,17 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
|||||||
else if (r.type === 'event') subtitle = [meta.venue].filter(Boolean).join(' · ')
|
else if (r.type === 'event') subtitle = [meta.venue].filter(Boolean).join(' · ')
|
||||||
else if (r.type === 'tour') subtitle = [meta.operator].filter(Boolean).join(' · ')
|
else if (r.type === 'tour') subtitle = [meta.operator].filter(Boolean).join(' · ')
|
||||||
const locationLine = r.location || meta.location || ''
|
const locationLine = r.location || meta.location || ''
|
||||||
const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : ''
|
const phase = pdfGetSpanPhase(r, day.id)
|
||||||
|
const spanLabel = pdfGetSpanLabel(r, phase)
|
||||||
|
const displayTime = pdfGetDisplayTime(r, day.id)
|
||||||
|
const time = displayTime?.includes('T') ? displayTime.split('T')[1]?.substring(0, 5) : ''
|
||||||
|
const titleHtml = `${spanLabel ? escHtml(spanLabel) + ': ' : ''}${escHtml(r.title)}`
|
||||||
return `
|
return `
|
||||||
<div class="note-card" style="border-left: 3px solid ${color};">
|
<div class="note-card" style="border-left: 3px solid ${color};">
|
||||||
<div class="note-line" style="background: ${color};"></div>
|
<div class="note-line" style="background: ${color};"></div>
|
||||||
<span class="note-icon">${icon}</span>
|
<span class="note-icon">${icon}</span>
|
||||||
<div class="note-body">
|
<div class="note-body">
|
||||||
<div class="note-text" style="font-weight: 600;">${escHtml(r.title)}${time ? ` <span style="color:#6b7280;font-weight:400;font-size:10px;">${time}</span>` : ''}</div>
|
<div class="note-text" style="font-weight: 600;">${titleHtml}${time ? ` <span style="color:#6b7280;font-weight:400;font-size:10px;">${time}</span>` : ''}</div>
|
||||||
${subtitle ? `<div class="note-time">${escHtml(subtitle)}</div>` : ''}
|
${subtitle ? `<div class="note-time">${escHtml(subtitle)}</div>` : ''}
|
||||||
${locationLine ? `<div class="note-time">${escHtml(locationLine)}</div>` : ''}
|
${locationLine ? `<div class="note-time">${escHtml(locationLine)}</div>` : ''}
|
||||||
${r.confirmation_number ? `<div class="note-time" style="font-size:9px;">Code: ${escHtml(r.confirmation_number)}</div>` : ''}
|
${r.confirmation_number ? `<div class="note-time" style="font-size:9px;">Code: ${escHtml(r.confirmation_number)}</div>` : ''}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export default function ConfirmDialog({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[10000] flex items-center justify-center px-4 trek-backdrop-enter"
|
className="fixed inset-0 z-[10000] flex items-center justify-center px-4 trek-backdrop-enter"
|
||||||
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)' }}
|
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingBottom: 'var(--bottom-nav-h)' }}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export default function CopyTripDialog({ isOpen, tripTitle, onClose, onConfirm }
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[10000] flex items-center justify-center px-4 trek-backdrop-enter"
|
className="fixed inset-0 z-[10000] flex items-center justify-center px-4 trek-backdrop-enter"
|
||||||
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)' }}
|
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingBottom: 'var(--bottom-nav-h)' }}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -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 });
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -355,6 +355,37 @@ describe('journeyStore', () => {
|
|||||||
expect(useJourneyStore.getState().loading).toBe(false);
|
expect(useJourneyStore.getState().loading).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── reorderEntries ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('FE-STORE-JOURNEY-018: reorderEntries reorders by sort_order not entry_time', async () => {
|
||||||
|
const a = buildEntry({ id: 201, entry_date: '2026-04-01', entry_time: '09:00', sort_order: 0 });
|
||||||
|
const b = buildEntry({ id: 202, entry_date: '2026-04-01', entry_time: '11:00', sort_order: 1 });
|
||||||
|
const c = buildEntry({ id: 203, entry_date: '2026-04-01', entry_time: '14:00', sort_order: 2 });
|
||||||
|
const detail = buildJourneyDetail({ id: 55, entries: [a, b, c] });
|
||||||
|
useJourneyStore.setState({ current: detail });
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.put('/api/journeys/55/entries/reorder', () => HttpResponse.json({ success: true }))
|
||||||
|
);
|
||||||
|
await useJourneyStore.getState().reorderEntries(55, [202, 201, 203]);
|
||||||
|
const ids = useJourneyStore.getState().current?.entries.map(e => e.id);
|
||||||
|
expect(ids).toEqual([202, 201, 203]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-STORE-JOURNEY-019: reorderEntries rolls back on API failure', async () => {
|
||||||
|
const a = buildEntry({ id: 211, entry_date: '2026-04-01', sort_order: 0 });
|
||||||
|
const b = buildEntry({ id: 212, entry_date: '2026-04-01', sort_order: 1 });
|
||||||
|
const detail = buildJourneyDetail({ id: 56, entries: [a, b] });
|
||||||
|
useJourneyStore.setState({ current: detail });
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.put('/api/journeys/56/entries/reorder', () => HttpResponse.json({}, { status: 403 }))
|
||||||
|
);
|
||||||
|
await expect(useJourneyStore.getState().reorderEntries(56, [212, 211])).rejects.toBeTruthy();
|
||||||
|
const ids = useJourneyStore.getState().current?.entries.map(e => e.id);
|
||||||
|
expect(ids).toEqual([211, 212]);
|
||||||
|
});
|
||||||
|
|
||||||
// ── clear ────────────────────────────────────────────────────────────────
|
// ── clear ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
it('FE-STORE-JOURNEY-015: clear resets state', () => {
|
it('FE-STORE-JOURNEY-015: clear resets state', () => {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -201,10 +223,8 @@ export const useJourneyStore = create<JourneyState>((set, get) => ({
|
|||||||
)
|
)
|
||||||
entries.sort((a, b) => {
|
entries.sort((a, b) => {
|
||||||
if (a.entry_date !== b.entry_date) return a.entry_date.localeCompare(b.entry_date)
|
if (a.entry_date !== b.entry_date) return a.entry_date.localeCompare(b.entry_date)
|
||||||
const atime = a.entry_time || ''
|
if (a.sort_order !== b.sort_order) return (a.sort_order || 0) - (b.sort_order || 0)
|
||||||
const btime = b.entry_time || ''
|
return a.id - b.id
|
||||||
if (atime !== btime) return atime.localeCompare(btime)
|
|
||||||
return (a.sort_order || 0) - (b.sort_order || 0)
|
|
||||||
})
|
})
|
||||||
return { current: { ...s.current, entries } }
|
return { current: { ...s.current, entries } }
|
||||||
})
|
})
|
||||||
@@ -228,12 +248,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 => {
|
||||||
|
|||||||
@@ -32,6 +32,13 @@ function triggerAnchorDownload(blobUrl: string, filename?: string): void {
|
|||||||
setTimeout(() => { URL.revokeObjectURL(blobUrl); a.remove() }, 100)
|
setTimeout(() => { URL.revokeObjectURL(blobUrl); a.remove() }, 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// navigator.standalone is true only on iOS when running as an
|
||||||
|
// add-to-home-screen PWA. In that context, target="_blank" hands off to
|
||||||
|
// Safari, which cannot access blob URLs sandboxed to the WebView.
|
||||||
|
function isIosStandalone(): boolean {
|
||||||
|
return (navigator as any).standalone === true
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches a protected file using cookie auth (credentials: include) and
|
* Fetches a protected file using cookie auth (credentials: include) and
|
||||||
* triggers a browser download. Works inside PWA standalone mode because the
|
* triggers a browser download. Works inside PWA standalone mode because the
|
||||||
@@ -56,7 +63,13 @@ export async function downloadFile(url: string, filename?: string): Promise<void
|
|||||||
* (including text/html and image/svg+xml which can execute script) are forced
|
* (including text/html and image/svg+xml which can execute script) are forced
|
||||||
* to download so that an uploaded file cannot run code in the TREK origin.
|
* to download so that an uploaded file cannot run code in the TREK origin.
|
||||||
*
|
*
|
||||||
* Falls back to a download trigger if the popup is blocked.
|
* Uses a synthetic <a target="_blank" rel="noopener noreferrer"> click rather
|
||||||
|
* than window.open(). window.open() called with the "noreferrer"/"noopener"
|
||||||
|
* window feature returns null per spec, which previously made the popup-block
|
||||||
|
* fallback trigger a download in the *current* tab on top of the new-tab open
|
||||||
|
* — i.e. the file opened twice. The anchor approach avoids that ambiguity:
|
||||||
|
* the new tab is opened by the browser's normal link-handling path, and no
|
||||||
|
* spurious in-page download is triggered.
|
||||||
*/
|
*/
|
||||||
export async function openFile(url: string, filename?: string): Promise<void> {
|
export async function openFile(url: string, filename?: string): Promise<void> {
|
||||||
assertRelativeUrl(url)
|
assertRelativeUrl(url)
|
||||||
@@ -71,11 +84,19 @@ export async function openFile(url: string, filename?: string): Promise<void> {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const win = window.open(blobUrl, '_blank', 'noreferrer')
|
// iOS PWA: target="_blank" would open Safari, which can't access the blob
|
||||||
if (win) {
|
if (isIosStandalone()) {
|
||||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 30_000)
|
|
||||||
} else {
|
|
||||||
// Popup blocked — fall back to download
|
|
||||||
triggerAnchorDownload(blobUrl, filename)
|
triggerAnchorDownload(blobUrl, filename)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = blobUrl
|
||||||
|
a.target = '_blank'
|
||||||
|
a.rel = 'noopener noreferrer'
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
// Keep the blob URL alive long enough for the new tab to load it, then
|
||||||
|
// clean up the DOM node and revoke the URL.
|
||||||
|
setTimeout(() => { URL.revokeObjectURL(blobUrl); a.remove() }, 30_000)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,32 +74,42 @@ describe('downloadFile', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('openFile', () => {
|
describe('openFile', () => {
|
||||||
it('fetches with credentials:include and opens blob URL in new tab', async () => {
|
it('fetches with credentials:include and opens blob URL via target=_blank anchor', async () => {
|
||||||
vi.stubGlobal('fetch', makeFetchMock(200))
|
vi.stubGlobal('fetch', makeFetchMock(200))
|
||||||
const mockWin = { closed: false }
|
const openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
|
||||||
const openSpy = vi.spyOn(window, 'open').mockReturnValue(mockWin as Window)
|
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||||
|
|
||||||
await openFile('/uploads/files/doc.pdf')
|
await openFile('/uploads/files/doc.pdf')
|
||||||
|
|
||||||
expect(window.fetch).toHaveBeenCalledWith('/uploads/files/doc.pdf', { credentials: 'include' })
|
expect(window.fetch).toHaveBeenCalledWith('/uploads/files/doc.pdf', { credentials: 'include' })
|
||||||
expect(URL.createObjectURL).toHaveBeenCalled()
|
expect(URL.createObjectURL).toHaveBeenCalled()
|
||||||
expect(openSpy).toHaveBeenCalledWith('blob:mock-url', '_blank', 'noreferrer')
|
// Must NOT call window.open — that path returns null when noreferrer is
|
||||||
|
// set, which previously caused the file to also open in the current tab.
|
||||||
|
expect(openSpy).not.toHaveBeenCalled()
|
||||||
|
expect(clickSpy).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
// The anchor used to open the new tab must be target=_blank, must NOT
|
||||||
|
// carry a `download` attribute (otherwise it would download in-page
|
||||||
|
// instead of opening), and must use rel=noopener noreferrer.
|
||||||
|
const appendCalls = (document.body.appendChild as ReturnType<typeof vi.fn>).mock.calls
|
||||||
|
const anchor = appendCalls[0]?.[0] as HTMLAnchorElement
|
||||||
|
expect(anchor.target).toBe('_blank')
|
||||||
|
expect(anchor.rel).toBe('noopener noreferrer')
|
||||||
|
expect(anchor.hasAttribute('download')).toBe(false)
|
||||||
|
|
||||||
// Revoke happens after 30s timeout
|
// Revoke happens after 30s timeout
|
||||||
vi.runAllTimers()
|
vi.runAllTimers()
|
||||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url')
|
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('falls back to anchor download when popup is blocked', async () => {
|
it('does not trigger a second in-page action for safe inline types (regression: no double-open)', async () => {
|
||||||
vi.stubGlobal('fetch', makeFetchMock(200))
|
vi.stubGlobal('fetch', makeFetchMock(200))
|
||||||
vi.spyOn(window, 'open').mockReturnValue(null)
|
|
||||||
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||||
|
|
||||||
await openFile('/uploads/files/doc.pdf')
|
await openFile('/uploads/files/doc.pdf', 'doc.pdf')
|
||||||
|
|
||||||
expect(clickSpy).toHaveBeenCalled()
|
// Exactly ONE anchor click — opening the new tab. No fallback download.
|
||||||
vi.runAllTimers()
|
expect(clickSpy).toHaveBeenCalledTimes(1)
|
||||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('throws on 401 response', async () => {
|
it('throws on 401 response', async () => {
|
||||||
@@ -108,28 +118,55 @@ describe('openFile', () => {
|
|||||||
expect(URL.createObjectURL).not.toHaveBeenCalled()
|
expect(URL.createObjectURL).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('forces download for unsafe MIME types (HTML, SVG) instead of opening inline', async () => {
|
it('forces download for unsafe MIME types (HTML) instead of opening inline', async () => {
|
||||||
const htmlBlob = new Blob(['<script>alert(1)</script>'], { type: 'text/html' })
|
const htmlBlob = new Blob(['<script>alert(1)</script>'], { type: 'text/html' })
|
||||||
vi.stubGlobal('fetch', makeFetchMock(200, htmlBlob))
|
vi.stubGlobal('fetch', makeFetchMock(200, htmlBlob))
|
||||||
const openSpy = vi.spyOn(window, 'open').mockReturnValue({} as Window)
|
const openSpy = vi.spyOn(window, 'open').mockReturnValue({} as Window)
|
||||||
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||||
|
|
||||||
await openFile('/uploads/files/malicious.html')
|
await openFile('/uploads/files/malicious.html', 'malicious.html')
|
||||||
|
|
||||||
// Must NOT open inline — download anchor clicked instead
|
// Must NOT open inline — download anchor clicked instead
|
||||||
expect(openSpy).not.toHaveBeenCalled()
|
expect(openSpy).not.toHaveBeenCalled()
|
||||||
expect(clickSpy).toHaveBeenCalled()
|
expect(clickSpy).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
const appendCalls = (document.body.appendChild as ReturnType<typeof vi.fn>).mock.calls
|
||||||
|
const anchor = appendCalls[0]?.[0] as HTMLAnchorElement
|
||||||
|
expect(anchor.download).toBe('malicious.html')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('forces download for SVG MIME type', async () => {
|
it('forces download for SVG MIME type', async () => {
|
||||||
const svgBlob = new Blob(['<svg><script>alert(1)</script></svg>'], { type: 'image/svg+xml' })
|
const svgBlob = new Blob(['<svg><script>alert(1)</script></svg>'], { type: 'image/svg+xml' })
|
||||||
vi.stubGlobal('fetch', makeFetchMock(200, svgBlob))
|
vi.stubGlobal('fetch', makeFetchMock(200, svgBlob))
|
||||||
vi.spyOn(window, 'open').mockReturnValue({} as Window)
|
const openSpy = vi.spyOn(window, 'open').mockReturnValue({} as Window)
|
||||||
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||||
|
|
||||||
await openFile('/uploads/files/malicious.svg')
|
await openFile('/uploads/files/malicious.svg')
|
||||||
|
|
||||||
expect(window.open).not.toHaveBeenCalled()
|
expect(openSpy).not.toHaveBeenCalled()
|
||||||
expect(clickSpy).toHaveBeenCalled()
|
expect(clickSpy).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to download in iOS PWA standalone mode (blob URL inaccessible to Safari)', async () => {
|
||||||
|
vi.stubGlobal('fetch', makeFetchMock(200))
|
||||||
|
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||||
|
// Simulate iOS PWA (Add-to-Home-Screen) context
|
||||||
|
Object.defineProperty(navigator, 'standalone', { configurable: true, value: true })
|
||||||
|
|
||||||
|
try {
|
||||||
|
await openFile('/uploads/files/doc.pdf', 'doc.pdf')
|
||||||
|
|
||||||
|
// Single anchor click — and it must be a DOWNLOAD anchor (no target=_blank),
|
||||||
|
// because target="_blank" in iOS PWA would hand off to Safari which cannot
|
||||||
|
// read the in-WebView blob URL.
|
||||||
|
expect(clickSpy).toHaveBeenCalledTimes(1)
|
||||||
|
const appendCalls = (document.body.appendChild as ReturnType<typeof vi.fn>).mock.calls
|
||||||
|
const anchor = appendCalls[0]?.[0] as HTMLAnchorElement
|
||||||
|
expect(anchor.target).toBe('')
|
||||||
|
expect(anchor.download).toBe('doc.pdf')
|
||||||
|
} finally {
|
||||||
|
// Clean up the non-standard iOS-only property we forced above.
|
||||||
|
delete (navigator as any).standalone
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Generated
+554
-14
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "trek-server",
|
"name": "trek-server",
|
||||||
"version": "2.9.14",
|
"version": "3.0.7",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "trek-server",
|
"name": "trek-server",
|
||||||
"version": "2.9.14",
|
"version": "3.0.7",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.28.0",
|
"@modelcontextprotocol/sdk": "^1.28.0",
|
||||||
"archiver": "^6.0.1",
|
"archiver": "^6.0.1",
|
||||||
@@ -25,11 +25,12 @@
|
|||||||
"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",
|
||||||
"unzipper": "^0.12.3",
|
"unzipper": "^0.12.3",
|
||||||
"uuid": "^9.0.0",
|
"uuid": "^14.0.0",
|
||||||
"ws": "^8.19.0",
|
"ws": "^8.19.0",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
@@ -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,471 @@
|
|||||||
"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"
|
||||||
|
],
|
||||||
|
"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"
|
||||||
|
],
|
||||||
|
"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"
|
||||||
|
],
|
||||||
|
"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"
|
||||||
|
],
|
||||||
|
"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"
|
||||||
|
],
|
||||||
|
"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"
|
||||||
|
],
|
||||||
|
"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"
|
||||||
|
],
|
||||||
|
"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"
|
||||||
|
],
|
||||||
|
"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"
|
||||||
|
],
|
||||||
|
"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"
|
||||||
|
],
|
||||||
|
"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"
|
||||||
|
],
|
||||||
|
"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"
|
||||||
|
],
|
||||||
|
"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"
|
||||||
|
],
|
||||||
|
"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"
|
||||||
|
],
|
||||||
|
"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"
|
||||||
|
],
|
||||||
|
"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"
|
||||||
|
],
|
||||||
|
"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",
|
||||||
@@ -1027,6 +1503,18 @@
|
|||||||
"url": "https://paulmillr.com/funding/"
|
"url": "https://paulmillr.com/funding/"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@nodable/entities": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/nodable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@otplib/core": {
|
"node_modules/@otplib/core": {
|
||||||
"version": "12.0.1",
|
"version": "12.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@otplib/core/-/core-12.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@otplib/core/-/core-12.0.1.tgz",
|
||||||
@@ -3243,9 +3731,9 @@
|
|||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
"node_modules/fast-xml-builder": {
|
"node_modules/fast-xml-builder": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.5.tgz",
|
||||||
"integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==",
|
"integrity": "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -3258,9 +3746,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fast-xml-parser": {
|
"node_modules/fast-xml-parser": {
|
||||||
"version": "5.5.12",
|
"version": "5.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.12.tgz",
|
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.1.tgz",
|
||||||
"integrity": "sha512-nUR0q8PPfoA/svPM43Gup7vLOZWppaNrYgGmrVqrAVJa7cOH4hMG6FX9M4mQ8dZA1/ObGZHzES7Ed88hxEBSJg==",
|
"integrity": "sha512-8Cc3f8GUGUULg34pBch/KGyPLglS+OFs05deyOlY7fL2MTagYPKrVQNmR1fLF/yJ9PH5ZSTd3YDF6pnmeZU+zA==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -3269,7 +3757,8 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-xml-builder": "^1.1.4",
|
"@nodable/entities": "^2.1.0",
|
||||||
|
"fast-xml-builder": "^1.1.5",
|
||||||
"path-expression-matcher": "^1.5.0",
|
"path-expression-matcher": "^1.5.0",
|
||||||
"strnum": "^2.2.3"
|
"strnum": "^2.2.3"
|
||||||
},
|
},
|
||||||
@@ -5083,6 +5572,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 +6299,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",
|
||||||
@@ -5906,16 +6446,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/uuid": {
|
"node_modules/uuid": {
|
||||||
"version": "9.0.1",
|
"version": "14.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz",
|
||||||
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
|
"integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==",
|
||||||
"funding": [
|
"funding": [
|
||||||
"https://github.com/sponsors/broofa",
|
"https://github.com/sponsors/broofa",
|
||||||
"https://github.com/sponsors/ctavan"
|
"https://github.com/sponsors/ctavan"
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"uuid": "dist/bin/uuid"
|
"uuid": "dist-node/bin/uuid"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vary": {
|
"node_modules/vary": {
|
||||||
|
|||||||
+3
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "trek-server",
|
"name": "trek-server",
|
||||||
"version": "2.9.14",
|
"version": "3.0.7",
|
||||||
"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,11 +30,12 @@
|
|||||||
"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",
|
||||||
"unzipper": "^0.12.3",
|
"unzipper": "^0.12.3",
|
||||||
"uuid": "^9.0.0",
|
"uuid": "^14.0.0",
|
||||||
"ws": "^8.19.0",
|
"ws": "^8.19.0",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
|
|||||||
+4
-2
@@ -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;
|
||||||
|
|||||||
@@ -1946,6 +1946,190 @@ 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)
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
// #846: make sort_order authoritative within a day. Previous ORDER BY put
|
||||||
|
// entry_time before sort_order, silently ignoring reorder clicks when two
|
||||||
|
// same-date entries had different times. Backfill renumbers using the old
|
||||||
|
// effective key (entry_time ASC, id ASC) so existing journeys retain their
|
||||||
|
// current visual order.
|
||||||
|
() => {
|
||||||
|
db.exec(`
|
||||||
|
WITH ranked AS (
|
||||||
|
SELECT id,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY journey_id, entry_date
|
||||||
|
ORDER BY entry_time ASC, id ASC
|
||||||
|
) - 1 AS rn
|
||||||
|
FROM journey_entries
|
||||||
|
)
|
||||||
|
UPDATE journey_entries
|
||||||
|
SET sort_order = (SELECT rn FROM ranked WHERE ranked.id = journey_entries.id)
|
||||||
|
`);
|
||||||
|
db.exec(
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_journey_entries_order ' +
|
||||||
|
'ON journey_entries(journey_id, entry_date, sort_order)'
|
||||||
|
);
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (currentVersion < migrations.length) {
|
if (currentVersion < migrations.length) {
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ router.get('/callback', async (req: Request, res: Response) => {
|
|||||||
tokenData.id_token,
|
tokenData.id_token,
|
||||||
doc,
|
doc,
|
||||||
config.clientId,
|
config.clientId,
|
||||||
config.issuer,
|
(doc.issuer ?? '').replace(/\/+$/, '') || config.issuer,
|
||||||
);
|
);
|
||||||
if (idVerify.ok !== true) {
|
if (idVerify.ok !== true) {
|
||||||
const reason = 'error' in idVerify ? idVerify.error : 'unknown';
|
const reason = 'error' in idVerify ? idVerify.error : 'unknown';
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -110,11 +120,11 @@ export function getJourneyFull(journeyId: number, userId: number) {
|
|||||||
if (!journey) return null;
|
if (!journey) return null;
|
||||||
|
|
||||||
const entries = db.prepare(
|
const entries = db.prepare(
|
||||||
'SELECT * FROM journey_entries WHERE journey_id = ? ORDER BY entry_date ASC, entry_time ASC, sort_order ASC'
|
'SELECT * FROM journey_entries WHERE journey_id = ? ORDER BY entry_date ASC, sort_order ASC, id ASC'
|
||||||
).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 },
|
||||||
@@ -296,12 +306,21 @@ export function syncTripPlaces(journeyId: number, tripId: number, authorId: numb
|
|||||||
).all(journeyId, tripId) as { source_place_id: number }[];
|
).all(journeyId, tripId) as { source_place_id: number }[];
|
||||||
const existingPlaceIds = new Set(existing.map(e => e.source_place_id));
|
const existingPlaceIds = new Set(existing.map(e => e.source_place_id));
|
||||||
|
|
||||||
|
// Track next sort_order per date so synced skeletons get unique, sequential positions.
|
||||||
|
const dateMaxOrder = new Map<string, number>();
|
||||||
|
const maxRows = db.prepare(
|
||||||
|
'SELECT entry_date, COALESCE(MAX(sort_order), -1) AS m FROM journey_entries WHERE journey_id = ? GROUP BY entry_date'
|
||||||
|
).all(journeyId) as { entry_date: string; m: number }[];
|
||||||
|
for (const row of maxRows) dateMaxOrder.set(row.entry_date, row.m);
|
||||||
|
|
||||||
for (const place of places) {
|
for (const place of places) {
|
||||||
if (existingPlaceIds.has(place.id)) continue;
|
if (existingPlaceIds.has(place.id)) continue;
|
||||||
existingPlaceIds.add(place.id);
|
existingPlaceIds.add(place.id);
|
||||||
|
|
||||||
const entryDate = place.day_date || new Date().toISOString().split('T')[0];
|
const entryDate = place.day_date || new Date().toISOString().split('T')[0];
|
||||||
const entryTime = place.assignment_time || place.place_time || null;
|
const entryTime = place.assignment_time || place.place_time || null;
|
||||||
|
const nextOrder = (dateMaxOrder.get(entryDate) ?? -1) + 1;
|
||||||
|
dateMaxOrder.set(entryDate, nextOrder);
|
||||||
|
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
INSERT INTO journey_entries (journey_id, source_trip_id, source_place_id, author_id, type, title, entry_date, entry_time, location_name, location_lat, location_lng, sort_order, created_at, updated_at)
|
INSERT INTO journey_entries (journey_id, source_trip_id, source_place_id, author_id, type, title, entry_date, entry_time, location_name, location_lat, location_lng, sort_order, created_at, updated_at)
|
||||||
@@ -310,51 +329,27 @@ export function syncTripPlaces(journeyId: number, tripId: number, authorId: numb
|
|||||||
journeyId, tripId, place.id, authorId,
|
journeyId, tripId, place.id, authorId,
|
||||||
place.name, entryDate, entryTime,
|
place.name, entryDate, entryTime,
|
||||||
place.address || place.name, place.lat || null, place.lng || null,
|
place.address || place.name, place.lat || null, place.lng || null,
|
||||||
place.day_number || 0, now, now
|
nextOrder, now, now
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,15 +376,19 @@ export function onPlaceCreated(tripId: number, placeId: number) {
|
|||||||
|
|
||||||
const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(link.journey_id) as { user_id: number };
|
const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(link.journey_id) as { user_id: number };
|
||||||
const entryDate = place.day_date;
|
const entryDate = place.day_date;
|
||||||
|
const maxOrder = db.prepare(
|
||||||
|
'SELECT MAX(sort_order) AS m FROM journey_entries WHERE journey_id = ? AND entry_date = ?'
|
||||||
|
).get(link.journey_id, entryDate) as { m: number | null };
|
||||||
|
const nextOrder = (maxOrder?.m ?? -1) + 1;
|
||||||
|
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
INSERT INTO journey_entries (journey_id, source_trip_id, source_place_id, author_id, type, title, entry_date, entry_time, location_name, location_lat, location_lng, sort_order, created_at, updated_at)
|
INSERT INTO journey_entries (journey_id, source_trip_id, source_place_id, author_id, type, title, entry_date, entry_time, location_name, location_lat, location_lng, sort_order, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, 'skeleton', ?, ?, ?, ?, ?, ?, 0, ?, ?)
|
VALUES (?, ?, ?, ?, 'skeleton', ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(
|
`).run(
|
||||||
link.journey_id, tripId, placeId, journey.user_id,
|
link.journey_id, tripId, placeId, journey.user_id,
|
||||||
place.name, entryDate, place.assignment_time || place.place_time || null,
|
place.name, entryDate, place.assignment_time || place.place_time || null,
|
||||||
place.address || place.name, place.lat || null, place.lng || null,
|
place.address || place.name, place.lat || null, place.lng || null,
|
||||||
now, now
|
nextOrder, now, now
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -444,7 +443,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;
|
||||||
@@ -465,11 +464,11 @@ export function listEntries(journeyId: number, userId: number) {
|
|||||||
if (!canAccessJourney(journeyId, userId)) return null;
|
if (!canAccessJourney(journeyId, userId)) return null;
|
||||||
|
|
||||||
const entries = db.prepare(
|
const entries = db.prepare(
|
||||||
'SELECT * FROM journey_entries WHERE journey_id = ? ORDER BY entry_date ASC, entry_time ASC, sort_order ASC'
|
'SELECT * FROM journey_entries WHERE journey_id = ? ORDER BY entry_date ASC, sort_order ASC, id ASC'
|
||||||
).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 +627,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 +641,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 +654,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 +697,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 ─────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -140,11 +140,21 @@ export async function discover(issuer: string, discoveryUrl?: string | null): Pr
|
|||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
if (!res.ok) throw new Error('Failed to fetch OIDC discovery document');
|
if (!res.ok) throw new Error('Failed to fetch OIDC discovery document');
|
||||||
const doc = (await res.json()) as OidcDiscoveryDoc;
|
const doc = (await res.json()) as OidcDiscoveryDoc;
|
||||||
// Validate that the discovery doc's issuer matches the operator-configured
|
// Validate that the discovery doc's issuer matches the operator-configured one.
|
||||||
// one. A MITM or compromised doc could otherwise supply a crafted issuer
|
// When no custom discoveryUrl is set, a mismatch signals a MITM or misconfiguration
|
||||||
// that passes jwt.verify() because we used doc.issuer as the expected value.
|
// and we reject. When the operator explicitly overrides the discovery URL (e.g.
|
||||||
if (doc.issuer && doc.issuer !== issuer) {
|
// Authentik realm paths), the discovery doc's issuer is the canonical value —
|
||||||
throw new Error(`OIDC discovery issuer mismatch: expected "${issuer}", got "${doc.issuer}"`);
|
// trust it and warn rather than blocking login.
|
||||||
|
const docIssuer = doc.issuer?.replace(/\/+$/, '') ?? '';
|
||||||
|
if (docIssuer && docIssuer !== issuer) {
|
||||||
|
if (discoveryUrl) {
|
||||||
|
console.warn(
|
||||||
|
`[OIDC] Discovery doc issuer "${doc.issuer}" differs from configured OIDC_ISSUER "${issuer}". ` +
|
||||||
|
`Using discovery doc issuer for id_token verification (custom OIDC_DISCOVERY_URL is set).`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw new Error(`OIDC discovery issuer mismatch: expected "${issuer}", got "${doc.issuer}"`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
doc._issuer = url;
|
doc._issuer = url;
|
||||||
discoveryCache = doc;
|
discoveryCache = doc;
|
||||||
@@ -313,7 +323,6 @@ export async function verifyIdToken(
|
|||||||
try {
|
try {
|
||||||
const verified = jwt.verify(idToken, publicKey, {
|
const verified = jwt.verify(idToken, publicKey, {
|
||||||
algorithms: [alg as jwt.Algorithm],
|
algorithms: [alg as jwt.Algorithm],
|
||||||
issuer: expectedIssuer,
|
|
||||||
audience: clientId,
|
audience: clientId,
|
||||||
});
|
});
|
||||||
claims = typeof verified === 'string' ? {} : (verified as Record<string, unknown>);
|
claims = typeof verified === 'string' ? {} : (verified as Record<string, unknown>);
|
||||||
@@ -322,6 +331,13 @@ export async function verifyIdToken(
|
|||||||
return { ok: false, error: `signature_or_claim_mismatch: ${msg}` };
|
return { ok: false, error: `signature_or_claim_mismatch: ${msg}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Normalize trailing slash before issuer comparison — some IdPs (e.g. Authentik)
|
||||||
|
// include a trailing slash in the id_token iss claim.
|
||||||
|
const tokenIssuer = typeof claims['iss'] === 'string' ? claims['iss'].replace(/\/+$/, '') : '';
|
||||||
|
if (tokenIssuer !== expectedIssuer) {
|
||||||
|
return { ok: false, error: `signature_or_claim_mismatch: jwt issuer invalid. expected: ${expectedIssuer}` };
|
||||||
|
}
|
||||||
|
|
||||||
return { ok: true, claims };
|
return { ok: true, claims };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ import {
|
|||||||
removeContributor,
|
removeContributor,
|
||||||
getSuggestions,
|
getSuggestions,
|
||||||
syncTripPlaces,
|
syncTripPlaces,
|
||||||
|
reorderEntries,
|
||||||
onPlaceCreated,
|
onPlaceCreated,
|
||||||
onPlaceUpdated,
|
onPlaceUpdated,
|
||||||
onPlaceDeleted,
|
onPlaceDeleted,
|
||||||
@@ -1325,9 +1326,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 +1397,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');
|
||||||
});
|
});
|
||||||
@@ -1469,3 +1466,108 @@ describe('addProviderPhoto — passphrase', () => {
|
|||||||
expect(row?.passphrase).not.toBe('secret-pp');
|
expect(row?.passphrase).not.toBe('secret-pp');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// -- reorderEntries (#846) ----------------------------------------------------
|
||||||
|
|
||||||
|
function insertEntry(journeyId: number, authorId: number, opts: { entry_date: string; entry_time?: string | null; sort_order?: number }): { id: number } {
|
||||||
|
const now = Date.now();
|
||||||
|
const res = testDb.prepare(`
|
||||||
|
INSERT INTO journey_entries (journey_id, author_id, type, entry_date, entry_time, sort_order, visibility, created_at, updated_at)
|
||||||
|
VALUES (?, ?, 'entry', ?, ?, ?, 'private', ?, ?)
|
||||||
|
`).run(journeyId, authorId, opts.entry_date, opts.entry_time ?? null, opts.sort_order ?? 0, now, now);
|
||||||
|
return { id: Number(res.lastInsertRowid) };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('reorderEntries', () => {
|
||||||
|
it('JOURNEY-SVC-089: reorder persists and listEntries returns requested order regardless of entry_time', () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const journey = createJourney(testDb, user.id);
|
||||||
|
const e1 = insertEntry(journey.id, user.id, { entry_date: '2026-08-01', entry_time: '09:00', sort_order: 0 });
|
||||||
|
const e2 = insertEntry(journey.id, user.id, { entry_date: '2026-08-01', entry_time: '14:00', sort_order: 1 });
|
||||||
|
|
||||||
|
const ok = reorderEntries(journey.id, user.id, [e2.id, e1.id]);
|
||||||
|
expect(ok).toBe(true);
|
||||||
|
|
||||||
|
const entries = listEntries(journey.id, user.id)!;
|
||||||
|
const dayEntries = entries.filter(e => e.entry_date === '2026-08-01');
|
||||||
|
expect(dayEntries.map(e => e.id)).toEqual([e2.id, e1.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('JOURNEY-SVC-090: reorderEntries rejects ids from another journey', () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const j1 = createJourney(testDb, user.id);
|
||||||
|
const j2 = createJourney(testDb, user.id);
|
||||||
|
const entry = createJourneyEntry(testDb, j2.id, user.id, { entry_date: '2026-08-02' });
|
||||||
|
|
||||||
|
const ok = reorderEntries(j1.id, user.id, [entry.id]);
|
||||||
|
expect(ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('JOURNEY-SVC-091: reorderEntries does not affect entries on other days', () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const journey = createJourney(testDb, user.id);
|
||||||
|
const day1a = insertEntry(journey.id, user.id, { entry_date: '2026-08-01', sort_order: 0 });
|
||||||
|
const day1b = insertEntry(journey.id, user.id, { entry_date: '2026-08-01', sort_order: 1 });
|
||||||
|
const day2 = insertEntry(journey.id, user.id, { entry_date: '2026-08-02', sort_order: 0 });
|
||||||
|
|
||||||
|
reorderEntries(journey.id, user.id, [day1b.id, day1a.id]);
|
||||||
|
|
||||||
|
const entries = listEntries(journey.id, user.id)!;
|
||||||
|
const day2Entry = entries.find(e => e.id === day2.id)!;
|
||||||
|
expect(day2Entry.sort_order).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('syncTripPlaces sort_order', () => {
|
||||||
|
it('JOURNEY-SVC-092: assigns unique sequential sort_order per date for same-day places', () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const journey = createJourney(testDb, user.id);
|
||||||
|
const trip = createTrip(testDb, user.id, {
|
||||||
|
title: 'Order Trip',
|
||||||
|
start_date: '2026-09-01',
|
||||||
|
end_date: '2026-09-02',
|
||||||
|
});
|
||||||
|
const day = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number };
|
||||||
|
const p1 = createPlace(testDb, trip.id, { name: 'Place A' });
|
||||||
|
const p2 = createPlace(testDb, trip.id, { name: 'Place B' });
|
||||||
|
const p3 = createPlace(testDb, trip.id, { name: 'Place C' });
|
||||||
|
createDayAssignment(testDb, day.id, p1.id);
|
||||||
|
createDayAssignment(testDb, day.id, p2.id);
|
||||||
|
createDayAssignment(testDb, day.id, p3.id);
|
||||||
|
|
||||||
|
syncTripPlaces(journey.id, trip.id, user.id);
|
||||||
|
|
||||||
|
const rows = testDb.prepare(
|
||||||
|
'SELECT sort_order FROM journey_entries WHERE journey_id = ? ORDER BY sort_order ASC'
|
||||||
|
).all(journey.id) as { sort_order: number }[];
|
||||||
|
const orders = rows.map(r => r.sort_order);
|
||||||
|
expect(new Set(orders).size).toBe(orders.length);
|
||||||
|
expect(orders).toEqual([0, 1, 2]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('onPlaceCreated sort_order', () => {
|
||||||
|
it('JOURNEY-SVC-093: assigns MAX+1 sort_order when entries already exist on the target date', () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const journey = createJourney(testDb, user.id);
|
||||||
|
const trip = createTrip(testDb, user.id, {
|
||||||
|
title: 'Append Trip',
|
||||||
|
start_date: '2026-10-01',
|
||||||
|
end_date: '2026-10-02',
|
||||||
|
});
|
||||||
|
addTripToJourney(journey.id, trip.id, user.id);
|
||||||
|
|
||||||
|
const day = testDb.prepare('SELECT id, date FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number; date: string };
|
||||||
|
insertEntry(journey.id, user.id, { entry_date: day.date, sort_order: 5 });
|
||||||
|
|
||||||
|
const place = createPlace(testDb, trip.id, { name: 'Late Addition' });
|
||||||
|
createDayAssignment(testDb, day.id, place.id);
|
||||||
|
onPlaceCreated(trip.id, place.id);
|
||||||
|
|
||||||
|
const newEntry = testDb.prepare(
|
||||||
|
'SELECT sort_order FROM journey_entries WHERE journey_id = ? AND source_place_id = ?'
|
||||||
|
).get(journey.id, place.id) as { sort_order: number } | undefined;
|
||||||
|
expect(newEntry).toBeDefined();
|
||||||
|
expect(newEntry!.sort_order).toBe(6);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
* discover caching, and the ReDoS-sensitive issuer trailing-slash regex.
|
* discover caching, and the ReDoS-sensitive issuer trailing-slash regex.
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
|
||||||
|
import { generateKeyPairSync } from 'crypto';
|
||||||
|
import jwtLib from 'jsonwebtoken';
|
||||||
|
|
||||||
// ── DB setup ──────────────────────────────────────────────────────────────────
|
// ── DB setup ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -50,6 +52,7 @@ import {
|
|||||||
frontendUrl,
|
frontendUrl,
|
||||||
findOrCreateUser,
|
findOrCreateUser,
|
||||||
discover,
|
discover,
|
||||||
|
verifyIdToken,
|
||||||
} from '../../../src/services/oidcService';
|
} from '../../../src/services/oidcService';
|
||||||
|
|
||||||
const MOCK_CONFIG = {
|
const MOCK_CONFIG = {
|
||||||
@@ -216,6 +219,59 @@ describe('discover', () => {
|
|||||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }));
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }));
|
||||||
await expect(discover('https://bad-issuer.example.com')).rejects.toThrow();
|
await expect(discover('https://bad-issuer.example.com')).rejects.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('OIDC-SVC-037: accepts mismatched doc issuer when discoveryUrl is explicit', async () => {
|
||||||
|
const doc = {
|
||||||
|
issuer: 'https://auth.example.com/application/o/myapp/',
|
||||||
|
authorization_endpoint: 'https://auth.example.com/application/o/myapp/authorize/',
|
||||||
|
token_endpoint: 'https://auth.example.com/application/o/token/',
|
||||||
|
userinfo_endpoint: 'https://auth.example.com/application/o/userinfo/',
|
||||||
|
};
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => doc }));
|
||||||
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
|
||||||
|
const result = await discover(
|
||||||
|
'https://auth.example.com',
|
||||||
|
'https://auth.example.com/application/o/myapp/.well-known/openid-configuration',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.issuer).toBe(doc.issuer);
|
||||||
|
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('differs from configured OIDC_ISSUER'));
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('OIDC-SVC-038: throws on mismatched doc issuer when discoveryUrl is omitted', async () => {
|
||||||
|
const doc = {
|
||||||
|
issuer: 'https://evil.example.com',
|
||||||
|
authorization_endpoint: 'https://unique-2.example.com/auth',
|
||||||
|
token_endpoint: 'https://unique-2.example.com/token',
|
||||||
|
userinfo_endpoint: 'https://unique-2.example.com/userinfo',
|
||||||
|
};
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => doc }));
|
||||||
|
|
||||||
|
await expect(discover('https://unique-2.example.com')).rejects.toThrow(
|
||||||
|
'OIDC discovery issuer mismatch',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('OIDC-SVC-039: trailing-slash-only mismatch with explicit discoveryUrl does not warn', async () => {
|
||||||
|
const doc = {
|
||||||
|
issuer: 'https://auth.example.com/',
|
||||||
|
authorization_endpoint: 'https://auth.example.com/auth',
|
||||||
|
token_endpoint: 'https://auth.example.com/token',
|
||||||
|
userinfo_endpoint: 'https://auth.example.com/userinfo',
|
||||||
|
};
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => doc }));
|
||||||
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
|
||||||
|
await discover(
|
||||||
|
'https://auth.example.com',
|
||||||
|
'https://auth.example.com/.well-known/openid-configuration',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(warnSpy).not.toHaveBeenCalled();
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── issuer trailing-slash regex (ReDoS guard) ─────────────────────────────────
|
// ── issuer trailing-slash regex (ReDoS guard) ─────────────────────────────────
|
||||||
@@ -460,3 +516,66 @@ describe('getUserInfo', () => {
|
|||||||
expect(fetchCall[1].headers.Authorization).toBe('Bearer access-token-123');
|
expect(fetchCall[1].headers.Authorization).toBe('Bearer access-token-123');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── verifyIdToken ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('verifyIdToken', () => {
|
||||||
|
const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
|
||||||
|
const jwk = publicKey.export({ format: 'jwk' }) as Record<string, unknown>;
|
||||||
|
const ISSUER = 'https://auth.example.com/application/o/trek';
|
||||||
|
const CLIENT_ID = 'trek-client';
|
||||||
|
const JWKS_URI = 'https://auth.example.com/.well-known/jwks.json';
|
||||||
|
|
||||||
|
function mockJwks() {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ keys: [jwk] }),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeToken(iss: string, overrides: object = {}) {
|
||||||
|
return jwtLib.sign(
|
||||||
|
{ sub: 'user-sub', email: 'user@example.com', ...overrides },
|
||||||
|
privateKey,
|
||||||
|
{ algorithm: 'RS256', audience: CLIENT_ID, issuer: iss, expiresIn: '1h' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = { jwks_uri: JWKS_URI } as any;
|
||||||
|
|
||||||
|
afterEach(() => { vi.unstubAllGlobals(); });
|
||||||
|
|
||||||
|
it('OIDC-SVC-033: accepts token whose iss matches expectedIssuer exactly', async () => {
|
||||||
|
mockJwks();
|
||||||
|
const token = makeToken(ISSUER);
|
||||||
|
const result = await verifyIdToken(token, doc, CLIENT_ID, ISSUER);
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('OIDC-SVC-034: accepts token whose iss has a trailing slash (Authentik)', async () => {
|
||||||
|
mockJwks();
|
||||||
|
const token = makeToken(ISSUER + '/');
|
||||||
|
const result = await verifyIdToken(token, doc, CLIENT_ID, ISSUER);
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('OIDC-SVC-035: rejects token with wrong issuer', async () => {
|
||||||
|
mockJwks();
|
||||||
|
const token = makeToken('https://evil.example.com');
|
||||||
|
const result = await verifyIdToken(token, doc, CLIENT_ID, ISSUER);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect((result as any).error).toMatch('jwt issuer invalid');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('OIDC-SVC-036: rejects token with wrong audience', async () => {
|
||||||
|
mockJwks();
|
||||||
|
const token = makeToken(ISSUER, {});
|
||||||
|
const wrongAudToken = jwtLib.sign(
|
||||||
|
{ sub: 'user-sub', iss: ISSUER },
|
||||||
|
privateKey,
|
||||||
|
{ algorithm: 'RS256', audience: 'wrong-client', expiresIn: '1h' }
|
||||||
|
);
|
||||||
|
const result = await verifyIdToken(wrongAudToken, doc, CLIENT_ID, ISSUER);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user