Compare commits

..

1 Commits

Author SHA1 Message Date
Julien G. 9e1982a57c Merge 881b9d0939 into 6df5edfbdb 2026-04-21 17:26:11 +02:00
114 changed files with 936 additions and 3874 deletions
-1
View File
@@ -62,7 +62,6 @@ body:
- Docker (standalone)
- Kubernetes / Helm
- Unraid template
- Proxmox Community Script
- Sources
- Other
validations:
+1 -4
View File
@@ -7,10 +7,7 @@ on:
- 'docs/**'
- '**/*.md'
- 'wiki/**'
- '.github/workflows/**'
- '.github/ISSUE_TEMPLATE/**'
- '.github/FUNDING.yml'
- '.github/PULL_REQUEST_TEMPLATE.md'
- '.github/workflows/wiki.yml'
workflow_dispatch:
inputs:
bump:
+1 -1
View File
@@ -23,4 +23,4 @@ jobs:
- name: Publish to GitHub wiki
uses: Andrew-Chen-Wang/github-wiki-action@v5
with:
strategy: clone
strategy: init
+13 -30
View File
@@ -6,29 +6,19 @@
<img src="docs/logo-trek-dark.gif" alt="TREK" height="96" />
</picture>
<br />
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/subtitle-light.png" />
<source media="(prefers-color-scheme: light)" srcset="docs/subtitle-dark.png" />
<img src="docs/subtitle-dark.png" alt="Your trips. Your plan. Your server." height="28" />
</picture>
### Your trips. Your plan. Your server.
A self-hosted, real-time collaborative travel planner — with maps, budgets, packing lists, a journal, and AI built in.
<br />
<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://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>
&nbsp;
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=for-the-badge" /></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>
&nbsp;
<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://discord.gg/NhZBDSd4qW"><img alt="Discord" src="https://img.shields.io/badge/Discord-community-5865F2?style=for-the-badge&logo=discord&logoColor=white" /></a>
&nbsp;
<a href="https://kanban.pakulat.org/shared/I4wxF6inOOMB0C6hH6kQm3efyNxFjwyI"><img alt="Roadmap" src="https://img.shields.io/badge/Roadmap-view-0EA5E9?style=for-the-badge" /></a>
<br />
<a href="https://ko-fi.com/mauriceboe"><img alt="Ko-fi" src="https://img.shields.io/badge/Ko--fi-support-FF5E5B?style=for-the-badge" /></a>
&nbsp;
<a href="https://www.buymeacoffee.com/mauriceboe"><img alt="BMAC" src="https://img.shields.io/badge/BMAC-support-FFDD00?style=for-the-badge" /></a>
<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>
<br />
<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>
@@ -127,23 +117,19 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
#### 🧩 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
- **Atlas** — world map of visited countries, bucket list, travel stats, streak tracking, liquid-glass UI
- **Journey** — magazine-style travel journal with entries, photos (Immich/Synology), maps, moods
- **Naver List Import** — one-click import from shared Naver Maps lists
- **MCP** — expose TREK to AI assistants via OAuth 2.1
- **Collab** — chat, notes, polls, day-by-day attendance
- **Journey** — magazine-style travel journal with entries, photos, maps, moods
- **Dashboard widgets** — currency converter and timezone clocks
</td>
<td width="50%" valign="top">
#### 🤖 AI / MCP
- **Built-in MCP server** — OAuth 2.1 authenticated. 150+ tools, 30 resources
- **Granular scopes** — 27 OAuth scopes across 13 permission groups
- **Built-in MCP server** — OAuth 2.1 authenticated. 80+ tools, 27 resources
- **Granular scopes** — 24 OAuth scopes across 13 permission groups
- **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`
- **Addon-aware** — exposes Atlas, Collab, Vacay when those addons are on
@@ -156,7 +142,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
#### ⚙️ Admin & customisation
- **Dashboard views** — card grid or compact list · **Dark mode** — full theme with matching status bar
- **15 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID
- **14 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
- **Auto-backups** — scheduled with configurable retention · **Units** — °C/°F, 12h/24h, map tile sources, default coordinates
@@ -176,7 +162,7 @@ ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d -p 3000:3000 \
-v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek
```
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`).
Open `http://localhost:3000`. The first user to register becomes admin.
<div align="center">
@@ -342,8 +328,7 @@ server {
ssl_certificate /etc/ssl/fullchain.pem;
ssl_certificate_key /etc/ssl/privkey.pem;
# 500 MB covers backup-restore uploads (capped at 500 MB server-side).
client_max_body_size 500m;
client_max_body_size 50m;
location / {
proxy_pass http://localhost:3000;
@@ -360,7 +345,6 @@ server {
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400;
}
}
```
@@ -400,7 +384,6 @@ Caddy handles TLS and WebSockets automatically.
| `DEFAULT_LANGUAGE` | Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar` | `en` |
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin |
| `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS, adds CSP `upgrade-insecure-requests`, forces the session cookie `secure` flag. Useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY`. | `false` |
| `HSTS_INCLUDE_SUBDOMAINS` | When `true`: adds the `includeSubDomains` directive to the HSTS header, extending HTTPS enforcement to all subdomains. Only effective when HSTS is active (`FORCE_HTTPS=true` or `NODE_ENV=production`). Leave `false` if you run other services on sibling subdomains over plain HTTP. | `false` |
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: on when `NODE_ENV=production` or `FORCE_HTTPS=true`. Escape hatch: set `false` to allow session cookies over plain HTTP. Not recommended in production. | auto |
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells Express to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` in production; off in dev unless set. | `1` |
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs (e.g. Immich on your LAN). Loopback and link-local addresses remain blocked. | `false` |
-121
View File
@@ -1,121 +0,0 @@
# Trademark Policy
## Introduction
This is the TREK project's policy for the use of our trademarks. While TREK is
available under the GNU Affero General Public License v3.0 (AGPL-3.0), that
license does not include a license to use our trademarks.
This policy describes how you may use our trademarks. Our goal is to strike a
balance between: 1) our need to ensure that our trademarks remain reliable
indicators of the software we release; and 2) our community members' desire to
be full participants in the TREK project.
## Our trademarks
This policy covers the name "TREK" as well as any associated logos, trade dress,
goodwill, or designs (our "Marks").
## In general
Whenever you use our Marks, you must always do so in a way that does not mislead
anyone about exactly who is the source of the software. For example, you cannot
say you are distributing TREK when you're distributing a modified version of it,
because people would think they would be getting the same software that they
can get directly from us when they aren't. You also cannot use our Marks on
your website in a way that suggests that your website is an official TREK
website or that we endorse your website. But, if true, you can say you like
TREK, that you participate in the TREK community, that you are providing an
unmodified version of TREK, or that you wrote a guide describing how to use
TREK.
This fundamental requirement, that it is always clear to people what they are
getting and from whom, is reflected throughout this policy. It should also
serve as your guide if you are not sure about how you are using the Marks.
In addition:
* You may not use or register, in whole or in part, the Marks as part of your
own trademark, service mark, domain name, company name, trade name, product
name or service name.
* Trademark law does not allow your use of names or trademarks that are too
similar to ours. You therefore may not use an obvious variation of any of our
Marks or any phonetic equivalent, foreign language equivalent, takeoff, or
abbreviation for a similar or compatible product or service.
* You agree that you will not acquire any rights in the Marks and that any
goodwill generated by your use of the Marks and participation in our
community inures solely to our benefit.
## Distribution of unmodified source code or unmodified executable code we have compiled
When you redistribute an unmodified copy of TREK, you are not changing the
quality or nature of it. Therefore, you may retain the Marks we have placed on
the software to identify your redistribution. This kind of use only applies if
you are redistributing an official TREK distribution that has not been changed
in any way.
## Distribution of executable code that you have compiled, or modified code
You may use the word mark "TREK", but not any TREK logos, to truthfully
describe the origin of the software that you are providing, that is, that the
code you are distributing is a modification of TREK. You may say, for example,
that "this software is derived from the source code for TREK."
Of course, you can place your own trademarks or logos on versions of the
software to which you have made substantive modifications, because by modifying
the software, you have become the origin of that exact version. In that case,
you should not use our Marks.
However, you may use our Marks for the distribution of code (source or
executable) on the condition that any executable is built from an official TREK
source code release and that any modifications are limited to switching on or
off features already included in the software, translations into other
languages, and incorporating minor bug-fix patches. Use of our Marks on any
further modification is not permitted.
## Mobile wrappers, hosted instances, and forks
The following clarifications apply specifically to common ways TREK is
redistributed:
* **Self-hosted instances of unmodified TREK.** You may refer to your instance
as "a TREK instance" or "running TREK." You may not name the service itself
in a way that suggests it is the official TREK ("TREK Cloud," "TREK
Official," etc.).
* **Mobile wrappers (WebView shells, Capacitor apps, native apps) pointing at
TREK.** You may describe your app as "a mobile client for TREK" or "for use
with TREK." You may not publish it on app stores under the name "TREK" or a
confusingly similar name, and you may not use the TREK logo as the app icon
unless your wrapper distributes only an unmodified, official TREK instance
and you have obtained permission.
* **Forks of the TREK source code.** Forks that diverge from upstream must use
a different name. You may state that your fork is "based on TREK" or "a fork
of TREK," but the project name itself must be your own.
## Statements about your software's relation to TREK
You may use the word mark, but not TREK logos, to truthfully describe the
relationship between your software and ours. The word mark "TREK" should be
used after a verb or preposition that describes the relationship between your
software and ours. So you may say, for example, "Bob's app for TREK" but may
not say "Bob's TREK app." Some other examples that may work for you are:
* [Your software] uses TREK
* [Your software] is powered by TREK
* [Your software] runs on TREK
* [Your software] for use with TREK
* [Your software] for TREK
## Questions and permission requests
If you are not sure whether your intended use of the Marks is permitted under
this policy, or if you would like to request explicit permission for a use that
is not covered, please open an issue on the TREK GitHub repository or contact
the maintainers directly.
---
These guidelines are based on the
[Model Trademark Guidelines](http://www.modeltrademarkguidelines.org), used
under a
[Creative Commons Attribution 3.0 Unported license](https://creativecommons.org/licenses/by/3.0/deed.en_US).
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2
name: trek
version: 3.0.10
version: 2.9.14
description: Minimal Helm chart for TREK app
appVersion: "3.0.10"
appVersion: "2.9.14"
-3
View File
@@ -22,9 +22,6 @@ data:
{{- if .Values.env.FORCE_HTTPS }}
FORCE_HTTPS: {{ .Values.env.FORCE_HTTPS | quote }}
{{- end }}
{{- if .Values.env.HSTS_INCLUDE_SUBDOMAINS }}
HSTS_INCLUDE_SUBDOMAINS: {{ .Values.env.HSTS_INCLUDE_SUBDOMAINS | quote }}
{{- end }}
{{- if .Values.env.COOKIE_SECURE }}
COOKIE_SECURE: {{ .Values.env.COOKIE_SECURE | quote }}
{{- end }}
-2
View File
@@ -30,8 +30,6 @@ env:
# Also used as the base URL for links in email notifications and other external links.
# FORCE_HTTPS: "false"
# Optional. When "true": HTTPS redirect, HSTS, CSP upgrade-insecure-requests, secure cookies. Only behind a TLS proxy. Requires TRUST_PROXY.
# HSTS_INCLUDE_SUBDOMAINS: "false"
# When "true": adds includeSubDomains to the HSTS header. Only effective when HSTS is active. Leave "false" if sibling subdomains still run over plain HTTP.
# COOKIE_SECURE: "true"
# Auto-derived (true in production or when FORCE_HTTPS=true). Set "false" to force cookies over plain HTTP. Not recommended for production.
# TRUST_PROXY: "1"
+5 -5
View File
@@ -1,12 +1,12 @@
{
"name": "trek-client",
"version": "3.0.10",
"version": "2.9.14",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "trek-client",
"version": "3.0.10",
"version": "2.9.14",
"dependencies": {
"@react-pdf/renderer": "^4.3.2",
"axios": "^1.6.7",
@@ -8907,9 +8907,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.10",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
"version": "8.5.9",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz",
"integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==",
"dev": true,
"funding": [
{
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "trek-client",
"version": "3.0.10",
"version": "2.9.14",
"private": true,
"type": "module",
"scripts": {
+1 -5
View File
@@ -356,13 +356,9 @@ export const journeyApi = {
// Photos
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),
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
linkPhoto: (entryId: number, journeyPhotoId: number) => apiClient.post(`/journeys/entries/${entryId}/link-photo`, { journey_photo_id: journeyPhotoId }).then(r => r.data),
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),
linkPhoto: (entryId: number, photoId: number) => apiClient.post(`/journeys/entries/${entryId}/link-photo`, { photo_id: photoId }).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),
+1 -1
View File
@@ -634,7 +634,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
}
const handleRenameCategory = async (oldName, newName) => {
if (!newName.trim() || newName.trim() === oldName) return
const items = grouped.get(oldName) || []
const items = grouped[oldName] || []
for (const item of Array.from(items)) await updateBudgetItem(tripId, item.id, { category: newName.trim() })
}
const handleAddCategory = () => {
+23 -16
View File
@@ -9,8 +9,6 @@ export interface MapMarkerItem {
label: string
mood?: string | null
time: string
dayColor: string
dayLabel: number
}
export interface JourneyMapHandle {
@@ -26,8 +24,6 @@ interface MapEntry {
title?: string | null
mood?: string | null
entry_date: string
dayColor?: string
dayLabel?: number
}
interface Props {
@@ -53,8 +49,6 @@ function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] {
label: e.title || 'Entry',
mood: e.mood,
time: e.entry_date,
dayColor: e.dayColor || '#52525B',
dayLabel: e.dayLabel ?? 1,
})
}
}
@@ -65,19 +59,30 @@ function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] {
const MARKER_W = 28
const MARKER_H = 36
function markerSvg(dayColor: string, dayLabel: number, highlighted: boolean): string {
const stroke = highlighted ? '#fff' : 'rgba(255,255,255,0.5)'
function markerSvg(index: number, highlighted: boolean, dark: boolean): string {
// Highlighted: inverted colors for contrast (black on light, white on dark)
const fill = dark
? (highlighted ? '#FAFAFA' : '#A1A1AA')
: (highlighted ? '#18181B' : '#52525B')
const textColor = dark
? (highlighted ? '#18181B' : '#18181B')
: (highlighted ? '#fff' : '#fff')
const stroke = highlighted
? (dark ? '#fff' : '#18181B')
: (dark ? '#3F3F46' : '#fff')
const shadow = highlighted
? 'filter:drop-shadow(0 0 10px rgba(0,0,0,0.4)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))'
? (dark
? 'filter:drop-shadow(0 0 10px rgba(255,255,255,0.35)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))'
: 'filter:drop-shadow(0 0 10px rgba(0,0,0,0.3)) drop-shadow(0 2px 6px rgba(0,0,0,0.3))')
: 'filter:drop-shadow(0 2px 4px rgba(0,0,0,0.25))'
const label = String(dayLabel)
const label = String(index + 1)
const scale = highlighted ? 1.2 : 1
return `<div style="transform:scale(${scale});transition:transform 0.2s ease;${shadow};transform-origin:bottom center">
<svg width="${MARKER_W}" height="${MARKER_H}" viewBox="0 0 28 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 34C14 34 26 22.36 26 13C26 6.37 20.63 1 14 1C7.37 1 2 6.37 2 13C2 22.36 14 34 14 34Z" fill="${dayColor}" stroke="${stroke}" stroke-width="1.5"/>
<circle cx="14" cy="13" r="8" fill="${dayColor}"/>
<text x="14" y="13" text-anchor="middle" dominant-baseline="central" fill="#fff" font-family="-apple-system,system-ui,sans-serif" font-size="11" font-weight="700">${label}</text>
<path d="M14 34C14 34 26 22.36 26 13C26 6.37 20.63 1 14 1C7.37 1 2 6.37 2 13C2 22.36 14 34 14 34Z" fill="${fill}" stroke="${stroke}" stroke-width="2"/>
<circle cx="14" cy="13" r="8" fill="${fill}"/>
<text x="14" y="13" text-anchor="middle" dominant-baseline="central" fill="${textColor}" font-family="-apple-system,system-ui,sans-serif" font-size="11" font-weight="700">${label}</text>
</svg>
</div>`
}
@@ -110,11 +115,12 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
const marker = markersRef.current.get(prev)
const item = itemsRef.current.find(i => i.id === prev)
if (marker && item) {
const idx = itemsRef.current.indexOf(item)
marker.setIcon(L.divIcon({
className: '',
iconSize: [MARKER_W, MARKER_H],
iconAnchor: [MARKER_W / 2, MARKER_H],
html: markerSvg(item.dayColor, item.dayLabel, false),
html: markerSvg(idx, false, isDark),
}))
marker.setZIndexOffset(0)
}
@@ -124,11 +130,12 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
const marker = markersRef.current.get(id)
const item = itemsRef.current.find(i => i.id === id)
if (marker && item) {
const idx = itemsRef.current.indexOf(item)
marker.setIcon(L.divIcon({
className: '',
iconSize: [MARKER_W, MARKER_H],
iconAnchor: [MARKER_W / 2, MARKER_H],
html: markerSvg(item.dayColor, item.dayLabel, true),
html: markerSvg(idx, true, isDark),
}))
marker.setZIndexOffset(1000)
}
@@ -219,7 +226,7 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
className: '',
iconSize: [MARKER_W, MARKER_H],
iconAnchor: [MARKER_W / 2, MARKER_H],
html: markerSvg(item.dayColor, item.dayLabel, false),
html: markerSvg(i, false, !!dark),
})
const marker = L.marker(pos, { icon }).addTo(map)
@@ -14,8 +14,6 @@ interface MapEntry {
location_name?: string | null
mood?: string | null
entry_date: string
dayColor?: string
dayLabel?: number
}
interface Props {
+17 -16
View File
@@ -18,8 +18,6 @@ interface MapEntry {
location_name?: string | null
mood?: string | null
entry_date: string
dayColor?: string
dayLabel?: number
}
interface Props {
@@ -41,8 +39,6 @@ interface Item {
label: string
locationName: string
time: string
dayColor: string
dayLabel: number
}
const MARKER_W = 28
@@ -59,8 +55,6 @@ function buildItems(entries: MapEntry[]): Item[] {
label: e.title || '',
locationName: e.location_name || '',
time: e.entry_date,
dayColor: e.dayColor || '#52525B',
dayLabel: e.dayLabel ?? 1,
})
}
}
@@ -163,15 +157,21 @@ function ensureJourneyPopupStyle() {
document.head.appendChild(s)
}
function markerHtml(dayColor: string, dayLabel: number, highlighted: boolean): HTMLDivElement {
const fill = dayColor
const textColor = '#fff'
const stroke = highlighted ? '#fff' : 'rgba(255,255,255,0.5)'
function markerHtml(index: number, highlighted: boolean, dark: boolean): HTMLDivElement {
const fill = dark
? (highlighted ? '#FAFAFA' : '#A1A1AA')
: (highlighted ? '#18181B' : '#52525B')
const textColor = highlighted ? (dark ? '#18181B' : '#fff') : '#fff'
const stroke = highlighted
? (dark ? '#fff' : '#18181B')
: (dark ? '#3F3F46' : '#fff')
const shadow = highlighted
? 'drop-shadow(0 0 10px rgba(0,0,0,0.4)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))'
? (dark
? 'drop-shadow(0 0 10px rgba(255,255,255,0.35)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))'
: 'drop-shadow(0 0 10px rgba(0,0,0,0.3)) drop-shadow(0 2px 6px rgba(0,0,0,0.3))')
: 'drop-shadow(0 2px 4px rgba(0,0,0,0.25))'
const scale = highlighted ? 1.2 : 1
const label = String(dayLabel)
const label = String(index + 1)
// Outer wrap holds the element mapbox positions via `transform: translate(...)`.
// Anything animated (scale, filter) has to live on an inner child — otherwise
@@ -183,7 +183,7 @@ function markerHtml(dayColor: string, dayLabel: number, highlighted: boolean): H
inner.className = 'trek-journey-marker-inner'
inner.style.cssText = `width:100%;height:100%;transform:scale(${scale});transform-origin:bottom center;transition:transform 0.2s ease;filter:${shadow};`
inner.innerHTML = `<svg width="${MARKER_W}" height="${MARKER_H}" viewBox="0 0 28 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 34C14 34 26 22.36 26 13C26 6.37 20.63 1 14 1C7.37 1 2 6.37 2 13C2 22.36 14 34 14 34Z" fill="${fill}" stroke="${stroke}" stroke-width="1.5"/>
<path d="M14 34C14 34 26 22.36 26 13C26 6.37 20.63 1 14 1C7.37 1 2 6.37 2 13C2 22.36 14 34 14 34Z" fill="${fill}" stroke="${stroke}" stroke-width="2"/>
<circle cx="14" cy="13" r="8" fill="${fill}"/>
<text x="14" y="13" text-anchor="middle" dominant-baseline="central" fill="${textColor}" font-family="-apple-system,system-ui,sans-serif" font-size="11" font-weight="700">${label}</text>
</svg>`
@@ -273,12 +273,13 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
const item = itemsRef.current.find(i => i.id === id)
const marker = markersRef.current.get(id)
if (!item || !marker) return
const idx = itemsRef.current.indexOf(item)
const el = marker.getElement()
const currentInner = el.querySelector('.trek-journey-marker-inner') as HTMLDivElement | null
if (!currentInner) return
// Only swap the inner element's styles/HTML. Touching `el.style.cssText`
// would wipe mapbox's positional transform and make the marker flicker.
const next = markerHtml(item.dayColor, item.dayLabel, highlighted)
const next = markerHtml(idx, highlighted, !!darkRef.current)
const nextInner = next.querySelector('.trek-journey-marker-inner') as HTMLDivElement
currentInner.style.cssText = nextInner.style.cssText
currentInner.innerHTML = nextInner.innerHTML
@@ -381,8 +382,8 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
}
// markers
items.forEach((item) => {
const el = markerHtml(item.dayColor, item.dayLabel, false)
items.forEach((item, i) => {
const el = markerHtml(i, false, !!darkRef.current)
const marker = new mapboxgl.Marker({ element: el, anchor: 'bottom' })
.setLngLat([item.lng, item.lat])
.addTo(map)
@@ -1,5 +1,4 @@
import { MapPin, Camera, Smile, Laugh, Meh, Frown, Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake } from 'lucide-react'
import { formatLocationName } from '../../utils/formatters'
import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
const MOOD_ICONS: Record<string, typeof Smile> = {
@@ -38,14 +37,13 @@ function stripMarkdown(text: string): string {
interface Props {
entry: JourneyEntry | { id: number; type: string; title?: string | null; location_name?: string | null; location_lat?: number | null; location_lng?: number | null; entry_date: string; entry_time?: string | null; mood?: string | null; weather?: string | null; photos?: { photo_id: number }[]; story?: string | null }
dayLabel: number
dayColor: string
index: number
isActive: boolean
onClick: () => void
publicPhotoUrl?: (photoId: number) => string
}
export default function MobileEntryCard({ entry, dayLabel, dayColor, isActive, onClick, publicPhotoUrl }: Props) {
export default function MobileEntryCard({ entry, index, isActive, onClick, publicPhotoUrl }: Props) {
const hasLocation = !!(entry.location_lat && entry.location_lng)
const hasPhotos = entry.photos && entry.photos.length > 0
const firstPhoto = hasPhotos ? entry.photos![0] : null
@@ -100,8 +98,8 @@ export default function MobileEntryCard({ entry, dayLabel, dayColor, isActive, o
<div className="flex-1 p-3 flex flex-col min-w-0">
{/* Day number + date + mood/weather */}
<div className="flex items-center gap-1.5 mb-1">
<span className="w-5 h-5 rounded text-white text-[10px] font-bold flex items-center justify-center flex-shrink-0" style={{ background: dayColor }}>
{dayLabel}
<span className="w-5 h-5 rounded bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[10px] font-bold flex items-center justify-center flex-shrink-0">
{index + 1}
</span>
<span className="text-[11px] text-zinc-400 font-medium">{dateStr}</span>
{entry.entry_time && (
@@ -143,7 +141,7 @@ export default function MobileEntryCard({ entry, dayLabel, dayColor, isActive, o
{hasLocation ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-zinc-700 text-[10px] font-medium text-zinc-600 dark:text-zinc-300 max-w-full overflow-hidden">
<MapPin size={10} className="flex-shrink-0" />
<span className="truncate">{formatLocationName(entry.location_name) || 'On the map'}</span>
<span className="truncate">{entry.location_name || 'On the map'}</span>
</span>
) : (
<span className="text-[10px] text-zinc-400 italic">No location</span>
@@ -6,7 +6,6 @@ import {
ThumbsUp, ThumbsDown, ChevronDown,
} from 'lucide-react'
import JournalBody from './JournalBody'
import { formatLocationName } from '../../utils/formatters'
import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
const MOOD_CONFIG: Record<string, { icon: typeof Smile; label: string; bg: string; text: string }> = {
@@ -25,22 +24,20 @@ const WEATHER_CONFIG: Record<string, { icon: typeof Sun; label: string }> = {
cold: { icon: Snowflake, label: 'Cold' },
}
function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'original', builder?: (id: number) => string): string {
if (builder) return builder(p.photo_id)
function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'original'): string {
return `/api/photos/${p.photo_id}/${size}`
}
interface Props {
entry: JourneyEntry
readOnly?: boolean
publicPhotoUrl?: (photoId: number) => string
onClose: () => void
onEdit: () => void
onDelete: () => void
onPhotoClick: (photos: JourneyPhoto[], index: number) => void
}
export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClose, onEdit, onDelete, onPhotoClick }: Props) {
export default function MobileEntryView({ entry, readOnly, onClose, onEdit, onDelete, onPhotoClick }: Props) {
const photos = entry.photos || []
const mood = entry.mood ? MOOD_CONFIG[entry.mood] : null
const weather = entry.weather ? WEATHER_CONFIG[entry.weather] : null
@@ -87,7 +84,7 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
{photos.length > 0 && (
<div className="relative">
<img
src={photoUrl(photos[0], 'original', publicPhotoUrl)}
src={photoUrl(photos[0])}
alt=""
className="w-full max-h-[50vh] object-cover cursor-pointer"
onClick={() => onPhotoClick(photos, 0)}
@@ -104,7 +101,7 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
{photos.map((p, i) => (
<img
key={p.id || i}
src={photoUrl(p, 'thumbnail', publicPhotoUrl)}
src={photoUrl(p, 'thumbnail')}
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"
onClick={() => onPhotoClick(photos, i)}
@@ -133,7 +130,7 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
<div className="mb-3">
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[12px] font-medium text-zinc-700 dark:text-zinc-300">
<MapPin size={12} className="text-zinc-500 dark:text-zinc-400 flex-shrink-0" />
{formatLocationName(entry.location_name)}
{entry.location_name}
</span>
</div>
)}
@@ -1,10 +1,9 @@
import { useRef, useState, useEffect, useCallback, useMemo } from 'react'
import { useRef, useState, useEffect, useCallback } from 'react'
import { Plus } from 'lucide-react'
import JourneyMap from './JourneyMap'
import MobileEntryCard from './MobileEntryCard'
import type { JourneyMapHandle } from './JourneyMap'
import type { JourneyEntry } from '../../store/journeyStore'
import { DAY_COLORS } from './dayColors'
interface MapEntry {
id: string
@@ -24,7 +23,6 @@ interface Props {
onEntryClick: (entry: any) => void
onAddEntry?: () => void
publicPhotoUrl?: (photoId: number) => string
carouselBottom?: string
}
export default function MobileMapTimeline({
@@ -36,23 +34,14 @@ export default function MobileMapTimeline({
onEntryClick,
onAddEntry,
publicPhotoUrl,
carouselBottom = 'calc(var(--bottom-nav-h, 84px) + 8px)',
}: Props) {
const mapRef = useRef<JourneyMapHandle>(null)
const carouselRef = useRef<HTMLDivElement>(null)
const [activeIndex, setActiveIndex] = useState(0)
const entryDayMeta = useMemo(() => {
const uniqueDates = [...new Set(entries.map((e: any) => e.entry_date).sort())]
const counters = new Map<string, number>()
return entries.map((e: any) => {
const dayIdx = uniqueDates.indexOf(e.entry_date)
const dayLabel = (counters.get(e.entry_date) ?? 0) + 1
counters.set(e.entry_date, dayLabel)
return { dayLabel, dayColor: DAY_COLORS[dayIdx % DAY_COLORS.length] }
})
}, [entries])
const cardRefs = useRef<Map<number, HTMLDivElement>>(new Map())
const activeIndexRef = useRef(activeIndex)
useEffect(() => { activeIndexRef.current = activeIndex }, [activeIndex])
// Sync map focus when carousel scrolls (with guard for uninitialized map)
const syncMapToCarousel = useCallback((index: number) => {
const entry = entries[index]
@@ -87,19 +76,29 @@ export default function MobileMapTimeline({
})
}, [syncMapToCarousel])
// Defer all state updates until scrolling settles — updating activeIndex
// mid-swipe resizes cards (240→320px), causing layout reflow every frame.
// Track scroll; debounce to re-center the active card when the user stops.
useEffect(() => {
const el = carouselRef.current
if (!el || entries.length === 0) return
let rafId: number | null = null
let settleTimer: number | null = null
const onScroll = () => {
if (rafId != null) return
rafId = requestAnimationFrame(() => {
pickNearestCard()
rafId = null
})
if (settleTimer != null) window.clearTimeout(settleTimer)
settleTimer = window.setTimeout(pickNearestCard, 150)
settleTimer = window.setTimeout(() => {
// Ensure the active card sits at the center once the user settles.
const card = cardRefs.current.get(activeIndexRef.current)
card?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' })
}, 180)
}
el.addEventListener('scroll', onScroll, { passive: true })
return () => {
el.removeEventListener('scroll', onScroll)
if (rafId != null) cancelAnimationFrame(rafId)
if (settleTimer != null) window.clearTimeout(settleTimer)
}
}, [entries.length, pickNearestCard])
@@ -143,10 +142,7 @@ export default function MobileMapTimeline({
if (entries.length === 0) {
return (
<div
className="fixed left-0 right-0 z-10"
style={{ top: 'var(--nav-h, 0px)', bottom: 'env(safe-area-inset-bottom, 0px)' }}
>
<div className="fixed inset-0 z-10" style={{ top: 0, bottom: 0 }}>
<JourneyMap
ref={mapRef}
entries={mapEntries}
@@ -172,10 +168,7 @@ export default function MobileMapTimeline({
}
return (
<div
className="fixed left-0 right-0 z-10"
style={{ top: 'var(--nav-h, 0px)', bottom: 'env(safe-area-inset-bottom, 0px)' }}
>
<div className="fixed inset-0 z-10" style={{ top: 0, bottom: 0 }}>
{/* Full-screen map */}
<JourneyMap
ref={mapRef}
@@ -193,13 +186,13 @@ export default function MobileMapTimeline({
{/* Bottom carousel */}
<div
className="fixed left-0 right-0 z-40"
style={{ touchAction: 'pan-x', bottom: carouselBottom }}
style={{ touchAction: 'pan-x', bottom: 'calc(var(--bottom-nav-h, 84px) + 8px)' }}
>
<div
ref={carouselRef}
className="flex gap-3 overflow-x-auto px-4 pb-3 pt-1"
className="flex gap-3 overflow-x-auto px-4 pb-3 pt-1 scroll-smooth"
style={{
scrollSnapType: 'x mandatory',
scrollSnapType: 'x proximity',
WebkitOverflowScrolling: 'touch',
scrollbarWidth: 'none',
msOverflowStyle: 'none',
@@ -214,8 +207,7 @@ export default function MobileMapTimeline({
>
<MobileEntryCard
entry={entry}
dayLabel={entryDayMeta[i]?.dayLabel ?? i + 1}
dayColor={entryDayMeta[i]?.dayColor ?? DAY_COLORS[0]}
index={i}
isActive={i === activeIndex}
onClick={() => handleCardTap(entry, i)}
publicPhotoUrl={publicPhotoUrl}
@@ -1,32 +0,0 @@
export const DAY_COLORS = [
'#6366f1', // indigo
'#f97316', // orange
'#14b8a6', // teal
'#ec4899', // pink
'#22c55e', // green
'#3b82f6', // blue
'#a855f7', // purple
'#ef4444', // red
'#f59e0b', // amber
'#06b6d4', // cyan
'#84cc16', // lime
'#f43f5e', // rose
'#8b5cf6', // violet
'#10b981', // emerald
'#fb923c', // orange-400
'#60a5fa', // blue-400
'#c084fc', // purple-400
'#34d399', // emerald-400
'#fbbf24', // amber-400
'#e879f9', // fuchsia
'#4ade80', // green-400
'#f87171', // red-400
'#38bdf8', // sky-400
'#a3e635', // lime-400
'#fb7185', // rose-400
'#818cf8', // indigo-400
'#2dd4bf', // teal-400
'#facc15', // yellow
'#c026d3', // fuchsia-600
'#0ea5e9', // sky-500
]
+5 -12
View File
@@ -266,22 +266,17 @@ export default function DemoBanner(): React.ReactElement | null {
return (
<div style={{
position: 'fixed', inset: 0, zIndex: 99999,
position: 'fixed', inset: 0, zIndex: 9999,
background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(8px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
paddingTop: 'max(16px, env(safe-area-inset-top))',
paddingBottom: 'max(16px, calc(env(safe-area-inset-bottom) + 80px))',
paddingLeft: 16, paddingRight: 16,
overflow: 'auto',
padding: 16, overflow: 'auto',
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
}} onClick={() => setDismissed(true)}>
<div style={{
background: 'white', borderRadius: 20, padding: '28px 24px 0',
background: 'white', borderRadius: 20, padding: '28px 24px 20px',
maxWidth: 480, width: '100%',
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
maxHeight: 'min(90vh, calc(100dvh - 96px))',
overflow: 'auto',
display: 'flex', flexDirection: 'column',
maxHeight: '90vh', overflow: 'auto',
}} onClick={(e: React.MouseEvent<HTMLDivElement>) => e.stopPropagation()}>
{/* Header */}
@@ -372,10 +367,8 @@ export default function DemoBanner(): React.ReactElement | null {
{/* Footer */}
<div style={{
padding: '14px 0 20px', borderTop: '1px solid #e5e7eb',
paddingTop: 14, borderTop: '1px solid #e5e7eb',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
position: 'sticky', bottom: 0, background: 'white',
marginTop: 'auto',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#9ca3af' }}>
<Github size={13} />
+1 -15
View File
@@ -61,25 +61,11 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
navigate('/login', { state: { noRedirect: true } })
}
// Keep track of the pending theme-transition cleanup so we can cancel it
// on unmount. Without this the timer fires after jsdom teardown in unit
// tests (document is gone) and triggers an unhandled ReferenceError that
// trips vitest's exit code.
const themeTransitionTimer = useRef<number | null>(null)
useEffect(() => () => {
if (themeTransitionTimer.current !== null) {
window.clearTimeout(themeTransitionTimer.current)
themeTransitionTimer.current = null
}
}, [])
const toggleDarkMode = () => {
document.documentElement.classList.add('trek-theme-transitioning')
updateSetting('dark_mode', dark ? 'light' : 'dark').catch(() => {})
if (themeTransitionTimer.current !== null) window.clearTimeout(themeTransitionTimer.current)
themeTransitionTimer.current = window.setTimeout(() => {
window.setTimeout(() => {
document.documentElement.classList.remove('trek-theme-transitioning')
themeTransitionTimer.current = null
}, 360)
}
+20 -26
View File
@@ -1,15 +1,11 @@
/**
* OfflineBanner connectivity + sync state indicator.
* OfflineBanner persistent top bar indicating connectivity + sync state.
*
* States:
* offline + N queued amber pill "Offline · N queued"
* offline + 0 queued amber pill "Offline"
* online + N pending blue pill "Syncing N…"
* offline + N queued amber bar "Offline N changes queued"
* offline + 0 queued amber bar "Offline"
* online + N pending blue bar "Syncing N changes…"
* online + 0 pending hidden
*
* Rendered as a small floating pill anchored to the bottom-center of the
* viewport so it never competes with top navigation or sticky modal
* headers. On mobile it hovers just above the bottom tab bar.
*/
import React, { useState, useEffect } from 'react'
import { WifiOff, RefreshCw } from 'lucide-react'
@@ -52,9 +48,9 @@ export default function OfflineBanner(): React.ReactElement | null {
const label = offline
? pendingCount > 0
? `Offline · ${pendingCount} queued`
? `Offline ${pendingCount} change${pendingCount !== 1 ? 's' : ''} queued`
: 'Offline'
: `Syncing ${pendingCount}`
: `Syncing ${pendingCount} change${pendingCount !== 1 ? 's' : ''}`
return (
<div
@@ -62,29 +58,27 @@ export default function OfflineBanner(): React.ReactElement | null {
aria-live="polite"
style={{
position: 'fixed',
// Hover above the mobile bottom nav; on desktop --bottom-nav-h is 0,
// so the pill sits 16px from the bottom.
bottom: 'calc(var(--bottom-nav-h) + 16px)',
left: '50%',
transform: 'translateX(-50%)',
top: 0,
left: 0,
right: 0,
zIndex: 9999,
background: bg,
color: text,
display: 'inline-flex',
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '6px 14px',
borderRadius: 999,
boxShadow: '0 4px 16px rgba(0,0,0,0.18), 0 0 0 1px rgba(255,255,255,0.08)',
fontSize: 12,
fontWeight: 600,
whiteSpace: 'nowrap',
pointerEvents: 'none',
justifyContent: 'center',
gap: 8,
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 6px)',
paddingBottom: '6px',
paddingLeft: '16px',
paddingRight: '16px',
fontSize: 13,
fontWeight: 500,
}}
>
{offline
? <WifiOff size={12} />
: <RefreshCw size={12} style={{ animation: 'spin 1s linear infinite' }} />
? <WifiOff size={14} />
: <RefreshCw size={14} style={{ animation: 'spin 1s linear infinite' }} />
}
{label}
</div>
@@ -78,7 +78,6 @@ const transportReservation = {
id: 400,
title: 'Flight to Rome',
type: 'flight',
day_id: 10,
reservation_time: '2025-06-01T14:30:00',
confirmation_number: 'ABC123',
metadata: JSON.stringify({ airline: 'Air Italia', flight_number: 'AI123', departure_airport: 'CDG', arrival_airport: 'FCO' }),
+8 -47
View File
@@ -140,58 +140,23 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
const totalCost = Object.values(assignments || {})
.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
const daysHtml = sorted.map((day, di) => {
const assigned = assignments[String(day.id)] || []
const notes = (dayNotes || []).filter(n => n.day_id === day.id)
const cost = dayCost(assignments, day.id, loc)
// Reservations for this day (hotel rendered via accommodations block; car middle-phase rendered in sidebar header only)
const dayReservations = pdfGetTransportForDay(day.id)
.filter(r => !(r.type === 'car' && pdfGetSpanPhase(r, day.id) === 'middle'))
// Reservations for this day (hotel rendered via accommodations block)
const dayReservations = (reservations || []).filter(r => {
if (!r.reservation_time || r.type === 'hotel') return false
return day.date && r.reservation_time.split('T')[0] === day.date
})
const merged = []
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 }))
dayReservations.forEach(r => {
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)
const pos = 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.sort((a, b) => a.k - b.k)
@@ -212,17 +177,13 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
else if (r.type === 'event') subtitle = [meta.venue].filter(Boolean).join(' · ')
else if (r.type === 'tour') subtitle = [meta.operator].filter(Boolean).join(' · ')
const locationLine = r.location || meta.location || ''
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)}`
const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : ''
return `
<div class="note-card" style="border-left: 3px solid ${color};">
<div class="note-line" style="background: ${color};"></div>
<span class="note-icon">${icon}</span>
<div class="note-body">
<div class="note-text" style="font-weight: 600;">${titleHtml}${time ? ` <span style="color:#6b7280;font-weight:400;font-size:10px;">${time}</span>` : ''}</div>
<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>
${subtitle ? `<div class="note-time">${escHtml(subtitle)}</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>` : ''}
@@ -208,14 +208,9 @@ interface ArtikelZeileProps {
canEdit?: boolean
}
// A category's first item is seeded with this sentinel because the server
// rejects empty names. Treat it as a placeholder in the UI.
const PACKING_PLACEHOLDER_NAME = '...'
function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true }: ArtikelZeileProps) {
const isPlaceholder = item.name === PACKING_PLACEHOLDER_NAME
const [editing, setEditing] = useState(false)
const [editName, setEditName] = useState(isPlaceholder ? '' : item.name)
const [editName, setEditName] = useState(item.name)
const [hovered, setHovered] = useState(false)
const [showCatPicker, setShowCatPicker] = useState(false)
const [showBagPicker, setShowBagPicker] = useState(false)
@@ -228,7 +223,7 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
const handleToggle = () => togglePackingItem(tripId, item.id, !item.checked)
const handleSaveName = async () => {
if (!editName.trim()) { setEditing(false); setEditName(isPlaceholder ? '' : item.name); return }
if (!editName.trim()) { setEditing(false); setEditName(item.name); return }
try { await updatePackingItem(tripId, item.id, { name: editName.trim() }); setEditing(false) }
catch { toast.error(t('packing.toast.saveError')) }
}
@@ -280,10 +275,9 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
{editing && canEdit ? (
<input
type="text" value={editName} autoFocus
placeholder={isPlaceholder ? '...' : undefined}
onChange={e => setEditName(e.target.value)}
onBlur={handleSaveName}
onKeyDown={e => { if (e.key === 'Enter') handleSaveName(); if (e.key === 'Escape') { setEditing(false); setEditName(isPlaceholder ? '' : item.name) } }}
onKeyDown={e => { if (e.key === 'Enter') handleSaveName(); if (e.key === 'Escape') { setEditing(false); setEditName(item.name) } }}
style={{ flex: 1, fontSize: 13.5, padding: '2px 8px', borderRadius: 6, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit' }}
/>
) : (
@@ -292,7 +286,7 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
style={{
flex: 1, fontSize: 13.5,
cursor: !canEdit || item.checked ? 'default' : 'text',
color: isPlaceholder ? 'var(--text-faint)' : (item.checked ? 'var(--text-faint)' : 'var(--text-primary)'),
color: item.checked ? 'var(--text-faint)' : 'var(--text-primary)',
transition: 'color 200ms cubic-bezier(0.23,1,0.32,1)',
textDecoration: item.checked ? 'line-through' : 'none',
}}
@@ -66,11 +66,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
const isFahrenheit = useSettingsStore(s => s.settings.temperature_unit) === 'fahrenheit'
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const blurCodes = useSettingsStore(s => s.settings.blur_booking_codes)
const fmtTime = (v) => {
if (!v) return v
if (v.includes('T')) return new Date(v).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: is12h })
return formatTime12(v, is12h)
}
const fmtTime = (v) => formatTime12(v, is12h)
const unit = isFahrenheit ? '°F' : '°C'
const collapsed = collapsedProp
const toggleCollapse = () => onToggleCollapse?.()
@@ -172,7 +168,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
return (
<div className="fixed z-50" style={{ bottom: 'calc(var(--bottom-nav-h) + 20px)', left: `calc(${leftWidth}px + (100vw - ${leftWidth}px - ${rightWidth}px) / 2)`, transform: 'translateX(-50%)', width: `min(800px, calc(100vw - ${leftWidth}px - ${rightWidth}px - 32px))`, ...font }}>
<div className="fixed z-50 bottom-[96px] md:bottom-5" style={{ left: `calc(${leftWidth}px + (100vw - ${leftWidth}px - ${rightWidth}px) / 2)`, transform: 'translateX(-50%)', width: `min(800px, calc(100vw - ${leftWidth}px - ${rightWidth}px - 32px))`, ...font }}>
<div style={{
background: 'var(--bg-elevated)',
backdropFilter: 'blur(40px) saturate(180%)',
@@ -1576,10 +1576,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
{res.reservation_time?.includes('T') && (
<span style={{ fontWeight: 400 }}>
{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
{res.reservation_end_time && ` ${(() => {
const endStr = res.reservation_end_time.includes('T') ? res.reservation_end_time : (res.reservation_time.split('T')[0] + 'T' + res.reservation_end_time)
return new Date(endStr).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })
})()}`}
{res.reservation_end_time && ` ${res.reservation_end_time}`}
</span>
)}
{(() => {
@@ -360,25 +360,6 @@ export default function PlaceFormModal({
onClose={onClose}
title={place ? t('places.editPlace') : t('places.addPlace')}
size="lg"
footer={
<div className="flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-900 border border-gray-200 rounded-lg hover:bg-gray-50"
>
{t('common.cancel')}
</button>
<button
type="button"
onClick={handleSubmit}
disabled={isSaving || hasTimeError}
className="px-6 py-2 bg-slate-900 text-white text-sm rounded-lg hover:bg-slate-700 disabled:opacity-60 font-medium"
>
{isSaving ? t('common.saving') : place ? t('common.update') : t('common.add')}
</button>
</div>
}
>
<form onSubmit={handleSubmit} className="space-y-4" onPaste={handlePaste}>
{/* Place Search */}
@@ -632,6 +613,23 @@ export default function PlaceFormModal({
</div>
)}
{/* Actions */}
<div className="flex justify-end gap-3 pt-2 border-t border-gray-100">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-900 border border-gray-200 rounded-lg hover:bg-gray-50"
>
{t('common.cancel')}
</button>
<button
type="submit"
disabled={isSaving || hasTimeError}
className="px-6 py-2 bg-slate-900 text-white text-sm rounded-lg hover:bg-slate-700 disabled:opacity-60 font-medium"
>
{isSaving ? t('common.saving') : place ? t('common.update') : t('common.add')}
</button>
</div>
</form>
</Modal>
)
@@ -203,10 +203,8 @@ describe('ReservationModal', () => {
fireEvent.change(datePickers[1], { target: { value: '2025-06-09' } });
fireEvent.change(timePickers[1], { target: { value: '09:00' } });
// When isEndBeforeStart=true the submit button is disabled, so fire submit on the form directly.
// The Save button now lives in the Modal's sticky footer (outside the <form>), so we query
// the form by tag instead of walking up from the button.
const form = document.querySelector('form')!;
// When isEndBeforeStart=true the submit button is disabled, so submit the form directly
const form = screen.getByRole('button', { name: /^Add$/i }).closest('form')!;
fireEvent.submit(form);
expect(onSave).not.toHaveBeenCalled();
@@ -182,8 +182,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
let combinedEndTime = form.reservation_end_time
if (form.end_date) {
combinedEndTime = form.reservation_end_time ? `${form.end_date}T${form.reservation_end_time}` : form.end_date
} else if (form.reservation_end_time && form.reservation_time) {
combinedEndTime = `${form.reservation_time.split('T')[0]}T${form.reservation_end_time}`
}
if (isBudgetEnabled) {
if (form.price) metadata.price = form.price
@@ -273,22 +271,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
const labelStyle = { display: 'block', fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', marginBottom: 5, textTransform: 'uppercase', letterSpacing: '0.03em' }
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={reservation ? t('reservations.editTitle') : t('reservations.newTitle')}
size="2xl"
footer={
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button type="button" onClick={onClose} style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 12, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
{t('common.cancel')}
</button>
<button type="button" onClick={handleSubmit} disabled={isSaving || !form.title.trim() || isEndBeforeStart} style={{ padding: '8px 20px', borderRadius: 10, border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)', fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: isSaving || !form.title.trim() || isEndBeforeStart ? 0.5 : 1 }}>
{isSaving ? t('common.saving') : reservation ? t('common.update') : t('common.add')}
</button>
</div>
}
>
<Modal isOpen={isOpen} onClose={onClose} title={reservation ? t('reservations.editTitle') : t('reservations.newTitle')} size="2xl">
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{/* Type selector */}
@@ -434,17 +417,12 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
<CustomSelect
value={form.hotel_place_id}
onChange={value => {
set('hotel_place_id', value)
const p = places.find(pl => pl.id === value)
setForm(prev => {
const next = { ...prev, hotel_place_id: value }
if (!value) {
next.location = ''
} else if (p) {
if (!prev.title) next.title = p.name
if (!prev.location && p.address) next.location = p.address
}
return next
})
if (p) {
if (!form.title) set('title', p.name)
if (!form.location && p.address) set('location', p.address)
}
}}
placeholder={t('reservations.meta.pickHotel')}
options={[
@@ -639,6 +617,15 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
</>
)}
{/* Actions */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, paddingTop: 4, borderTop: '1px solid var(--border-secondary)' }}>
<button type="button" onClick={onClose} style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 12, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
{t('common.cancel')}
</button>
<button type="submit" disabled={isSaving || !form.title.trim() || isEndBeforeStart} style={{ padding: '8px 20px', borderRadius: 10, border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)', fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: isSaving || !form.title.trim() || isEndBeforeStart ? 0.5 : 1 }}>
{isSaving ? t('common.saving') : reservation ? t('common.update') : t('common.add')}
</button>
</div>
</form>
</Modal>
)
@@ -112,30 +112,17 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
const TRANSPORT_TYPES_SET = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
const isTransportType = TRANSPORT_TYPES_SET.has(r.type)
const isHotel = r.type === 'hotel'
const startDay = r.day_id ? days.find(d => d.id === r.day_id)
: (isHotel && r.accommodation_start_day_id) ? days.find(d => d.id === r.accommodation_start_day_id)
: undefined
const endDay = r.end_day_id ? days.find(d => d.id === r.end_day_id)
: (isHotel && r.accommodation_end_day_id) ? days.find(d => d.id === r.accommodation_end_day_id)
: undefined
const DayLabel = ({ day }: { day: typeof startDay }) => {
if (!day) return null
const name = day.title || t('dayplan.dayN', { n: day.day_number })
const badge = day.date
? new Date(day.date + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })
: null
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
<span>{name}</span>
{badge && (
<span style={{
fontSize: 10, fontWeight: 600, color: 'var(--text-faint)',
background: 'var(--bg-secondary)', padding: '1px 6px', borderRadius: 999,
}}>{badge}</span>
)}
</span>
)
const startDay = r.day_id ? days.find(d => d.id === r.day_id) : undefined
const endDay = r.end_day_id ? days.find(d => d.id === r.end_day_id) : undefined
const dayLabel = (day: typeof startDay): string => {
if (!day) return ''
const base = day.title || t('dayplan.dayN', { n: day.day_number })
if (day.date) {
const d = new Date(day.date + 'T00:00:00Z')
const dateStr = d.toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })
return `${base} · ${dateStr}`
}
return base
}
return (
@@ -148,15 +135,13 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
onMouseEnter={e => e.currentTarget.style.boxShadow = '0 2px 12px rgba(0,0,0,0.06)'}
onMouseLeave={e => e.currentTarget.style.boxShadow = 'none'}
>
{/* Header wraps to a second row on narrow screens so the status/category chips
never collide with the title. */}
{/* Header */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
flexWrap: 'wrap',
padding: '12px 14px',
background: confirmed ? 'rgba(22,163,74,0.06)' : 'rgba(217,119,6,0.06)',
}}>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 10, minWidth: 0, flexWrap: 'wrap' }}>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
fontSize: 12, fontWeight: 600, color: confirmed ? '#16a34a' : '#d97706',
@@ -217,15 +202,12 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
{/* Body */}
<div style={{ padding: 14, display: 'flex', flexDirection: 'column', gap: 12, flex: 1 }}>
{/* Day label for transport/hotel reservations linked to days */}
{(isTransportType || isHotel) && startDay && (
{/* Day label for transport reservations linked to a day */}
{isTransportType && startDay && (
<div>
<div style={fieldLabelStyle}>{t('reservations.date')}</div>
<div style={{ ...fieldValueStyle, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, flexWrap: 'wrap' }}>
<DayLabel day={startDay} />
{endDay && endDay.id !== startDay.id && (
<><span style={{ color: 'var(--text-faint)' }}></span><DayLabel day={endDay} /></>
)}
<div style={{ ...fieldValueStyle, textAlign: 'center' }}>
{dayLabel(startDay)}{endDay && endDay.id !== startDay.id ? ` ${dayLabel(endDay)}` : ''}
</div>
</div>
)}
@@ -236,16 +218,7 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
<div style={fieldLabelStyle}>{t('reservations.date')}</div>
<div style={{ ...fieldValueStyle, textAlign: 'center' }}>
{fmtDate(r.reservation_time)}
{(() => {
const endDatePart = r.reservation_end_time
? r.reservation_end_time.includes('T')
? r.reservation_end_time.split('T')[0]
: /^\d{4}-\d{2}-\d{2}$/.test(r.reservation_end_time)
? r.reservation_end_time
: null
: null
return endDatePart && endDatePart !== r.reservation_time.split('T')[0]
})() && (
{r.reservation_end_time && (r.reservation_end_time.includes('T') ? r.reservation_end_time.split('T')[0] : r.reservation_end_time) !== r.reservation_time.split('T')[0] && (
<> {fmtDate(r.reservation_end_time)}</>
)}
</div>
@@ -1,4 +1,4 @@
import { useState, useEffect, useMemo } from 'react'
import { useState, useEffect } from 'react'
import { Plane, Train, Car, Ship } from 'lucide-react'
import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect'
@@ -7,8 +7,6 @@ import AirportSelect, { type Airport } from './AirportSelect'
import LocationSelect, { type LocationPoint } from './LocationSelect'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import { useTripStore } from '../../store/tripStore'
import { useAddonStore } from '../../store/addonStore'
import { formatDate } from '../../utils/formatters'
import type { Day, Reservation, ReservationEndpoint } from '../../types'
@@ -77,8 +75,6 @@ const defaultForm = {
arrival_time: '',
confirmation_number: '',
notes: '',
price: '',
budget_category: '',
meta_airline: '',
meta_flight_number: '',
meta_train_number: '',
@@ -98,13 +94,6 @@ interface TransportModalProps {
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId }: TransportModalProps) {
const { t, locale } = useTranslation()
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 [isSaving, setIsSaving] = useState(false)
const [fromPick, setFromPick] = useState<EndpointPick>({})
@@ -137,8 +126,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
meta_train_number: meta.train_number || '',
meta_platform: meta.platform || '',
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') {
setFromPick({ airport: airportFromEndpoint(from) || undefined })
@@ -152,7 +139,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
setFromPick({})
setToPick({})
}
}, [isOpen, reservation, selectedDayId, budgetItems])
}, [isOpen, reservation, selectedDayId])
const set = (field: string, value: any) => setForm(prev => ({ ...prev, [field]: value }))
@@ -186,10 +173,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
if (form.meta_platform) metadata.platform = form.meta_platform
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 endDate = (endDay ?? startDay)?.date ?? null
@@ -217,11 +200,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
endpoints,
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)
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
@@ -259,16 +237,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
onClose={onClose}
title={reservation ? t('transport.modalTitle.edit') : t('transport.modalTitle.create')}
size="2xl"
footer={
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button type="button" onClick={onClose} style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 12, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
{t('common.cancel')}
</button>
<button type="button" onClick={handleSubmit} disabled={isSaving || !form.title.trim()} style={{ padding: '8px 20px', borderRadius: 10, border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)', fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: isSaving || !form.title.trim() ? 0.5 : 1 }}>
{isSaving ? t('common.saving') : reservation ? t('common.update') : t('common.add')}
</button>
</div>
}
>
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
@@ -444,40 +412,15 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
style={{ ...inputStyle, resize: 'none', lineHeight: 1.5 }} />
</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>
)}
</>
)}
{/* Actions */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, paddingTop: 4, borderTop: '1px solid var(--border-secondary)' }}>
<button type="button" onClick={onClose} style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 12, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
{t('common.cancel')}
</button>
<button type="submit" disabled={isSaving || !form.title.trim()} style={{ padding: '8px 20px', borderRadius: 10, border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)', fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: isSaving || !form.title.trim() ? 0.5 : 1 }}>
{isSaving ? t('common.saving') : reservation ? t('common.update') : t('common.add')}
</button>
</div>
</form>
</Modal>
)
@@ -155,9 +155,7 @@ describe('DisplaySettingsTab', () => {
const updateSetting = vi.fn().mockResolvedValue(undefined);
seedStore(useSettingsStore, { settings: buildSettings({ time_format: '12h' }), updateSetting });
render(<DisplaySettingsTab />);
// The label is split across a text node ('24h') and a responsive span (' (14:30)').
// Click the button that contains the 24h text instead of matching the full string.
await user.click(screen.getByRole('button', { name: /24h/ }));
await user.click(screen.getByText('24h (14:30)'));
expect(updateSetting).toHaveBeenCalledWith('time_format', '24h');
});
@@ -188,8 +188,8 @@ export default function DisplaySettingsTab(): React.ReactElement {
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.timeFormat')}</label>
<div className="flex gap-3">
{[
{ value: '24h', short: '24h', example: '14:30' },
{ value: '12h', short: '12h', example: '2:30 PM' },
{ value: '24h', label: '24h (14:30)' },
{ value: '12h', label: '12h (2:30 PM)' },
].map(opt => (
<button
key={opt.value}
@@ -207,8 +207,7 @@ export default function DisplaySettingsTab(): React.ReactElement {
transition: 'all 0.15s',
}}
>
{opt.short}
<span className="hidden sm:inline">{` (${opt.example})`}</span>
{opt.label}
</button>
))}
</div>
@@ -240,18 +240,14 @@ export default function MapSettingsTab(): React.ReactElement {
: 'border-slate-200 hover:border-slate-400 dark:border-slate-700'
}`}
>
<Box size={18} className="mt-0.5 flex-shrink-0 text-slate-700 dark:text-slate-300" />
<div className="min-w-0">
<div className="text-sm font-medium text-slate-900 dark:text-white">
<span className="sm:hidden">Mapbox</span>
<span className="hidden sm:inline">Mapbox GL</span>
</div>
<div className="hidden sm:block text-xs text-slate-500 mt-0.5">{t('settings.mapMapboxSubtitle')}</div>
</div>
{/* Experimental badge only on ≥sm; on mobile there's no room next to the title. */}
<span className="hidden sm:inline-block absolute top-2 right-2 text-[9px] font-semibold tracking-wide uppercase px-1.5 py-[3px] rounded bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300 leading-none">
<span className="absolute top-2 right-2 text-[9px] font-semibold tracking-wide uppercase px-1.5 py-[3px] rounded bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300 leading-none">
{t('settings.mapExperimental')}
</span>
<Box size={18} className="mt-0.5 flex-shrink-0 text-slate-700 dark:text-slate-300" />
<div>
<div className="text-sm font-medium text-slate-900 dark:text-white">Mapbox GL</div>
<div className="hidden sm:block text-xs text-slate-500 mt-0.5">{t('settings.mapMapboxSubtitle')}</div>
</div>
</button>
</div>
<p className="text-xs text-slate-400 mt-2">
@@ -5,7 +5,6 @@ import { useToast } from '../../components/shared/Toast'
import apiClient from '../../api/client'
import { useAddonStore } from '../../store/addonStore'
import Section from './Section'
import ToggleSwitch from './ToggleSwitch'
interface ProviderField {
key: string
@@ -223,13 +222,15 @@ export default function PhotoProvidersSection(): React.ReactElement {
{fields.map(field => (
<div key={`${provider.id}-${field.key}`}>
{field.input_type === 'checkbox' ? (
<div className="flex items-center gap-3">
<ToggleSwitch
on={values[field.key] === 'true'}
onToggle={() => handleProviderFieldChange(provider.id, field.key, values[field.key] === 'true' ? 'false' : 'true')}
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={values[field.key] === 'true'}
onChange={e => handleProviderFieldChange(provider.id, field.key, e.target.checked ? 'true' : 'false')}
className="w-4 h-4 rounded border-slate-300 accent-slate-900"
/>
<span className="text-sm font-medium text-slate-700">{t(`memories.${field.label}`)}</span>
</div>
</label>
) : (
<>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t(`memories.${field.label}`)}</label>
@@ -247,9 +248,7 @@ export default function PhotoProvidersSection(): React.ReactElement {
)}
</div>
))}
{/* Wraps on mobile so the connection badge drops to its own row
instead of clipping off the side of the card. */}
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-3">
<button
onClick={() => handleSaveProvider(provider)}
disabled={!canSave || !!saving[provider.id] || isProviderSaveDisabled(provider)}
@@ -267,17 +266,15 @@ export default function PhotoProvidersSection(): React.ReactElement {
{testing
? <div className="w-4 h-4 border-2 border-slate-300 border-t-slate-700 rounded-full animate-spin" />
: <Camera className="w-4 h-4" />}
<span className="sm:hidden">{t('memories.testShort')}</span>
<span className="hidden sm:inline">{t('memories.testConnection')}</span>
{t('memories.testConnection')}
</button>
{/* On mobile the badge sits on its own row thanks to flex-wrap, so force a line break via basis-full. */}
{connected ? (
<span className="basis-full sm:basis-auto text-xs font-medium text-green-600 flex items-center gap-1">
<span className="text-xs font-medium text-green-600 flex items-center gap-1">
<span className="w-2 h-2 bg-green-500 rounded-full" />
{t('memories.connected')}
</span>
) : (
<span className="basis-full sm:basis-auto text-xs font-medium text-slate-400 flex items-center gap-1">
<span className="text-xs font-medium text-slate-400 flex items-center gap-1">
<span className="w-2 h-2 bg-slate-300 rounded-full" />
{t('memories.disconnected')}
</span>
@@ -2,10 +2,9 @@ import React from 'react'
export default function ToggleSwitch({ on, onToggle }: { on: boolean; onToggle: () => void }) {
return (
<button type="button" onClick={onToggle}
<button onClick={onToggle}
style={{
position: 'relative', width: 44, height: 24, minWidth: 44, flexShrink: 0,
borderRadius: 12, border: 'none', padding: 0, cursor: 'pointer',
position: 'relative', width: 44, height: 24, borderRadius: 12, border: 'none', cursor: 'pointer',
background: on ? 'var(--accent, #111827)' : 'var(--border-primary, #d1d5db)',
transition: 'background 0.2s',
}}>
@@ -41,7 +41,7 @@ export default function ConfirmDialog({
return (
<div
className="fixed inset-0 z-[10000] flex items-center justify-center px-4 trek-backdrop-enter"
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingBottom: 'var(--bottom-nav-h)' }}
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)' }}
onClick={onClose}
>
<div
@@ -1,108 +0,0 @@
import React, { useEffect, useCallback } from 'react'
import { Check, X } from 'lucide-react'
import { useTranslation } from '../../i18n'
interface CopyTripDialogProps {
isOpen: boolean
tripTitle: string
onClose: () => void
onConfirm: () => void
}
const WILL_COPY_KEYS = [
'dashboard.confirm.copy.will1',
'dashboard.confirm.copy.will2',
'dashboard.confirm.copy.will3',
'dashboard.confirm.copy.will4',
'dashboard.confirm.copy.will5',
'dashboard.confirm.copy.will6',
]
const WONT_COPY_KEYS = [
'dashboard.confirm.copy.wont1',
'dashboard.confirm.copy.wont2',
'dashboard.confirm.copy.wont3',
'dashboard.confirm.copy.wont4',
]
export default function CopyTripDialog({ isOpen, tripTitle, onClose, onConfirm }: CopyTripDialogProps) {
const { t } = useTranslation()
const handleEsc = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}, [onClose])
useEffect(() => {
if (isOpen) document.addEventListener('keydown', handleEsc)
return () => document.removeEventListener('keydown', handleEsc)
}, [isOpen, handleEsc])
if (!isOpen) return null
return (
<div
className="fixed inset-0 z-[10000] flex items-center justify-center px-4 trek-backdrop-enter"
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingBottom: 'var(--bottom-nav-h)' }}
onClick={onClose}
>
<div
className="trek-modal-enter rounded-2xl shadow-2xl w-full max-w-md p-6"
style={{ background: 'var(--bg-card)' }}
onClick={e => e.stopPropagation()}
>
<h3 className="text-base font-semibold mb-1" style={{ color: 'var(--text-primary)' }}>
{t('dashboard.confirm.copy.title')}
</h3>
<p className="text-sm mb-4" style={{ color: 'var(--text-secondary)' }}>
{tripTitle}
</p>
<div className="flex flex-col gap-3">
<div className="rounded-xl p-3" style={{ background: 'var(--bg-subtle)', border: '1px solid var(--border-secondary)' }}>
<p className="text-xs font-semibold uppercase tracking-wide mb-2" style={{ color: '#16a34a' }}>
{t('dashboard.confirm.copy.willCopy')}
</p>
<ul className="flex flex-col gap-1">
{WILL_COPY_KEYS.map(key => (
<li key={key} className="flex items-center gap-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
<Check size={13} className="flex-shrink-0" style={{ color: '#16a34a' }} />
{t(key)}
</li>
))}
</ul>
</div>
<div className="rounded-xl p-3" style={{ background: 'var(--bg-subtle)', border: '1px solid var(--border-secondary)' }}>
<p className="text-xs font-semibold uppercase tracking-wide mb-2" style={{ color: 'var(--text-muted)' }}>
{t('dashboard.confirm.copy.wontCopy')}
</p>
<ul className="flex flex-col gap-1">
{WONT_COPY_KEYS.map(key => (
<li key={key} className="flex items-center gap-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
<X size={13} className="flex-shrink-0" style={{ color: 'var(--text-muted)' }} />
{t(key)}
</li>
))}
</ul>
</div>
</div>
<div className="flex justify-end gap-3 mt-5">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium rounded-lg transition-colors"
style={{ color: 'var(--text-secondary)', border: '1px solid var(--border-secondary)' }}
>
{t('common.cancel')}
</button>
<button
onClick={() => { onConfirm(); onClose() }}
className="px-4 py-2 text-sm font-medium rounded-lg transition-colors text-white bg-blue-600 hover:bg-blue-700"
>
{t('dashboard.confirm.copy.confirm')}
</button>
</div>
</div>
</div>
)
}
@@ -119,14 +119,13 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {}, com
...(() => {
const r = ref.current?.getBoundingClientRect()
if (!r) return { top: 0, left: 0 }
const w = 268, pad = 8, h = 360
const w = 268, pad = 8
const vw = window.innerWidth
const vh = window.visualViewport?.height ?? window.innerHeight
const vh = window.innerHeight
let left = r.left
let top = r.bottom + 4
if (left + w > vw - pad) left = Math.max(pad, vw - w - pad)
if (top + h > vh - pad) top = r.top - h - 4
top = Math.max(pad, Math.min(top, vh - h - pad))
if (top + 320 > vh) top = Math.max(pad, r.top - 320)
if (vw < 360) left = Math.max(pad, (vw - w) / 2)
return { top, left }
})(),
+8 -9
View File
@@ -61,15 +61,14 @@ export default function Modal({
<div
className={`
trek-modal-enter
rounded-2xl overflow-hidden shadow-2xl w-full ${sizeClasses[size] || sizeClasses.md}
flex flex-col
max-h-[calc(100dvh-var(--bottom-nav-h)-90px)] sm:max-h-[calc(100dvh-90px)]
rounded-2xl shadow-2xl w-full ${sizeClasses[size] || sizeClasses.md}
flex flex-col max-h-[calc(100vh-180px)] md:max-h-[calc(100vh-90px)]
`}
style={{ background: 'var(--bg-card)' }}
onClick={e => e.stopPropagation()}
>
{/* Header — stays put even while the body scrolls */}
<div className="flex items-center justify-between p-6 flex-shrink-0" style={{ borderBottom: '1px solid var(--border-secondary)' }}>
{/* Header */}
<div className="flex items-center justify-between p-6" style={{ borderBottom: '1px solid var(--border-secondary)' }}>
<h2 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{title}</h2>
{!hideCloseButton && (
<button
@@ -81,14 +80,14 @@ export default function Modal({
)}
</div>
{/* Body — scrolls when content overflows. min-h-0 lets the flex child shrink below its intrinsic height. */}
<div className="flex-1 overflow-y-auto p-6 min-h-0">
{/* Body */}
<div className="flex-1 overflow-y-auto p-6">
{children}
</div>
{/* Footer — sticky at the bottom of the modal, never compressed */}
{/* Footer */}
{footer && (
<div className="p-6 flex-shrink-0" style={{ borderTop: '1px solid var(--border-secondary)' }}>
<div className="p-6" style={{ borderTop: '1px solid var(--border-secondary)' }}>
{footer}
</div>
)}
-3
View File
@@ -34,8 +34,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'common.none': 'لا شيء',
'common.date': 'التاريخ',
'common.rename': 'إعادة تسمية',
'common.discardChanges': 'تجاهل التغييرات',
'common.discard': 'تجاهل',
'common.name': 'الاسم',
'common.email': 'البريد الإلكتروني',
'common.password': 'كلمة المرور',
@@ -1625,7 +1623,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'memories.immichAutoUpload': 'نسخ صور الرحلة إلى Immich عند الرفع',
'memories.providerUrlHintSynology': 'أدرج مسار تطبيق Photos في URL، مثل https://nas:5001/photo',
'memories.testConnection': 'اختبار الاتصال',
'memories.testShort': 'اختبار',
'memories.testFirst': 'اختبر الاتصال أولاً',
'memories.connected': 'متصل',
'memories.disconnected': 'غير متصل',
-3
View File
@@ -30,8 +30,6 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'common.none': 'Nenhum',
'common.date': 'Data',
'common.rename': 'Renomear',
'common.discardChanges': 'Descartar alterações',
'common.discard': 'Descartar',
'common.name': 'Nome',
'common.email': 'E-mail',
'common.password': 'Senha',
@@ -1664,7 +1662,6 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'memories.immichAutoUpload': 'Espelhar fotos da jornada no Immich ao enviar',
'memories.providerUrlHintSynology': 'Inclua o caminho do aplicativo Photos na URL, ex. https://nas:5001/photo',
'memories.testConnection': 'Testar conexão',
'memories.testShort': 'Testar',
'memories.testFirst': 'Teste a conexão primeiro',
'memories.connected': 'Conectado',
'memories.disconnected': 'Não conectado',
-3
View File
@@ -30,8 +30,6 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'common.none': 'Žádné',
'common.date': 'Datum',
'common.rename': 'Přejmenovat',
'common.discardChanges': 'Zahodit změny',
'common.discard': 'Zahodit',
'common.name': 'Jméno',
'common.email': 'E-mail',
'common.password': 'Heslo',
@@ -1623,7 +1621,6 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'memories.immichAutoUpload': 'Zrcadlit fotky journey při nahrávání také do Immich',
'memories.providerUrlHintSynology': 'Zahrňte cestu aplikace Photos do URL, např. https://nas:5001/photo',
'memories.testConnection': 'Otestovat připojení',
'memories.testShort': 'Otestovat',
'memories.testFirst': 'Nejprve otestujte připojení',
'memories.connected': 'Připojeno',
'memories.disconnected': 'Nepřipojeno',
-3
View File
@@ -30,8 +30,6 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'common.none': 'Keine',
'common.date': 'Datum',
'common.rename': 'Umbenennen',
'common.discardChanges': 'Änderungen verwerfen',
'common.discard': 'Verwerfen',
'common.name': 'Name',
'common.email': 'E-Mail',
'common.password': 'Passwort',
@@ -1627,7 +1625,6 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'memories.immichAutoUpload': 'Journey-Fotos beim Upload auch zu Immich spiegeln',
'memories.providerUrlHintSynology': 'Füge den Fotos-App-Pfad in die URL ein, z.B. https://nas:5001/photo',
'memories.testConnection': 'Verbindung testen',
'memories.testShort': 'Testen',
'memories.testFirst': 'Verbindung zuerst testen',
'memories.connected': 'Verbunden',
'memories.disconnected': 'Nicht verbunden',
-17
View File
@@ -30,8 +30,6 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'common.none': 'None',
'common.date': 'Date',
'common.rename': 'Rename',
'common.discardChanges': 'Discard Changes',
'common.discard': 'Discard',
'common.name': 'Name',
'common.email': 'Email',
'common.password': 'Password',
@@ -124,20 +122,6 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'dashboard.toast.copied': 'Trip copied!',
'dashboard.toast.copyError': 'Failed to copy trip',
'dashboard.confirm.delete': 'Delete trip "{title}"? All places and plans will be permanently deleted.',
'dashboard.confirm.copy.title': 'Copy this trip?',
'dashboard.confirm.copy.willCopy': 'Will be copied',
'dashboard.confirm.copy.will1': 'Days, places & day assignments',
'dashboard.confirm.copy.will2': 'Accommodations & reservations',
'dashboard.confirm.copy.will3': 'Budget items & category order',
'dashboard.confirm.copy.will4': 'Packing lists (unchecked)',
'dashboard.confirm.copy.will5': 'TODOs (unassigned & unchecked)',
'dashboard.confirm.copy.will6': 'Day notes',
'dashboard.confirm.copy.wontCopy': "Won't be copied",
'dashboard.confirm.copy.wont1': 'Collaborators & member assignments',
'dashboard.confirm.copy.wont2': 'Collab notes, polls & messages',
'dashboard.confirm.copy.wont3': 'Files & photos',
'dashboard.confirm.copy.wont4': 'Share tokens',
'dashboard.confirm.copy.confirm': 'Copy trip',
'dashboard.editTrip': 'Edit Trip',
'dashboard.createTrip': 'Create New Trip',
'dashboard.tripTitle': 'Title',
@@ -1700,7 +1684,6 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'memories.immichAutoUpload': 'Mirror journey photos to Immich on upload',
'memories.providerUrlHintSynology': 'Include the Photos app path in the URL, e.g. https://nas:5001/photo',
'memories.testConnection': 'Test connection',
'memories.testShort': 'Test',
'memories.testFirst': 'Test connection first',
'memories.connected': 'Connected',
'memories.disconnected': 'Not connected',
-3
View File
@@ -30,8 +30,6 @@ const es: Record<string, string> = {
'common.none': 'Ninguno',
'common.date': 'Fecha',
'common.rename': 'Renombrar',
'common.discardChanges': 'Descartar cambios',
'common.discard': 'Descartar',
'common.name': 'Nombre',
'common.email': 'Correo',
'common.password': 'Contraseña',
@@ -1564,7 +1562,6 @@ const es: Record<string, string> = {
'memories.immichAutoUpload': 'Duplicar las fotos del journey en Immich al subirlas',
'memories.providerUrlHintSynology': 'Incluye la ruta de la aplicación Photos en la URL, p.ej. https://nas:5001/photo',
'memories.testConnection': 'Probar conexión',
'memories.testShort': 'Probar',
'memories.testFirst': 'Probar conexión primero',
'memories.connected': 'Conectado',
'memories.disconnected': 'No conectado',
-3
View File
@@ -30,8 +30,6 @@ const fr: Record<string, string> = {
'common.none': 'Aucun',
'common.date': 'Date',
'common.rename': 'Renommer',
'common.discardChanges': 'Ignorer les modifications',
'common.discard': 'Ignorer',
'common.name': 'Nom',
'common.email': 'E-mail',
'common.password': 'Mot de passe',
@@ -1621,7 +1619,6 @@ const fr: Record<string, string> = {
'memories.immichAutoUpload': 'Répliquer les photos du journey vers Immich au téléversement',
'memories.providerUrlHintSynology': 'Incluez le chemin de l\'application Photos dans l\'URL, ex. https://nas:5001/photo',
'memories.testConnection': 'Tester la connexion',
'memories.testShort': 'Tester',
'memories.testFirst': 'Testez la connexion avant de sauvegarder',
'memories.connected': 'Connecté',
'memories.disconnected': 'Non connecté',
-3
View File
@@ -30,8 +30,6 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'common.none': 'Nincs',
'common.date': 'Dátum',
'common.rename': 'Átnevezés',
'common.discardChanges': 'Változtatások elvetése',
'common.discard': 'Elveti',
'common.name': 'Név',
'common.email': 'E-mail',
'common.password': 'Jelszó',
@@ -1692,7 +1690,6 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'memories.immichAutoUpload': 'Journey-fotók feltöltésekor másolat Immich-be is',
'memories.providerUrlHintSynology': 'Adja meg a Photos alkalmazás elérési útját az URL-ben, pl. https://nas:5001/photo',
'memories.testConnection': 'Kapcsolat tesztelése',
'memories.testShort': 'Teszt',
'memories.testFirst': 'Először teszteld a kapcsolatot',
'memories.connected': 'Csatlakoztatva',
'memories.disconnected': 'Nincs csatlakoztatva',
-3
View File
@@ -30,8 +30,6 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'common.none': 'Tidak ada',
'common.date': 'Tanggal',
'common.rename': 'Ganti nama',
'common.discardChanges': 'Buang perubahan',
'common.discard': 'Buang',
'common.name': 'Nama',
'common.email': 'Email',
'common.password': 'Kata sandi',
@@ -1684,7 +1682,6 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'memories.immichAutoUpload': 'Salin foto journey ke Immich saat diunggah',
'memories.providerUrlHintSynology': 'Sertakan path aplikasi Photos di URL, mis. https://nas:5001/photo',
'memories.testConnection': 'Uji koneksi',
'memories.testShort': 'Uji',
'memories.testFirst': 'Uji koneksi terlebih dahulu',
'memories.connected': 'Terhubung',
'memories.disconnected': 'Tidak terhubung',
-3
View File
@@ -30,8 +30,6 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'common.none': 'Nessuno',
'common.date': 'Data',
'common.rename': 'Rinomina',
'common.discardChanges': 'Scarta modifiche',
'common.discard': 'Scarta',
'common.name': 'Nome',
'common.email': 'Email',
'common.password': 'Password',
@@ -1622,7 +1620,6 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'memories.immichAutoUpload': 'Rispecchia le foto del journey su Immich al caricamento',
'memories.providerUrlHintSynology': 'Includi il percorso dell\'app Foto nell\'URL, es. https://nas:5001/photo',
'memories.testConnection': 'Test connessione',
'memories.testShort': 'Prova',
'memories.testFirst': 'Testa prima la connessione',
'memories.connected': 'Connesso',
'memories.disconnected': 'Non connesso',
+4 -7
View File
@@ -30,8 +30,6 @@ const nl: Record<string, string> = {
'common.none': 'Geen',
'common.date': 'Datum',
'common.rename': 'Hernoemen',
'common.discardChanges': 'Wijzigingen verwerpen',
'common.discard': 'Verwerpen',
'common.name': 'Naam',
'common.email': 'E-mail',
'common.password': 'Wachtwoord',
@@ -614,8 +612,8 @@ const nl: Record<string, string> = {
'admin.collab.chat.subtitle': 'Realtime berichten voor reissamenwerking',
'admin.collab.notes.title': 'Notities',
'admin.collab.notes.subtitle': 'Gedeelde notities en documenten',
'admin.collab.polls.title': 'Polls',
'admin.collab.polls.subtitle': 'Groepspolls en stemmen',
'admin.collab.polls.title': 'Peilingen',
'admin.collab.polls.subtitle': 'Groepspeilingen en stemmen',
'admin.collab.whatsnext.title': 'Wat nu',
'admin.collab.whatsnext.subtitle': 'Activiteitssuggesties en volgende stappen',
'admin.tabs.config': 'Personalisatie',
@@ -1621,7 +1619,6 @@ const nl: Record<string, string> = {
'memories.immichAutoUpload': 'Journey-foto\'s bij upload ook naar Immich spiegelen',
'memories.providerUrlHintSynology': 'Voeg het pad van de Photos-app toe aan de URL, bijv. https://nas:5001/photo',
'memories.testConnection': 'Verbinding testen',
'memories.testShort': 'Testen',
'memories.testFirst': 'Test eerst de verbinding',
'memories.connected': 'Verbonden',
'memories.disconnected': 'Niet verbonden',
@@ -1661,7 +1658,7 @@ const nl: Record<string, string> = {
// Collab Addon
'collab.tabs.chat': 'Chat',
'collab.tabs.notes': 'Notities',
'collab.tabs.polls': 'Polls',
'collab.tabs.polls': 'Peilingen',
'collab.whatsNext.title': 'Wat komt er',
'collab.whatsNext.today': 'Vandaag',
'collab.whatsNext.tomorrow': 'Morgen',
@@ -1707,7 +1704,7 @@ const nl: Record<string, string> = {
'collab.notes.attachFiles': 'Bestanden bijvoegen',
'collab.notes.noCategoriesYet': 'Nog geen categorieën',
'collab.notes.emptyDesc': 'Maak een notitie om te beginnen',
'collab.polls.title': 'Polls',
'collab.polls.title': 'Peilingen',
'collab.polls.new': 'Nieuwe poll',
'collab.polls.empty': 'Nog geen polls',
'collab.polls.emptyHint': 'Stel de groep een vraag en stem samen',
-3
View File
@@ -26,8 +26,6 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'common.none': 'Brak',
'common.date': 'Data',
'common.rename': 'Zmień nazwę',
'common.discardChanges': 'Odrzuć zmiany',
'common.discard': 'Odrzuć',
'common.name': 'Nazwa',
'common.email': 'E-mail',
'common.password': 'Hasło',
@@ -1573,7 +1571,6 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'memories.immichAutoUpload': 'Przy przesyłaniu kopiuj zdjęcia journey także do Immich',
'memories.providerUrlHintSynology': 'Uwzględnij ścieżkę aplikacji Photos w URL, np. https://nas:5001/photo',
'memories.testConnection': 'Test',
'memories.testShort': 'Test',
'memories.connected': 'Połączono',
'memories.disconnected': 'Nie połączono',
'memories.connectionSuccess': 'Połączono z Immich',
-3
View File
@@ -30,8 +30,6 @@ const ru: Record<string, string> = {
'common.none': 'Нет',
'common.date': 'Дата',
'common.rename': 'Переименовать',
'common.discardChanges': 'Отменить изменения',
'common.discard': 'Отменить',
'common.name': 'Имя',
'common.email': 'Эл. почта',
'common.password': 'Пароль',
@@ -1621,7 +1619,6 @@ const ru: Record<string, string> = {
'memories.immichAutoUpload': 'Дублировать фото journey в Immich при загрузке',
'memories.providerUrlHintSynology': 'Включите путь приложения Photos в URL, например https://nas:5001/photo',
'memories.testConnection': 'Проверить подключение',
'memories.testShort': 'Проверить',
'memories.testFirst': 'Сначала проверьте подключение',
'memories.connected': 'Подключено',
'memories.disconnected': 'Не подключено',
-3
View File
@@ -30,8 +30,6 @@ const zh: Record<string, string> = {
'common.none': '无',
'common.date': '日期',
'common.rename': '重命名',
'common.discardChanges': '放弃更改',
'common.discard': '放弃',
'common.name': '名称',
'common.email': '邮箱',
'common.password': '密码',
@@ -1621,7 +1619,6 @@ const zh: Record<string, string> = {
'memories.immichAutoUpload': '上传 Journey 照片时同步到 Immich',
'memories.providerUrlHintSynology': '在 URL 中包含照片应用路径,例如 https://nas:5001/photo',
'memories.testConnection': '测试连接',
'memories.testShort': '测试',
'memories.testFirst': '请先测试连接',
'memories.connected': '已连接',
'memories.disconnected': '未连接',
-3
View File
@@ -30,8 +30,6 @@ const zhTw: Record<string, string> = {
'common.none': '無',
'common.date': '日期',
'common.rename': '重新命名',
'common.discardChanges': '捨棄變更',
'common.discard': '捨棄',
'common.name': '名稱',
'common.email': '郵箱',
'common.password': '密碼',
@@ -1681,7 +1679,6 @@ const zhTw: Record<string, string> = {
'memories.immichAutoUpload': '上傳 Journey 照片時同步到 Immich',
'memories.providerUrlHintSynology': '在網址中包含照片應用程式路徑,例如 https://nas:5001/photo',
'memories.testConnection': '測試連線',
'memories.testShort': '測試',
'memories.testFirst': '請先測試連線',
'memories.connected': '已連線',
'memories.disconnected': '未連線',
+2 -11
View File
@@ -1240,15 +1240,6 @@ interface SidebarContentProps {
function SidebarContent({ data, stats, countries, selectedCountry, countryDetail, resolveName, onTripClick, onUnmarkCountry, bucketList, bucketTab, setBucketTab, showBucketAdd, setShowBucketAdd, bucketForm, setBucketForm, onAddBucket, onDeleteBucket, onSearchBucket, onSelectBucketPoi, bucketSearchResults, setBucketSearchResults, bucketPoiMonth, setBucketPoiMonth, bucketPoiYear, setBucketPoiYear, bucketSearching, bucketSearch, setBucketSearch, t, dark }: SidebarContentProps): React.ReactElement {
const { language } = useTranslation()
const statsContentRef = useRef<HTMLDivElement>(null)
const [statsWidth, setStatsWidth] = useState<number | undefined>(undefined)
useEffect(() => {
const el = statsContentRef.current
if (!el || typeof ResizeObserver === 'undefined') return
const ro = new ResizeObserver(() => setStatsWidth(el.offsetWidth))
ro.observe(el)
return () => ro.disconnect()
}, [])
const bg = (o) => dark ? `rgba(255,255,255,${o})` : `rgba(0,0,0,${o})`
const tp = dark ? '#f1f5f9' : '#0f172a'
const tm = dark ? '#94a3b8' : '#64748b'
@@ -1299,7 +1290,7 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
// Bucket list content
const bucketContent = (
<>
<div className="flex items-stretch" style={{ overflowX: 'auto', padding: '0 8px', maxWidth: statsWidth, width: '100%' }}>
<div className="flex items-stretch" style={{ overflowX: 'auto', padding: '0 8px' }}>
{bucketList.map(item => (
<div key={item.id} className="group flex flex-col items-center justify-center shrink-0" style={{ padding: '8px 14px', position: 'relative', minWidth: 80 }}>
{(() => {
@@ -1409,7 +1400,7 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
{/* Both tabs always rendered so the wider one sets the panel width */}
<div style={{ display: 'grid' }}>
<div style={bucketTab === 'bucket' ? { visibility: 'hidden' as const, gridArea: '1/1' } : { gridArea: '1/1' }}>
<div ref={statsContentRef} className="flex items-stretch justify-center">
<div className="flex items-stretch justify-center">
{/* ═══ SECTION 1: Numbers ═══ */}
{/* Countries hero */}
-8
View File
@@ -401,10 +401,6 @@ describe('DashboardPage', () => {
const copyButtons = screen.getAllByRole('button', { name: /copy/i });
await user.click(copyButtons[0]);
// Confirm the copy dialog
const confirmButton = await screen.findByRole('button', { name: /copy trip/i });
await user.click(confirmButton);
await waitFor(() => {
expect(screen.getAllByText('Paris Adventure (Copy)')[0]).toBeInTheDocument();
});
@@ -770,10 +766,6 @@ describe('DashboardPage', () => {
expect(copyButtons.length).toBeGreaterThan(0);
await user.click(copyButtons[0]);
// Confirm the copy dialog
const confirmButton = await screen.findByRole('button', { name: /copy trip/i });
await user.click(confirmButton);
await waitFor(() => {
expect(screen.getAllByText('Paris Adventure (Copy)').length).toBeGreaterThan(0);
});
+2 -15
View File
@@ -12,7 +12,6 @@ import CurrencyWidget from '../components/Dashboard/CurrencyWidget'
import TimezoneWidget from '../components/Dashboard/TimezoneWidget'
import TripFormModal from '../components/Trips/TripFormModal'
import ConfirmDialog from '../components/shared/ConfirmDialog'
import CopyTripDialog from '../components/shared/CopyTripDialog'
import { useToast } from '../components/shared/Toast'
import { useCountUp } from '../hooks/useCountUp'
import {
@@ -700,7 +699,6 @@ export default function DashboardPage(): React.ReactElement {
const [showWidgetSettings, setShowWidgetSettings] = useState<boolean | 'mobile' | 'mobile-currency' | 'mobile-timezone'>(false)
const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => (localStorage.getItem('trek_dashboard_view') as 'grid' | 'list') || 'grid')
const [deleteTrip, setDeleteTrip] = useState<DashboardTrip | null>(null)
const [copyTrip, setCopyTrip] = useState<DashboardTrip | null>(null)
const toggleViewMode = () => {
setViewMode(prev => {
@@ -817,18 +815,14 @@ export default function DashboardPage(): React.ReactElement {
setArchivedTrips(prev => prev.map(update))
}
const handleCopy = (trip: DashboardTrip) => setCopyTrip(trip)
const confirmCopy = async () => {
if (!copyTrip) return
const handleCopy = async (trip: DashboardTrip) => {
try {
const data = await tripsApi.copy(copyTrip.id, { title: `${copyTrip.title} (${t('dashboard.copySuffix')})` })
const data = await tripsApi.copy(trip.id, { title: `${trip.title} (${t('dashboard.copySuffix')})` })
setTrips(prev => sortTrips([data.trip, ...prev]))
toast.success(t('dashboard.toast.copied'))
} catch {
toast.error(t('dashboard.toast.copyError'))
}
setCopyTrip(null)
}
const today = new Date().toISOString().split('T')[0]
@@ -1211,13 +1205,6 @@ export default function DashboardPage(): React.ReactElement {
message={t('dashboard.confirm.delete', { title: deleteTrip?.title || '' })}
/>
<CopyTripDialog
isOpen={!!copyTrip}
tripTitle={copyTrip?.title || ''}
onClose={() => setCopyTrip(null)}
onConfirm={confirmCopy}
/>
<style>{`
@keyframes pulse {
0%, 100% { opacity: 1 }
+28 -42
View File
@@ -177,24 +177,6 @@ const mockJourneyDetail = {
},
],
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 ─────────────────────────────────────────────────────────────
@@ -1486,7 +1468,7 @@ describe('JourneyDetailPage', () => {
// ── FE-PAGE-JOURNEYDETAIL-074 ──────────────────────────────────────────
describe('FE-PAGE-JOURNEYDETAIL-074: Delete share link removes it', () => {
it('clicking "Delete link" calls DELETE and returns to create state', async () => {
it('clicking "Remove share link" calls DELETE and returns to create state', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
let deleteCalled = false;
@@ -1511,10 +1493,10 @@ describe('JourneyDetailPage', () => {
await openSettingsDialog(user);
await waitFor(() => {
expect(screen.getByText('Delete link')).toBeInTheDocument();
expect(screen.getByText('Remove share link')).toBeInTheDocument();
});
await user.click(screen.getByText('Delete link'));
await user.click(screen.getByText('Remove share link'));
await waitFor(() => {
expect(deleteCalled).toBe(true);
@@ -1742,14 +1724,13 @@ describe('JourneyDetailPage', () => {
it('renders the empty gallery state when journey has no photos', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
// Override with entries that have no photos and empty gallery
// Override with entries that have no photos
const emptyEntry = {
...mockJourneyDetail.entries[0],
photos: [],
};
setupDefaultHandlers({
entries: [emptyEntry],
gallery: [],
stats: { entries: 1, photos: 0, places: 1 },
});
@@ -2000,9 +1981,10 @@ describe('JourneyDetailPage', () => {
expect(screen.getByText(/1 photos/i)).toBeInTheDocument();
});
// Gallery photos render in a grid; each photo has a group container
const photos = document.querySelectorAll('[class*="aspect-square"]');
expect(photos.length).toBeGreaterThanOrEqual(1);
// The entry date '2026-03-15' is shown as a formatted overlay on each gallery photo
// The component uses toLocaleDateString which produces "Mar 15, 2026" in en-US
const dateOverlay = document.querySelector('[class*="opacity-0"]');
expect(dateOverlay).toBeTruthy();
});
});
@@ -2040,11 +2022,6 @@ describe('JourneyDetailPage', () => {
setupDefaultHandlers({
entries: [immichEntry, mockJourneyDetail.entries[1]],
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 />);
@@ -2079,11 +2056,6 @@ describe('JourneyDetailPage', () => {
setupDefaultHandlers({
entries: [synologyEntry, mockJourneyDetail.entries[1]],
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 />);
@@ -2933,7 +2905,7 @@ describe('JourneyDetailPage', () => {
// The permission toggles show Timeline, Gallery, Map labels within the share section
// These reuse the same i18n keys as the main tab bar
expect(screen.getByText('Delete link')).toBeInTheDocument();
expect(screen.getByText('Remove share link')).toBeInTheDocument();
expect(screen.getByText('Copy')).toBeInTheDocument();
});
});
@@ -3293,14 +3265,25 @@ describe('JourneyDetailPage', () => {
// ── FE-PAGE-JOURNEYDETAIL-141 ──────────────────────────────────────────
describe('FE-PAGE-JOURNEYDETAIL-141: Gallery upload triggers file input and calls API', () => {
it('uploading files in gallery calls gallery upload API', async () => {
it('uploading files in gallery creates an entry and uploads photos', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
let createCalled = false;
let uploadCalled = false;
server.use(
http.post('/api/journeys/1/gallery/photos', () => {
http.post('/api/journeys/1/entries', () => {
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;
return HttpResponse.json({ photos: [] });
return HttpResponse.json([]);
}),
);
@@ -3321,6 +3304,9 @@ describe('JourneyDetailPage', () => {
const testFile = new File(['fake-content'], 'test-photo.jpg', { type: 'image/jpeg' });
await user.upload(fileInput, testFile);
await waitFor(() => {
expect(createCalled).toBe(true);
});
await waitFor(() => {
expect(uploadCalled).toBe(true);
});
@@ -3334,9 +3320,9 @@ describe('JourneyDetailPage', () => {
let deleteCalled = false;
server.use(
http.delete('/api/journeys/1/gallery/100', () => {
http.delete('/api/journeys/photos/100', () => {
deleteCalled = true;
return new HttpResponse(null, { status: 204 });
return HttpResponse.json({ success: true });
}),
);
+106 -133
View File
@@ -1,5 +1,4 @@
import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
import { formatLocationName } from '../utils/formatters'
import { createPortal } from 'react-dom'
import { useParams, useNavigate } from 'react-router-dom'
import { useJourneyStore } from '../store/journeyStore'
@@ -9,7 +8,6 @@ import { journeyApi, authApi, addonsApi, mapsApi } from '../api/client'
import { addListener, removeListener } from '../api/websocket'
import Navbar from '../components/Layout/Navbar'
import JourneyMap from '../components/Journey/JourneyMapAuto'
import { DAY_COLORS } from '../components/Journey/dayColors'
import type { JourneyMapAutoHandle as JourneyMapHandle } from '../components/Journey/JourneyMapAuto'
import JournalBody from '../components/Journey/JournalBody'
import MarkdownToolbar from '../components/Journey/MarkdownToolbar'
@@ -27,7 +25,7 @@ import {
import MobileMapTimeline from '../components/Journey/MobileMapTimeline'
import MobileEntryView from '../components/Journey/MobileEntryView'
import { useIsMobile } from '../hooks/useIsMobile'
import type { JourneyEntry, JourneyPhoto, GalleryPhoto, JourneyTrip, JourneyDetail } from '../store/journeyStore'
import type { JourneyEntry, JourneyPhoto, JourneyDetail } from '../store/journeyStore'
import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
const GRADIENTS = [
@@ -69,18 +67,16 @@ function groupByDate(entries: JourneyEntry[]): Map<string, JourneyEntry[]> {
return groups
}
function formatDate(d: string, locale?: string): { weekday: string; month: string; day: number } {
function formatDate(d: string): { weekday: string; month: string; day: number } {
const date = new Date(d + 'T00:00:00')
// Pass the app's selected locale so weekday/month follow the UI language
// instead of the browser's navigator.language.
return {
weekday: date.toLocaleDateString(locale, { weekday: 'long' }),
month: date.toLocaleDateString(locale, { month: 'long' }),
weekday: date.toLocaleDateString(undefined, { weekday: 'long' }),
month: date.toLocaleDateString(undefined, { month: 'long' }),
day: date.getDate(),
}
}
function photoUrl(p: { photo_id: number }, size: 'thumbnail' | 'original' = 'thumbnail'): string {
function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'thumbnail'): string {
return `/api/photos/${p.photo_id}/${size}`
}
@@ -88,7 +84,7 @@ export default function JourneyDetailPage() {
const { id } = useParams()
const navigate = useNavigate()
const toast = useToast()
const { t, locale } = useTranslation()
const { t } = useTranslation()
const { current, loading, notFound, loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos, deletePhoto } = useJourneyStore()
const mapRef = useRef<JourneyMapHandle>(null)
const fullMapRef = useRef<JourneyMapHandle>(null)
@@ -190,9 +186,7 @@ export default function JourneyDetailPage() {
const winner = lastPast || firstAhead
if (winner) {
setActiveEntryId(winner.id)
if (locatedEntryIdsRef.current.has(winner.id)) {
mapRef.current?.highlightMarker(winner.id)
}
mapRef.current?.highlightMarker(winner.id)
}
}
const onScroll = () => {
@@ -283,38 +277,16 @@ export default function JourneyDetailPage() {
[current?.entries]
)
const sidebarMapItems = useMemo(() => {
const allDates = [...new Set(
(current?.entries || [])
.filter(e => e.title !== 'Gallery' && e.title !== '[Trip Photos]')
.map(e => e.entry_date)
.sort()
)]
const sorted = [...mapEntries].sort((a, b) => a.entry_date.localeCompare(b.entry_date))
const dayCounters = new Map<string, number>()
return sorted.map(e => {
const dayIdx = allDates.indexOf(e.entry_date)
const dayLabel = (dayCounters.get(e.entry_date) ?? 0) + 1
dayCounters.set(e.entry_date, dayLabel)
return {
id: String(e.id),
lat: e.location_lat!,
lng: e.location_lng!,
title: e.title || '',
location_name: e.location_name || '',
mood: e.mood,
created_at: e.entry_date,
entry_date: e.entry_date,
dayColor: DAY_COLORS[dayIdx % DAY_COLORS.length],
dayLabel,
}
})
}, [mapEntries, current?.entries])
const locatedEntryIdsRef = useRef(new Set<string>())
useEffect(() => {
locatedEntryIdsRef.current = new Set(sidebarMapItems.map(m => m.id))
}, [sidebarMapItems])
const sidebarMapItems = useMemo(() => mapEntries.map(e => ({
id: String(e.id),
lat: e.location_lat!,
lng: e.location_lng!,
title: e.title || '',
location_name: e.location_name || '',
mood: e.mood,
created_at: e.entry_date,
entry_date: e.entry_date,
})), [mapEntries])
const tripDates = useMemo(() => {
const dates = new Set<string>()
@@ -341,7 +313,7 @@ export default function JourneyDetailPage() {
)
}
const timelineEntries = current.entries.filter(e => (!hideSkeletons || e.type !== 'skeleton'))
const timelineEntries = current.entries.filter(e => e.title !== 'Gallery' && e.title !== '[Trip Photos]' && (!hideSkeletons || e.type !== 'skeleton'))
const dayGroups = groupByDate(timelineEntries)
const sortedDates = [...dayGroups.keys()].sort()
@@ -450,7 +422,7 @@ export default function JourneyDetailPage() {
? 'max-w-[1440px] mx-auto px-0 pt-0'
: 'flex w-full overflow-hidden'
}
style={!isMobile ? { height: 'calc(100dvh - var(--nav-h, 56px))' } : undefined}
style={!isMobile ? { height: 'calc(100vh - var(--nav-h, 56px))' } : undefined}
>
{/* LEFT column (full width on mobile, scrollable feed on desktop) */}
<div
@@ -458,7 +430,7 @@ export default function JourneyDetailPage() {
className={
isMobile
? ''
: 'flex-1 xl:max-w-[50%] overflow-y-auto journey-feed-scroll'
: 'flex-1 overflow-y-auto journey-feed-scroll'
}
>
<div className={isMobile ? '' : 'w-full px-8 py-6'}>
@@ -510,7 +482,7 @@ export default function JourneyDetailPage() {
>
{hideSkeletons ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
<span className="absolute top-full mt-2 right-0 px-2 py-1 rounded-md bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 text-[11px] font-medium whitespace-nowrap opacity-0 pointer-events-none group-hover:opacity-100 transition-opacity">
<span className="absolute top-full mt-2 left-1/2 -translate-x-1/2 px-2 py-1 rounded-md bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 text-[11px] font-medium whitespace-nowrap opacity-0 pointer-events-none group-hover:opacity-100 transition-opacity">
{hideSkeletons ? t('journey.skeletons.show') : t('journey.skeletons.hide')}
</span>
</div>
@@ -603,14 +575,14 @@ export default function JourneyDetailPage() {
{sortedDates.map((date, dayIdx) => {
const entries = dayGroups.get(date)!
const fd = formatDate(date, locale)
const fd = formatDate(date)
const locations = [...new Set(entries.map(e => e.location_name).filter(Boolean))]
return (
<div key={date} className="flex flex-col gap-3 trek-stagger">
<div className="bg-white/95 dark:bg-zinc-900/95 backdrop-blur border-y md:border border-zinc-200 dark:border-zinc-700 rounded-none md:rounded-xl -mx-4 md:mx-0 px-4 py-3.5 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg flex items-center justify-center text-[13px] font-bold text-white" style={{ background: DAY_COLORS[dayIdx % DAY_COLORS.length] }}>
<div className="w-8 h-8 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center text-[13px] font-bold">
{dayIdx + 1}
</div>
<div>
@@ -639,7 +611,7 @@ export default function JourneyDetailPage() {
.catch(() => toast.error(t('common.errorOccurred')))
}
return (
<div key={entry.id} data-entry-id={String(entry.id)} className={`relative ${canReorder ? 'flex items-stretch gap-2' : ''}`} onMouseEnter={() => { setActiveEntryId(String(entry.id)); mapRef.current?.highlightMarker(String(entry.id)) }} style={String(entry.id) === activeEntryId ? { outline: `2px solid ${DAY_COLORS[dayIdx % DAY_COLORS.length]}`, outlineOffset: '3px', borderRadius: '12px' } : undefined}>
<div key={entry.id} data-entry-id={String(entry.id)} className={`relative ${canReorder ? 'flex items-stretch gap-2' : ''}`}>
{canReorder && (
<div className="flex flex-col gap-1 justify-center flex-shrink-0 py-1">
<button
@@ -693,11 +665,10 @@ export default function JourneyDetailPage() {
>
<GalleryView
entries={current.entries}
gallery={current.gallery || []}
journeyId={current.id}
userId={useAuthStore.getState().user?.id || 0}
trips={current.trips}
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption ?? null, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })}
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })}
onRefresh={() => loadJourney(Number(id))}
/>
</div>
@@ -734,7 +705,7 @@ export default function JourneyDetailPage() {
entry={editingEntry}
journeyId={current.id}
tripDates={tripDates}
galleryPhotos={current.gallery || []}
galleryPhotos={current.entries.flatMap(e => e.photos || [])}
onClose={() => setEditingEntry(null)}
onSave={async (data) => {
let entryId = editingEntry.id
@@ -762,8 +733,7 @@ export default function JourneyDetailPage() {
journey={current}
onClose={() => setShowSettings(false)}
onSaved={() => { setShowSettings(false); loadJourney(Number(id)) }}
onOpenInvite={() => { setShowInvite(true) }}
onRefresh={() => loadJourney(Number(id))}
onOpenInvite={() => { setShowSettings(false); setShowInvite(true) }}
/>
)}
@@ -846,7 +816,7 @@ function MapView({ entries, mapEntries, sortedDates, activeLocationId, fullMapRe
fullMapRef: React.RefObject<JourneyMapHandle | null>
onLocationClick: (id: string) => void
}) {
const { t, locale } = useTranslation()
const { t } = useTranslation()
// group map entries by date
const byDate = new Map<string, { entry: JourneyEntry; globalIdx: number }[]>()
mapEntries.forEach((e, i) => {
@@ -902,7 +872,7 @@ function MapView({ entries, mapEntries, sortedDates, activeLocationId, fullMapRe
<div className="px-5 pb-5">
{dates.map((date, dayIdx) => {
const items = byDate.get(date)!
const fd = formatDate(date, locale)
const fd = formatDate(date)
return (
<div key={date}>
@@ -945,7 +915,7 @@ function MapView({ entries, mapEntries, sortedDates, activeLocationId, fullMapRe
<span className="text-[14px] font-semibold text-zinc-900 dark:text-white truncate">{e.title || e.location_name}</span>
</div>
<div className="text-[11px] text-zinc-500 truncate">
{formatLocationName(e.location_name)}{e.entry_time ? ` · ${e.entry_time}` : ''}
{e.location_name}{e.entry_time ? ` · ${e.entry_time}` : ''}
</div>
</div>
@@ -972,13 +942,12 @@ function MapView({ entries, mapEntries, sortedDates, activeLocationId, fullMapRe
// ── Gallery View ──────────────────────────────────────────────────────────
function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick, onRefresh }: {
function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefresh }: {
entries: JourneyEntry[]
gallery: GalleryPhoto[]
journeyId: number
userId: number
trips: JourneyTrip[]
onPhotoClick: (photos: GalleryPhoto[], index: number) => void
onPhotoClick: (photos: JourneyPhoto[], index: number) => void
onRefresh: () => void
}) {
const { t } = useTranslation()
@@ -1011,7 +980,12 @@ function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick,
})()
}, [])
const allPhotos = gallery
const allPhotos: { photo: JourneyPhoto; entry: JourneyEntry }[] = []
for (const e of entries) {
for (const p of e.photos) {
allPhotos.push({ photo: p, entry: e })
}
}
const entriesWithContent = entries.filter(e => e.type !== 'skeleton' || e.title)
@@ -1027,9 +1001,22 @@ function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick,
if (!files?.length) return
setGalleryUploading(true)
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()
for (const f of files) formData.append('photos', f)
await journeyApi.uploadGalleryPhotos(journeyId, formData)
await journeyApi.uploadPhotos(entryId, formData)
toast.success(t('journey.photosUploaded', { count: files.length }))
onRefresh()
} catch {
@@ -1040,27 +1027,24 @@ function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick,
e.target.value = ''
}
const handleDeletePhoto = async (galleryPhotoId: number) => {
const handleDeletePhoto = async (photoId: number) => {
// Optimistic update — remove photo from local state immediately
const store = useJourneyStore.getState()
if (!store.current) return
// Optimistic update — remove from gallery and all entry photo lists
useJourneyStore.setState({
current: {
if (store.current) {
const updated = {
...store.current,
gallery: (store.current.gallery || []).filter(p => p.id !== galleryPhotoId),
entries: store.current.entries.map(e => ({
...e,
photos: e.photos.filter(p => p.id !== galleryPhotoId),
})),
},
})
photos: e.photos.filter(p => p.id !== photoId),
})).filter(e => e.type !== 'entry' || e.title !== 'Gallery' || e.photos.length > 0 || e.story),
}
useJourneyStore.setState({ current: updated })
}
try {
await journeyApi.deleteGalleryPhoto(journeyId, galleryPhotoId)
await journeyApi.deletePhoto(photoId)
} catch {
toast.error(t('common.error'))
onRefresh()
onRefresh() // Revert on error
}
}
@@ -1108,11 +1092,11 @@ function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick,
</div>
) : (
<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, i) => (
{allPhotos.map(({ photo, entry }, i) => (
<div
key={photo.id}
className="relative aspect-square rounded-lg overflow-hidden cursor-pointer group"
onClick={() => onPhotoClick(allPhotos, i)}
onClick={() => onPhotoClick(allPhotos.map(a => a.photo), i)}
>
<img
src={photoUrl(photo, 'thumbnail')}
@@ -1141,6 +1125,11 @@ function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick,
<p className="text-[10px] text-white truncate">{photo.caption}</p>
</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>
@@ -1153,19 +1142,25 @@ function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick,
userId={userId}
entries={entriesWithContent}
trips={trips}
existingAssetIds={new Set(gallery.filter(p => p.asset_id).map(p => p.asset_id!))}
existingAssetIds={new Set(entries.flatMap(e => (e.photos || []).filter(p => p.asset_id).map(p => p.asset_id!)))}
onClose={() => setShowPicker(false)}
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
for (const group of groups) {
try {
if (entryId) {
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
}
const result = await journeyApi.addProviderPhotos(targetId, pickerProvider!, group.assetIds, undefined, group.passphrase)
added += result.added || 0
} catch {}
}
if (added > 0) {
@@ -1363,7 +1358,7 @@ function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: {
{entry.location_name && (
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-black/40 backdrop-blur-sm rounded-full text-[10px] font-semibold text-white tracking-wide max-w-full overflow-hidden">
<MapPin size={10} className="flex-shrink-0" />
<span className="truncate">{formatLocationName(entry.location_name)}</span>
<span className="truncate">{entry.location_name}</span>
</span>
)}
{entry.entry_time && (
@@ -1406,7 +1401,7 @@ function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: {
<div className="flex items-center gap-2 min-w-0 flex-1 mr-2">
{entry.location_name && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-zinc-100 dark:bg-zinc-800 rounded-full text-[10px] font-semibold text-zinc-500 max-w-full overflow-hidden">
<MapPin size={10} className="flex-shrink-0" /> <span className="truncate">{formatLocationName(entry.location_name)}</span>
<MapPin size={10} className="flex-shrink-0" /> <span className="truncate">{entry.location_name}</span>
</span>
)}
{entry.entry_time && (
@@ -1485,7 +1480,7 @@ function SkeletonCard({ entry, onClick }: { entry: JourneyEntry; onClick?: () =>
{entry.title || t('journey.detail.newEntry')}
</div>
<div className="text-[11px] text-zinc-500 mt-0.5">
{formatLocationName(entry.location_name)}{entry.entry_time ? ` · ${entry.entry_time}` : ''}
{entry.location_name || ''}{entry.entry_time ? ` · ${entry.entry_time}` : ''}
</div>
</div>
<div className="text-[11px] text-zinc-500 font-medium flex-shrink-0">
@@ -1769,11 +1764,11 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
: t('journey.picker.newGallery')
return (
<div className="fixed inset-0 z-[200] flex items-end md:items-center justify-center md:p-5 overscroll-none" style={{ background: 'rgba(9,9,11,0.75)' }} onClick={onClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}>
<div className="bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[720px] md:max-w-[960px] w-full max-h-[calc(100dvh-var(--bottom-nav-h)-20px)] md:max-h-[85vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} onClick={e => e.stopPropagation()}>
<div className="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.75)' }}>
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[720px] md:max-w-[960px] w-full max-h-[85vh] flex flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700 flex-shrink-0">
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">
{provider === 'immich' ? 'Immich' : 'Synology Photos'}
</h2>
@@ -1783,7 +1778,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
</div>
{/* Filter bar */}
<div className="px-6 py-3 border-b border-zinc-200 dark:border-zinc-700 flex-shrink-0">
<div className="px-6 py-3 border-b border-zinc-200 dark:border-zinc-700">
{/* Tabs */}
<div className="flex gap-1.5 mb-3">
{[
@@ -1869,7 +1864,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
</div>
{/* Add-to entry selector */}
<div className="px-6 py-2.5 border-b border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50 flex-shrink-0">
<div className="px-6 py-2.5 border-b border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50">
<div className="relative flex items-center gap-2">
<span className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500">{t('journey.picker.addTo')}</span>
<button
@@ -1922,7 +1917,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
const allSelected = selectable.length > 0 && selectable.every((a: any) => selected.has(a.id))
if (selectable.length === 0) return null
return (
<div className="px-4 py-2 border-b border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 flex-shrink-0">
<div className="px-4 py-2 border-b border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900">
<button
onClick={() => {
if (allSelected) {
@@ -1947,7 +1942,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
})()}
{/* Photo grid */}
<div className="flex-1 overflow-y-auto overscroll-contain p-4 min-h-0">
<div className="flex-1 overflow-y-auto p-4">
{loading ? (
<div className="flex justify-center py-12">
<div className="w-6 h-6 border-2 border-zinc-300 border-t-zinc-900 rounded-full animate-spin" />
@@ -2020,7 +2015,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
</div>
{/* Footer */}
<div className="flex items-center justify-between px-6 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50 flex-shrink-0">
<div className="flex items-center justify-between px-6 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50">
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-zinc-200/60 dark:bg-zinc-700/60 text-[11px] leading-none text-zinc-500 dark:text-zinc-400">
<span className="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[10px] leading-none font-bold">{selected.size}</span>
<span className="leading-[18px]">{t('journey.picker.selected')}</span>
@@ -2166,7 +2161,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
entry: JourneyEntry
journeyId: number
tripDates: Set<string>
galleryPhotos: GalleryPhoto[]
galleryPhotos: JourneyPhoto[]
onClose: () => void
onSave: (data: Record<string, unknown>) => Promise<number>
onUploadPhotos: (entryId: number, formData: FormData) => Promise<JourneyPhoto[]>
@@ -2192,7 +2187,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
const [cons, setCons] = useState<string[]>(entry.pros_cons?.cons?.length ? entry.pros_cons.cons : [''])
const [saving, setSaving] = useState(false)
const [uploading, setUploading] = useState(false)
const [photos, setPhotos] = useState<(JourneyPhoto | GalleryPhoto)[]>(entry.photos || [])
const [photos, setPhotos] = useState<JourneyPhoto[]>(entry.photos || [])
const [pendingFiles, setPendingFiles] = useState<File[]>([])
const [pendingLinkIds, setPendingLinkIds] = useState<number[]>([])
const [showGalleryPick, setShowGalleryPick] = useState(false)
@@ -2219,8 +2214,6 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
pendingLinkIds.length > 0
)
const availableGalleryPhotos = galleryPhotos.filter(gp => !photos.some(p => p.id === gp.id))
const handleClose = () => {
if (isDirty && !window.confirm(t('journey.editor.discardChangesConfirm'))) return
onClose()
@@ -2330,7 +2323,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
{showGalleryPick && (
<div className="mt-2 border border-zinc-200 dark:border-zinc-700 rounded-xl p-3 bg-zinc-50 dark:bg-zinc-800/50">
<div className="grid grid-cols-5 sm:grid-cols-6 gap-1.5 max-h-[160px] overflow-y-auto">
{availableGalleryPhotos.map(gp => (
{galleryPhotos.filter(gp => !photos.some(p => p.id === gp.id)).map(gp => (
<div
key={gp.id}
onClick={async () => {
@@ -2350,7 +2343,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
<img src={photoUrl(gp)} alt="" className="absolute inset-0 w-full h-full object-cover" loading="lazy" onError={e => { const img = e.currentTarget; const orig = photoUrl(gp, 'original'); if (!img.src.includes('/original')) img.src = orig }} />
</div>
))}
{availableGalleryPhotos.length === 0 && (
{galleryPhotos.filter(gp => !photos.some(p => p.id === gp.id)).length === 0 && (
<div className="col-span-full text-center py-3 text-[11px] text-zinc-400">{t('journey.editor.allPhotosAdded')}</div>
)}
</div>
@@ -2385,13 +2378,8 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
<button
onClick={async (e) => {
e.stopPropagation()
await journeyApi.deletePhoto(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"
>
@@ -2964,7 +2952,7 @@ function JourneyShareSection({ journeyId }: { journeyId: number }) {
onClick={deleteLink}
className="text-[11px] font-medium text-red-500 hover:text-red-600 self-start"
>
{t('share.deleteLink')}
Remove share link
</button>
</div>
)}
@@ -2972,12 +2960,11 @@ function JourneyShareSection({ journeyId }: { journeyId: number }) {
)
}
function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite, onRefresh }: {
function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
journey: JourneyDetail
onClose: () => void
onSaved: () => void
onOpenInvite: () => void
onRefresh: () => void
}) {
const { t } = useTranslation()
const [title, setTitle] = useState(journey.title)
@@ -2985,10 +2972,6 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite, onRefr
const [saving, setSaving] = useState(false)
const [showAddTrip, setShowAddTrip] = useState(false)
const [unlinkTarget, setUnlinkTarget] = useState<{ trip_id: number; title: string } | null>(null)
const [showDiscardConfirm, setShowDiscardConfirm] = useState(false)
const isDirty = title !== journey.title || subtitle !== (journey.subtitle || '')
const handleClose = () => { if (isDirty) setShowDiscardConfirm(true); else onClose() }
const coverRef = useRef<HTMLInputElement>(null)
const toast = useToast()
const navigate = useNavigate()
@@ -3047,12 +3030,12 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite, onRefr
}
return (
<div className="fixed inset-0 z-[200] flex items-end md:items-center justify-center md:p-5 overscroll-none" style={{ background: 'rgba(9,9,11,0.75)' }} onClick={handleClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}>
<div className="fixed inset-0 z-[200] flex items-end md:items-center justify-center md:p-5 overscroll-none" style={{ background: 'rgba(9,9,11,0.75)' }} onClick={onClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}>
<div className="bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[480px] w-full max-h-[85vh] md:max-h-[90vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">{t('journey.settings.title')}</h2>
<button onClick={handleClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
<button onClick={onClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
<X size={16} />
</button>
</div>
@@ -3148,7 +3131,7 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite, onRefr
try {
await journeyApi.removeContributor(journey.id, c.user_id)
toast.success(t('journey.contributors.removed'))
onRefresh()
onSaved()
} catch {
toast.error(t('journey.contributors.removeFailed'))
}
@@ -3199,7 +3182,7 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite, onRefr
{journey.status === 'archived' ? <ArchiveRestore size={14} /> : <Archive size={14} />}
<span className="hidden md:inline">{journey.status === 'archived' ? t('journey.settings.reopenJourney') : t('journey.settings.endJourney')}</span>
</button>
<button onClick={handleClose} className="h-9 px-3.5 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">{t('common.cancel')}</button>
<button onClick={onClose} className="h-9 px-3.5 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">{t('common.cancel')}</button>
<button onClick={handleSave} disabled={saving || !title.trim()} className="h-9 px-3.5 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-40">
{saving ? t('common.saving') : t('common.save')}
</button>
@@ -3246,16 +3229,6 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite, onRefr
confirmLabel={t('common.delete')}
danger
/>
<ConfirmDialog
isOpen={showDiscardConfirm}
onClose={() => setShowDiscardConfirm(false)}
onConfirm={() => { setShowDiscardConfirm(false); onClose() }}
title={t('common.discardChanges')}
message={t('journey.editor.discardChangesConfirm')}
confirmLabel={t('common.discard')}
danger
/>
</div>
)
}
+25 -69
View File
@@ -56,21 +56,6 @@ 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';
// ── Fixtures ─────────────────────────────────────────────────────────────────
@@ -121,9 +106,6 @@ const mockJourneyData = {
share_gallery: 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: {
entries: 2,
photos: 1,
@@ -154,7 +136,6 @@ function setup404() {
beforeEach(() => {
resetAllStores();
vi.clearAllMocks();
mockIsMobile.value = false;
});
// ── Tests ────────────────────────────────────────────────────────────────────
@@ -253,20 +234,28 @@ describe('JourneyPublicPage', () => {
}
});
it('FE-PAGE-PUBLICJOURNEY-009: map is always visible in desktop two-column layout', async () => {
it('FE-PAGE-PUBLICJOURNEY-009: map tab switches view', async () => {
setupSuccess();
render(<JourneyPublicPage />);
await waitFor(() => {
expect(screen.getByText('Tokyo 2026')).toBeInTheDocument();
});
// Desktop two-column: map sidebar is always rendered alongside the timeline;
// there is no standalone "Map" tab button on desktop.
await waitFor(() => {
expect(screen.getByTestId('journey-map')).toBeInTheDocument();
});
// Timeline entries remain visible (two-column shows both simultaneously)
expect(screen.getByText('Shibuya Crossing')).toBeInTheDocument();
const buttons = screen.getAllByRole('button');
const mapBtn = buttons.find(
btn => btn.textContent && /map/i.test(btn.textContent),
);
expect(mapBtn).toBeDefined();
if (mapBtn) {
fireEvent.click(mapBtn);
// After clicking map tab, the timeline entries should no longer be visible
// and the map view content should be rendered (even if JourneyMap errors internally
// due to jsdom limitations, the tab state switches)
await waitFor(() => {
// Shibuya Crossing (timeline-only) should not appear once map is active
expect(screen.queryByText('Shibuya Crossing')).not.toBeInTheDocument();
});
}
});
it('FE-PAGE-PUBLICJOURNEY-010: shows journey stats', async () => {
@@ -314,18 +303,24 @@ describe('JourneyPublicPage', () => {
});
// FE-PAGE-PUBLICJOURNEY-012
it('FE-PAGE-PUBLICJOURNEY-012: map component renders with located entries in desktop two-column layout', async () => {
it('FE-PAGE-PUBLICJOURNEY-012: tab switching from timeline to map shows map component', async () => {
const user = userEvent.setup();
setupSuccess();
render(<JourneyPublicPage />);
await waitFor(() => {
expect(screen.getByText('Tokyo 2026')).toBeInTheDocument();
});
// Desktop two-column: map sidebar is always rendered; no tab click required.
const mapBtn = screen.getAllByRole('button').find(
btn => btn.textContent && /map/i.test(btn.textContent),
);
expect(mapBtn).toBeDefined();
await user.click(mapBtn!);
await waitFor(() => {
expect(screen.getByTestId('journey-map')).toBeInTheDocument();
});
// Both fixture entries have coordinates → map receives 2 located entries
// Map receives entries with lat/lng
expect(screen.getByTestId('journey-map').textContent).toContain('2');
});
@@ -359,11 +354,6 @@ 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 },
};
@@ -415,40 +405,6 @@ describe('JourneyPublicPage', () => {
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
it('FE-PAGE-PUBLICJOURNEY-016: language picker opens and switches language', async () => {
const user = userEvent.setup();
+167 -437
View File
@@ -1,23 +1,14 @@
import { useEffect, useState, useMemo, useRef, useCallback } from 'react'
import { useEffect, useState, useMemo } from 'react'
import { useParams } from 'react-router-dom'
import { journeyApi } from '../api/client'
import { useTranslation, SUPPORTED_LANGUAGES } from '../i18n'
import { useSettingsStore } from '../store/settingsStore'
import {
List, Grid, MapPin, Camera, BookOpen, Image, Clock,
Laugh, Smile, Meh, Frown,
Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake,
ThumbsUp, ThumbsDown,
} from 'lucide-react'
import { List, Grid, MapPin, Camera, BookOpen, Image } from 'lucide-react'
import JourneyMap from '../components/Journey/JourneyMap'
import type { JourneyMapHandle } from '../components/Journey/JourneyMap'
import JournalBody from '../components/Journey/JournalBody'
import PhotoLightbox from '../components/Journey/PhotoLightbox'
import MobileMapTimeline from '../components/Journey/MobileMapTimeline'
import MobileEntryView from '../components/Journey/MobileEntryView'
import { useIsMobile } from '../hooks/useIsMobile'
import { formatLocationName } from '../utils/formatters'
import { DAY_COLORS } from '../components/Journey/dayColors'
interface PublicEntry {
id: number
@@ -45,42 +36,15 @@ interface PublicPhoto {
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 }> = {
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' },
neutral: { icon: Meh, label: 'Neutral', bg: 'bg-zinc-100 dark:bg-zinc-800', text: 'text-zinc-500 dark:text-zinc-400' },
rough: { icon: Frown, label: 'Rough', bg: 'bg-violet-50 dark:bg-violet-900/20', text: 'text-violet-600 dark:text-violet-400' },
}
const WEATHER_CONFIG: Record<string, { icon: typeof Sun; label: string }> = {
sunny: { icon: Sun, label: 'Sunny' },
partly: { icon: CloudSun, label: 'Partly cloudy' },
cloudy: { icon: Cloud, label: 'Cloudy' },
rainy: { icon: CloudRain, label: 'Rainy' },
stormy: { icon: CloudLightning, label: 'Stormy' },
cold: { icon: Snowflake, label: 'Cold' },
}
function photoUrl(p: { photo_id: number }, shareToken: string, kind: 'thumbnail' | 'original' = 'original'): string {
function photoUrl(p: PublicPhoto, shareToken: string, kind: 'thumbnail' | 'original' = 'original'): string {
return `/api/public/journey/${shareToken}/photos/${p.photo_id}/${kind}`
}
function formatDate(d: string, locale?: string): { weekday: string; month: string; day: number } {
function formatDate(d: string): { weekday: string; month: string; day: number } {
const date = new Date(d + 'T00:00:00')
return {
weekday: date.toLocaleDateString(locale || 'en', { weekday: 'long' }),
month: date.toLocaleDateString(locale || 'en', { month: 'long' }),
weekday: date.toLocaleDateString('en', { weekday: 'long' }),
month: date.toLocaleDateString('en', { month: 'long' }),
day: date.getDate(),
}
}
@@ -106,16 +70,6 @@ export default function JourneyPublicPage() {
const { t } = useTranslation()
const [showLangPicker, setShowLangPicker] = useState(false)
const locale = useSettingsStore(s => s.settings.language) || 'en'
const mapRef = useRef<JourneyMapHandle>(null)
const [activeEntryId, setActiveEntryId] = useState<string | null>(null)
const [viewingEntry, setViewingEntry] = useState<PublicEntry | null>(null)
const handleMarkerClick = useCallback((entryId: string) => {
setActiveEntryId(entryId)
mapRef.current?.highlightMarker(entryId)
document.querySelector(`[data-entry-id="${entryId}"]`)
?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, [])
useEffect(() => {
if (!token) return
@@ -126,45 +80,25 @@ export default function JourneyPublicPage() {
}, [token])
const entries = (data?.entries || []) as PublicEntry[]
const gallery = (data?.gallery || []) as PublicGalleryPhoto[]
const perms = data?.permissions || {}
const journey = data?.journey || {}
const stats = data?.stats || {}
const timelineEntries = useMemo(() => entries, [entries])
// `[Trip Photos]` and `Gallery` are synthetic photo-only containers
// produced by the trip→journey sync. They have no story and no
// location, and the owner view strips them from the timeline the
// same way (JourneyDetailPage.tsx). Gallery keeps their photos.
const timelineEntries = useMemo(
() => entries.filter(e => e.title !== '[Trip Photos]' && e.title !== 'Gallery'),
[entries],
)
const groupedEntries = useMemo(() => groupByDate(timelineEntries), [timelineEntries])
const sortedDates = useMemo(() => [...groupedEntries.keys()].sort(), [groupedEntries])
const mapEntries = useMemo(
() => timelineEntries.filter(e => e.location_lat && e.location_lng),
[timelineEntries],
)
const allPhotos = gallery
// Map entries with day color/label for colored markers.
// dayIdx is derived from sortedDates (ALL timeline dates) so marker colors
// stay in sync with the timeline day headers even when some days have no locations.
const sidebarMapItems = useMemo(() => {
const counters = new Map<string, number>()
return mapEntries.map(e => {
const dayIdx = sortedDates.indexOf(e.entry_date)
const dayLabel = (counters.get(e.entry_date) ?? 0) + 1
counters.set(e.entry_date, dayLabel)
return {
id: String(e.id),
lat: e.location_lat!,
lng: e.location_lng!,
title: e.title || '',
mood: e.mood,
created_at: e.entry_date,
entry_date: e.entry_date,
dayColor: DAY_COLORS[dayIdx % DAY_COLORS.length],
dayLabel,
}
})
}, [mapEntries, sortedDates])
// Two-column desktop layout: timeline feed left + sticky map right
const desktopTwoColumn = !isMobile && perms.share_timeline && perms.share_map
const allPhotos = useMemo(() => entries.flatMap(e => (e.photos || []).map(p => ({ photo: p, entry: e }))), [entries])
// Set default view based on permissions
useEffect(() => {
@@ -172,11 +106,6 @@ export default function JourneyPublicPage() {
else if (!perms.share_timeline && !perms.share_gallery && perms.share_map) setView('map')
}, [perms])
// When switching to desktop two-column, 'map' standalone tab no longer exists
useEffect(() => {
if (desktopTwoColumn && view === 'map') setView('timeline')
}, [desktopTwoColumn, view])
if (loading) {
return (
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950 flex items-center justify-center">
@@ -196,262 +125,21 @@ export default function JourneyPublicPage() {
)
}
// In desktop two-column mode the map is always visible — exclude the standalone 'map' tab
const availableViews = [
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') },
!desktopTwoColumn && !isMobile && perms.share_map && { id: 'map' as const, icon: MapPin, label: t('journey.share.map') },
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 }[]
// Shared timeline renderer used in both layout modes
const renderTimeline = () => (
<div className="flex flex-col gap-6">
{sortedDates.length === 0 && (
<div className="text-center py-16">
<div className="w-16 h-16 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center mx-auto mb-4">
<BookOpen size={24} className="text-zinc-400" />
</div>
<p className="text-[15px] font-medium text-zinc-700 dark:text-zinc-300">No entries yet</p>
</div>
)}
{sortedDates.map((date, dayIdx) => {
const dayEntries = groupedEntries.get(date)!
const fd = formatDate(date, locale)
const dayColor = DAY_COLORS[dayIdx % DAY_COLORS.length]
return (
<div key={date}>
{/* Day header */}
<div className="flex items-center gap-3 mb-4">
<div
className="w-10 h-10 rounded-xl flex items-center justify-center text-[14px] font-bold text-white flex-shrink-0"
style={{ background: dayColor }}
>
{dayIdx + 1}
</div>
<div>
<div className="text-[14px] font-semibold text-zinc-900 dark:text-white">{fd.weekday}</div>
<div className="text-[11px] text-zinc-500">{fd.month} {fd.day}</div>
</div>
</div>
{/* Entries */}
<div className="flex flex-col gap-4 pl-[52px]">
{dayEntries.map(entry => {
const photos = entry.photos || []
const mood = entry.mood ? MOOD_CONFIG[entry.mood] : null
const weather = entry.weather ? WEATHER_CONFIG[entry.weather] : null
const prosArr = entry.pros_cons?.pros ?? []
const consArr = entry.pros_cons?.cons ?? []
const hasProscons = prosArr.length > 0 || consArr.length > 0
const lightboxPhotos = photos.map(p => ({ id: String(p.id), src: photoUrl(p, token!, 'original'), caption: p.caption }))
const isActive = activeEntryId === String(entry.id)
return (
<div
key={entry.id}
data-entry-id={String(entry.id)}
onMouseEnter={() => {
if (!desktopTwoColumn) return
setActiveEntryId(String(entry.id))
mapRef.current?.highlightMarker(String(entry.id))
}}
style={isActive && desktopTwoColumn ? { outline: `2px solid ${dayColor}`, outlineOffset: '3px', borderRadius: '16px' } : undefined}
className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-2xl overflow-hidden">
{/* Photo area */}
{photos.length === 1 && (
<div className="relative cursor-pointer" onClick={() => setLightbox({ photos: lightboxPhotos, index: 0 })}>
<img src={photoUrl(photos[0], token!)} className="w-full h-64 object-cover" alt="" />
<div className="absolute inset-x-0 bottom-0 pointer-events-none" style={{ background: 'linear-gradient(to top, rgba(0,0,0,0.55) 0%, rgba(0,0,0,0.15) 60%, transparent 100%)', height: '65%' }} />
{entry.location_name && (
<div className="absolute top-3 left-4">
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-black/40 backdrop-blur-sm rounded-full text-[10px] font-semibold text-white">
<MapPin size={10} className="flex-shrink-0" />
<span className="truncate max-w-[200px]">{formatLocationName(entry.location_name)}</span>
</span>
</div>
)}
{entry.title && (
<div className="absolute bottom-4 left-5 right-5 pointer-events-none">
<h3 className="text-[18px] font-bold text-white drop-shadow-sm leading-tight">{entry.title}</h3>
</div>
)}
</div>
)}
{photos.length === 2 && (
<div className="grid grid-cols-2 gap-0.5 overflow-hidden">
{photos.slice(0, 2).map((p, i) => (
<img
key={p.id}
src={photoUrl(p, token!, 'thumbnail')}
alt=""
className="w-full h-52 object-cover cursor-pointer"
onClick={() => setLightbox({ photos: lightboxPhotos, index: i })}
/>
))}
</div>
)}
{photos.length >= 3 && (
<div className="overflow-hidden flex" style={{ height: 280, gap: 2 }}>
<div className="flex-1 min-w-0 cursor-pointer" onClick={() => setLightbox({ photos: lightboxPhotos, index: 0 })}>
<img src={photoUrl(photos[0], token!, 'thumbnail')} alt="" className="w-full h-full object-cover" />
</div>
<div className="flex-1 min-w-0 flex flex-col" style={{ gap: 2 }}>
<div className="flex-1 min-h-0 cursor-pointer" onClick={() => setLightbox({ photos: lightboxPhotos, index: 1 })}>
<img src={photoUrl(photos[1], token!, 'thumbnail')} alt="" className="w-full h-full object-cover" />
</div>
<div className="flex-1 min-h-0 relative cursor-pointer" onClick={() => setLightbox({ photos: lightboxPhotos, index: 2 })}>
<img src={photoUrl(photos[2], token!, 'thumbnail')} alt="" className="w-full h-full object-cover" />
{photos.length > 3 && (
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
<span className="text-white text-[13px] font-semibold flex items-center gap-1">
<Image size={13} /> +{photos.length - 3}
</span>
</div>
)}
</div>
</div>
</div>
)}
{/* Content */}
<div className="px-5 pt-4 pb-5 cursor-pointer" onClick={() => setViewingEntry(entry)}>
{/* Title (only when no single photo — photo has it in overlay) */}
{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>
)}
{/* Location + time badges */}
{(entry.location_name || entry.entry_time) && photos.length !== 1 && (
<div className="flex items-center gap-2 flex-wrap mb-2">
{entry.location_name && (
<span className="inline-flex items-center gap-1 text-[11px] text-zinc-500">
<MapPin size={11} className="flex-shrink-0" />
{formatLocationName(entry.location_name)}
</span>
)}
{entry.entry_time && (
<span className="inline-flex items-center gap-1 text-[11px] text-zinc-400">
<Clock size={11} />
{entry.entry_time.slice(0, 5)}
</span>
)}
</div>
)}
{entry.entry_time && photos.length === 1 && (
<div className="flex items-center gap-1 text-[11px] text-zinc-400 mb-2">
<Clock size={11} />
{entry.entry_time.slice(0, 5)}
</div>
)}
{/* Story */}
{entry.story && (
<div className="text-[13px] text-zinc-700 dark:text-zinc-300 leading-relaxed">
<JournalBody text={entry.story} />
</div>
)}
{/* Pros & Cons */}
{hasProscons && (
<div className={`grid gap-3 mt-4 ${prosArr.length > 0 && consArr.length > 0 ? 'grid-cols-2' : 'grid-cols-1'}`}>
{prosArr.length > 0 && (
<div className="rounded-xl border border-green-200 dark:border-green-800/30 p-3" style={{ background: 'linear-gradient(180deg, #F0FDF4 0%, white 100%)' }}>
<div className="flex items-center gap-1 text-[10px] font-bold uppercase tracking-wide text-green-700 mb-2">
<ThumbsUp size={10} /> Pros
</div>
{prosArr.map((p, i) => (
<div key={i} className="flex items-start gap-1.5 text-[12px] text-green-900 mb-1">
<span className="w-[5px] h-[5px] rounded-full bg-green-500 flex-shrink-0 mt-[6px]" />{p}
</div>
))}
</div>
)}
{consArr.length > 0 && (
<div className="rounded-xl border border-red-200 dark:border-red-800/30 p-3" style={{ background: 'linear-gradient(180deg, #FEF2F2 0%, white 100%)' }}>
<div className="flex items-center gap-1 text-[10px] font-bold uppercase tracking-wide text-red-700 mb-2">
<ThumbsDown size={10} /> Cons
</div>
{consArr.map((c, i) => (
<div key={i} className="flex items-start gap-1.5 text-[12px] text-red-900 mb-1">
<span className="w-[5px] h-[5px] rounded-full bg-red-500 flex-shrink-0 mt-[6px]" />{c}
</div>
))}
</div>
)}
</div>
)}
{/* Mood + weather */}
{(mood || weather) && (
<div className="flex items-center gap-1.5 pt-3 mt-3 border-t border-zinc-100 dark:border-zinc-800">
{mood && (
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium ${mood.bg} ${mood.text}`}>
<mood.icon size={11} /> {mood.label}
</span>
)}
{weather && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400">
<weather.icon size={11} /> {weather.label}
</span>
)}
</div>
)}
</div>
</div>
)
})}
</div>
</div>
)
})}
</div>
)
// Shared gallery renderer
const renderGallery = () => (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5">
{allPhotos.map((photo, idx) => (
<div
key={photo.id}
className="aspect-square rounded-lg overflow-hidden cursor-pointer"
onClick={() => setLightbox({ photos: allPhotos.map(p => ({ id: String(p.id), src: photoUrl(p, token!, 'original'), caption: p.caption })), index: idx })}
>
<img src={photoUrl(photo, token!, 'thumbnail')} className="w-full h-full object-cover hover:scale-105 transition-transform" alt="" loading="lazy" />
</div>
))}
</div>
)
// Shared view tab bar
const renderTabs = (views: typeof availableViews) => views.length > 1 && (
<div className="flex bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg overflow-hidden mb-6 w-fit">
{views.map(v => (
<button
key={v.id}
onClick={() => setView(v.id)}
className={`flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium ${
view === v.id
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
: 'text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
}`}
>
<v.icon size={13} />
{v.label}
</button>
))}
</div>
)
return (
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
{/* Hero */}
<div className="relative text-center text-white" style={{ background: 'linear-gradient(135deg, #000 0%, #0f172a 50%, #1e293b 100%)', padding: '32px 20px 28px', overflow: 'hidden' }}>
<div className="relative text-center text-white" style={{ background: 'linear-gradient(135deg, #000 0%, #0f172a 50%, #1e293b 100%)', padding: '32px 20px 28px' }}>
{/* Cover image background */}
{journey.cover_image && (
<div style={{ position: 'absolute', inset: 0, backgroundImage: `url(/uploads/${journey.cover_image})`, backgroundSize: 'cover', backgroundPosition: 'center', opacity: 0.15 }} />
)}
{/* Decorative circles */}
<div style={{ position: 'absolute', top: -60, right: -60, width: 200, height: 200, borderRadius: '50%', background: 'rgba(255,255,255,0.03)' }} />
<div style={{ position: 'absolute', bottom: -40, left: -40, width: 150, height: 150, borderRadius: '50%', background: 'rgba(255,255,255,0.02)' }} />
@@ -506,98 +194,160 @@ export default function JourneyPublicPage() {
</div>
{/* Content */}
{desktopTwoColumn ? (
// ── Desktop two-column: scrollable timeline feed + sticky map ──────────
<div className="max-w-[1440px] mx-auto flex" style={{ alignItems: 'flex-start' }}>
{/* Left: feed */}
<div className="flex-1 xl:max-w-[50%] min-w-0 px-8 py-6">
{renderTabs(availableViews)}
{view === 'timeline' && perms.share_timeline && renderTimeline()}
{view === 'gallery' && perms.share_gallery && renderGallery()}
<div className="max-w-[900px] mx-auto px-4 md:px-8 py-6">
{/* View tabs */}
{availableViews.length > 1 && (
<div className="flex bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg overflow-hidden mb-6 w-fit">
{availableViews.map(v => (
<button
key={v.id}
onClick={() => setView(v.id)}
className={`flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium ${
view === v.id
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
: 'text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
}`}
>
<v.icon size={13} />
{v.label}
</button>
))}
</div>
)}
{/* Right: sticky map — matches auth page aside proportions */}
<aside
className="flex-shrink-0"
style={{
width: '44%', minWidth: 420, maxWidth: 760,
position: 'sticky', top: 0, height: '100dvh',
padding: '16px 16px 16px 0',
alignSelf: 'flex-start',
}}
>
<div className="h-full rounded-2xl overflow-hidden border border-zinc-200 dark:border-zinc-800 shadow-sm">
<JourneyMap
ref={mapRef}
checkins={[]}
entries={sidebarMapItems as any}
height={9999}
fullScreen
activeMarkerId={activeEntryId ?? undefined}
onMarkerClick={handleMarkerClick}
/>
</div>
</aside>
</div>
) : (
// ── Single-column layout (mobile + desktop-without-map) ───────────────
<div className="max-w-[900px] mx-auto px-4 md:px-8 py-6">
{/* Mobile combined map+timeline (public, read-only) */}
{isMobile && view === 'timeline' && perms.share_timeline && perms.share_map && (
<MobileMapTimeline
entries={timelineEntries}
mapEntries={mapEntries.map(e => ({ id: String(e.id), lat: e.location_lat!, lng: e.location_lng!, title: e.title, mood: e.mood, entry_date: e.entry_date }))}
dark={document.documentElement.classList.contains('dark')}
readOnly
onEntryClick={() => {}}
publicPhotoUrl={(photoId) => `/api/public/journey/${token}/photos/${photoId}/original`}
/>
)}
{/* Floating view toggle — visible above the fullscreen map on mobile */}
{isMobile && view === 'timeline' && perms.share_timeline && perms.share_map && availableViews.length > 1 && (
<div className="fixed left-0 right-0 z-50 flex justify-center px-4" style={{ top: 'calc(env(safe-area-inset-top, 0px) + 12px)' }}>
<div className="flex bg-white/90 dark:bg-zinc-800/90 backdrop-blur-lg border border-zinc-200 dark:border-zinc-700 rounded-lg overflow-hidden shadow-lg">
{availableViews.map(v => (
<button
key={v.id}
onClick={() => setView(v.id)}
className={`flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium ${
view === v.id
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
: 'text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
}`}
>
<v.icon size={13} />
{v.label}
</button>
))}
{/* Timeline (desktop, or mobile without map permission) */}
{(!isMobile || !perms.share_map) && view === 'timeline' && perms.share_timeline && (
<div className="flex flex-col gap-6">
{sortedDates.map(date => {
const dayEntries = groupedEntries.get(date)!
const fd = formatDate(date)
return (
<div key={date}>
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center text-[14px] font-bold">{fd.day}</div>
<div>
<div className="text-[14px] font-semibold text-zinc-900 dark:text-white">{fd.weekday}</div>
<div className="text-[11px] text-zinc-500">{fd.month} {fd.day}</div>
</div>
</div>
<div className="flex flex-col gap-4 pl-[52px]">
{dayEntries.map(entry => (
<div key={entry.id} className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-2xl overflow-hidden">
{entry.photos.length > 0 && (
<div className="relative">
<img
src={photoUrl(entry.photos[0], token!)}
className="w-full h-52 object-cover cursor-pointer"
alt=""
onClick={() => setLightbox({ photos: entry.photos.map(p => ({ id: String(p.id), src: photoUrl(p, token!), caption: p.caption })), index: 0 })}
/>
{entry.photos.length > 1 && (
<div className="absolute bottom-2 right-2 bg-black/60 backdrop-blur text-white rounded-full px-2 py-0.5 text-[10px] font-semibold flex items-center gap-1">
<Image size={10} /> +{entry.photos.length - 1}
</div>
)}
{entry.title && (
<div className="absolute inset-x-0 bottom-0 p-4" style={{ background: 'linear-gradient(to top, rgba(0,0,0,0.5) 0%, transparent 100%)' }}>
<h3 className="text-[18px] font-bold text-white drop-shadow-sm">{entry.title}</h3>
</div>
)}
</div>
)}
<div className="px-5 py-4">
{!entry.photos.length && entry.title && (
<h3 className="text-[16px] font-semibold text-zinc-900 dark:text-white mb-1">{entry.title}</h3>
)}
{entry.location_name && (
<div className="flex items-center gap-1.5 text-[11px] text-zinc-500 mb-2">
<MapPin size={11} /> {entry.location_name}
</div>
)}
{entry.story && (
<div className="text-[13px] text-zinc-700 dark:text-zinc-300 leading-relaxed">
<JournalBody text={entry.story} />
</div>
)}
{entry.pros_cons && ((entry.pros_cons.pros?.length ?? 0) > 0 || (entry.pros_cons.cons?.length ?? 0) > 0) && (
<div className="grid grid-cols-2 gap-3 mt-4">
{(entry.pros_cons.pros?.length ?? 0) > 0 && (
<div className="rounded-xl border border-green-200 dark:border-green-800/30 p-3" style={{ background: 'linear-gradient(180deg, #F0FDF4 0%, white 100%)' }}>
<div className="text-[10px] font-bold uppercase tracking-wide text-green-700 mb-2">{t('journey.editor.pros')}</div>
{entry.pros_cons.pros!.map((p, i) => (
<div key={i} className="flex items-start gap-1.5 text-[12px] text-green-900 mb-1">
<span className="w-[5px] h-[5px] rounded-full bg-green-500 flex-shrink-0 mt-[6px]" />{p}
</div>
))}
</div>
)}
{(entry.pros_cons.cons?.length ?? 0) > 0 && (
<div className="rounded-xl border border-red-200 dark:border-red-800/30 p-3" style={{ background: 'linear-gradient(180deg, #FEF2F2 0%, white 100%)' }}>
<div className="text-[10px] font-bold uppercase tracking-wide text-red-700 mb-2">{t('journey.editor.cons')}</div>
{entry.pros_cons.cons!.map((c, i) => (
<div key={i} className="flex items-start gap-1.5 text-[12px] text-red-900 mb-1">
<span className="w-[5px] h-[5px] rounded-full bg-red-500 flex-shrink-0 mt-[6px]" />{c}
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
))}
</div>
</div>
)
})}
</div>
)}
{/* Gallery */}
{view === 'gallery' && perms.share_gallery && (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5">
{allPhotos.map(({ photo }, idx) => (
<div
key={photo.id}
className="aspect-square rounded-lg overflow-hidden cursor-pointer"
onClick={() => setLightbox({ photos: allPhotos.map(({ photo: p }) => ({ id: String(p.id), src: photoUrl(p, token!), 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" />
</div>
</div>
)}
))}
</div>
)}
{renderTabs(availableViews)}
{/* Mobile combined map+timeline (public, read-only) */}
{isMobile && view === 'timeline' && perms.share_timeline && perms.share_map && (
<MobileMapTimeline
entries={timelineEntries}
mapEntries={sidebarMapItems as any}
dark={document.documentElement.classList.contains('dark')}
readOnly
onEntryClick={(entry) => setViewingEntry(entry as any)}
publicPhotoUrl={(photoId) => `/api/public/journey/${token}/photos/${photoId}/original`}
carouselBottom="calc(env(safe-area-inset-bottom, 16px) + 8px)"
{/* Map */}
{view === 'map' && perms.share_map && (
<div className="rounded-2xl overflow-hidden border border-zinc-200 dark:border-zinc-700">
<JourneyMap
checkins={[]}
entries={mapEntries.map(e => ({
id: String(e.id),
lat: e.location_lat!,
lng: e.location_lng!,
title: e.title || '',
mood: e.mood,
created_at: e.entry_date,
entry_date: e.entry_date,
})) as any}
height={500}
/>
)}
{/* Timeline (desktop, or mobile without map permission) */}
{(!isMobile || !perms.share_map) && view === 'timeline' && perms.share_timeline && renderTimeline()}
{/* Gallery */}
{view === 'gallery' && perms.share_gallery && renderGallery()}
{/* Map (standalone tab — only in single-column mode) */}
{view === 'map' && perms.share_map && (
<div className="rounded-2xl overflow-hidden border border-zinc-200 dark:border-zinc-700">
<JourneyMap
checkins={[]}
entries={sidebarMapItems as any}
height={500}
/>
</div>
)}
</div>
)}
</div>
)}
</div>
{/* Powered by */}
<div className="flex flex-col items-center py-8 gap-2">
@@ -618,26 +368,6 @@ export default function JourneyPublicPage() {
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>
)
}
+2 -6
View File
@@ -343,10 +343,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
}, [tripId])
useEffect(() => {
if (tripId) {
tripActions.loadReservations(tripId)
tripActions.loadBudgetItems?.(tripId)
}
if (tripId) tripActions.loadReservations(tripId)
}, [tripId])
useTripWebSocket(tripId)
@@ -645,7 +642,6 @@ export default function TripPlannerPage(): React.ReactElement | null {
const r = await tripActions.updateReservation(tripId, editingReservation.id, { ...data, day_id: selectedDayId || null })
toast.success(t('trip.toast.reservationUpdated'))
setShowReservationModal(false)
setEditingReservation(null)
if (data.type === 'hotel') {
accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
}
@@ -1109,7 +1105,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
</div>
<div style={{ flex: 1, overflow: 'auto' }}>
{mobileSidebarOpen === 'left'
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId); setMobileSidebarOpen(null) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} />
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId); setMobileSidebarOpen(null) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} />
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} />
}
</div>
-31
View File
@@ -355,37 +355,6 @@ describe('journeyStore', () => {
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 ────────────────────────────────────────────────────────────────
it('FE-STORE-JOURNEY-015: clear resets state', () => {
+4 -67
View File
@@ -57,24 +57,6 @@ export interface JourneyPhoto {
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 {
trip_id: number
added_at: number
@@ -97,7 +79,6 @@ export interface JourneyContributor {
export interface JourneyDetail extends Journey {
entries: JourneyEntry[]
gallery: GalleryPhoto[]
trips: JourneyTrip[]
contributors: JourneyContributor[]
stats: { entries: number; photos: number; places: number }
@@ -122,9 +103,6 @@ interface JourneyState {
reorderEntries: (journeyId: number, orderedIds: number[]) => Promise<void>
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>
clear: () => void
@@ -223,8 +201,10 @@ export const useJourneyStore = create<JourneyState>((set, get) => ({
)
entries.sort((a, b) => {
if (a.entry_date !== b.entry_date) return a.entry_date.localeCompare(b.entry_date)
if (a.sort_order !== b.sort_order) return (a.sort_order || 0) - (b.sort_order || 0)
return a.id - b.id
const atime = a.entry_time || ''
const btime = b.entry_time || ''
if (atime !== btime) return atime.localeCompare(btime)
return (a.sort_order || 0) - (b.sort_order || 0)
})
return { current: { ...s.current, entries } }
})
@@ -248,55 +228,12 @@ export const useJourneyStore = create<JourneyState>((set, get) => ({
entries: s.current.entries.map(e =>
e.id === entryId ? { ...e, photos: [...(e.photos || []), ...photos] } : e
),
gallery: [...(s.current.gallery || []), ...(data.gallery || [])],
},
}
})
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) => {
await journeyApi.deletePhoto(photoId)
set(s => {
-2
View File
@@ -171,8 +171,6 @@ export interface Reservation {
place_id?: number | null
assignment_id?: number | null
accommodation_id?: number | null
accommodation_start_day_id?: number | null
accommodation_end_day_id?: number | null
day_plan_position?: number | null
metadata?: Record<string, string> | string | null
needs_review?: number
+6 -27
View File
@@ -32,13 +32,6 @@ function triggerAnchorDownload(blobUrl: string, filename?: string): void {
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
* triggers a browser download. Works inside PWA standalone mode because the
@@ -63,13 +56,7 @@ export async function downloadFile(url: string, filename?: string): Promise<void
* (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.
*
* 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.
* Falls back to a download trigger if the popup is blocked.
*/
export async function openFile(url: string, filename?: string): Promise<void> {
assertRelativeUrl(url)
@@ -84,19 +71,11 @@ export async function openFile(url: string, filename?: string): Promise<void> {
return
}
// iOS PWA: target="_blank" would open Safari, which can't access the blob
if (isIosStandalone()) {
const win = window.open(blobUrl, '_blank', 'noreferrer')
if (win) {
setTimeout(() => URL.revokeObjectURL(blobUrl), 30_000)
} else {
// Popup blocked — fall back to download
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)
}
-33
View File
@@ -1,38 +1,5 @@
import type { AssignmentsMap } from '../types'
// Collapses verbose Nominatim display_name strings (e.g. "Place, 1, Road, Neighbourhood,
// City, County, State, Country, Postcode, Country") into "Place, Postcode, Country".
// Clean short names (≤3 parts) pass through untouched.
export function formatLocationName(raw: string | null | undefined): string {
if (!raw) return ''
const parts = raw.split(',').map(p => p.trim()).filter(Boolean)
if (parts.length <= 3) return raw.trim()
// Dedup preserving insertion order
const seen = new Set<string>()
const unique: string[] = []
for (const p of parts) {
if (!seen.has(p.toLowerCase())) { seen.add(p.toLowerCase()); unique.push(p) }
}
if (unique.length <= 3) return unique.join(', ')
const name = unique[0]
const last = unique[unique.length - 1]
const secondLast = unique.length >= 2 ? unique[unique.length - 2] : null
// Detect postcode at tail: short alphanumeric with at least one digit, ≤10 chars
const postalRe = /^[A-Z0-9][A-Z0-9\s\-]{1,8}$/i
const isLastPostal = postalRe.test(last) && /\d/.test(last) && last.length <= 10
const postcode = isLastPostal ? last : null
const country = isLastPostal ? secondLast : last
const result: string[] = [name]
if (postcode && postcode !== name) result.push(postcode)
if (country && country !== name && country !== postcode) result.push(country)
return result.join(', ')
}
const ZERO_DECIMAL_CURRENCIES = new Set(['JPY', 'KRW', 'VND', 'CLP', 'ISK', 'HUF'])
export function currencyDecimals(currency: string): number {
+5 -7
View File
@@ -64,13 +64,11 @@ class _MockIntersectionObserver {
globalThis.IntersectionObserver = _MockIntersectionObserver as unknown as typeof IntersectionObserver;
// ResizeObserver — used by resizable panels
class _MockResizeObserver {
observe = vi.fn()
unobserve = vi.fn()
disconnect = vi.fn()
constructor(_callback: ResizeObserverCallback) {}
}
globalThis.ResizeObserver = _MockResizeObserver as unknown as typeof ResizeObserver;
globalThis.ResizeObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
})) as unknown as typeof ResizeObserver;
// URL.createObjectURL / revokeObjectURL — Node 22 URL.createObjectURL requires
// a native node:buffer Blob; passing a jsdom Blob throws ERR_INVALID_ARG_TYPE.
+16 -53
View File
@@ -74,42 +74,32 @@ describe('downloadFile', () => {
})
describe('openFile', () => {
it('fetches with credentials:include and opens blob URL via target=_blank anchor', async () => {
it('fetches with credentials:include and opens blob URL in new tab', async () => {
vi.stubGlobal('fetch', makeFetchMock(200))
const openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
const mockWin = { closed: false }
const openSpy = vi.spyOn(window, 'open').mockReturnValue(mockWin as Window)
await openFile('/uploads/files/doc.pdf')
expect(window.fetch).toHaveBeenCalledWith('/uploads/files/doc.pdf', { credentials: 'include' })
expect(URL.createObjectURL).toHaveBeenCalled()
// 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)
expect(openSpy).toHaveBeenCalledWith('blob:mock-url', '_blank', 'noreferrer')
// Revoke happens after 30s timeout
vi.runAllTimers()
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url')
})
it('does not trigger a second in-page action for safe inline types (regression: no double-open)', async () => {
it('falls back to anchor download when popup is blocked', async () => {
vi.stubGlobal('fetch', makeFetchMock(200))
vi.spyOn(window, 'open').mockReturnValue(null)
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
await openFile('/uploads/files/doc.pdf', 'doc.pdf')
await openFile('/uploads/files/doc.pdf')
// Exactly ONE anchor click — opening the new tab. No fallback download.
expect(clickSpy).toHaveBeenCalledTimes(1)
expect(clickSpy).toHaveBeenCalled()
vi.runAllTimers()
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url')
})
it('throws on 401 response', async () => {
@@ -118,55 +108,28 @@ describe('openFile', () => {
expect(URL.createObjectURL).not.toHaveBeenCalled()
})
it('forces download for unsafe MIME types (HTML) instead of opening inline', async () => {
it('forces download for unsafe MIME types (HTML, SVG) instead of opening inline', async () => {
const htmlBlob = new Blob(['<script>alert(1)</script>'], { type: 'text/html' })
vi.stubGlobal('fetch', makeFetchMock(200, htmlBlob))
const openSpy = vi.spyOn(window, 'open').mockReturnValue({} as Window)
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
await openFile('/uploads/files/malicious.html', 'malicious.html')
await openFile('/uploads/files/malicious.html')
// Must NOT open inline — download anchor clicked instead
expect(openSpy).not.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')
expect(clickSpy).toHaveBeenCalled()
})
it('forces download for SVG MIME type', async () => {
const svgBlob = new Blob(['<svg><script>alert(1)</script></svg>'], { type: 'image/svg+xml' })
vi.stubGlobal('fetch', makeFetchMock(200, svgBlob))
const openSpy = vi.spyOn(window, 'open').mockReturnValue({} as Window)
vi.spyOn(window, 'open').mockReturnValue({} as Window)
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
await openFile('/uploads/files/malicious.svg')
expect(openSpy).not.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
}
expect(window.open).not.toHaveBeenCalled()
expect(clickSpy).toHaveBeenCalled()
})
})
-1
View File
@@ -24,7 +24,6 @@ services:
# - DEFAULT_LANGUAGE=en # Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links
# - FORCE_HTTPS=true # Optional. Enables HTTPS redirect, HSTS, CSP upgrade-insecure-requests, and secure cookies behind a TLS proxy
# - HSTS_INCLUDE_SUBDOMAINS=false # When true: adds includeSubDomains to the HSTS header. Only effective when HSTS is active. Leave false if sibling subdomains still run over plain HTTP.
# - COOKIE_SECURE=false # Escape hatch: force session cookies over plain HTTP even in production. Not recommended.
# - TRUST_PROXY=1 # Trusted proxy count for X-Forwarded-For / X-Forwarded-Proto. Required for FORCE_HTTPS to work.
# - ALLOW_INTERNAL_NETWORK=false # Set to true if Immich or other services are hosted on your local network (RFC-1918 IPs). Loopback and link-local addresses remain blocked regardless.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

-1
View File
@@ -13,7 +13,6 @@ LOG_LEVEL=info # info = concise user actions; debug = verbose admin-level detail
ALLOWED_ORIGINS=https://trek.example.com # Comma-separated origins for CORS and email links
FORCE_HTTPS=false # Optional. When true: HTTPS redirect + HSTS + CSP upgrade-insecure-requests + secure cookies. Only behind a TLS proxy.
# HSTS_INCLUDE_SUBDOMAINS=false # When true: adds includeSubDomains to the HSTS header. Only effective when HSTS is active (FORCE_HTTPS=true or NODE_ENV=production). Leave false if you run other services on sibling subdomains over plain HTTP.
COOKIE_SECURE=true # Auto-derived (true when NODE_ENV=production or FORCE_HTTPS=true). Set false to force cookies over plain HTTP.
TRUST_PROXY=1 # Trusted proxy hops (parseInt or 1). Active in production by default; off in dev unless set. Needed for FORCE_HTTPS.
ALLOW_INTERNAL_NETWORK=false # Allow outbound requests to private/RFC1918 IPs (e.g. Immich hosted on your LAN). Loopback and link-local addresses are always blocked.
+17 -916
View File
File diff suppressed because it is too large Load Diff
+2 -3
View File
@@ -1,6 +1,6 @@
{
"name": "trek-server",
"version": "3.0.10",
"version": "2.9.14",
"main": "src/index.ts",
"scripts": {
"start": "node --import tsx src/index.ts",
@@ -23,7 +23,6 @@
"express": "^4.18.3",
"fast-xml-parser": "^5.5.10",
"helmet": "^8.1.0",
"jimp": "^1.6.1",
"jsonwebtoken": "^9.0.2",
"multer": "^2.1.1",
"node-cron": "^4.2.1",
@@ -35,7 +34,7 @@
"typescript": "^6.0.2",
"undici": "^7.0.0",
"unzipper": "^0.12.3",
"uuid": "^14.0.0",
"uuid": "^9.0.0",
"ws": "^8.19.0",
"zod": "^4.3.6"
},
+2 -5
View File
@@ -124,7 +124,6 @@ export function createApp(): express.Application {
},
crossOriginEmbedderPolicy: false,
hsts: hstsActive ? { maxAge: 31536000, includeSubDomains: hstsIncludeSubdomains } : false,
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
}));
if (shouldForceHttps) {
@@ -373,10 +372,8 @@ export function createApp(): express.Application {
} else {
console.error('Unhandled error:', err);
}
const status = err.statusCode || err.status || 500;
// 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 });
const status = err.statusCode || 500;
res.status(status).json({ error: 'Internal server error' });
});
return app;
-184
View File
@@ -1946,190 +1946,6 @@ 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) {
+24 -83
View File
@@ -9,7 +9,6 @@ import * as svc from '../services/journeyService';
import { db } from '../db/database';
import { createOrUpdateJourneyShareLink, getJourneyShareLink, deleteJourneyShareLink, getPublicJourney } from '../services/journeyShareService';
import { uploadToImmich } from '../services/memories/immichService';
import { getAllowedExtensions } from '../services/fileService';
const router = express.Router();
@@ -26,26 +25,9 @@ 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({
storage,
limits: { fileSize: 20 * 1024 * 1024 },
fileFilter: imageFilter,
});
// ── Static prefix routes (MUST come before /:id) ─────────────────────────
@@ -122,11 +104,10 @@ router.post('/entries/:entryId/photos', authenticate, upload.array('photos', 10)
try {
const immichId = await uploadToImmich(authReq.user.id, relativePath, file.originalname);
if (immichId) {
// photo.id is now the gallery photo id (journey_photos.id)
svc.setPhotoProvider(photo.id, 'immich', immichId, authReq.user.id);
(photo as any).provider = 'immich';
(photo as any).asset_id = immichId;
(photo as any).owner_id = authReq.user.id;
photo.provider = 'immich' as any;
photo.asset_id = immichId;
photo.owner_id = authReq.user.id;
}
} catch {}
}
@@ -160,25 +141,16 @@ router.post('/entries/:entryId/provider-photos', authenticate, (req: Request, re
res.status(201).json(photo);
});
// Link a gallery photo to an entry
// Link an existing photo to a (different) entry
router.post('/entries/:entryId/link-photo', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
// Accept journey_photo_id (new) or photo_id (legacy alias) for backwards compat
const journeyPhotoId = (req.body || {}).journey_photo_id ?? (req.body || {}).photo_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);
const { photo_id } = req.body || {};
if (!photo_id) return res.status(400).json({ error: 'photo_id required' });
const result = svc.linkPhotoToEntry(Number(req.params.entryId), Number(photo_id), authReq.user.id);
if (!result) return res.status(403).json({ error: 'Not allowed' });
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) => {
const authReq = req as AuthRequest;
const result = svc.updatePhoto(Number(req.params.photoId), authReq.user.id, req.body || {});
@@ -186,65 +158,34 @@ router.patch('/photos/:photoId', authenticate, (req: Request, res: Response) =>
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) => {
const authReq = req as AuthRequest;
const photo = svc.deletePhoto(Number(req.params.photoId), authReq.user.id);
if (!photo) return res.status(404).json({ error: 'Photo not found' });
// delete local file
if (photo.file_path) {
const fullPath = path.join(__dirname, '../../uploads', photo.file_path);
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 });
});
// ── 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) ───────────
router.get('/:id', authenticate, (req: Request, res: Response) => {
+1 -1
View File
@@ -112,7 +112,7 @@ router.get('/callback', async (req: Request, res: Response) => {
tokenData.id_token,
doc,
config.clientId,
(doc.issuer ?? '').replace(/\/+$/, '') || config.issuer,
config.issuer,
);
if (idVerify.ok !== true) {
const reason = 'error' in idVerify ? idVerify.error : 'unknown';
+152 -167
View File
@@ -7,22 +7,12 @@ function ts(): number {
return Date.now();
}
// 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.
// Joined SELECT for journey_photos + trek_photos — returns fields matching JourneyPhoto interface
const JP_SELECT = `
gp.id, jep.entry_id, gp.photo_id, gp.caption, jep.sort_order, gp.shared, gp.created_at,
tp.provider, tp.asset_id, tp.owner_id, tp.file_path, tp.thumbnail_path, tp.width, tp.height
jp.id, jp.entry_id, jp.photo_id, jp.caption, jp.sort_order, jp.shared, jp.created_at,
tkp.provider, tkp.asset_id, tkp.owner_id, tkp.file_path, tkp.thumbnail_path, tkp.width, tkp.height
`;
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';
const JP_JOIN = 'journey_photos jp JOIN trek_photos tkp ON tkp.id = jp.photo_id';
function broadcastJourneyEvent(journeyId: number, event: string, data: Record<string, unknown>, excludeSocketId?: string | number) {
const contributors = db.prepare(
@@ -68,7 +58,7 @@ export function listJourneys(userId: number) {
return db.prepare(`
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_photos jp WHERE jp.journey_id = j.id) as photo_count,
(SELECT COUNT(*) 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(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 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
@@ -120,11 +110,11 @@ export function getJourneyFull(journeyId: number, userId: number) {
if (!journey) return null;
const entries = db.prepare(
'SELECT * FROM journey_entries WHERE journey_id = ? ORDER BY entry_date ASC, sort_order ASC, id ASC'
'SELECT * FROM journey_entries WHERE journey_id = ? ORDER BY entry_date ASC, entry_time ASC, sort_order ASC'
).all(journeyId) as JourneyEntry[];
const photos = db.prepare(
`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`
`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`
).all(journeyId) as JourneyPhoto[];
// group photos by entry
@@ -133,11 +123,12 @@ export function getJourneyFull(journeyId: number, userId: number) {
(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
.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 => ({
...e,
tags: e.tags ? JSON.parse(e.tags) : [],
@@ -169,7 +160,7 @@ export function getJourneyFull(journeyId: number, userId: number) {
// stats
const entryCount = entries.filter(e => e.type === 'entry').length;
const photoCount = (gallery as any[]).length;
const photoCount = photos.length;
const places = [...new Set(entries.map(e => e.location_name).filter(Boolean))];
const userPrefs = db.prepare(
@@ -192,7 +183,6 @@ export function getJourneyFull(journeyId: number, userId: number) {
return {
...journey,
entries: enrichedEntries,
gallery,
trips,
contributors,
stats: { entries: entryCount, photos: photoCount, places: places.length },
@@ -306,21 +296,12 @@ export function syncTripPlaces(journeyId: number, tripId: number, authorId: numb
).all(journeyId, tripId) as { source_place_id: number }[];
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) {
if (existingPlaceIds.has(place.id)) continue;
existingPlaceIds.add(place.id);
const entryDate = place.day_date || new Date().toISOString().split('T')[0];
const entryTime = place.assignment_time || place.place_time || null;
const nextOrder = (dateMaxOrder.get(entryDate) ?? -1) + 1;
dateMaxOrder.set(entryDate, nextOrder);
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)
@@ -329,27 +310,51 @@ export function syncTripPlaces(journeyId: number, tripId: number, authorId: numb
journeyId, tripId, place.id, authorId,
place.name, entryDate, entryTime,
place.address || place.name, place.lat || null, place.lng || null,
nextOrder, now, now
place.day_number || 0, now, now
);
}
}
// import trip_photos into journey gallery when a trip is linked
// import trip_photos into journey when a trip is linked
function syncTripPhotos(journeyId: number, tripId: number) {
const tripPhotos = db.prepare(
'SELECT tp.photo_id, tp.shared FROM trip_photos tp WHERE tp.trip_id = ?'
).all(tripId) as { photo_id: number; shared: number }[];
'SELECT tp.photo_id, tp.user_id, tp.shared FROM trip_photos tp WHERE tp.trip_id = ?'
).all(tripId) as { photo_id: number; user_id: number; shared: number }[];
if (!tripPhotos.length) return;
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) {
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(`
INSERT OR IGNORE INTO journey_photos (journey_id, photo_id, shared, sort_order, created_at)
INSERT INTO journey_photos (entry_id, photo_id, shared, sort_order, created_at)
VALUES (?, ?, ?, ?, ?)
`).run(journeyId, tp.photo_id, tp.shared, nextOrder++, now);
`).run(photoEntry.id, tp.photo_id, tp.shared, (maxOrder?.m ?? -1) + 1, now);
}
}
@@ -376,19 +381,15 @@ 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 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(`
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', ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, 'skeleton', ?, ?, ?, ?, ?, ?, 0, ?, ?)
`).run(
link.journey_id, tripId, placeId, journey.user_id,
place.name, entryDate, place.assignment_time || place.place_time || null,
place.address || place.name, place.lat || null, place.lng || null,
nextOrder, now, now
now, now
);
}
}
@@ -443,7 +444,7 @@ export function onPlaceDeleted(placeId: number) {
for (const entry of entries) {
if (entry.type === 'skeleton') {
// no content: just delete
const hasPhotos = db.prepare('SELECT 1 FROM journey_entry_photos WHERE entry_id = ?').get(entry.id);
const hasPhotos = db.prepare('SELECT 1 FROM journey_photos WHERE entry_id = ?').get(entry.id);
if (!hasPhotos && !entry.story) {
db.prepare('DELETE FROM journey_entries WHERE id = ?').run(entry.id);
continue;
@@ -464,11 +465,11 @@ export function listEntries(journeyId: number, userId: number) {
if (!canAccessJourney(journeyId, userId)) return null;
const entries = db.prepare(
'SELECT * FROM journey_entries WHERE journey_id = ? ORDER BY entry_date ASC, sort_order ASC, id ASC'
'SELECT * FROM journey_entries WHERE journey_id = ? ORDER BY entry_date ASC, entry_time ASC, sort_order ASC'
).all(journeyId) as JourneyEntry[];
const photos = db.prepare(
`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`
`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`
).all(journeyId) as JourneyPhoto[];
const photosByEntry: Record<number, JourneyPhoto[]> = {};
@@ -627,6 +628,9 @@ export function deleteEntry(entryId: number, userId: number, sid?: string): bool
if (!entry) 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') {
// Revert filled entry back to skeleton instead of deleting
db.prepare(`
@@ -641,6 +645,12 @@ export function deleteEntry(entryId: number, userId: number, sid?: string): bool
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;
}
@@ -654,40 +664,23 @@ function promoteSkeletonIfNeeded(entry: JourneyEntry): void {
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 {
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
if (!entry) return null;
if (!canEdit(entry.journey_id, userId)) return null;
const trekPhotoId = getOrCreateLocalTrekPhoto(filePath, thumbnailPath);
const galleryId = db.transaction(() => ensureInGallery(entry.journey_id, trekPhotoId, caption))();
const result = linkGalleryPhotoToEntry(galleryId, entryId);
const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE entry_id = ?').get(entryId) as { m: number | null };
const now = ts();
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);
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 {
@@ -697,127 +690,119 @@ export function addProviderPhoto(entryId: number, userId: number, provider: stri
const trekPhotoId = getOrCreateTrekPhoto(provider, assetId, userId, passphrase);
// skip if this photo is already linked to this entry
const alreadyLinked = db.prepare(`
SELECT 1 FROM journey_entry_photos jep
JOIN journey_photos gp ON gp.id = jep.journey_photo_id
WHERE jep.entry_id = ? AND gp.photo_id = ?
`).get(entryId, trekPhotoId);
if (alreadyLinked) return null;
// skip if already added
const exists = db.prepare('SELECT 1 FROM journey_photos WHERE entry_id = ? AND photo_id = ?').get(entryId, trekPhotoId);
if (exists) return null;
const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE entry_id = ?').get(entryId) as { m: number | null };
const now = ts();
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);
return result;
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(Number(res.lastInsertRowid)) as JourneyPhoto;
}
// Link a gallery photo (by its journey_photos.id) to an entry — idempotent.
export function linkPhotoToEntry(entryId: number, journeyPhotoId: number, userId: number): JourneyPhoto | null {
export function linkPhotoToEntry(entryId: number, photoId: number, userId: number): JourneyPhoto | null {
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
if (!entry) return null;
if (!canEdit(entry.journey_id, userId)) return null;
// Verify the gallery photo belongs to this journey
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;
const source = db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(photoId) as JourneyPhoto | undefined;
if (!source) return null;
const result = linkGalleryPhotoToEntry(galleryRow.id, entryId);
promoteSkeletonIfNeeded(entry);
return result;
}
if (source.entry_id === entryId) return source;
// Upload photos to the journey gallery only (no entry association).
export function uploadGalleryPhotos(journeyId: number, userId: number, filePaths: { path: string; thumbnail?: string }[]): JourneyPhoto[] {
if (!canEdit(journeyId, userId)) return [];
const results: any[] = [];
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;
const oldEntry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(source.entry_id) as JourneyEntry | undefined;
const sourceIsGallery = oldEntry?.title === 'Gallery';
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);
// 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;
}
return results;
}
// 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;
}
promoteSkeletonIfNeeded(entry);
// 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;
// If we moved out of a Gallery entry (shouldn't happen with the guard above,
// but kept for any legacy data), clean up the Gallery wrapper if emptied.
if (!sourceIsGallery && oldEntry && oldEntry.title === 'Gallery') {
const remaining = db.prepare('SELECT COUNT(*) as c FROM journey_photos WHERE entry_id = ?').get(source.entry_id) as { c: number };
if (remaining.c === 0) {
db.prepare('DELETE FROM journey_entries WHERE id = ?').run(source.entry_id);
}
}
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 };
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(resultId) as JourneyPhoto;
}
export function setPhotoProvider(photoId: number, provider: string, assetId: string, ownerId: number) {
// photoId = journey_photos.id (gallery row); look up the trek_photo_id
// Get the trek_photo_id from the journey_photo, then update the central registry
const jp = db.prepare('SELECT photo_id FROM journey_photos WHERE id = ?').get(photoId) as { photo_id: number } | undefined;
if (!jp) return;
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 {
// photoId = journey_photos.id (gallery row)
const row = db.prepare('SELECT id, journey_id FROM journey_photos WHERE id = ?').get(photoId) as { id: number; journey_id: number } | undefined;
if (!row) return null;
if (!canEdit(row.journey_id, userId)) return null;
const photo = db.prepare(`
SELECT ${JP_SELECT}, je.journey_id FROM ${JP_JOIN}
JOIN journey_entries je ON jp.entry_id = je.id
WHERE jp.id = ?
`).get(photoId) as (JourneyPhoto & { journey_id: number }) | undefined;
if (!photo) return null;
if (!canEdit(photo.journey_id, userId)) return null;
// caption lives on the gallery row; sort_order lives on the junction table
// (JP_SELECT reads jep.sort_order, so updating journey_photos.sort_order
// would not be reflected in the returned row).
if (data.caption !== undefined) {
db.prepare('UPDATE journey_photos SET caption = ? WHERE id = ?').run(data.caption, photoId);
}
if (data.sort_order !== undefined) {
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 gp.id = ? LIMIT 1`).get(photoId) as JourneyPhoto | null;
const fields: string[] = [];
const values: unknown[] = [];
if (data.caption !== undefined) { fields.push('caption = ?'); values.push(data.caption); }
if (data.sort_order !== undefined) { fields.push('sort_order = ?'); values.push(data.sort_order); }
if (!fields.length) return photo;
values.push(photoId);
db.prepare(`UPDATE journey_photos SET ${fields.join(', ')} WHERE id = ?`).run(...values);
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(photoId) as JourneyPhoto;
}
// deletePhoto: hard-delete (backwards compat name used by old route).
export function deletePhoto(photoId: number, userId: number): { id: number; photo_id: number; file_path?: string | null; journey_id: number } | null {
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;
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;
export function deletePhoto(photoId: number, userId: number): (JourneyPhoto & { journey_id: number }) | null {
const photo = db.prepare(`
SELECT ${JP_SELECT}, je.journey_id FROM ${JP_JOIN}
JOIN journey_entries je ON jp.entry_id = je.id
WHERE jp.id = ?
`).get(photoId) as (JourneyPhoto & { journey_id: number }) | undefined;
if (!photo) return null;
if (!canEdit(photo.journey_id, userId)) return null;
db.prepare('DELETE FROM journey_photos WHERE id = ?').run(photoId);
deleteTrekPhotoIfOrphan(row.photo_id);
deleteTrekPhotoIfOrphan(photo.photo_id);
return { id: row.id, photo_id: row.photo_id, file_path: trekRow?.file_path ?? null, journey_id: row.journey_id };
// clean up empty Gallery entries left behind
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 ─────────────────────────────────────────────────────────
+21 -24
View File
@@ -66,10 +66,11 @@ 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;
if (!row) return null;
const photo = db.prepare(`
SELECT gp.photo_id, tkp.owner_id, gp.journey_id
FROM journey_photos gp
JOIN trek_photos tkp ON tkp.id = gp.photo_id
WHERE gp.photo_id = ? AND gp.journey_id = ?
SELECT jp.photo_id, tkp.owner_id, je.journey_id
FROM journey_photos jp
JOIN trek_photos tkp ON tkp.id = jp.photo_id
JOIN journey_entries je ON jp.entry_id = je.id
WHERE jp.photo_id = ? AND je.journey_id = ?
`).get(photoId, row.journey_id) as any;
if (!photo) return null;
const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(row.journey_id) as any;
@@ -80,9 +81,10 @@ 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;
if (!row) return null;
const photo = db.prepare(`
SELECT tkp.owner_id FROM journey_photos gp
JOIN trek_photos tkp ON tkp.id = gp.photo_id
WHERE tkp.asset_id = ? AND gp.journey_id = ?
SELECT tkp.owner_id FROM journey_photos jp
JOIN trek_photos tkp ON tkp.id = jp.photo_id
JOIN journey_entries je ON jp.entry_id = je.id
WHERE tkp.asset_id = ? AND je.journey_id = ?
`).get(assetId, row.journey_id) as any;
if (!photo) {
const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(row.journey_id) as any;
@@ -106,13 +108,13 @@ export function getPublicJourney(token: string) {
`).all(row.journey_id) as any[];
const photos = db.prepare(`
SELECT gp.id, jep.entry_id, gp.photo_id, gp.caption, jep.sort_order, gp.shared, gp.created_at,
SELECT jp.id, jp.entry_id, jp.photo_id, jp.caption, jp.sort_order, jp.shared, jp.created_at,
tkp.provider, tkp.asset_id, tkp.owner_id, tkp.file_path, tkp.thumbnail_path, tkp.width, tkp.height
FROM journey_entry_photos jep
JOIN journey_photos gp ON gp.id = jep.journey_photo_id
JOIN trek_photos tkp ON tkp.id = gp.photo_id
WHERE gp.journey_id = ?
ORDER BY jep.sort_order
FROM journey_photos jp
JOIN trek_photos tkp ON tkp.id = jp.photo_id
JOIN journey_entries je ON jp.entry_id = je.id
WHERE je.journey_id = ?
ORDER BY jp.sort_order
`).all(row.journey_id) as any[];
const photosByEntry: Record<number, any[]> = {};
@@ -120,16 +122,12 @@ export function getPublicJourney(token: string) {
(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
.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 => ({
...e,
tags: e.tags ? JSON.parse(e.tags) : [],
@@ -140,7 +138,7 @@ export function getPublicJourney(token: string) {
// Stats
const stats = {
entries: entries.length,
photos: gallery.length,
photos: photos.length,
places: new Set(entries.filter(e => e.location_name).map(e => e.location_name)).size,
};
@@ -152,7 +150,6 @@ export function getPublicJourney(token: string) {
status: journey.status,
},
entries: enrichedEntries,
gallery,
stats,
permissions: {
share_timeline: !!row.share_timeline,
+10 -8
View File
@@ -129,14 +129,15 @@ export function canAccessUserPhoto(requestingUserId: number, ownerUserId: number
// Journey photos use tripId=0 — check journey_photos + journey_contributors
if (tripId === '0') {
const journeyPhoto = db.prepare(`
SELECT gp.journey_id
FROM journey_photos gp
JOIN trek_photos tkp ON tkp.id = gp.photo_id
SELECT jp.entry_id, je.journey_id
FROM journey_photos jp
JOIN trek_photos tkp ON tkp.id = jp.photo_id
JOIN journey_entries je ON je.id = jp.entry_id
WHERE tkp.asset_id = ?
AND tkp.provider = ?
AND tkp.owner_id = ?
LIMIT 1
`).get(assetId, provider, ownerUserId) as { journey_id: number } | undefined;
`).get(assetId, provider, ownerUserId) as { entry_id: number; journey_id: number } | undefined;
if (!journeyPhoto) return false;
const access = db.prepare(`
@@ -193,12 +194,13 @@ export function canAccessTrekPhoto(requestingUserId: number, trekPhotoId: number
// Check journey_photos — is this photo in a journey the user can access?
const journeyAccess = db.prepare(`
SELECT 1 FROM journey_photos gp
WHERE gp.photo_id = ?
SELECT 1 FROM journey_photos jp
JOIN journey_entries je ON je.id = jp.entry_id
WHERE jp.photo_id = ?
AND EXISTS (
SELECT 1 FROM journeys j WHERE j.id = gp.journey_id AND j.user_id = ?
SELECT 1 FROM journeys j WHERE j.id = je.journey_id AND j.user_id = ?
UNION ALL
SELECT 1 FROM journey_contributors jc WHERE jc.journey_id = gp.journey_id AND jc.user_id = ?
SELECT 1 FROM journey_contributors jc WHERE jc.journey_id = je.journey_id AND jc.user_id = ?
)
LIMIT 1
`).get(trekPhotoId, requestingUserId, requestingUserId);
@@ -9,7 +9,6 @@ import type { ServiceResult, AssetInfo } from './helpersService';
import { fail, success } from './helpersService';
import { encrypt_api_key, decrypt_api_key } from '../apiKeyCrypto';
import * as photoCache from './trekPhotoCache';
import { ensureLocalThumbnail } from './thumbnailService';
// ── Lookup / Register ────────────────────────────────────────────────────
@@ -102,31 +101,7 @@ export async function streamPhoto(
}
if (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);
const localPath = path.join(__dirname, '../../../uploads', photo.file_path);
if (fs.existsSync(localPath)) {
res.set('Cache-Control', 'public, max-age=86400');
res.sendFile(localPath);
@@ -1,50 +0,0 @@
import { Jimp } from 'jimp'
import path from 'path'
import fs from 'fs/promises'
import crypto from 'crypto'
import { isAddonEnabled } from '../adminService'
import { ADDON_IDS } from '../../addons'
const THUMB_MAX = 800
const THUMB_QUALITY = 80
export async function ensureLocalThumbnail(
uploadsRoot: string,
originalRelPath: string,
): Promise<{ thumbnailRelPath: string; width: number; height: number } | null> {
if (!isAddonEnabled(ADDON_IDS.JOURNEY)) return 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}.jpg`
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 img = await Jimp.read(thumbAbs)
return { thumbnailRelPath: thumbRel, width: img.bitmap.width, height: img.bitmap.height }
}
await fs.mkdir(path.dirname(thumbAbs), { recursive: true })
// Jimp auto-applies EXIF orientation on read, matching sharp's .rotate() behavior.
const img = await Jimp.read(originalAbs)
const { width: w, height: h } = img.bitmap
if (w > THUMB_MAX || h > THUMB_MAX) {
img.scaleToFit({ w: THUMB_MAX, h: THUMB_MAX })
}
await img.write(thumbAbs as `${string}.jpg`, { quality: THUMB_QUALITY })
return { thumbnailRelPath: thumbRel, width: img.bitmap.width, height: img.bitmap.height }
} catch {
// Unsupported format, corrupt file, etc. — fall back to original in caller.
return null
}
}
+6 -22
View File
@@ -140,21 +140,11 @@ export async function discover(issuer: string, discoveryUrl?: string | null): Pr
const res = await fetch(url);
if (!res.ok) throw new Error('Failed to fetch OIDC discovery document');
const doc = (await res.json()) as OidcDiscoveryDoc;
// Validate that the discovery doc's issuer matches the operator-configured one.
// When no custom discoveryUrl is set, a mismatch signals a MITM or misconfiguration
// and we reject. When the operator explicitly overrides the discovery URL (e.g.
// Authentik realm paths), the discovery doc's issuer is the canonical value —
// 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}"`);
}
// Validate that the discovery doc's issuer matches the operator-configured
// one. A MITM or compromised doc could otherwise supply a crafted issuer
// that passes jwt.verify() because we used doc.issuer as the expected value.
if (doc.issuer && doc.issuer !== issuer) {
throw new Error(`OIDC discovery issuer mismatch: expected "${issuer}", got "${doc.issuer}"`);
}
doc._issuer = url;
discoveryCache = doc;
@@ -323,6 +313,7 @@ export async function verifyIdToken(
try {
const verified = jwt.verify(idToken, publicKey, {
algorithms: [alg as jwt.Algorithm],
issuer: expectedIssuer,
audience: clientId,
});
claims = typeof verified === 'string' ? {} : (verified as Record<string, unknown>);
@@ -331,13 +322,6 @@ export async function verifyIdToken(
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 };
}
+6 -12
View File
@@ -1,5 +1,4 @@
import { db, canAccessTrip } from '../db/database';
import { avatarUrl } from './authService';
const BAG_COLORS = ['#6366f1', '#ec4899', '#f97316', '#10b981', '#06b6d4', '#8b5cf6', '#ef4444', '#f59e0b'];
@@ -132,10 +131,7 @@ export function listBags(tripId: string | number) {
if (!membersByBag.has(m.bag_id)) membersByBag.set(m.bag_id, []);
membersByBag.get(m.bag_id)!.push(m);
}
return bags.map(b => ({
...b,
members: (membersByBag.get(b.id) || []).map(m => ({ ...m, avatar: avatarUrl(m) })),
}));
return bags.map(b => ({ ...b, members: membersByBag.get(b.id) || [] }));
}
export function setBagMembers(tripId: string | number, bagId: string | number, userIds: number[]) {
@@ -144,12 +140,11 @@ export function setBagMembers(tripId: string | number, bagId: string | number, u
db.prepare('DELETE FROM packing_bag_members WHERE bag_id = ?').run(bagId);
const ins = db.prepare('INSERT OR IGNORE INTO packing_bag_members (bag_id, user_id) VALUES (?, ?)');
for (const uid of userIds) ins.run(bagId, uid);
const rows = db.prepare(`
return db.prepare(`
SELECT bm.user_id, u.username, u.avatar
FROM packing_bag_members bm JOIN users u ON bm.user_id = u.id
WHERE bm.bag_id = ?
`).all(bagId) as { user_id: number; username: string; avatar: string | null }[];
return rows.map(m => ({ ...m, avatar: avatarUrl(m) }));
`).all(bagId);
}
export function createBag(tripId: string | number, data: { name: string; color?: string }) {
@@ -265,7 +260,7 @@ export function getCategoryAssignees(tripId: string | number) {
const assignees: Record<string, { user_id: number; username: string; avatar: string | null }[]> = {};
for (const row of rows as any[]) {
if (!assignees[row.category_name]) assignees[row.category_name] = [];
assignees[row.category_name].push({ user_id: row.user_id, username: row.username, avatar: avatarUrl(row) });
assignees[row.category_name].push({ user_id: row.user_id, username: row.username, avatar: row.avatar });
}
return assignees;
@@ -279,13 +274,12 @@ export function updateCategoryAssignees(tripId: string | number, categoryName: s
for (const uid of userIds) insert.run(tripId, categoryName, uid);
}
const updated = db.prepare(`
return db.prepare(`
SELECT pca.user_id, u.username, u.avatar
FROM packing_category_assignees pca
JOIN users u ON pca.user_id = u.id
WHERE pca.trip_id = ? AND pca.category_name = ?
`).all(tripId, categoryName) as { user_id: number; username: string; avatar: string | null }[];
return updated.map(m => ({ ...m, avatar: avatarUrl(m) }));
`).all(tripId, categoryName);
}
// ── Reorder ────────────────────────────────────────────────────────────────
+25 -90
View File
@@ -43,48 +43,21 @@ function loadEndpoints(reservationId: number): 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;
}
function saveEndpoints(reservationId: number, endpoints: EndpointInput[]): void {
// Bind the transaction lazily on each call. Binding at module load time
// captures the DB connection that was open then, which becomes invalid
// after demo-reset / restore-from-backup closes and reinitialises the
// connection — every later endpoint save would throw
// "The database connection is not open".
const tx = db.transaction((rid: number, eps: EndpointInput[]) => {
db.prepare('DELETE FROM reservation_endpoints WHERE reservation_id = ?').run(rid);
const insert = db.prepare(`
INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
eps.forEach((e, i) => {
insert.run(rid, e.role, e.sequence ?? i, e.name, e.code ?? null, e.lat, e.lng, e.timezone ?? null, e.local_time ?? null, e.local_date ?? null);
});
const saveEndpoints = db.transaction((reservationId: number, endpoints: EndpointInput[]) => {
db.prepare('DELETE FROM reservation_endpoints WHERE reservation_id = ?').run(reservationId);
const insert = db.prepare(`
INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
endpoints.forEach((e, i) => {
insert.run(reservationId, e.role, e.sequence ?? i, e.name, e.code ?? null, e.lat, e.lng, e.timezone ?? null, e.local_time ?? null, e.local_date ?? null);
});
tx(reservationId, endpoints);
}
});
export function listReservations(tripId: string | number) {
const reservations = db.prepare(`
SELECT r.*, d.day_number, p.name as place_name, r.assignment_id,
ap.place_id as accommodation_place_id, acc_p.name as accommodation_name,
ap.start_day_id as accommodation_start_day_id, ap.end_day_id as accommodation_end_day_id
ap.place_id as accommodation_place_id, acc_p.name as accommodation_name
FROM reservations r
LEFT JOIN days d ON r.day_id = d.id
LEFT JOIN places p ON r.place_id = p.id
@@ -120,8 +93,7 @@ export function listReservations(tripId: string | number) {
export function getReservationWithJoins(id: string | number) {
const row = db.prepare(`
SELECT r.*, d.day_number, p.name as place_name, r.assignment_id,
ap.place_id as accommodation_place_id, acc_p.name as accommodation_name,
ap.start_day_id as accommodation_start_day_id, ap.end_day_id as accommodation_end_day_id
ap.place_id as accommodation_place_id, acc_p.name as accommodation_name
FROM reservations r
LEFT JOIN days d ON r.day_id = d.id
LEFT JOIN places p ON r.place_id = p.id
@@ -186,26 +158,13 @@ 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(`
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
tripId,
resolvedDayId,
resolvedEndDayId,
day_id || null,
end_day_id ?? null,
place_id || null,
assignment_id || null,
title,
@@ -215,7 +174,7 @@ export function createReservation(tripId: string | number, data: CreateReservati
confirmation_number || null,
notes || null,
status || 'pending',
resolvedType,
type || 'other',
resolvedAccommodationId,
metadata ? JSON.stringify(metadata) : null,
needs_review ? 1 : 0
@@ -317,8 +276,13 @@ export function updateReservation(id: string | number, tripId: string | number,
const { place_id: accPlaceId, start_day_id, end_day_id, check_in, check_out, confirmation: accConf } = create_accommodation;
if (start_day_id && end_day_id) {
if (resolvedAccId) {
db.prepare('UPDATE day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, check_in = ?, check_out = ?, confirmation = ? WHERE id = ?')
.run(accPlaceId || null, start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null, resolvedAccId);
if (accPlaceId) {
db.prepare('UPDATE day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, check_in = ?, check_out = ?, confirmation = ? WHERE id = ?')
.run(accPlaceId, start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null, resolvedAccId);
} else {
db.prepare('UPDATE day_accommodations SET start_day_id = ?, end_day_id = ?, check_in = ?, check_out = ?, confirmation = ? WHERE id = ?')
.run(start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null, resolvedAccId);
}
} else if (accPlaceId) {
const accResult = db.prepare(
'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation) VALUES (?, ?, ?, ?, ?, ?, ?)'
@@ -329,35 +293,6 @@ 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(`
UPDATE reservations SET
title = COALESCE(?, title),
@@ -378,13 +313,13 @@ export function updateReservation(id: string | number, tripId: string | number,
WHERE id = ?
`).run(
title || null,
nextReservationTime,
nextReservationEndTime,
(type ?? current.type) === 'hotel' ? null : (reservation_time !== undefined ? (reservation_time || null) : current.reservation_time),
(type ?? current.type) === 'hotel' ? null : (reservation_end_time !== undefined ? (reservation_end_time || null) : current.reservation_end_time),
location !== undefined ? (location || null) : current.location,
confirmation_number !== undefined ? (confirmation_number || null) : current.confirmation_number,
notes !== undefined ? (notes || null) : current.notes,
nextDayId,
nextEndDayId,
day_id !== undefined ? (day_id || null) : current.day_id,
end_day_id !== undefined ? (end_day_id ?? null) : (current as any).end_day_id ?? null,
place_id !== undefined ? (place_id || null) : current.place_id,
assignment_id !== undefined ? (assignment_id || null) : current.assignment_id,
status || null,
-18
View File
@@ -681,24 +681,6 @@ export function copyTripById(sourceTripId: string | number, newOwnerId: number,
if (newDayId) insertNote.run(newDayId, newTripId, n.text, n.time, n.icon, n.sort_order);
}
const oldTodos = db.prepare('SELECT * FROM todo_items WHERE trip_id = ?').all(sourceTripId) as any[];
const insertTodo = db.prepare(`
INSERT INTO todo_items (trip_id, name, checked, category, sort_order, due_date, description, assigned_user_id, priority)
VALUES (?, ?, 0, ?, ?, ?, ?, NULL, ?)
`);
for (const t of oldTodos) {
insertTodo.run(newTripId, t.name, t.category, t.sort_order, t.due_date, t.description, t.priority);
}
const oldCategoryOrder = db.prepare('SELECT category, sort_order FROM budget_category_order WHERE trip_id = ?').all(sourceTripId) as any[];
const insertCategoryOrder = db.prepare(`
INSERT INTO budget_category_order (trip_id, category, sort_order)
VALUES (?, ?, ?)
`);
for (const o of oldCategoryOrder) {
insertCategoryOrder.run(newTripId, o.category, o.sort_order);
}
return Number(newTripId);
});
-18
View File
@@ -389,24 +389,6 @@ export interface JourneyPhoto {
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 {
journey_id: number;
trip_id: number;
+1 -1
View File
@@ -649,7 +649,7 @@ describe('Link photo to entry', () => {
.send({});
expect(res.status).toBe(400);
expect(res.body.error).toBe('journey_photo_id required');
expect(res.body.error).toBe('photo_id required');
});
});
@@ -84,9 +84,8 @@ describe('GET /api/system-notices/active', () => {
it('returns empty array for non-first-login user with no applicable notices', async () => {
const { user } = createUser(testDb);
// login_count > 1 means firstLogin condition does not match for any notice;
// 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);
// 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);
const res = await request(app)
.get('/api/system-notices/active')
.set('Cookie', authCookie(user.id));
@@ -123,7 +122,7 @@ describe('GET /api/system-notices/active', () => {
SYSTEM_NOTICES.push(TEST_NOTICE);
try {
const { user } = createUser(testDb);
testDb.prepare('UPDATE users SET login_count = 5, first_seen_version = ? WHERE id = ?').run('3.0.0', user.id);
testDb.prepare('UPDATE users SET login_count = 5 WHERE id = ?').run(user.id);
const res = await request(app)
.get('/api/system-notices/active')
-46
View File
@@ -950,52 +950,6 @@ describe('Copy trip with data', () => {
expect(newNotes).toHaveLength(1);
expect(newNotes[0].text).toBe('Pack early!');
});
it('TRIP-027 — copy preserves todos (unchecked, unassigned) and budget category order', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Todo Trip' });
// Two todos: one checked and assigned — both should arrive unchecked and unassigned
testDb.prepare(
'INSERT INTO todo_items (trip_id, name, checked, category, sort_order, due_date, description, priority) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
).run(trip.id, 'Buy tickets', 0, 'Transport', 0, '2026-06-01', 'Check Ryanair', 1);
testDb.prepare(
'INSERT INTO todo_items (trip_id, name, checked, category, sort_order, assigned_user_id, priority) VALUES (?, ?, ?, ?, ?, ?, ?)'
).run(trip.id, 'Book hotel', 1, 'Accommodation', 1, user.id, 0);
// Two budget category order rows
const insOrder = testDb.prepare('INSERT INTO budget_category_order (trip_id, category, sort_order) VALUES (?, ?, ?)');
insOrder.run(trip.id, 'Transport', 0);
insOrder.run(trip.id, 'Accommodation', 1);
const res = await request(app)
.post(`/api/trips/${trip.id}/copy`)
.set('Cookie', authCookie(user.id))
.send({ title: 'Todo Trip (Copy)' });
expect(res.status).toBe(201);
const newId = res.body.trip.id;
// Todos copied with checked reset and assigned_user_id nulled
const newTodos = testDb.prepare('SELECT * FROM todo_items WHERE trip_id = ? ORDER BY sort_order').all(newId) as any[];
expect(newTodos).toHaveLength(2);
expect(newTodos[0].name).toBe('Buy tickets');
expect(newTodos[0].category).toBe('Transport');
expect(newTodos[0].checked).toBe(0);
expect(newTodos[0].assigned_user_id).toBeNull();
expect(newTodos[0].due_date).toBe('2026-06-01');
expect(newTodos[0].description).toBe('Check Ryanair');
expect(newTodos[0].priority).toBe(1);
expect(newTodos[1].name).toBe('Book hotel');
expect(newTodos[1].checked).toBe(0);
expect(newTodos[1].assigned_user_id).toBeNull();
// Budget category order copied
const newOrder = testDb.prepare('SELECT category, sort_order FROM budget_category_order WHERE trip_id = ? ORDER BY sort_order').all(newId) as any[];
expect(newOrder).toHaveLength(2);
expect(newOrder[0]).toMatchObject({ category: 'Transport', sort_order: 0 });
expect(newOrder[1]).toMatchObject({ category: 'Accommodation', sort_order: 1 });
});
});
// ─────────────────────────────────────────────────────────────────────────────
+11 -113
View File
@@ -68,7 +68,6 @@ import {
removeContributor,
getSuggestions,
syncTripPlaces,
reorderEntries,
onPlaceCreated,
onPlaceUpdated,
onPlaceDeleted,
@@ -1326,10 +1325,9 @@ describe('Edge cases', () => {
const result = deleteEntry(entry.id, user.id);
expect(result).toBe(true);
// Junction row must be gone (ON DELETE CASCADE from journey_entries).
// Gallery row (journey_photos) is preserved — photo may belong to other entries.
const junctionRow = testDb.prepare('SELECT * FROM journey_entry_photos WHERE entry_id = ?').get(entry.id) as any;
expect(junctionRow).toBeUndefined();
// Photo should be deleted with the entry
const deletedPhoto = testDb.prepare('SELECT * FROM journey_photos WHERE id = ?').get(photo!.id) as any;
expect(deletedPhoto).toBeUndefined();
});
it('JOURNEY-SVC-082: updateJourney can set cover_gradient', () => {
@@ -1397,12 +1395,17 @@ describe('Edge cases', () => {
addTripToJourney(journey.id, trip.id, user.id);
// Trip photos now go straight into the journey gallery (no wrapper entry).
// Should have a [Trip Photos] entry with the imported photo
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(`
SELECT jp.*, tkp.asset_id FROM journey_photos jp
JOIN trek_photos tkp ON tkp.id = jp.photo_id
WHERE jp.journey_id = ?
`).all(journey.id);
WHERE jp.entry_id = ?
`).all(photoEntry.id);
expect(photos.length).toBe(1);
expect((photos[0] as any).asset_id).toBe('immich-photo-1');
});
@@ -1466,108 +1469,3 @@ describe('addProviderPhoto — passphrase', () => {
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 ------------------------------------------------------------------
/** Insert a trek_photos + journey_photos (gallery) + journey_entry_photos row and return the trek_photos id (used as photoId in public URLs). */
/** Insert a trek_photos + journey_photos row and return the trek_photos id (used as photoId in public URLs). */
function insertJourneyPhoto(
entryId: number,
opts: { filePath?: string; assetId?: string; ownerId?: number } = {}
@@ -70,24 +70,10 @@ function insertJourneyPhoto(
VALUES (?, ?, ?, ?, ?)
`).run(provider, opts.assetId ?? null, filePath, opts.ownerId ?? null, Date.now());
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(`
INSERT OR IGNORE INTO journey_photos (journey_id, photo_id, caption, sort_order, created_at)
INSERT INTO journey_photos (entry_id, photo_id, caption, sort_order, created_at)
VALUES (?, ?, NULL, 0, ?)
`).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);
`).run(entryId, trekId, Date.now());
// 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
return trekId;
@@ -4,8 +4,6 @@
* discover caching, and the ReDoS-sensitive issuer trailing-slash regex.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
import { generateKeyPairSync } from 'crypto';
import jwtLib from 'jsonwebtoken';
// ── DB setup ──────────────────────────────────────────────────────────────────
@@ -52,7 +50,6 @@ import {
frontendUrl,
findOrCreateUser,
discover,
verifyIdToken,
} from '../../../src/services/oidcService';
const MOCK_CONFIG = {
@@ -219,59 +216,6 @@ describe('discover', () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }));
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) ─────────────────────────────────
@@ -516,66 +460,3 @@ describe('getUserInfo', () => {
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);
});
});
-1
View File
@@ -37,7 +37,6 @@
<Config Name="ALLOWED_ORIGINS" Target="ALLOWED_ORIGINS" Default="" Mode="" Description="Comma-separated origins allowed for CORS and used as base URL in email notification links (e.g. https://trek.example.com)." Type="Variable" Display="always" Required="false" Mask="false"/>
<Config Name="APP_URL" Target="APP_URL" Default="" Mode="" Description="Public base URL of this instance (e.g. https://trek.example.com). Required when OIDC is enabled — must match the redirect URI registered with your IdP. Also used as base URL for email notification links." Type="Variable" Display="always" Required="false" Mask="false"/>
<Config Name="FORCE_HTTPS" Target="FORCE_HTTPS" Default="false" Mode="" Description="Optional. When true: HTTPS redirect, HSTS header, CSP upgrade-insecure-requests, and secure cookies. Only useful behind a TLS-terminating proxy. Requires TRUST_PROXY." Type="Variable" Display="advanced" Required="false" Mask="false">false</Config>
<Config Name="HSTS_INCLUDE_SUBDOMAINS" Target="HSTS_INCLUDE_SUBDOMAINS" Default="false" Mode="" Description="When true: adds includeSubDomains to the HSTS header, extending HTTPS enforcement to all subdomains. Only effective when HSTS is active (FORCE_HTTPS=true or NODE_ENV=production). Leave false if you run other services on sibling subdomains over plain HTTP." Type="Variable" Display="advanced" Required="false" Mask="false">false</Config>
<Config Name="COOKIE_SECURE" Target="COOKIE_SECURE" Default="true" Mode="" Description="Auto-derived (true in production or when FORCE_HTTPS=true). Set to false to force session cookies over plain HTTP. Not recommended for production." Type="Variable" Display="advanced" Required="false" Mask="false">true</Config>
<Config Name="TRUST_PROXY" Target="TRUST_PROXY" Default="1" Mode="" Description="Trusted proxy hops for X-Forwarded-For/X-Forwarded-Proto. Defaults to 1 in production; off in development unless set. Required for FORCE_HTTPS." Type="Variable" Display="advanced" Required="false" Mask="false">1</Config>
<Config Name="ALLOW_INTERNAL_NETWORK" Target="ALLOW_INTERNAL_NETWORK" Default="false" Mode="" Description="Allow outbound requests to private/RFC-1918 IP addresses. Set to true if Immich or other integrated services are hosted on your local network." Type="Variable" Display="advanced" Required="false" Mask="false">false</Config>
+1 -1
View File
@@ -59,7 +59,7 @@ If a toggle fails (e.g., network error), it rolls back to its previous state.
Some addons require credentials or environment variables before they are functional:
- **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).
- **Journey**requires photo provider credentials (Immich or Synology Photos) configured per-user in their personal Settings. See [Photo-Providers](Photo-Providers).
- **MCP** — requires `APP_URL` to be set so OAuth redirect URIs resolve correctly.
## Related pages
+7 -8
View File
@@ -48,12 +48,11 @@ Verified in `server/src/config.ts` (line 107):
## HTTPS / Proxy
These three variables work together behind a TLS-terminating reverse proxy. See [Reverse-Proxy](Reverse-Proxy) for the full explanation.
These three variables work together behind a TLS-terminating reverse proxy. See [Reverse-Proxy] for the full explanation.
| Variable | Description | Default |
|---|---|---|
| `FORCE_HTTPS` | When `true`: 301-redirects HTTP→HTTPS, sends HSTS (`max-age=31536000`), adds CSP `upgrade-insecure-requests`, forces cookie `secure` flag. Only useful behind a TLS proxy. Requires `TRUST_PROXY`. | `false` |
| `HSTS_INCLUDE_SUBDOMAINS` | When `true`: adds the `includeSubDomains` directive to the HSTS header, extending HTTPS enforcement to all subdomains. Only effective when HSTS is active (`FORCE_HTTPS=true` or `NODE_ENV=production`). Leave `false` if you run other services on sibling subdomains over plain HTTP. | `false` |
| `TRUST_PROXY` | Number of trusted proxy hops. Tells Express to read the real client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` automatically in production. Required for `FORCE_HTTPS` to detect the forwarded protocol. | `1` (production) |
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived as `true` when `NODE_ENV=production` or `FORCE_HTTPS=true`. Set to `false` only as an escape hatch for LAN testing without TLS — not recommended in production. | auto |
@@ -63,7 +62,7 @@ These three variables work together behind a TLS-terminating reverse proxy. See
## OIDC / SSO
For setup instructions, see [OIDC-SSO](OIDC-SSO).
For setup instructions, see [OIDC-SSO].
| Variable | Description | Default |
|---|---|---|
@@ -111,7 +110,7 @@ Both variables must be set together. If either is omitted, the account is create
## MCP
For setup instructions, see [MCP-Overview](MCP-Overview).
For setup instructions, see [MCP-Overview].
| Variable | Description | Default |
|---|---|---|
@@ -130,7 +129,7 @@ For setup instructions, see [MCP-Overview](MCP-Overview).
## Related Pages
- [Reverse-Proxy](Reverse-Proxy) — HTTPS proxy setup and the `FORCE_HTTPS` / `TRUST_PROXY` / `COOKIE_SECURE` trio
- [OIDC-SSO](OIDC-SSO) — complete OIDC configuration guide
- [MCP-Overview](MCP-Overview) — MCP server setup and rate limiting
- [Encryption-Key-Rotation](Encryption-Key-Rotation) — rotating the `ENCRYPTION_KEY` without losing data
- [Reverse-Proxy] — HTTPS proxy setup and the `FORCE_HTTPS` / `TRUST_PROXY` / `COOKIE_SECURE` trio
- [OIDC-SSO] — complete OIDC configuration guide
- [MCP-Overview] — MCP server setup and rate limiting
- [Encryption-Key-Rotation] — rotating the `ENCRYPTION_KEY` without losing data

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