mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
Compare commits
79 Commits
v3.0.0-pre.54
...
v3.0.2
| Author | SHA1 | Date | |
|---|---|---|---|
| ec1ed60117 | |||
| ed4c21eade | |||
| 9093948ff6 | |||
| 2cea4d73aa | |||
| a2a6f52e6e | |||
| 0978b40b6d | |||
| 6155b6dc86 | |||
| 314486325e | |||
| 523bca3a20 | |||
| d5be528d4b | |||
| 3ada075b1a | |||
| afce302b59 | |||
| 8e8433fa9d | |||
| ff42fa0b8c | |||
| ccea7f7a65 | |||
| 45a5b4e588 | |||
| 82cce365f7 | |||
| ed7e2badca | |||
| ba7b99fb7d | |||
| 71aa8f8051 | |||
| 7c9e945b8c | |||
| f6b3931bc4 | |||
| 9e3041305c | |||
| 78fc557143 | |||
| 8a2fec8de0 | |||
| e109dc0b51 | |||
| 88d980c657 | |||
| 3f489880da | |||
| 45fa6fd0d3 | |||
| a8c27f9d4a | |||
| 288d33ba42 | |||
| e7fb78dc1e | |||
| 4d3bf390a5 | |||
| 001b2365a1 | |||
| 7d5dadc441 | |||
| c912ad4b01 | |||
| bd6cd55a13 | |||
| 757764d046 | |||
| 94e64acc34 | |||
| 70ba24bfe1 | |||
| 32f431e879 | |||
| 906d8821a4 | |||
| 82b16a4bf5 | |||
| 069269e69c | |||
| 534149ba22 | |||
| 2dd6e04b44 | |||
| 0e3d9f6ddc | |||
| 3b7442c2d5 | |||
| 78b45d7c19 | |||
| 9e5100c71c | |||
| fccf13a7e2 | |||
| 09431f725c | |||
| 13162c0920 | |||
| e25b513d0b | |||
| 9012bffabc | |||
| 24a85b0f91 | |||
| 43a503b593 | |||
| a81fe3da0a | |||
| 70ba4d5435 | |||
| 881b9d0939 | |||
| 758de855bf | |||
| 9652874bbd | |||
| 840f5e82aa | |||
| d59b3334dc | |||
| 5a64d8994e | |||
| e6222894e9 | |||
| 9d48c06068 | |||
| 9f70b56a3a | |||
| 232dc78cc9 | |||
| d2c44380a4 | |||
| 2f9d7adf4a | |||
| ba4a64241b | |||
| ee14f706c8 | |||
| 1cc43f63df | |||
| 3450bd59f8 | |||
| 457d436cf6 | |||
| 1127efb9c4 | |||
| 0a98d3c2e7 | |||
| 5eaf7492dc |
@@ -30,3 +30,7 @@ sonar-project.properties
|
|||||||
server/tests/
|
server/tests/
|
||||||
server/vitest.config.ts
|
server/vitest.config.ts
|
||||||
server/reset-admin.js
|
server/reset-admin.js
|
||||||
|
**/*.test.ts
|
||||||
|
wiki/
|
||||||
|
scripts/
|
||||||
|
charts/
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ body:
|
|||||||
required: true
|
required: true
|
||||||
- label: I am running the latest available version of TREK
|
- label: I am running the latest available version of TREK
|
||||||
required: true
|
required: true
|
||||||
|
- label: I have read the [Troubleshooting guide](https://github.com/mauriceboe/TREK/wiki/Troubleshooting) and my issue is not covered there
|
||||||
|
required: true
|
||||||
|
|
||||||
- type: input
|
- type: input
|
||||||
id: version
|
id: version
|
||||||
|
|||||||
@@ -23,4 +23,4 @@ jobs:
|
|||||||
- name: Publish to GitHub wiki
|
- name: Publish to GitHub wiki
|
||||||
uses: Andrew-Chen-Wang/github-wiki-action@v5
|
uses: Andrew-Chen-Wang/github-wiki-action@v5
|
||||||
with:
|
with:
|
||||||
strategy: init
|
strategy: clone
|
||||||
|
|||||||
+2
-1
@@ -60,4 +60,5 @@ coverage
|
|||||||
.scannerwork
|
.scannerwork
|
||||||
test-data
|
test-data
|
||||||
|
|
||||||
.run
|
.run
|
||||||
|
.full-review
|
||||||
+1
-1
@@ -4,7 +4,7 @@ Thanks for your interest in contributing! Please read these guidelines before op
|
|||||||
|
|
||||||
## Ground Rules
|
## Ground Rules
|
||||||
|
|
||||||
1. **Ask in Discord first** — Before writing any code, pitch your idea in the `#github-pr` channel on our [Discord server](https://discord.gg/P7TUxHJs). We'll let you know if the PR is wanted and give direction. PRs that show up without prior discussion will be closed
|
1. **Ask in Discord first** — Before writing any code, pitch your idea in the `#github-pr` channel on our [Discord server](https://discord.gg/NhZBDSd4qW). We'll let you know if the PR is wanted and give direction. PRs that show up without prior discussion will be closed
|
||||||
2. **One change per PR** — Keep it focused. Don't bundle unrelated fixes or refactors
|
2. **One change per PR** — Keep it focused. Don't bundle unrelated fixes or refactors
|
||||||
3. **No breaking changes** — Backwards compatibility is non-negotiable
|
3. **No breaking changes** — Backwards compatibility is non-negotiable
|
||||||
4. **Target the `dev` branch** — All PRs must be opened against `dev`, not `main`
|
4. **Target the `dev` branch** — All PRs must be opened against `dev`, not `main`
|
||||||
|
|||||||
@@ -6,19 +6,29 @@
|
|||||||
<img src="docs/logo-trek-dark.gif" alt="TREK" height="96" />
|
<img src="docs/logo-trek-dark.gif" alt="TREK" height="96" />
|
||||||
</picture>
|
</picture>
|
||||||
|
|
||||||
### Your trips. Your plan. Your server.
|
<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>
|
||||||
|
|
||||||
A self-hosted, real-time collaborative travel planner — with maps, budgets, packing lists, a journal, and AI built in.
|
A self-hosted, real-time collaborative travel planner — with maps, budgets, packing lists, a journal, and AI built in.
|
||||||
|
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
<a href="https://demo-nomad.pakulat.org"><img alt="Live Demo" src="https://img.shields.io/badge/Live_Demo-try_it_now-111827?style=for-the-badge" /></a>
|
<a href="https://demo-nomad.pakulat.org"><img alt="Demo" src="https://img.shields.io/badge/Demo-try-111827?style=for-the-badge" /></a>
|
||||||
|
|
||||||
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=for-the-badge&logo=docker&logoColor=white" /></a>
|
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=for-the-badge" /></a>
|
||||||
|
|
||||||
<a href="https://discord.gg/NhZBDSd4qW"><img alt="Discord" src="https://img.shields.io/badge/Discord-community-5865F2?style=for-the-badge&logo=discord&logoColor=white" /></a>
|
<a href="https://discord.gg/NhZBDSd4qW"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?style=for-the-badge" /></a>
|
||||||
|
|
||||||
<a href="https://kanban.pakulat.org/shared/I4wxF6inOOMB0C6hH6kQm3efyNxFjwyI"><img alt="Roadmap" src="https://img.shields.io/badge/Roadmap-board-0EA5E9?style=for-the-badge&logo=trello&logoColor=white" /></a>
|
<a href="https://kanban.pakulat.org/shared/I4wxF6inOOMB0C6hH6kQm3efyNxFjwyI"><img alt="Roadmap" src="https://img.shields.io/badge/Roadmap-view-0EA5E9?style=for-the-badge" /></a>
|
||||||
|
<br />
|
||||||
|
<a href="https://ko-fi.com/mauriceboe"><img alt="Ko-fi" src="https://img.shields.io/badge/Ko--fi-support-FF5E5B?style=for-the-badge" /></a>
|
||||||
|
|
||||||
|
<a href="https://www.buymeacoffee.com/mauriceboe"><img alt="BMAC" src="https://img.shields.io/badge/BMAC-support-FFDD00?style=for-the-badge" /></a>
|
||||||
<br />
|
<br />
|
||||||
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-AGPL_v3-6B7280?style=flat-square" /></a>
|
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-AGPL_v3-6B7280?style=flat-square" /></a>
|
||||||
<a href="https://github.com/mauriceboe/TREK/releases"><img alt="Latest Release" src="https://img.shields.io/github/v/release/mauriceboe/TREK?include_prereleases&style=flat-square&color=6B7280" /></a>
|
<a href="https://github.com/mauriceboe/TREK/releases"><img alt="Latest Release" src="https://img.shields.io/github/v/release/mauriceboe/TREK?include_prereleases&style=flat-square&color=6B7280" /></a>
|
||||||
@@ -117,19 +127,23 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
|||||||
|
|
||||||
#### 🧩 Addons (admin-toggleable)
|
#### 🧩 Addons (admin-toggleable)
|
||||||
|
|
||||||
|
- **Lists** — packing lists + to-dos with templates, member assignments, optional bag tracking
|
||||||
|
- **Budget** — expense tracker with splits, pie chart, multi-currency
|
||||||
|
- **Documents** — file attachments on trips, places, and reservations
|
||||||
|
- **Collab** — chat, notes, polls, day-by-day attendance
|
||||||
- **Vacay** — personal vacation planner with calendar, 100+ country holidays, carry-over tracking
|
- **Vacay** — personal vacation planner with calendar, 100+ country holidays, carry-over tracking
|
||||||
- **Atlas** — world map of visited countries, bucket list, travel stats, streak tracking, liquid-glass UI
|
- **Atlas** — world map of visited countries, bucket list, travel stats, streak tracking, liquid-glass UI
|
||||||
- **Collab** — chat, notes, polls, day-by-day attendance
|
- **Journey** — magazine-style travel journal with entries, photos (Immich/Synology), maps, moods
|
||||||
- **Journey** — magazine-style travel journal with entries, photos, maps, moods
|
- **Naver List Import** — one-click import from shared Naver Maps lists
|
||||||
- **Dashboard widgets** — currency converter and timezone clocks
|
- **MCP** — expose TREK to AI assistants via OAuth 2.1
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
|
|
||||||
#### 🤖 AI / MCP
|
#### 🤖 AI / MCP
|
||||||
|
|
||||||
- **Built-in MCP server** — OAuth 2.1 authenticated. 80+ tools, 27 resources
|
- **Built-in MCP server** — OAuth 2.1 authenticated. 150+ tools, 30 resources
|
||||||
- **Granular scopes** — 24 OAuth scopes across 13 permission groups
|
- **Granular scopes** — 27 OAuth scopes across 13 permission groups
|
||||||
- **Full automation** — AI can create trips, plan days, build packing lists, manage budgets, mark countries visited
|
- **Full automation** — AI can create trips, plan days, build packing lists, manage budgets, mark countries visited
|
||||||
- **Pre-built prompts** — `trip-summary`, `packing-list`, `budget-overview`
|
- **Pre-built prompts** — `trip-summary`, `packing-list`, `budget-overview`
|
||||||
- **Addon-aware** — exposes Atlas, Collab, Vacay when those addons are on
|
- **Addon-aware** — exposes Atlas, Collab, Vacay when those addons are on
|
||||||
@@ -142,7 +156,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
|||||||
#### ⚙️ Admin & customisation
|
#### ⚙️ Admin & customisation
|
||||||
|
|
||||||
- **Dashboard views** — card grid or compact list · **Dark mode** — full theme with matching status bar
|
- **Dashboard views** — card grid or compact list · **Dark mode** — full theme with matching status bar
|
||||||
- **14 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID
|
- **15 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID
|
||||||
- **Admin panel** — users, invites, packing templates, categories, addons, API keys, backups, GitHub history
|
- **Admin panel** — users, invites, packing templates, categories, addons, API keys, backups, GitHub history
|
||||||
- **Auto-backups** — scheduled with configurable retention · **Units** — °C/°F, 12h/24h, map tile sources, default coordinates
|
- **Auto-backups** — scheduled with configurable retention · **Units** — °C/°F, 12h/24h, map tile sources, default coordinates
|
||||||
|
|
||||||
@@ -162,7 +176,7 @@ ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d -p 3000:3000 \
|
|||||||
-v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek
|
-v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek
|
||||||
```
|
```
|
||||||
|
|
||||||
Open `http://localhost:3000`. The first user to register becomes admin.
|
Open `http://localhost:3000`. On first boot TREK seeds an admin account — if you set `ADMIN_EMAIL`/`ADMIN_PASSWORD` those are used, otherwise the credentials are printed to the container log (`docker logs trek`).
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
@@ -328,7 +342,8 @@ server {
|
|||||||
ssl_certificate /etc/ssl/fullchain.pem;
|
ssl_certificate /etc/ssl/fullchain.pem;
|
||||||
ssl_certificate_key /etc/ssl/privkey.pem;
|
ssl_certificate_key /etc/ssl/privkey.pem;
|
||||||
|
|
||||||
client_max_body_size 50m;
|
# 500 MB covers backup-restore uploads (capped at 500 MB server-side).
|
||||||
|
client_max_body_size 500m;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://localhost:3000;
|
proxy_pass http://localhost:3000;
|
||||||
@@ -345,6 +360,7 @@ server {
|
|||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection "upgrade";
|
proxy_set_header Connection "upgrade";
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
|
proxy_read_timeout 86400;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
apiVersion: v2
|
apiVersion: v2
|
||||||
name: trek
|
name: trek
|
||||||
version: 2.9.14
|
version: 3.0.2
|
||||||
description: Minimal Helm chart for TREK app
|
description: Minimal Helm chart for TREK app
|
||||||
appVersion: "2.9.14"
|
appVersion: "3.0.2"
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "trek-client",
|
"name": "trek-client",
|
||||||
"version": "2.9.14",
|
"version": "3.0.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "trek-client",
|
"name": "trek-client",
|
||||||
"version": "2.9.14",
|
"version": "3.0.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-pdf/renderer": "^4.3.2",
|
"@react-pdf/renderer": "^4.3.2",
|
||||||
"axios": "^1.6.7",
|
"axios": "^1.6.7",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "trek-client",
|
"name": "trek-client",
|
||||||
"version": "2.9.14",
|
"version": "3.0.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -356,9 +356,13 @@ export const journeyApi = {
|
|||||||
|
|
||||||
// Photos
|
// Photos
|
||||||
uploadPhotos: (entryId: number, formData: FormData) => apiClient.post(`/journeys/entries/${entryId}/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
|
uploadPhotos: (entryId: number, formData: FormData) => apiClient.post(`/journeys/entries/${entryId}/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
|
||||||
|
uploadGalleryPhotos: (journeyId: number, formData: FormData) => apiClient.post(`/journeys/${journeyId}/gallery/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
|
||||||
|
addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
||||||
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
||||||
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
||||||
linkPhoto: (entryId: number, photoId: number) => apiClient.post(`/journeys/entries/${entryId}/link-photo`, { photo_id: photoId }).then(r => r.data),
|
linkPhoto: (entryId: number, journeyPhotoId: number) => apiClient.post(`/journeys/entries/${entryId}/link-photo`, { journey_photo_id: journeyPhotoId }).then(r => r.data),
|
||||||
|
unlinkPhoto: (entryId: number, journeyPhotoId: number) => apiClient.delete(`/journeys/entries/${entryId}/photos/${journeyPhotoId}`).then(r => r.data),
|
||||||
|
deleteGalleryPhoto: (journeyId: number, journeyPhotoId: number) => apiClient.delete(`/journeys/${journeyId}/gallery/${journeyPhotoId}`).then(r => r.data),
|
||||||
updatePhoto: (photoId: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/photos/${photoId}`, data).then(r => r.data),
|
updatePhoto: (photoId: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/photos/${photoId}`, data).then(r => r.data),
|
||||||
deletePhoto: (photoId: number) => apiClient.delete(`/journeys/photos/${photoId}`).then(r => r.data),
|
deletePhoto: (photoId: number) => apiClient.delete(`/journeys/photos/${photoId}`).then(r => r.data),
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { useCanDo } from '../../store/permissionsStore'
|
|||||||
import { useTripStore } from '../../store/tripStore'
|
import { useTripStore } from '../../store/tripStore'
|
||||||
|
|
||||||
import { getAuthUrl } from '../../api/authUrl'
|
import { getAuthUrl } from '../../api/authUrl'
|
||||||
import { downloadFile, openFile } from '../../utils/fileDownload'
|
import { downloadFile, openFile as openFileUrl } from '../../utils/fileDownload'
|
||||||
|
|
||||||
function isImage(mimeType) {
|
function isImage(mimeType) {
|
||||||
if (!mimeType) return false
|
if (!mimeType) return false
|
||||||
@@ -113,7 +113,7 @@ function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxProps) {
|
|||||||
</span>
|
</span>
|
||||||
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
||||||
<button
|
<button
|
||||||
onClick={() => openFile(file.url).catch(() => {})}
|
onClick={() => openFileUrl(file.url, file.original_name).catch(() => {})}
|
||||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 4 }}
|
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 4 }}
|
||||||
title={t('files.openTab')}>
|
title={t('files.openTab')}>
|
||||||
<ExternalLink size={16} />
|
<ExternalLink size={16} />
|
||||||
@@ -649,8 +649,17 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
</div>
|
</div>
|
||||||
{dayGroups.map(({ day, dayPlaces }) => (
|
{dayGroups.map(({ day, dayPlaces }) => (
|
||||||
<div key={day.id}>
|
<div key={day.id}>
|
||||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', padding: '8px 10px 2px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', padding: '8px 10px 2px' }}>
|
||||||
{day.title || `${t('dayplan.dayN', { n: day.day_number })}${day.date ? ` · ${day.date}` : ''}`}
|
<span>{day.title || t('dayplan.dayN', { n: day.day_number })}</span>
|
||||||
|
{(() => {
|
||||||
|
const badge = day.date || (day.title ? t('dayplan.dayN', { n: day.day_number }) : null)
|
||||||
|
return badge ? (
|
||||||
|
<span style={{
|
||||||
|
fontSize: 10, fontWeight: 600, color: 'var(--text-faint)',
|
||||||
|
background: 'var(--bg-tertiary)', padding: '1px 6px', borderRadius: 999,
|
||||||
|
}}>{badge}</span>
|
||||||
|
) : null
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
{dayPlaces.map(placeBtn)}
|
{dayPlaces.map(placeBtn)}
|
||||||
</div>
|
</div>
|
||||||
@@ -743,7 +752,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{previewFile.original_name}</span>
|
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{previewFile.original_name}</span>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
|
||||||
<button
|
<button
|
||||||
onClick={() => openFile(previewFile.url).catch(() => {})}
|
onClick={() => openFileUrl(previewFile.url, previewFile.original_name).catch(() => toast.error(t('files.openError')))}
|
||||||
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'none', padding: '4px 8px', borderRadius: 6, transition: 'color 0.15s' }}
|
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'none', padding: '4px 8px', borderRadius: 6, transition: 'color 0.15s' }}
|
||||||
onMouseEnter={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'}
|
onMouseEnter={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'}
|
||||||
onMouseLeave={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-muted)'}>
|
onMouseLeave={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-muted)'}>
|
||||||
@@ -771,7 +780,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
title={previewFile.original_name}
|
title={previewFile.original_name}
|
||||||
>
|
>
|
||||||
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
|
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||||
<button onClick={() => openFile(previewFile.url).catch(() => {})} style={{ color: 'var(--text-primary)', textDecoration: 'underline', background: 'none', border: 'none', cursor: 'pointer', font: 'inherit' }}>{t('files.downloadPdf')}</button>
|
<button onClick={() => openFileUrl(previewFile.url, previewFile.original_name).catch(() => toast.error(t('files.openError')))} style={{ color: 'var(--text-primary)', textDecoration: 'underline', background: 'none', border: 'none', cursor: 'pointer', font: 'inherit' }}>{t('files.downloadPdf')}</button>
|
||||||
</p>
|
</p>
|
||||||
</object>
|
</object>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export interface MapMarkerItem {
|
|||||||
label: string
|
label: string
|
||||||
mood?: string | null
|
mood?: string | null
|
||||||
time: string
|
time: string
|
||||||
|
dayColor: string
|
||||||
|
dayLabel: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JourneyMapHandle {
|
export interface JourneyMapHandle {
|
||||||
@@ -24,6 +26,8 @@ interface MapEntry {
|
|||||||
title?: string | null
|
title?: string | null
|
||||||
mood?: string | null
|
mood?: string | null
|
||||||
entry_date: string
|
entry_date: string
|
||||||
|
dayColor?: string
|
||||||
|
dayLabel?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -49,6 +53,8 @@ function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] {
|
|||||||
label: e.title || 'Entry',
|
label: e.title || 'Entry',
|
||||||
mood: e.mood,
|
mood: e.mood,
|
||||||
time: e.entry_date,
|
time: e.entry_date,
|
||||||
|
dayColor: e.dayColor || '#52525B',
|
||||||
|
dayLabel: e.dayLabel ?? 1,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,30 +65,19 @@ function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] {
|
|||||||
const MARKER_W = 28
|
const MARKER_W = 28
|
||||||
const MARKER_H = 36
|
const MARKER_H = 36
|
||||||
|
|
||||||
function markerSvg(index: number, highlighted: boolean, dark: boolean): string {
|
function markerSvg(dayColor: string, dayLabel: number, highlighted: boolean): string {
|
||||||
// Highlighted: inverted colors for contrast (black on light, white on dark)
|
const stroke = highlighted ? '#fff' : 'rgba(255,255,255,0.5)'
|
||||||
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
|
const shadow = highlighted
|
||||||
? (dark
|
? 'filter:drop-shadow(0 0 10px rgba(0,0,0,0.4)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))'
|
||||||
? '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))'
|
: 'filter:drop-shadow(0 2px 4px rgba(0,0,0,0.25))'
|
||||||
const label = String(index + 1)
|
const label = String(dayLabel)
|
||||||
const scale = highlighted ? 1.2 : 1
|
const scale = highlighted ? 1.2 : 1
|
||||||
|
|
||||||
return `<div style="transform:scale(${scale});transition:transform 0.2s ease;${shadow};transform-origin:bottom center">
|
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">
|
<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="2"/>
|
<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="${fill}"/>
|
<circle cx="14" cy="13" r="8" fill="${dayColor}"/>
|
||||||
<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>
|
<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>
|
||||||
</svg>
|
</svg>
|
||||||
</div>`
|
</div>`
|
||||||
}
|
}
|
||||||
@@ -115,12 +110,11 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
|
|||||||
const marker = markersRef.current.get(prev)
|
const marker = markersRef.current.get(prev)
|
||||||
const item = itemsRef.current.find(i => i.id === prev)
|
const item = itemsRef.current.find(i => i.id === prev)
|
||||||
if (marker && item) {
|
if (marker && item) {
|
||||||
const idx = itemsRef.current.indexOf(item)
|
|
||||||
marker.setIcon(L.divIcon({
|
marker.setIcon(L.divIcon({
|
||||||
className: '',
|
className: '',
|
||||||
iconSize: [MARKER_W, MARKER_H],
|
iconSize: [MARKER_W, MARKER_H],
|
||||||
iconAnchor: [MARKER_W / 2, MARKER_H],
|
iconAnchor: [MARKER_W / 2, MARKER_H],
|
||||||
html: markerSvg(idx, false, isDark),
|
html: markerSvg(item.dayColor, item.dayLabel, false),
|
||||||
}))
|
}))
|
||||||
marker.setZIndexOffset(0)
|
marker.setZIndexOffset(0)
|
||||||
}
|
}
|
||||||
@@ -130,12 +124,11 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
|
|||||||
const marker = markersRef.current.get(id)
|
const marker = markersRef.current.get(id)
|
||||||
const item = itemsRef.current.find(i => i.id === id)
|
const item = itemsRef.current.find(i => i.id === id)
|
||||||
if (marker && item) {
|
if (marker && item) {
|
||||||
const idx = itemsRef.current.indexOf(item)
|
|
||||||
marker.setIcon(L.divIcon({
|
marker.setIcon(L.divIcon({
|
||||||
className: '',
|
className: '',
|
||||||
iconSize: [MARKER_W, MARKER_H],
|
iconSize: [MARKER_W, MARKER_H],
|
||||||
iconAnchor: [MARKER_W / 2, MARKER_H],
|
iconAnchor: [MARKER_W / 2, MARKER_H],
|
||||||
html: markerSvg(idx, true, isDark),
|
html: markerSvg(item.dayColor, item.dayLabel, true),
|
||||||
}))
|
}))
|
||||||
marker.setZIndexOffset(1000)
|
marker.setZIndexOffset(1000)
|
||||||
}
|
}
|
||||||
@@ -226,7 +219,7 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
|
|||||||
className: '',
|
className: '',
|
||||||
iconSize: [MARKER_W, MARKER_H],
|
iconSize: [MARKER_W, MARKER_H],
|
||||||
iconAnchor: [MARKER_W / 2, MARKER_H],
|
iconAnchor: [MARKER_W / 2, MARKER_H],
|
||||||
html: markerSvg(i, false, !!dark),
|
html: markerSvg(item.dayColor, item.dayLabel, false),
|
||||||
})
|
})
|
||||||
|
|
||||||
const marker = L.marker(pos, { icon }).addTo(map)
|
const marker = L.marker(pos, { icon }).addTo(map)
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ interface MapEntry {
|
|||||||
location_name?: string | null
|
location_name?: string | null
|
||||||
mood?: string | null
|
mood?: string | null
|
||||||
entry_date: string
|
entry_date: string
|
||||||
|
dayColor?: string
|
||||||
|
dayLabel?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ interface MapEntry {
|
|||||||
location_name?: string | null
|
location_name?: string | null
|
||||||
mood?: string | null
|
mood?: string | null
|
||||||
entry_date: string
|
entry_date: string
|
||||||
|
dayColor?: string
|
||||||
|
dayLabel?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -39,6 +41,8 @@ interface Item {
|
|||||||
label: string
|
label: string
|
||||||
locationName: string
|
locationName: string
|
||||||
time: string
|
time: string
|
||||||
|
dayColor: string
|
||||||
|
dayLabel: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const MARKER_W = 28
|
const MARKER_W = 28
|
||||||
@@ -55,6 +59,8 @@ function buildItems(entries: MapEntry[]): Item[] {
|
|||||||
label: e.title || '',
|
label: e.title || '',
|
||||||
locationName: e.location_name || '',
|
locationName: e.location_name || '',
|
||||||
time: e.entry_date,
|
time: e.entry_date,
|
||||||
|
dayColor: e.dayColor || '#52525B',
|
||||||
|
dayLabel: e.dayLabel ?? 1,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -157,21 +163,15 @@ function ensureJourneyPopupStyle() {
|
|||||||
document.head.appendChild(s)
|
document.head.appendChild(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
function markerHtml(index: number, highlighted: boolean, dark: boolean): HTMLDivElement {
|
function markerHtml(dayColor: string, dayLabel: number, highlighted: boolean): HTMLDivElement {
|
||||||
const fill = dark
|
const fill = dayColor
|
||||||
? (highlighted ? '#FAFAFA' : '#A1A1AA')
|
const textColor = '#fff'
|
||||||
: (highlighted ? '#18181B' : '#52525B')
|
const stroke = highlighted ? '#fff' : 'rgba(255,255,255,0.5)'
|
||||||
const textColor = highlighted ? (dark ? '#18181B' : '#fff') : '#fff'
|
|
||||||
const stroke = highlighted
|
|
||||||
? (dark ? '#fff' : '#18181B')
|
|
||||||
: (dark ? '#3F3F46' : '#fff')
|
|
||||||
const shadow = highlighted
|
const shadow = highlighted
|
||||||
? (dark
|
? 'drop-shadow(0 0 10px rgba(0,0,0,0.4)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))'
|
||||||
? '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))'
|
: 'drop-shadow(0 2px 4px rgba(0,0,0,0.25))'
|
||||||
const scale = highlighted ? 1.2 : 1
|
const scale = highlighted ? 1.2 : 1
|
||||||
const label = String(index + 1)
|
const label = String(dayLabel)
|
||||||
|
|
||||||
// Outer wrap holds the element mapbox positions via `transform: translate(...)`.
|
// Outer wrap holds the element mapbox positions via `transform: translate(...)`.
|
||||||
// Anything animated (scale, filter) has to live on an inner child — otherwise
|
// Anything animated (scale, filter) has to live on an inner child — otherwise
|
||||||
@@ -183,7 +183,7 @@ function markerHtml(index: number, highlighted: boolean, dark: boolean): HTMLDiv
|
|||||||
inner.className = 'trek-journey-marker-inner'
|
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.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">
|
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="2"/>
|
<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"/>
|
||||||
<circle cx="14" cy="13" r="8" fill="${fill}"/>
|
<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>
|
<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>`
|
</svg>`
|
||||||
@@ -273,13 +273,12 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
|
|||||||
const item = itemsRef.current.find(i => i.id === id)
|
const item = itemsRef.current.find(i => i.id === id)
|
||||||
const marker = markersRef.current.get(id)
|
const marker = markersRef.current.get(id)
|
||||||
if (!item || !marker) return
|
if (!item || !marker) return
|
||||||
const idx = itemsRef.current.indexOf(item)
|
|
||||||
const el = marker.getElement()
|
const el = marker.getElement()
|
||||||
const currentInner = el.querySelector('.trek-journey-marker-inner') as HTMLDivElement | null
|
const currentInner = el.querySelector('.trek-journey-marker-inner') as HTMLDivElement | null
|
||||||
if (!currentInner) return
|
if (!currentInner) return
|
||||||
// Only swap the inner element's styles/HTML. Touching `el.style.cssText`
|
// Only swap the inner element's styles/HTML. Touching `el.style.cssText`
|
||||||
// would wipe mapbox's positional transform and make the marker flicker.
|
// would wipe mapbox's positional transform and make the marker flicker.
|
||||||
const next = markerHtml(idx, highlighted, !!darkRef.current)
|
const next = markerHtml(item.dayColor, item.dayLabel, highlighted)
|
||||||
const nextInner = next.querySelector('.trek-journey-marker-inner') as HTMLDivElement
|
const nextInner = next.querySelector('.trek-journey-marker-inner') as HTMLDivElement
|
||||||
currentInner.style.cssText = nextInner.style.cssText
|
currentInner.style.cssText = nextInner.style.cssText
|
||||||
currentInner.innerHTML = nextInner.innerHTML
|
currentInner.innerHTML = nextInner.innerHTML
|
||||||
@@ -382,8 +381,8 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
|
|||||||
}
|
}
|
||||||
|
|
||||||
// markers
|
// markers
|
||||||
items.forEach((item, i) => {
|
items.forEach((item) => {
|
||||||
const el = markerHtml(i, false, !!darkRef.current)
|
const el = markerHtml(item.dayColor, item.dayLabel, false)
|
||||||
const marker = new mapboxgl.Marker({ element: el, anchor: 'bottom' })
|
const marker = new mapboxgl.Marker({ element: el, anchor: 'bottom' })
|
||||||
.setLngLat([item.lng, item.lat])
|
.setLngLat([item.lng, item.lat])
|
||||||
.addTo(map)
|
.addTo(map)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { MapPin, Camera, Smile, Laugh, Meh, Frown, Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake } from 'lucide-react'
|
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'
|
import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
|
||||||
|
|
||||||
const MOOD_ICONS: Record<string, typeof Smile> = {
|
const MOOD_ICONS: Record<string, typeof Smile> = {
|
||||||
@@ -37,13 +38,14 @@ function stripMarkdown(text: string): string {
|
|||||||
|
|
||||||
interface Props {
|
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 }
|
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 }
|
||||||
index: number
|
dayLabel: number
|
||||||
|
dayColor: string
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
onClick: () => void
|
onClick: () => void
|
||||||
publicPhotoUrl?: (photoId: number) => string
|
publicPhotoUrl?: (photoId: number) => string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MobileEntryCard({ entry, index, isActive, onClick, publicPhotoUrl }: Props) {
|
export default function MobileEntryCard({ entry, dayLabel, dayColor, isActive, onClick, publicPhotoUrl }: Props) {
|
||||||
const hasLocation = !!(entry.location_lat && entry.location_lng)
|
const hasLocation = !!(entry.location_lat && entry.location_lng)
|
||||||
const hasPhotos = entry.photos && entry.photos.length > 0
|
const hasPhotos = entry.photos && entry.photos.length > 0
|
||||||
const firstPhoto = hasPhotos ? entry.photos![0] : null
|
const firstPhoto = hasPhotos ? entry.photos![0] : null
|
||||||
@@ -98,8 +100,8 @@ export default function MobileEntryCard({ entry, index, isActive, onClick, publi
|
|||||||
<div className="flex-1 p-3 flex flex-col min-w-0">
|
<div className="flex-1 p-3 flex flex-col min-w-0">
|
||||||
{/* Day number + date + mood/weather */}
|
{/* Day number + date + mood/weather */}
|
||||||
<div className="flex items-center gap-1.5 mb-1">
|
<div className="flex items-center gap-1.5 mb-1">
|
||||||
<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">
|
<span className="w-5 h-5 rounded text-white text-[10px] font-bold flex items-center justify-center flex-shrink-0" style={{ background: dayColor }}>
|
||||||
{index + 1}
|
{dayLabel}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[11px] text-zinc-400 font-medium">{dateStr}</span>
|
<span className="text-[11px] text-zinc-400 font-medium">{dateStr}</span>
|
||||||
{entry.entry_time && (
|
{entry.entry_time && (
|
||||||
@@ -141,7 +143,7 @@ export default function MobileEntryCard({ entry, index, isActive, onClick, publi
|
|||||||
{hasLocation ? (
|
{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">
|
<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" />
|
<MapPin size={10} className="flex-shrink-0" />
|
||||||
<span className="truncate">{entry.location_name || 'On the map'}</span>
|
<span className="truncate">{formatLocationName(entry.location_name) || 'On the map'}</span>
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-[10px] text-zinc-400 italic">No location</span>
|
<span className="text-[10px] text-zinc-400 italic">No location</span>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
ThumbsUp, ThumbsDown, ChevronDown,
|
ThumbsUp, ThumbsDown, ChevronDown,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import JournalBody from './JournalBody'
|
import JournalBody from './JournalBody'
|
||||||
|
import { formatLocationName } from '../../utils/formatters'
|
||||||
import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
|
import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
|
||||||
|
|
||||||
const MOOD_CONFIG: Record<string, { icon: typeof Smile; label: string; bg: string; text: string }> = {
|
const MOOD_CONFIG: Record<string, { icon: typeof Smile; label: string; bg: string; text: string }> = {
|
||||||
@@ -24,20 +25,22 @@ const WEATHER_CONFIG: Record<string, { icon: typeof Sun; label: string }> = {
|
|||||||
cold: { icon: Snowflake, label: 'Cold' },
|
cold: { icon: Snowflake, label: 'Cold' },
|
||||||
}
|
}
|
||||||
|
|
||||||
function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'original'): string {
|
function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'original', builder?: (id: number) => string): string {
|
||||||
|
if (builder) return builder(p.photo_id)
|
||||||
return `/api/photos/${p.photo_id}/${size}`
|
return `/api/photos/${p.photo_id}/${size}`
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
entry: JourneyEntry
|
entry: JourneyEntry
|
||||||
readOnly?: boolean
|
readOnly?: boolean
|
||||||
|
publicPhotoUrl?: (photoId: number) => string
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onEdit: () => void
|
onEdit: () => void
|
||||||
onDelete: () => void
|
onDelete: () => void
|
||||||
onPhotoClick: (photos: JourneyPhoto[], index: number) => void
|
onPhotoClick: (photos: JourneyPhoto[], index: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MobileEntryView({ entry, readOnly, onClose, onEdit, onDelete, onPhotoClick }: Props) {
|
export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClose, onEdit, onDelete, onPhotoClick }: Props) {
|
||||||
const photos = entry.photos || []
|
const photos = entry.photos || []
|
||||||
const mood = entry.mood ? MOOD_CONFIG[entry.mood] : null
|
const mood = entry.mood ? MOOD_CONFIG[entry.mood] : null
|
||||||
const weather = entry.weather ? WEATHER_CONFIG[entry.weather] : null
|
const weather = entry.weather ? WEATHER_CONFIG[entry.weather] : null
|
||||||
@@ -84,7 +87,7 @@ export default function MobileEntryView({ entry, readOnly, onClose, onEdit, onDe
|
|||||||
{photos.length > 0 && (
|
{photos.length > 0 && (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<img
|
<img
|
||||||
src={photoUrl(photos[0])}
|
src={photoUrl(photos[0], 'original', publicPhotoUrl)}
|
||||||
alt=""
|
alt=""
|
||||||
className="w-full max-h-[50vh] object-cover cursor-pointer"
|
className="w-full max-h-[50vh] object-cover cursor-pointer"
|
||||||
onClick={() => onPhotoClick(photos, 0)}
|
onClick={() => onPhotoClick(photos, 0)}
|
||||||
@@ -101,7 +104,7 @@ export default function MobileEntryView({ entry, readOnly, onClose, onEdit, onDe
|
|||||||
{photos.map((p, i) => (
|
{photos.map((p, i) => (
|
||||||
<img
|
<img
|
||||||
key={p.id || i}
|
key={p.id || i}
|
||||||
src={photoUrl(p, 'thumbnail')}
|
src={photoUrl(p, 'thumbnail', publicPhotoUrl)}
|
||||||
alt=""
|
alt=""
|
||||||
className="w-16 h-16 rounded-lg object-cover flex-shrink-0 cursor-pointer hover:ring-2 ring-zinc-900/30 dark:ring-white/30 transition-all"
|
className="w-16 h-16 rounded-lg object-cover flex-shrink-0 cursor-pointer hover:ring-2 ring-zinc-900/30 dark:ring-white/30 transition-all"
|
||||||
onClick={() => onPhotoClick(photos, i)}
|
onClick={() => onPhotoClick(photos, i)}
|
||||||
@@ -130,7 +133,7 @@ export default function MobileEntryView({ entry, readOnly, onClose, onEdit, onDe
|
|||||||
<div className="mb-3">
|
<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">
|
<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" />
|
<MapPin size={12} className="text-zinc-500 dark:text-zinc-400 flex-shrink-0" />
|
||||||
{entry.location_name}
|
{formatLocationName(entry.location_name)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useRef, useState, useEffect, useCallback } from 'react'
|
import { useRef, useState, useEffect, useCallback, useMemo } from 'react'
|
||||||
import { Plus } from 'lucide-react'
|
import { Plus } from 'lucide-react'
|
||||||
import JourneyMap from './JourneyMap'
|
import JourneyMap from './JourneyMap'
|
||||||
import MobileEntryCard from './MobileEntryCard'
|
import MobileEntryCard from './MobileEntryCard'
|
||||||
import type { JourneyMapHandle } from './JourneyMap'
|
import type { JourneyMapHandle } from './JourneyMap'
|
||||||
import type { JourneyEntry } from '../../store/journeyStore'
|
import type { JourneyEntry } from '../../store/journeyStore'
|
||||||
|
import { DAY_COLORS } from './dayColors'
|
||||||
|
|
||||||
interface MapEntry {
|
interface MapEntry {
|
||||||
id: string
|
id: string
|
||||||
@@ -23,6 +24,7 @@ interface Props {
|
|||||||
onEntryClick: (entry: any) => void
|
onEntryClick: (entry: any) => void
|
||||||
onAddEntry?: () => void
|
onAddEntry?: () => void
|
||||||
publicPhotoUrl?: (photoId: number) => string
|
publicPhotoUrl?: (photoId: number) => string
|
||||||
|
carouselBottom?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MobileMapTimeline({
|
export default function MobileMapTimeline({
|
||||||
@@ -34,14 +36,23 @@ export default function MobileMapTimeline({
|
|||||||
onEntryClick,
|
onEntryClick,
|
||||||
onAddEntry,
|
onAddEntry,
|
||||||
publicPhotoUrl,
|
publicPhotoUrl,
|
||||||
|
carouselBottom = 'calc(var(--bottom-nav-h, 84px) + 8px)',
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const mapRef = useRef<JourneyMapHandle>(null)
|
const mapRef = useRef<JourneyMapHandle>(null)
|
||||||
const carouselRef = useRef<HTMLDivElement>(null)
|
const carouselRef = useRef<HTMLDivElement>(null)
|
||||||
const [activeIndex, setActiveIndex] = useState(0)
|
const [activeIndex, setActiveIndex] = useState(0)
|
||||||
const cardRefs = useRef<Map<number, HTMLDivElement>>(new Map())
|
|
||||||
const activeIndexRef = useRef(activeIndex)
|
|
||||||
useEffect(() => { activeIndexRef.current = activeIndex }, [activeIndex])
|
|
||||||
|
|
||||||
|
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())
|
||||||
// Sync map focus when carousel scrolls (with guard for uninitialized map)
|
// Sync map focus when carousel scrolls (with guard for uninitialized map)
|
||||||
const syncMapToCarousel = useCallback((index: number) => {
|
const syncMapToCarousel = useCallback((index: number) => {
|
||||||
const entry = entries[index]
|
const entry = entries[index]
|
||||||
@@ -76,29 +87,19 @@ export default function MobileMapTimeline({
|
|||||||
})
|
})
|
||||||
}, [syncMapToCarousel])
|
}, [syncMapToCarousel])
|
||||||
|
|
||||||
// Track scroll; debounce to re-center the active card when the user stops.
|
// Defer all state updates until scrolling settles — updating activeIndex
|
||||||
|
// mid-swipe resizes cards (240→320px), causing layout reflow every frame.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = carouselRef.current
|
const el = carouselRef.current
|
||||||
if (!el || entries.length === 0) return
|
if (!el || entries.length === 0) return
|
||||||
let rafId: number | null = null
|
|
||||||
let settleTimer: number | null = null
|
let settleTimer: number | null = null
|
||||||
const onScroll = () => {
|
const onScroll = () => {
|
||||||
if (rafId != null) return
|
|
||||||
rafId = requestAnimationFrame(() => {
|
|
||||||
pickNearestCard()
|
|
||||||
rafId = null
|
|
||||||
})
|
|
||||||
if (settleTimer != null) window.clearTimeout(settleTimer)
|
if (settleTimer != null) window.clearTimeout(settleTimer)
|
||||||
settleTimer = window.setTimeout(() => {
|
settleTimer = window.setTimeout(pickNearestCard, 150)
|
||||||
// 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 })
|
el.addEventListener('scroll', onScroll, { passive: true })
|
||||||
return () => {
|
return () => {
|
||||||
el.removeEventListener('scroll', onScroll)
|
el.removeEventListener('scroll', onScroll)
|
||||||
if (rafId != null) cancelAnimationFrame(rafId)
|
|
||||||
if (settleTimer != null) window.clearTimeout(settleTimer)
|
if (settleTimer != null) window.clearTimeout(settleTimer)
|
||||||
}
|
}
|
||||||
}, [entries.length, pickNearestCard])
|
}, [entries.length, pickNearestCard])
|
||||||
@@ -142,7 +143,10 @@ export default function MobileMapTimeline({
|
|||||||
|
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-10" style={{ top: 0, bottom: 0 }}>
|
<div
|
||||||
|
className="fixed left-0 right-0 z-10"
|
||||||
|
style={{ top: 'var(--nav-h, 0px)', bottom: 'env(safe-area-inset-bottom, 0px)' }}
|
||||||
|
>
|
||||||
<JourneyMap
|
<JourneyMap
|
||||||
ref={mapRef}
|
ref={mapRef}
|
||||||
entries={mapEntries}
|
entries={mapEntries}
|
||||||
@@ -168,7 +172,10 @@ export default function MobileMapTimeline({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-10" style={{ top: 0, bottom: 0 }}>
|
<div
|
||||||
|
className="fixed left-0 right-0 z-10"
|
||||||
|
style={{ top: 'var(--nav-h, 0px)', bottom: 'env(safe-area-inset-bottom, 0px)' }}
|
||||||
|
>
|
||||||
{/* Full-screen map */}
|
{/* Full-screen map */}
|
||||||
<JourneyMap
|
<JourneyMap
|
||||||
ref={mapRef}
|
ref={mapRef}
|
||||||
@@ -186,13 +193,13 @@ export default function MobileMapTimeline({
|
|||||||
{/* Bottom carousel */}
|
{/* Bottom carousel */}
|
||||||
<div
|
<div
|
||||||
className="fixed left-0 right-0 z-40"
|
className="fixed left-0 right-0 z-40"
|
||||||
style={{ touchAction: 'pan-x', bottom: 'calc(var(--bottom-nav-h, 84px) + 8px)' }}
|
style={{ touchAction: 'pan-x', bottom: carouselBottom }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref={carouselRef}
|
ref={carouselRef}
|
||||||
className="flex gap-3 overflow-x-auto px-4 pb-3 pt-1 scroll-smooth"
|
className="flex gap-3 overflow-x-auto px-4 pb-3 pt-1"
|
||||||
style={{
|
style={{
|
||||||
scrollSnapType: 'x proximity',
|
scrollSnapType: 'x mandatory',
|
||||||
WebkitOverflowScrolling: 'touch',
|
WebkitOverflowScrolling: 'touch',
|
||||||
scrollbarWidth: 'none',
|
scrollbarWidth: 'none',
|
||||||
msOverflowStyle: 'none',
|
msOverflowStyle: 'none',
|
||||||
@@ -207,7 +214,8 @@ export default function MobileMapTimeline({
|
|||||||
>
|
>
|
||||||
<MobileEntryCard
|
<MobileEntryCard
|
||||||
entry={entry}
|
entry={entry}
|
||||||
index={i}
|
dayLabel={entryDayMeta[i]?.dayLabel ?? i + 1}
|
||||||
|
dayColor={entryDayMeta[i]?.dayColor ?? DAY_COLORS[0]}
|
||||||
isActive={i === activeIndex}
|
isActive={i === activeIndex}
|
||||||
onClick={() => handleCardTap(entry, i)}
|
onClick={() => handleCardTap(entry, i)}
|
||||||
publicPhotoUrl={publicPhotoUrl}
|
publicPhotoUrl={publicPhotoUrl}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
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
|
||||||
|
]
|
||||||
@@ -61,11 +61,25 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
|||||||
navigate('/login', { state: { noRedirect: true } })
|
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 = () => {
|
const toggleDarkMode = () => {
|
||||||
document.documentElement.classList.add('trek-theme-transitioning')
|
document.documentElement.classList.add('trek-theme-transitioning')
|
||||||
updateSetting('dark_mode', dark ? 'light' : 'dark').catch(() => {})
|
updateSetting('dark_mode', dark ? 'light' : 'dark').catch(() => {})
|
||||||
window.setTimeout(() => {
|
if (themeTransitionTimer.current !== null) window.clearTimeout(themeTransitionTimer.current)
|
||||||
|
themeTransitionTimer.current = window.setTimeout(() => {
|
||||||
document.documentElement.classList.remove('trek-theme-transitioning')
|
document.documentElement.classList.remove('trek-theme-transitioning')
|
||||||
|
themeTransitionTimer.current = null
|
||||||
}, 360)
|
}, 360)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* OfflineBanner — persistent top bar indicating connectivity + sync state.
|
* OfflineBanner — connectivity + sync state indicator.
|
||||||
*
|
*
|
||||||
* States:
|
* States:
|
||||||
* offline + N queued → amber bar "Offline — N changes queued"
|
* offline + N queued → amber pill "Offline · N queued"
|
||||||
* offline + 0 queued → amber bar "Offline"
|
* offline + 0 queued → amber pill "Offline"
|
||||||
* online + N pending → blue bar "Syncing N changes…"
|
* online + N pending → blue pill "Syncing N…"
|
||||||
* online + 0 pending → hidden
|
* 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 React, { useState, useEffect } from 'react'
|
||||||
import { WifiOff, RefreshCw } from 'lucide-react'
|
import { WifiOff, RefreshCw } from 'lucide-react'
|
||||||
@@ -48,9 +52,9 @@ export default function OfflineBanner(): React.ReactElement | null {
|
|||||||
|
|
||||||
const label = offline
|
const label = offline
|
||||||
? pendingCount > 0
|
? pendingCount > 0
|
||||||
? `Offline — ${pendingCount} change${pendingCount !== 1 ? 's' : ''} queued`
|
? `Offline · ${pendingCount} queued`
|
||||||
: 'Offline'
|
: 'Offline'
|
||||||
: `Syncing ${pendingCount} change${pendingCount !== 1 ? 's' : ''}…`
|
: `Syncing ${pendingCount}…`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -58,27 +62,29 @@ export default function OfflineBanner(): React.ReactElement | null {
|
|||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
style={{
|
style={{
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
top: 0,
|
// Hover above the mobile bottom nav; on desktop --bottom-nav-h is 0,
|
||||||
left: 0,
|
// so the pill sits 16px from the bottom.
|
||||||
right: 0,
|
bottom: 'calc(var(--bottom-nav-h) + 16px)',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
zIndex: 9999,
|
zIndex: 9999,
|
||||||
background: bg,
|
background: bg,
|
||||||
color: text,
|
color: text,
|
||||||
display: 'flex',
|
display: 'inline-flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
gap: 6,
|
||||||
gap: 8,
|
padding: '6px 14px',
|
||||||
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 6px)',
|
borderRadius: 999,
|
||||||
paddingBottom: '6px',
|
boxShadow: '0 4px 16px rgba(0,0,0,0.18), 0 0 0 1px rgba(255,255,255,0.08)',
|
||||||
paddingLeft: '16px',
|
fontSize: 12,
|
||||||
paddingRight: '16px',
|
fontWeight: 600,
|
||||||
fontSize: 13,
|
whiteSpace: 'nowrap',
|
||||||
fontWeight: 500,
|
pointerEvents: 'none',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{offline
|
{offline
|
||||||
? <WifiOff size={14} />
|
? <WifiOff size={12} />
|
||||||
: <RefreshCw size={14} style={{ animation: 'spin 1s linear infinite' }} />
|
: <RefreshCw size={12} style={{ animation: 'spin 1s linear infinite' }} />
|
||||||
}
|
}
|
||||||
{label}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -96,12 +96,12 @@ async function fetchPlacePhotos(assignments) {
|
|||||||
const allPlaces = Object.values(assignments).flatMap(a => a.map(x => x.place)).filter(Boolean)
|
const allPlaces = Object.values(assignments).flatMap(a => a.map(x => x.place)).filter(Boolean)
|
||||||
const unique = [...new Map(allPlaces.map(p => [p.id, p])).values()]
|
const unique = [...new Map(allPlaces.map(p => [p.id, p])).values()]
|
||||||
|
|
||||||
const toFetch = unique.filter(p => !p.image_url && p.google_place_id)
|
const toFetch = unique.filter(p => !p.image_url && (p.google_place_id || p.osm_id))
|
||||||
|
|
||||||
await Promise.allSettled(
|
await Promise.allSettled(
|
||||||
toFetch.map(async (place) => {
|
toFetch.map(async (place) => {
|
||||||
try {
|
try {
|
||||||
const data = await mapsApi.placePhoto(place.google_place_id)
|
const data = await mapsApi.placePhoto(place.google_place_id || place.osm_id, place.lat, place.lng, place.name)
|
||||||
if (data.photoUrl) photoMap[place.id] = data.photoUrl
|
if (data.photoUrl) photoMap[place.id] = data.photoUrl
|
||||||
} catch {}
|
} catch {}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -208,9 +208,14 @@ interface ArtikelZeileProps {
|
|||||||
canEdit?: boolean
|
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) {
|
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 [editing, setEditing] = useState(false)
|
||||||
const [editName, setEditName] = useState(item.name)
|
const [editName, setEditName] = useState(isPlaceholder ? '' : item.name)
|
||||||
const [hovered, setHovered] = useState(false)
|
const [hovered, setHovered] = useState(false)
|
||||||
const [showCatPicker, setShowCatPicker] = useState(false)
|
const [showCatPicker, setShowCatPicker] = useState(false)
|
||||||
const [showBagPicker, setShowBagPicker] = useState(false)
|
const [showBagPicker, setShowBagPicker] = useState(false)
|
||||||
@@ -223,7 +228,7 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
|
|||||||
const handleToggle = () => togglePackingItem(tripId, item.id, !item.checked)
|
const handleToggle = () => togglePackingItem(tripId, item.id, !item.checked)
|
||||||
|
|
||||||
const handleSaveName = async () => {
|
const handleSaveName = async () => {
|
||||||
if (!editName.trim()) { setEditing(false); setEditName(item.name); return }
|
if (!editName.trim()) { setEditing(false); setEditName(isPlaceholder ? '' : item.name); return }
|
||||||
try { await updatePackingItem(tripId, item.id, { name: editName.trim() }); setEditing(false) }
|
try { await updatePackingItem(tripId, item.id, { name: editName.trim() }); setEditing(false) }
|
||||||
catch { toast.error(t('packing.toast.saveError')) }
|
catch { toast.error(t('packing.toast.saveError')) }
|
||||||
}
|
}
|
||||||
@@ -275,9 +280,10 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
|
|||||||
{editing && canEdit ? (
|
{editing && canEdit ? (
|
||||||
<input
|
<input
|
||||||
type="text" value={editName} autoFocus
|
type="text" value={editName} autoFocus
|
||||||
|
placeholder={isPlaceholder ? '...' : undefined}
|
||||||
onChange={e => setEditName(e.target.value)}
|
onChange={e => setEditName(e.target.value)}
|
||||||
onBlur={handleSaveName}
|
onBlur={handleSaveName}
|
||||||
onKeyDown={e => { if (e.key === 'Enter') handleSaveName(); if (e.key === 'Escape') { setEditing(false); setEditName(item.name) } }}
|
onKeyDown={e => { if (e.key === 'Enter') handleSaveName(); if (e.key === 'Escape') { setEditing(false); setEditName(isPlaceholder ? '' : item.name) } }}
|
||||||
style={{ flex: 1, fontSize: 13.5, padding: '2px 8px', borderRadius: 6, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit' }}
|
style={{ flex: 1, fontSize: 13.5, padding: '2px 8px', borderRadius: 6, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit' }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -286,7 +292,7 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
|
|||||||
style={{
|
style={{
|
||||||
flex: 1, fontSize: 13.5,
|
flex: 1, fontSize: 13.5,
|
||||||
cursor: !canEdit || item.checked ? 'default' : 'text',
|
cursor: !canEdit || item.checked ? 'default' : 'text',
|
||||||
color: item.checked ? 'var(--text-faint)' : 'var(--text-primary)',
|
color: isPlaceholder ? 'var(--text-faint)' : (item.checked ? 'var(--text-faint)' : 'var(--text-primary)'),
|
||||||
transition: 'color 200ms cubic-bezier(0.23,1,0.32,1)',
|
transition: 'color 200ms cubic-bezier(0.23,1,0.32,1)',
|
||||||
textDecoration: item.checked ? 'line-through' : 'none',
|
textDecoration: item.checked ? 'line-through' : 'none',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -168,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" }
|
const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<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 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 style={{
|
<div style={{
|
||||||
background: 'var(--bg-elevated)',
|
background: 'var(--bg-elevated)',
|
||||||
backdropFilter: 'blur(40px) saturate(180%)',
|
backdropFilter: 'blur(40px) saturate(180%)',
|
||||||
@@ -462,7 +462,10 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
onChange={v => setHotelDayRange(prev => ({ start: v, end: Math.max(v, prev.end) }))}
|
onChange={v => setHotelDayRange(prev => ({ start: v, end: Math.max(v, prev.end) }))}
|
||||||
options={days.map((d, i) => ({
|
options={days.map((d, i) => ({
|
||||||
value: d.id,
|
value: d.id,
|
||||||
label: `${d.title || t('planner.dayN', { n: i + 1 })}${d.date ? ` — ${new Date(d.date + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })}` : ''}`,
|
label: d.title || t('planner.dayN', { n: i + 1 }),
|
||||||
|
badge: d.date
|
||||||
|
? new Date(d.date + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||||
|
: (d.title ? t('planner.dayN', { n: i + 1 }) : undefined),
|
||||||
}))}
|
}))}
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
@@ -474,7 +477,10 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
onChange={v => setHotelDayRange(prev => ({ start: Math.min(prev.start, v), end: v }))}
|
onChange={v => setHotelDayRange(prev => ({ start: Math.min(prev.start, v), end: v }))}
|
||||||
options={days.map((d, i) => ({
|
options={days.map((d, i) => ({
|
||||||
value: d.id,
|
value: d.id,
|
||||||
label: `${d.title || t('planner.dayN', { n: i + 1 })}${d.date ? ` — ${new Date(d.date + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })}` : ''}`,
|
label: d.title || t('planner.dayN', { n: i + 1 }),
|
||||||
|
badge: d.date
|
||||||
|
? new Date(d.date + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||||
|
: (d.title ? t('planner.dayN', { n: i + 1 }) : undefined),
|
||||||
}))}
|
}))}
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -360,6 +360,25 @@ export default function PlaceFormModal({
|
|||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title={place ? t('places.editPlace') : t('places.addPlace')}
|
title={place ? t('places.editPlace') : t('places.addPlace')}
|
||||||
size="lg"
|
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}>
|
<form onSubmit={handleSubmit} className="space-y-4" onPaste={handlePaste}>
|
||||||
{/* Place Search */}
|
{/* Place Search */}
|
||||||
@@ -613,23 +632,6 @@ export default function PlaceFormModal({
|
|||||||
</div>
|
</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>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -203,8 +203,10 @@ describe('ReservationModal', () => {
|
|||||||
fireEvent.change(datePickers[1], { target: { value: '2025-06-09' } });
|
fireEvent.change(datePickers[1], { target: { value: '2025-06-09' } });
|
||||||
fireEvent.change(timePickers[1], { target: { value: '09:00' } });
|
fireEvent.change(timePickers[1], { target: { value: '09:00' } });
|
||||||
|
|
||||||
// When isEndBeforeStart=true the submit button is disabled, so submit the form directly
|
// When isEndBeforeStart=true the submit button is disabled, so fire submit on the form directly.
|
||||||
const form = screen.getByRole('button', { name: /^Add$/i }).closest('form')!;
|
// 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')!;
|
||||||
fireEvent.submit(form);
|
fireEvent.submit(form);
|
||||||
|
|
||||||
expect(onSave).not.toHaveBeenCalled();
|
expect(onSave).not.toHaveBeenCalled();
|
||||||
|
|||||||
@@ -271,7 +271,22 @@ 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' }
|
const labelStyle = { display: 'block', fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', marginBottom: 5, textTransform: 'uppercase', letterSpacing: '0.03em' }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={onClose} title={reservation ? t('reservations.editTitle') : t('reservations.newTitle')} size="2xl">
|
<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>
|
||||||
|
}
|
||||||
|
>
|
||||||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
|
|
||||||
{/* Type selector */}
|
{/* Type selector */}
|
||||||
@@ -417,12 +432,17 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
<CustomSelect
|
<CustomSelect
|
||||||
value={form.hotel_place_id}
|
value={form.hotel_place_id}
|
||||||
onChange={value => {
|
onChange={value => {
|
||||||
set('hotel_place_id', value)
|
|
||||||
const p = places.find(pl => pl.id === value)
|
const p = places.find(pl => pl.id === value)
|
||||||
if (p) {
|
setForm(prev => {
|
||||||
if (!form.title) set('title', p.name)
|
const next = { ...prev, hotel_place_id: value }
|
||||||
if (!form.location && p.address) set('location', p.address)
|
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
|
||||||
|
})
|
||||||
}}
|
}}
|
||||||
placeholder={t('reservations.meta.pickHotel')}
|
placeholder={t('reservations.meta.pickHotel')}
|
||||||
options={[
|
options={[
|
||||||
@@ -439,7 +459,15 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
value={form.hotel_start_day}
|
value={form.hotel_start_day}
|
||||||
onChange={value => set('hotel_start_day', value)}
|
onChange={value => set('hotel_start_day', value)}
|
||||||
placeholder={t('reservations.meta.selectDay')}
|
placeholder={t('reservations.meta.selectDay')}
|
||||||
options={days.map(d => ({ value: d.id, label: d.title || `${t('dayplan.dayN', { n: d.day_number })}${d.date ? ` · ${formatDate(d.date, locale)}` : ''}` }))}
|
options={days.map(d => {
|
||||||
|
const dateBadge = d.date ? (formatDate(d.date, locale) ?? undefined) : undefined
|
||||||
|
const dayBadge = d.title ? t('dayplan.dayN', { n: d.day_number }) : undefined
|
||||||
|
return {
|
||||||
|
value: d.id,
|
||||||
|
label: d.title || t('dayplan.dayN', { n: d.day_number }),
|
||||||
|
badge: dateBadge ?? dayBadge,
|
||||||
|
}
|
||||||
|
})}
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -449,7 +477,15 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
value={form.hotel_end_day}
|
value={form.hotel_end_day}
|
||||||
onChange={value => set('hotel_end_day', value)}
|
onChange={value => set('hotel_end_day', value)}
|
||||||
placeholder={t('reservations.meta.selectDay')}
|
placeholder={t('reservations.meta.selectDay')}
|
||||||
options={days.map(d => ({ value: d.id, label: d.title || `${t('dayplan.dayN', { n: d.day_number })}${d.date ? ` · ${formatDate(d.date, locale)}` : ''}` }))}
|
options={days.map(d => {
|
||||||
|
const dateBadge = d.date ? (formatDate(d.date, locale) ?? undefined) : undefined
|
||||||
|
const dayBadge = d.title ? t('dayplan.dayN', { n: d.day_number }) : undefined
|
||||||
|
return {
|
||||||
|
value: d.id,
|
||||||
|
label: d.title || t('dayplan.dayN', { n: d.day_number }),
|
||||||
|
badge: dateBadge ?? dayBadge,
|
||||||
|
}
|
||||||
|
})}
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -601,15 +637,6 @@ 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>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -112,17 +112,30 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
|||||||
|
|
||||||
const TRANSPORT_TYPES_SET = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
|
const TRANSPORT_TYPES_SET = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
|
||||||
const isTransportType = TRANSPORT_TYPES_SET.has(r.type)
|
const isTransportType = TRANSPORT_TYPES_SET.has(r.type)
|
||||||
const startDay = r.day_id ? days.find(d => d.id === r.day_id) : undefined
|
const isHotel = r.type === 'hotel'
|
||||||
const endDay = r.end_day_id ? days.find(d => d.id === r.end_day_id) : undefined
|
const startDay = r.day_id ? days.find(d => d.id === r.day_id)
|
||||||
const dayLabel = (day: typeof startDay): string => {
|
: (isHotel && r.accommodation_start_day_id) ? days.find(d => d.id === r.accommodation_start_day_id)
|
||||||
if (!day) return ''
|
: undefined
|
||||||
const base = day.title || t('dayplan.dayN', { n: day.day_number })
|
const endDay = r.end_day_id ? days.find(d => d.id === r.end_day_id)
|
||||||
if (day.date) {
|
: (isHotel && r.accommodation_end_day_id) ? days.find(d => d.id === r.accommodation_end_day_id)
|
||||||
const d = new Date(day.date + 'T00:00:00Z')
|
: undefined
|
||||||
const dateStr = d.toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })
|
const DayLabel = ({ day }: { day: typeof startDay }) => {
|
||||||
return `${base} · ${dateStr}`
|
if (!day) return null
|
||||||
}
|
const name = day.title || t('dayplan.dayN', { n: day.day_number })
|
||||||
return base
|
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>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -135,13 +148,15 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
|||||||
onMouseEnter={e => e.currentTarget.style.boxShadow = '0 2px 12px rgba(0,0,0,0.06)'}
|
onMouseEnter={e => e.currentTarget.style.boxShadow = '0 2px 12px rgba(0,0,0,0.06)'}
|
||||||
onMouseLeave={e => e.currentTarget.style.boxShadow = 'none'}
|
onMouseLeave={e => e.currentTarget.style.boxShadow = 'none'}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header — wraps to a second row on narrow screens so the status/category chips
|
||||||
|
never collide with the title. */}
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
|
||||||
|
flexWrap: 'wrap',
|
||||||
padding: '12px 14px',
|
padding: '12px 14px',
|
||||||
background: confirmed ? 'rgba(22,163,74,0.06)' : 'rgba(217,119,6,0.06)',
|
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 }}>
|
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 10, minWidth: 0, flexWrap: 'wrap' }}>
|
||||||
<span style={{
|
<span style={{
|
||||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||||
fontSize: 12, fontWeight: 600, color: confirmed ? '#16a34a' : '#d97706',
|
fontSize: 12, fontWeight: 600, color: confirmed ? '#16a34a' : '#d97706',
|
||||||
@@ -202,12 +217,15 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
|||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
<div style={{ padding: 14, display: 'flex', flexDirection: 'column', gap: 12, flex: 1 }}>
|
<div style={{ padding: 14, display: 'flex', flexDirection: 'column', gap: 12, flex: 1 }}>
|
||||||
{/* Day label for transport reservations linked to a day */}
|
{/* Day label for transport/hotel reservations linked to days */}
|
||||||
{isTransportType && startDay && (
|
{(isTransportType || isHotel) && startDay && (
|
||||||
<div>
|
<div>
|
||||||
<div style={fieldLabelStyle}>{t('reservations.date')}</div>
|
<div style={fieldLabelStyle}>{t('reservations.date')}</div>
|
||||||
<div style={{ ...fieldValueStyle, textAlign: 'center' }}>
|
<div style={{ ...fieldValueStyle, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||||
{dayLabel(startDay)}{endDay && endDay.id !== startDay.id ? ` – ${dayLabel(endDay)}` : ''}
|
<DayLabel day={startDay} />
|
||||||
|
{endDay && endDay.id !== startDay.id && (
|
||||||
|
<><span style={{ color: 'var(--text-faint)' }}>–</span><DayLabel day={endDay} /></>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useMemo } from 'react'
|
||||||
import { Plane, Train, Car, Ship } from 'lucide-react'
|
import { Plane, Train, Car, Ship } from 'lucide-react'
|
||||||
import Modal from '../shared/Modal'
|
import Modal from '../shared/Modal'
|
||||||
import CustomSelect from '../shared/CustomSelect'
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
@@ -7,6 +7,8 @@ import AirportSelect, { type Airport } from './AirportSelect'
|
|||||||
import LocationSelect, { type LocationPoint } from './LocationSelect'
|
import LocationSelect, { type LocationPoint } from './LocationSelect'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
|
import { useTripStore } from '../../store/tripStore'
|
||||||
|
import { useAddonStore } from '../../store/addonStore'
|
||||||
import { formatDate } from '../../utils/formatters'
|
import { formatDate } from '../../utils/formatters'
|
||||||
import type { Day, Reservation, ReservationEndpoint } from '../../types'
|
import type { Day, Reservation, ReservationEndpoint } from '../../types'
|
||||||
|
|
||||||
@@ -75,6 +77,8 @@ const defaultForm = {
|
|||||||
arrival_time: '',
|
arrival_time: '',
|
||||||
confirmation_number: '',
|
confirmation_number: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
|
price: '',
|
||||||
|
budget_category: '',
|
||||||
meta_airline: '',
|
meta_airline: '',
|
||||||
meta_flight_number: '',
|
meta_flight_number: '',
|
||||||
meta_train_number: '',
|
meta_train_number: '',
|
||||||
@@ -94,6 +98,13 @@ interface TransportModalProps {
|
|||||||
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId }: TransportModalProps) {
|
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId }: TransportModalProps) {
|
||||||
const { t, locale } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
|
||||||
|
const budgetItems = useTripStore(s => s.budgetItems)
|
||||||
|
const budgetCategories = useMemo(() => {
|
||||||
|
const cats = new Set<string>()
|
||||||
|
budgetItems.forEach(i => { if (i.category) cats.add(i.category) })
|
||||||
|
return Array.from(cats).sort()
|
||||||
|
}, [budgetItems])
|
||||||
const [form, setForm] = useState({ ...defaultForm })
|
const [form, setForm] = useState({ ...defaultForm })
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
const [fromPick, setFromPick] = useState<EndpointPick>({})
|
const [fromPick, setFromPick] = useState<EndpointPick>({})
|
||||||
@@ -126,6 +137,8 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
|||||||
meta_train_number: meta.train_number || '',
|
meta_train_number: meta.train_number || '',
|
||||||
meta_platform: meta.platform || '',
|
meta_platform: meta.platform || '',
|
||||||
meta_seat: meta.seat || '',
|
meta_seat: meta.seat || '',
|
||||||
|
price: meta.price || '',
|
||||||
|
budget_category: (meta.budget_category && budgetItems.some(i => i.category === meta.budget_category)) ? meta.budget_category : '',
|
||||||
})
|
})
|
||||||
if (type === 'flight') {
|
if (type === 'flight') {
|
||||||
setFromPick({ airport: airportFromEndpoint(from) || undefined })
|
setFromPick({ airport: airportFromEndpoint(from) || undefined })
|
||||||
@@ -139,7 +152,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
|||||||
setFromPick({})
|
setFromPick({})
|
||||||
setToPick({})
|
setToPick({})
|
||||||
}
|
}
|
||||||
}, [isOpen, reservation, selectedDayId])
|
}, [isOpen, reservation, selectedDayId, budgetItems])
|
||||||
|
|
||||||
const set = (field: string, value: any) => setForm(prev => ({ ...prev, [field]: value }))
|
const set = (field: string, value: any) => setForm(prev => ({ ...prev, [field]: value }))
|
||||||
|
|
||||||
@@ -173,6 +186,10 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
|||||||
if (form.meta_platform) metadata.platform = form.meta_platform
|
if (form.meta_platform) metadata.platform = form.meta_platform
|
||||||
if (form.meta_seat) metadata.seat = form.meta_seat
|
if (form.meta_seat) metadata.seat = form.meta_seat
|
||||||
}
|
}
|
||||||
|
if (isBudgetEnabled) {
|
||||||
|
if (form.price) metadata.price = form.price
|
||||||
|
if (form.budget_category) metadata.budget_category = form.budget_category
|
||||||
|
}
|
||||||
|
|
||||||
const startDate = startDay?.date ?? null
|
const startDate = startDay?.date ?? null
|
||||||
const endDate = (endDay ?? startDay)?.date ?? null
|
const endDate = (endDay ?? startDay)?.date ?? null
|
||||||
@@ -200,6 +217,11 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
|||||||
endpoints,
|
endpoints,
|
||||||
needs_review: false,
|
needs_review: false,
|
||||||
}
|
}
|
||||||
|
if (isBudgetEnabled) {
|
||||||
|
(payload as any).create_budget_entry = form.price && parseFloat(form.price) > 0
|
||||||
|
? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' }
|
||||||
|
: { total_price: 0 }
|
||||||
|
}
|
||||||
await onSave(payload)
|
await onSave(payload)
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
||||||
@@ -220,10 +242,15 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
|||||||
|
|
||||||
const dayOptions = [
|
const dayOptions = [
|
||||||
{ value: '', label: '—' },
|
{ value: '', label: '—' },
|
||||||
...days.map(d => ({
|
...days.map(d => {
|
||||||
value: d.id,
|
const dateBadge = d.date ? (formatDate(d.date, locale) ?? undefined) : undefined
|
||||||
label: d.title || `${t('dayplan.dayN', { n: d.day_number })}${d.date ? ` · ${formatDate(d.date, locale) ?? ''}` : ''}`,
|
const dayBadge = d.title ? t('dayplan.dayN', { n: d.day_number }) : undefined
|
||||||
})),
|
return {
|
||||||
|
value: d.id,
|
||||||
|
label: d.title || t('dayplan.dayN', { n: d.day_number }),
|
||||||
|
badge: dateBadge ?? dayBadge,
|
||||||
|
}
|
||||||
|
}),
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -232,6 +259,16 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
|||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title={reservation ? t('transport.modalTitle.edit') : t('transport.modalTitle.create')}
|
title={reservation ? t('transport.modalTitle.edit') : t('transport.modalTitle.create')}
|
||||||
size="2xl"
|
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 }}>
|
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
|
|
||||||
@@ -407,15 +444,40 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
|||||||
style={{ ...inputStyle, resize: 'none', lineHeight: 1.5 }} />
|
style={{ ...inputStyle, resize: 'none', lineHeight: 1.5 }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Price + Budget Category */}
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, paddingTop: 4, borderTop: '1px solid var(--border-secondary)' }}>
|
{isBudgetEnabled && (
|
||||||
<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')}
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
</button>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<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 }}>
|
<label style={labelStyle}>{t('reservations.price')}</label>
|
||||||
{isSaving ? t('common.saving') : reservation ? t('common.update') : t('common.add')}
|
<input type="text" inputMode="decimal" value={form.price}
|
||||||
</button>
|
onChange={e => { const v = e.target.value; if (v === '' || /^\d*[.,]?\d{0,2}$/.test(v)) set('price', v.replace(',', '.')) }}
|
||||||
</div>
|
onPaste={e => { e.preventDefault(); let txt = e.clipboardData.getData('text').trim().replace(/[^\d.,-]/g, ''); const lc = txt.lastIndexOf(','), ld = txt.lastIndexOf('.'), dp = Math.max(lc, ld); if (dp > -1) { txt = txt.substring(0, dp).replace(/[.,]/g, '') + '.' + txt.substring(dp + 1) } else { txt = txt.replace(/[.,]/g, '') } set('price', txt) }}
|
||||||
|
placeholder="0.00"
|
||||||
|
style={inputStyle} />
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<label style={labelStyle}>{t('reservations.budgetCategory')}</label>
|
||||||
|
<CustomSelect
|
||||||
|
value={form.budget_category}
|
||||||
|
onChange={v => set('budget_category', v)}
|
||||||
|
options={[
|
||||||
|
{ value: '', label: t('reservations.budgetCategoryAuto') },
|
||||||
|
...budgetCategories.map(c => ({ value: c, label: c })),
|
||||||
|
]}
|
||||||
|
placeholder={t('reservations.budgetCategoryAuto')}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{form.price && parseFloat(form.price) > 0 && (
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: -4 }}>
|
||||||
|
{t('reservations.budgetHint')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -155,7 +155,9 @@ describe('DisplaySettingsTab', () => {
|
|||||||
const updateSetting = vi.fn().mockResolvedValue(undefined);
|
const updateSetting = vi.fn().mockResolvedValue(undefined);
|
||||||
seedStore(useSettingsStore, { settings: buildSettings({ time_format: '12h' }), updateSetting });
|
seedStore(useSettingsStore, { settings: buildSettings({ time_format: '12h' }), updateSetting });
|
||||||
render(<DisplaySettingsTab />);
|
render(<DisplaySettingsTab />);
|
||||||
await user.click(screen.getByText('24h (14:30)'));
|
// 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/ }));
|
||||||
expect(updateSetting).toHaveBeenCalledWith('time_format', '24h');
|
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>
|
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.timeFormat')}</label>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
{[
|
{[
|
||||||
{ value: '24h', label: '24h (14:30)' },
|
{ value: '24h', short: '24h', example: '14:30' },
|
||||||
{ value: '12h', label: '12h (2:30 PM)' },
|
{ value: '12h', short: '12h', example: '2:30 PM' },
|
||||||
].map(opt => (
|
].map(opt => (
|
||||||
<button
|
<button
|
||||||
key={opt.value}
|
key={opt.value}
|
||||||
@@ -207,7 +207,8 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
|||||||
transition: 'all 0.15s',
|
transition: 'all 0.15s',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{opt.label}
|
{opt.short}
|
||||||
|
<span className="hidden sm:inline">{` (${opt.example})`}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -240,14 +240,18 @@ export default function MapSettingsTab(): React.ReactElement {
|
|||||||
: 'border-slate-200 hover:border-slate-400 dark:border-slate-700'
|
: 'border-slate-200 hover:border-slate-400 dark:border-slate-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<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" />
|
<Box size={18} className="mt-0.5 flex-shrink-0 text-slate-700 dark:text-slate-300" />
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<div className="text-sm font-medium text-slate-900 dark:text-white">Mapbox GL</div>
|
<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 className="hidden sm:block text-xs text-slate-500 mt-0.5">{t('settings.mapMapboxSubtitle')}</div>
|
||||||
</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">
|
||||||
|
{t('settings.mapExperimental')}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-slate-400 mt-2">
|
<p className="text-xs text-slate-400 mt-2">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useToast } from '../../components/shared/Toast'
|
|||||||
import apiClient from '../../api/client'
|
import apiClient from '../../api/client'
|
||||||
import { useAddonStore } from '../../store/addonStore'
|
import { useAddonStore } from '../../store/addonStore'
|
||||||
import Section from './Section'
|
import Section from './Section'
|
||||||
|
import ToggleSwitch from './ToggleSwitch'
|
||||||
|
|
||||||
interface ProviderField {
|
interface ProviderField {
|
||||||
key: string
|
key: string
|
||||||
@@ -222,15 +223,13 @@ export default function PhotoProvidersSection(): React.ReactElement {
|
|||||||
{fields.map(field => (
|
{fields.map(field => (
|
||||||
<div key={`${provider.id}-${field.key}`}>
|
<div key={`${provider.id}-${field.key}`}>
|
||||||
{field.input_type === 'checkbox' ? (
|
{field.input_type === 'checkbox' ? (
|
||||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
<div className="flex items-center gap-3">
|
||||||
<input
|
<ToggleSwitch
|
||||||
type="checkbox"
|
on={values[field.key] === 'true'}
|
||||||
checked={values[field.key] === 'true'}
|
onToggle={() => handleProviderFieldChange(provider.id, field.key, values[field.key] === 'true' ? 'false' : '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>
|
<span className="text-sm font-medium text-slate-700">{t(`memories.${field.label}`)}</span>
|
||||||
</label>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t(`memories.${field.label}`)}</label>
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t(`memories.${field.label}`)}</label>
|
||||||
@@ -248,7 +247,9 @@ export default function PhotoProvidersSection(): React.ReactElement {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<div className="flex items-center gap-3">
|
{/* 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">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSaveProvider(provider)}
|
onClick={() => handleSaveProvider(provider)}
|
||||||
disabled={!canSave || !!saving[provider.id] || isProviderSaveDisabled(provider)}
|
disabled={!canSave || !!saving[provider.id] || isProviderSaveDisabled(provider)}
|
||||||
@@ -266,15 +267,17 @@ export default function PhotoProvidersSection(): React.ReactElement {
|
|||||||
{testing
|
{testing
|
||||||
? <div className="w-4 h-4 border-2 border-slate-300 border-t-slate-700 rounded-full animate-spin" />
|
? <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" />}
|
: <Camera className="w-4 h-4" />}
|
||||||
{t('memories.testConnection')}
|
<span className="sm:hidden">{t('memories.testShort')}</span>
|
||||||
|
<span className="hidden sm:inline">{t('memories.testConnection')}</span>
|
||||||
</button>
|
</button>
|
||||||
|
{/* On mobile the badge sits on its own row thanks to flex-wrap, so force a line break via basis-full. */}
|
||||||
{connected ? (
|
{connected ? (
|
||||||
<span className="text-xs font-medium text-green-600 flex items-center gap-1">
|
<span className="basis-full sm:basis-auto text-xs font-medium text-green-600 flex items-center gap-1">
|
||||||
<span className="w-2 h-2 bg-green-500 rounded-full" />
|
<span className="w-2 h-2 bg-green-500 rounded-full" />
|
||||||
{t('memories.connected')}
|
{t('memories.connected')}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs font-medium text-slate-400 flex items-center gap-1">
|
<span className="basis-full sm:basis-auto text-xs font-medium text-slate-400 flex items-center gap-1">
|
||||||
<span className="w-2 h-2 bg-slate-300 rounded-full" />
|
<span className="w-2 h-2 bg-slate-300 rounded-full" />
|
||||||
{t('memories.disconnected')}
|
{t('memories.disconnected')}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import React from 'react'
|
|||||||
|
|
||||||
export default function ToggleSwitch({ on, onToggle }: { on: boolean; onToggle: () => void }) {
|
export default function ToggleSwitch({ on, onToggle }: { on: boolean; onToggle: () => void }) {
|
||||||
return (
|
return (
|
||||||
<button onClick={onToggle}
|
<button type="button" onClick={onToggle}
|
||||||
style={{
|
style={{
|
||||||
position: 'relative', width: 44, height: 24, borderRadius: 12, border: 'none', cursor: 'pointer',
|
position: 'relative', width: 44, height: 24, minWidth: 44, flexShrink: 0,
|
||||||
|
borderRadius: 12, border: 'none', padding: 0, cursor: 'pointer',
|
||||||
background: on ? 'var(--accent, #111827)' : 'var(--border-primary, #d1d5db)',
|
background: on ? 'var(--accent, #111827)' : 'var(--border-primary, #d1d5db)',
|
||||||
transition: 'background 0.2s',
|
transition: 'background 0.2s',
|
||||||
}}>
|
}}>
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
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)' }}
|
||||||
|
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,13 +119,14 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {}, com
|
|||||||
...(() => {
|
...(() => {
|
||||||
const r = ref.current?.getBoundingClientRect()
|
const r = ref.current?.getBoundingClientRect()
|
||||||
if (!r) return { top: 0, left: 0 }
|
if (!r) return { top: 0, left: 0 }
|
||||||
const w = 268, pad = 8
|
const w = 268, pad = 8, h = 360
|
||||||
const vw = window.innerWidth
|
const vw = window.innerWidth
|
||||||
const vh = window.innerHeight
|
const vh = window.visualViewport?.height ?? window.innerHeight
|
||||||
let left = r.left
|
let left = r.left
|
||||||
let top = r.bottom + 4
|
let top = r.bottom + 4
|
||||||
if (left + w > vw - pad) left = Math.max(pad, vw - w - pad)
|
if (left + w > vw - pad) left = Math.max(pad, vw - w - pad)
|
||||||
if (top + 320 > vh) top = Math.max(pad, r.top - 320)
|
if (top + h > vh - pad) top = r.top - h - 4
|
||||||
|
top = Math.max(pad, Math.min(top, vh - h - pad))
|
||||||
if (vw < 360) left = Math.max(pad, (vw - w) / 2)
|
if (vw < 360) left = Math.max(pad, (vw - w) / 2)
|
||||||
return { top, left }
|
return { top, left }
|
||||||
})(),
|
})(),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ interface SelectOption {
|
|||||||
isHeader?: boolean
|
isHeader?: boolean
|
||||||
searchLabel?: string
|
searchLabel?: string
|
||||||
groupLabel?: string
|
groupLabel?: string
|
||||||
|
badge?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CustomSelectProps {
|
interface CustomSelectProps {
|
||||||
@@ -104,6 +105,13 @@ export default function CustomSelect({
|
|||||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: selected ? 'var(--text-primary)' : 'var(--text-faint)' }}>
|
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: selected ? 'var(--text-primary)' : 'var(--text-faint)' }}>
|
||||||
{selected ? selected.label : placeholder}
|
{selected ? selected.label : placeholder}
|
||||||
</span>
|
</span>
|
||||||
|
{selected?.badge && (
|
||||||
|
<span style={{
|
||||||
|
flexShrink: 0, fontSize: 10, fontWeight: 600, color: 'var(--text-muted)',
|
||||||
|
background: 'var(--bg-tertiary)', padding: '2px 7px', borderRadius: 999,
|
||||||
|
letterSpacing: '0.01em',
|
||||||
|
}}>{selected.badge}</span>
|
||||||
|
)}
|
||||||
<ChevronDown size={sm ? 12 : 14} style={{ flexShrink: 0, color: 'var(--text-faint)', transition: 'transform 200ms cubic-bezier(0.23,1,0.32,1)', transform: open ? 'rotate(180deg)' : 'none' }} />
|
<ChevronDown size={sm ? 12 : 14} style={{ flexShrink: 0, color: 'var(--text-faint)', transition: 'transform 200ms cubic-bezier(0.23,1,0.32,1)', transform: open ? 'rotate(180deg)' : 'none' }} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -186,6 +194,13 @@ export default function CustomSelect({
|
|||||||
>
|
>
|
||||||
{option.icon && <span style={{ display: 'flex', flexShrink: 0 }}>{option.icon}</span>}
|
{option.icon && <span style={{ display: 'flex', flexShrink: 0 }}>{option.icon}</span>}
|
||||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{option.label}</span>
|
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{option.label}</span>
|
||||||
|
{option.badge && (
|
||||||
|
<span style={{
|
||||||
|
flexShrink: 0, fontSize: 10, fontWeight: 600, color: 'var(--text-muted)',
|
||||||
|
background: 'var(--bg-tertiary)', padding: '2px 7px', borderRadius: 999,
|
||||||
|
letterSpacing: '0.01em',
|
||||||
|
}}>{option.badge}</span>
|
||||||
|
)}
|
||||||
{isSelected && <Check size={13} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />}
|
{isSelected && <Check size={13} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -61,14 +61,15 @@ export default function Modal({
|
|||||||
<div
|
<div
|
||||||
className={`
|
className={`
|
||||||
trek-modal-enter
|
trek-modal-enter
|
||||||
rounded-2xl shadow-2xl w-full ${sizeClasses[size] || sizeClasses.md}
|
rounded-2xl overflow-hidden shadow-2xl w-full ${sizeClasses[size] || sizeClasses.md}
|
||||||
flex flex-col max-h-[calc(100vh-180px)] md:max-h-[calc(100vh-90px)]
|
flex flex-col
|
||||||
|
max-h-[calc(100dvh-var(--bottom-nav-h)-90px)] sm:max-h-[calc(100dvh-90px)]
|
||||||
`}
|
`}
|
||||||
style={{ background: 'var(--bg-card)' }}
|
style={{ background: 'var(--bg-card)' }}
|
||||||
onClick={e => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header — stays put even while the body scrolls */}
|
||||||
<div className="flex items-center justify-between p-6" style={{ borderBottom: '1px solid var(--border-secondary)' }}>
|
<div className="flex items-center justify-between p-6 flex-shrink-0" style={{ borderBottom: '1px solid var(--border-secondary)' }}>
|
||||||
<h2 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{title}</h2>
|
<h2 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{title}</h2>
|
||||||
{!hideCloseButton && (
|
{!hideCloseButton && (
|
||||||
<button
|
<button
|
||||||
@@ -80,14 +81,14 @@ export default function Modal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
{/* 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">
|
<div className="flex-1 overflow-y-auto p-6 min-h-0">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer — sticky at the bottom of the modal, never compressed */}
|
||||||
{footer && (
|
{footer && (
|
||||||
<div className="p-6" style={{ borderTop: '1px solid var(--border-secondary)' }}>
|
<div className="p-6 flex-shrink-0" style={{ borderTop: '1px solid var(--border-secondary)' }}>
|
||||||
{footer}
|
{footer}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'common.none': 'لا شيء',
|
'common.none': 'لا شيء',
|
||||||
'common.date': 'التاريخ',
|
'common.date': 'التاريخ',
|
||||||
'common.rename': 'إعادة تسمية',
|
'common.rename': 'إعادة تسمية',
|
||||||
|
'common.discardChanges': 'تجاهل التغييرات',
|
||||||
|
'common.discard': 'تجاهل',
|
||||||
'common.name': 'الاسم',
|
'common.name': 'الاسم',
|
||||||
'common.email': 'البريد الإلكتروني',
|
'common.email': 'البريد الإلكتروني',
|
||||||
'common.password': 'كلمة المرور',
|
'common.password': 'كلمة المرور',
|
||||||
@@ -1222,6 +1224,8 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'files.title': 'الملفات',
|
'files.title': 'الملفات',
|
||||||
'files.pageTitle': 'الملفات والمستندات',
|
'files.pageTitle': 'الملفات والمستندات',
|
||||||
'files.subtitle': '{count} ملف لـ {trip}',
|
'files.subtitle': '{count} ملف لـ {trip}',
|
||||||
|
'files.download': 'تنزيل',
|
||||||
|
'files.openError': 'تعذر فتح الملف',
|
||||||
'files.downloadPdf': 'تنزيل PDF',
|
'files.downloadPdf': 'تنزيل PDF',
|
||||||
'files.count': '{count} ملفات',
|
'files.count': '{count} ملفات',
|
||||||
'files.countSingular': 'ملف واحد',
|
'files.countSingular': 'ملف واحد',
|
||||||
@@ -1621,6 +1625,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'memories.immichAutoUpload': 'نسخ صور الرحلة إلى Immich عند الرفع',
|
'memories.immichAutoUpload': 'نسخ صور الرحلة إلى Immich عند الرفع',
|
||||||
'memories.providerUrlHintSynology': 'أدرج مسار تطبيق Photos في URL، مثل https://nas:5001/photo',
|
'memories.providerUrlHintSynology': 'أدرج مسار تطبيق Photos في URL، مثل https://nas:5001/photo',
|
||||||
'memories.testConnection': 'اختبار الاتصال',
|
'memories.testConnection': 'اختبار الاتصال',
|
||||||
|
'memories.testShort': 'اختبار',
|
||||||
'memories.testFirst': 'اختبر الاتصال أولاً',
|
'memories.testFirst': 'اختبر الاتصال أولاً',
|
||||||
'memories.connected': 'متصل',
|
'memories.connected': 'متصل',
|
||||||
'memories.disconnected': 'غير متصل',
|
'memories.disconnected': 'غير متصل',
|
||||||
@@ -2136,9 +2141,9 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
// System notices — personal thank you
|
// System notices — personal thank you
|
||||||
'system_notice.v3_thankyou.title': 'كلمة شخصية مني',
|
'system_notice.v3_thankyou.title': 'كلمة شخصية مني',
|
||||||
'system_notice.v3_thankyou.body': 'قبل أن تمضي — أريد أن أتوقف لحظة.\n\nبدأ TREK كمشروع جانبي بنيته لرحلاتي الخاصة. لم أتخيل يومًا أنه سيكبر ليصبح شيئًا يعتمد عليه 4,000 منكم لتخطيط مغامراتهم. كل نجمة، كل مشكلة، كل طلب ميزة — أقرأها جميعًا، وهي ما يبقيني مستمرًا في الليالي المتأخرة بين عمل بدوام كامل والجامعة.\n\nأريدكم أن تعرفوا: TREK سيبقى دائمًا مفتوح المصدر، دائمًا مستضافًا ذاتيًا، دائمًا ملككم. لا تتبع، لا اشتراكات، لا شروط خفية. مجرد أداة بناها شخص يحب السفر بقدر ما تحبونه.\n\nشكر خاص لـ [jubnl](https://github.com/jubnl) — لقد أصبحت متعاونًا رائعًا. الكثير مما يجعل الإصدار 3.0 عظيمًا يحمل بصماتك. شكرًا لإيمانك بهذا المشروع عندما كان لا يزال في بداياته.\n\nولكل واحد منكم ممن أبلغ عن خطأ، أو ترجم نصًا، أو شارك TREK مع صديق، أو ببساطة استخدمه لتخطيط رحلة — **شكرًا لكم**. أنتم السبب في وجود هذا.\n\nإلى المزيد من المغامرات معًا.\n\n— Maurice\n\n---\n\n[انضم إلى المجتمع على Discord](https://discord.gg/7Q6M6jDwzf)\n\nإذا جعل TREK رحلاتك أفضل، [فنجان قهوة صغير](https://ko-fi.com/mauriceboe) يبقي الأضواء مشتعلة.',
|
'system_notice.v3_thankyou.body': 'قبل أن تمضي — أريد أن أتوقف لحظة.\n\nبدأ TREK كمشروع جانبي بنيته لرحلاتي الخاصة. لم أتخيل يومًا أنه سيكبر ليصبح شيئًا يعتمد عليه 4,000 منكم لتخطيط مغامراتهم. كل نجمة، كل مشكلة، كل طلب ميزة — أقرأها جميعًا، وهي ما يبقيني مستمرًا في الليالي المتأخرة بين عمل بدوام كامل والجامعة.\n\nأريدكم أن تعرفوا: TREK سيبقى دائمًا مفتوح المصدر، دائمًا مستضافًا ذاتيًا، دائمًا ملككم. لا تتبع، لا اشتراكات، لا شروط خفية. مجرد أداة بناها شخص يحب السفر بقدر ما تحبونه.\n\nشكر خاص لـ [jubnl](https://github.com/jubnl) — لقد أصبحت متعاونًا رائعًا. الكثير مما يجعل الإصدار 3.0 عظيمًا يحمل بصماتك. شكرًا لإيمانك بهذا المشروع عندما كان لا يزال في بداياته.\n\nولكل واحد منكم ممن أبلغ عن خطأ، أو ترجم نصًا، أو شارك TREK مع صديق، أو ببساطة استخدمه لتخطيط رحلة — **شكرًا لكم**. أنتم السبب في وجود هذا.\n\nإلى المزيد من المغامرات معًا.\n\n— Maurice\n\n---\n\n[انضم إلى المجتمع على Discord](https://discord.gg/7Q6M6jDwzf)\n\nإذا جعل TREK رحلاتك أفضل، [فنجان قهوة صغير](https://ko-fi.com/mauriceboe) يبقي الأضواء مشتعلة.',
|
||||||
'transport.addTransport': 'Add transport',
|
'transport.addTransport': 'إضافة وسيلة نقل',
|
||||||
'transport.modalTitle.create': 'Add transport',
|
'transport.modalTitle.create': 'إضافة وسيلة نقل',
|
||||||
'transport.modalTitle.edit': 'Edit transport',
|
'transport.modalTitle.edit': 'تعديل وسيلة النقل',
|
||||||
'transport.title': 'المواصلات',
|
'transport.title': 'المواصلات',
|
||||||
'transport.addManual': 'نقل يدوي',
|
'transport.addManual': 'نقل يدوي',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'common.none': 'Nenhum',
|
'common.none': 'Nenhum',
|
||||||
'common.date': 'Data',
|
'common.date': 'Data',
|
||||||
'common.rename': 'Renomear',
|
'common.rename': 'Renomear',
|
||||||
|
'common.discardChanges': 'Descartar alterações',
|
||||||
|
'common.discard': 'Descartar',
|
||||||
'common.name': 'Nome',
|
'common.name': 'Nome',
|
||||||
'common.email': 'E-mail',
|
'common.email': 'E-mail',
|
||||||
'common.password': 'Senha',
|
'common.password': 'Senha',
|
||||||
@@ -1191,6 +1193,8 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'files.title': 'Arquivos',
|
'files.title': 'Arquivos',
|
||||||
'files.pageTitle': 'Arquivos e documentos',
|
'files.pageTitle': 'Arquivos e documentos',
|
||||||
'files.subtitle': '{count} arquivos para {trip}',
|
'files.subtitle': '{count} arquivos para {trip}',
|
||||||
|
'files.download': 'Baixar',
|
||||||
|
'files.openError': 'Não foi possível abrir o arquivo',
|
||||||
'files.downloadPdf': 'Baixar PDF',
|
'files.downloadPdf': 'Baixar PDF',
|
||||||
'files.count': '{count} arquivos',
|
'files.count': '{count} arquivos',
|
||||||
'files.countSingular': '1 arquivo',
|
'files.countSingular': '1 arquivo',
|
||||||
@@ -1660,6 +1664,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'memories.immichAutoUpload': 'Espelhar fotos da jornada no Immich ao enviar',
|
'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.providerUrlHintSynology': 'Inclua o caminho do aplicativo Photos na URL, ex. https://nas:5001/photo',
|
||||||
'memories.testConnection': 'Testar conexão',
|
'memories.testConnection': 'Testar conexão',
|
||||||
|
'memories.testShort': 'Testar',
|
||||||
'memories.testFirst': 'Teste a conexão primeiro',
|
'memories.testFirst': 'Teste a conexão primeiro',
|
||||||
'memories.connected': 'Conectado',
|
'memories.connected': 'Conectado',
|
||||||
'memories.disconnected': 'Não conectado',
|
'memories.disconnected': 'Não conectado',
|
||||||
@@ -2339,9 +2344,9 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
// System notices — personal thank you
|
// System notices — personal thank you
|
||||||
'system_notice.v3_thankyou.title': 'Uma nota pessoal minha',
|
'system_notice.v3_thankyou.title': 'Uma nota pessoal minha',
|
||||||
'system_notice.v3_thankyou.body': 'Antes de seguir em frente — quero fazer uma pausa.\n\nO TREK começou como um projeto paralelo que criei para minhas próprias viagens. Nunca imaginei que cresceria a ponto de 4.000 de vocês confiarem nele para planejar suas aventuras. Cada estrela, cada issue, cada pedido de recurso — eu leio todos, e eles me mantêm firme nas noites longas entre um trabalho em tempo integral e a universidade.\n\nQuero que saibam: o TREK sempre será open source, sempre self-hosted, sempre de vocês. Sem rastreamento, sem assinaturas, sem pegadinhas. Apenas uma ferramenta feita por alguém que ama viajar tanto quanto vocês.\n\nAgradecimento especial ao [jubnl](https://github.com/jubnl) — você se tornou um colaborador incrível. Muito do que torna a versão 3.0 especial tem a sua marca. Obrigado por acreditar neste projeto quando ele ainda era bem cru.\n\nE a cada um de vocês que reportou um bug, traduziu uma string, compartilhou o TREK com um amigo ou simplesmente o usou para planejar uma viagem — **obrigado**. Vocês são a razão de tudo isso existir.\n\nQue venham muitas mais aventuras juntos.\n\n— Maurice\n\n---\n\n[Junte-se à comunidade no Discord](https://discord.gg/7Q6M6jDwzf)\n\nSe o TREK torna suas viagens melhores, um [cafezinho](https://ko-fi.com/mauriceboe) sempre mantém as luzes acesas.',
|
'system_notice.v3_thankyou.body': 'Antes de seguir em frente — quero fazer uma pausa.\n\nO TREK começou como um projeto paralelo que criei para minhas próprias viagens. Nunca imaginei que cresceria a ponto de 4.000 de vocês confiarem nele para planejar suas aventuras. Cada estrela, cada issue, cada pedido de recurso — eu leio todos, e eles me mantêm firme nas noites longas entre um trabalho em tempo integral e a universidade.\n\nQuero que saibam: o TREK sempre será open source, sempre self-hosted, sempre de vocês. Sem rastreamento, sem assinaturas, sem pegadinhas. Apenas uma ferramenta feita por alguém que ama viajar tanto quanto vocês.\n\nAgradecimento especial ao [jubnl](https://github.com/jubnl) — você se tornou um colaborador incrível. Muito do que torna a versão 3.0 especial tem a sua marca. Obrigado por acreditar neste projeto quando ele ainda era bem cru.\n\nE a cada um de vocês que reportou um bug, traduziu uma string, compartilhou o TREK com um amigo ou simplesmente o usou para planejar uma viagem — **obrigado**. Vocês são a razão de tudo isso existir.\n\nQue venham muitas mais aventuras juntos.\n\n— Maurice\n\n---\n\n[Junte-se à comunidade no Discord](https://discord.gg/7Q6M6jDwzf)\n\nSe o TREK torna suas viagens melhores, um [cafezinho](https://ko-fi.com/mauriceboe) sempre mantém as luzes acesas.',
|
||||||
'transport.addTransport': 'Add transport',
|
'transport.addTransport': 'Adicionar transporte',
|
||||||
'transport.modalTitle.create': 'Add transport',
|
'transport.modalTitle.create': 'Adicionar transporte',
|
||||||
'transport.modalTitle.edit': 'Edit transport',
|
'transport.modalTitle.edit': 'Editar transporte',
|
||||||
'transport.title': 'Transportes',
|
'transport.title': 'Transportes',
|
||||||
'transport.addManual': 'Transporte Manual',
|
'transport.addManual': 'Transporte Manual',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'common.none': 'Žádné',
|
'common.none': 'Žádné',
|
||||||
'common.date': 'Datum',
|
'common.date': 'Datum',
|
||||||
'common.rename': 'Přejmenovat',
|
'common.rename': 'Přejmenovat',
|
||||||
|
'common.discardChanges': 'Zahodit změny',
|
||||||
|
'common.discard': 'Zahodit',
|
||||||
'common.name': 'Jméno',
|
'common.name': 'Jméno',
|
||||||
'common.email': 'E-mail',
|
'common.email': 'E-mail',
|
||||||
'common.password': 'Heslo',
|
'common.password': 'Heslo',
|
||||||
@@ -1220,6 +1222,8 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'files.title': 'Soubory',
|
'files.title': 'Soubory',
|
||||||
'files.pageTitle': 'Soubory a dokumenty',
|
'files.pageTitle': 'Soubory a dokumenty',
|
||||||
'files.subtitle': '{count} souborů pro {trip}',
|
'files.subtitle': '{count} souborů pro {trip}',
|
||||||
|
'files.download': 'Stáhnout',
|
||||||
|
'files.openError': 'Soubor nelze otevřít',
|
||||||
'files.downloadPdf': 'Stáhnout PDF',
|
'files.downloadPdf': 'Stáhnout PDF',
|
||||||
'files.count': '{count} souborů',
|
'files.count': '{count} souborů',
|
||||||
'files.countSingular': '1 soubor',
|
'files.countSingular': '1 soubor',
|
||||||
@@ -1619,6 +1623,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'memories.immichAutoUpload': 'Zrcadlit fotky journey při nahrávání také do Immich',
|
'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.providerUrlHintSynology': 'Zahrňte cestu aplikace Photos do URL, např. https://nas:5001/photo',
|
||||||
'memories.testConnection': 'Otestovat připojení',
|
'memories.testConnection': 'Otestovat připojení',
|
||||||
|
'memories.testShort': 'Otestovat',
|
||||||
'memories.testFirst': 'Nejprve otestujte připojení',
|
'memories.testFirst': 'Nejprve otestujte připojení',
|
||||||
'memories.connected': 'Připojeno',
|
'memories.connected': 'Připojeno',
|
||||||
'memories.disconnected': 'Nepřipojeno',
|
'memories.disconnected': 'Nepřipojeno',
|
||||||
@@ -2343,9 +2348,9 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
// System notices — personal thank you
|
// System notices — personal thank you
|
||||||
'system_notice.v3_thankyou.title': 'Osobní slovo ode mě',
|
'system_notice.v3_thankyou.title': 'Osobní slovo ode mě',
|
||||||
'system_notice.v3_thankyou.body': 'Než budete pokračovat — chci se na chvíli zastavit.\n\nTREK začal jako vedlejší projekt, který jsem vytvořil pro své vlastní cesty. Nikdy jsem si nepředstavoval, že vyroste v něco, čemu 4 000 z vás důvěřuje při plánování svých dobrodružství. Každou hvězdičku, každý issue, každý požadavek na funkci — všechny čtu a právě ony mě drží při životě během pozdních nocí mezi prací na plný úvazek a univerzitou.\n\nChci, abyste věděli: TREK bude vždy open source, vždy self-hosted, vždy váš. Žádné sledování, žádná předplatná, žádné háčky. Jen nástroj vytvořený někým, kdo miluje cestování stejně jako vy.\n\nZvláštní poděkování patří [jubnl](https://github.com/jubnl) — stal ses neuvěřitelným spolupracovníkem. Tolik z toho, co dělá verzi 3.0 skvělou, nese tvůj rukopis. Děkuji, že jsi věřil tomuto projektu, když byl ještě v plenkách.\n\nA každému z vás, kdo nahlásil chybu, přeložil řetězec, sdílel TREK s přítelem nebo ho jednoduše použil k plánování cesty — **děkuji**. Vy jste důvod, proč tohle existuje.\n\nNa mnoho dalších dobrodružství společně.\n\n— Maurice\n\n---\n\n[Přidej se ke komunitě na Discordu](https://discord.gg/7Q6M6jDwzf)\n\nPokud ti TREK zlepšuje cestování, [malá káva](https://ko-fi.com/mauriceboe) vždy pomůže udržet světla rozsvícená.',
|
'system_notice.v3_thankyou.body': 'Než budete pokračovat — chci se na chvíli zastavit.\n\nTREK začal jako vedlejší projekt, který jsem vytvořil pro své vlastní cesty. Nikdy jsem si nepředstavoval, že vyroste v něco, čemu 4 000 z vás důvěřuje při plánování svých dobrodružství. Každou hvězdičku, každý issue, každý požadavek na funkci — všechny čtu a právě ony mě drží při životě během pozdních nocí mezi prací na plný úvazek a univerzitou.\n\nChci, abyste věděli: TREK bude vždy open source, vždy self-hosted, vždy váš. Žádné sledování, žádná předplatná, žádné háčky. Jen nástroj vytvořený někým, kdo miluje cestování stejně jako vy.\n\nZvláštní poděkování patří [jubnl](https://github.com/jubnl) — stal ses neuvěřitelným spolupracovníkem. Tolik z toho, co dělá verzi 3.0 skvělou, nese tvůj rukopis. Děkuji, že jsi věřil tomuto projektu, když byl ještě v plenkách.\n\nA každému z vás, kdo nahlásil chybu, přeložil řetězec, sdílel TREK s přítelem nebo ho jednoduše použil k plánování cesty — **děkuji**. Vy jste důvod, proč tohle existuje.\n\nNa mnoho dalších dobrodružství společně.\n\n— Maurice\n\n---\n\n[Přidej se ke komunitě na Discordu](https://discord.gg/7Q6M6jDwzf)\n\nPokud ti TREK zlepšuje cestování, [malá káva](https://ko-fi.com/mauriceboe) vždy pomůže udržet světla rozsvícená.',
|
||||||
'transport.addTransport': 'Add transport',
|
'transport.addTransport': 'Přidat dopravu',
|
||||||
'transport.modalTitle.create': 'Add transport',
|
'transport.modalTitle.create': 'Přidat dopravu',
|
||||||
'transport.modalTitle.edit': 'Edit transport',
|
'transport.modalTitle.edit': 'Upravit dopravu',
|
||||||
'transport.title': 'Doprava',
|
'transport.title': 'Doprava',
|
||||||
'transport.addManual': 'Ruční doprava',
|
'transport.addManual': 'Ruční doprava',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'common.none': 'Keine',
|
'common.none': 'Keine',
|
||||||
'common.date': 'Datum',
|
'common.date': 'Datum',
|
||||||
'common.rename': 'Umbenennen',
|
'common.rename': 'Umbenennen',
|
||||||
|
'common.discardChanges': 'Änderungen verwerfen',
|
||||||
|
'common.discard': 'Verwerfen',
|
||||||
'common.name': 'Name',
|
'common.name': 'Name',
|
||||||
'common.email': 'E-Mail',
|
'common.email': 'E-Mail',
|
||||||
'common.password': 'Passwort',
|
'common.password': 'Passwort',
|
||||||
@@ -1224,6 +1226,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'files.title': 'Dateien',
|
'files.title': 'Dateien',
|
||||||
'files.pageTitle': 'Dateien & Dokumente',
|
'files.pageTitle': 'Dateien & Dokumente',
|
||||||
'files.subtitle': '{count} Dateien für {trip}',
|
'files.subtitle': '{count} Dateien für {trip}',
|
||||||
|
'files.download': 'Herunterladen',
|
||||||
|
'files.openError': 'Datei konnte nicht geöffnet werden',
|
||||||
'files.downloadPdf': 'PDF herunterladen',
|
'files.downloadPdf': 'PDF herunterladen',
|
||||||
'files.count': '{count} Dateien',
|
'files.count': '{count} Dateien',
|
||||||
'files.countSingular': '1 Datei',
|
'files.countSingular': '1 Datei',
|
||||||
@@ -1623,6 +1627,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'memories.immichAutoUpload': 'Journey-Fotos beim Upload auch zu Immich spiegeln',
|
'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.providerUrlHintSynology': 'Füge den Fotos-App-Pfad in die URL ein, z.B. https://nas:5001/photo',
|
||||||
'memories.testConnection': 'Verbindung testen',
|
'memories.testConnection': 'Verbindung testen',
|
||||||
|
'memories.testShort': 'Testen',
|
||||||
'memories.testFirst': 'Verbindung zuerst testen',
|
'memories.testFirst': 'Verbindung zuerst testen',
|
||||||
'memories.connected': 'Verbunden',
|
'memories.connected': 'Verbunden',
|
||||||
'memories.disconnected': 'Nicht verbunden',
|
'memories.disconnected': 'Nicht verbunden',
|
||||||
@@ -2349,9 +2354,9 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
// System notices — persönlicher Dank
|
// System notices — persönlicher Dank
|
||||||
'system_notice.v3_thankyou.title': 'Ein persönliches Wort von mir',
|
'system_notice.v3_thankyou.title': 'Ein persönliches Wort von mir',
|
||||||
'system_notice.v3_thankyou.body': 'Bevor du weiterklickst — einen Moment noch.\n\nTREK hat als Nebenprojekt für meine eigenen Reisen angefangen. Ich hätte nie gedacht, dass es jemals so weit kommt, dass 4.000 von euch damit ihre Abenteuer planen. Jeder Stern, jedes Issue, jeder Feature-Wunsch — ich lese sie alle, und sie halten mich am Laufen durch die späten Nächte zwischen Vollzeitjob und Studium.\n\nEins will ich euch sagen: TREK wird immer Open Source bleiben, immer self-hosted, immer eures. Kein Tracking, keine Abos, keine versteckten Haken. Einfach ein Tool, gebaut von jemandem, der das Reisen genauso liebt wie ihr.\n\nBesonderer Dank an [jubnl](https://github.com/jubnl) — du bist ein unglaublicher Mitstreiter geworden. So vieles, was 3.0 großartig macht, trägt deine Handschrift. Danke, dass du an dieses Projekt geglaubt hast, als es noch holprig war.\n\nUnd an jeden einzelnen von euch, der einen Bug gemeldet, einen String übersetzt, TREK mit Freunden geteilt oder einfach damit eine Reise geplant hat — **danke**. Ihr seid der Grund, warum es das hier gibt.\n\nAuf viele weitere Abenteuer zusammen.\n\n— Maurice\n\n---\n\n[Tritt der Community auf Discord bei](https://discord.gg/7Q6M6jDwzf)\n\nWenn TREK deine Reisen besser macht, hält ein [kleiner Kaffee](https://ko-fi.com/mauriceboe) die Lichter an.',
|
'system_notice.v3_thankyou.body': 'Bevor du weiterklickst — einen Moment noch.\n\nTREK hat als Nebenprojekt für meine eigenen Reisen angefangen. Ich hätte nie gedacht, dass es jemals so weit kommt, dass 4.000 von euch damit ihre Abenteuer planen. Jeder Stern, jedes Issue, jeder Feature-Wunsch — ich lese sie alle, und sie halten mich am Laufen durch die späten Nächte zwischen Vollzeitjob und Studium.\n\nEins will ich euch sagen: TREK wird immer Open Source bleiben, immer self-hosted, immer eures. Kein Tracking, keine Abos, keine versteckten Haken. Einfach ein Tool, gebaut von jemandem, der das Reisen genauso liebt wie ihr.\n\nBesonderer Dank an [jubnl](https://github.com/jubnl) — du bist ein unglaublicher Mitstreiter geworden. So vieles, was 3.0 großartig macht, trägt deine Handschrift. Danke, dass du an dieses Projekt geglaubt hast, als es noch holprig war.\n\nUnd an jeden einzelnen von euch, der einen Bug gemeldet, einen String übersetzt, TREK mit Freunden geteilt oder einfach damit eine Reise geplant hat — **danke**. Ihr seid der Grund, warum es das hier gibt.\n\nAuf viele weitere Abenteuer zusammen.\n\n— Maurice\n\n---\n\n[Tritt der Community auf Discord bei](https://discord.gg/7Q6M6jDwzf)\n\nWenn TREK deine Reisen besser macht, hält ein [kleiner Kaffee](https://ko-fi.com/mauriceboe) die Lichter an.',
|
||||||
'transport.addTransport': 'Add transport',
|
'transport.addTransport': 'Transport hinzufügen',
|
||||||
'transport.modalTitle.create': 'Add transport',
|
'transport.modalTitle.create': 'Transport hinzufügen',
|
||||||
'transport.modalTitle.edit': 'Edit transport',
|
'transport.modalTitle.edit': 'Transport bearbeiten',
|
||||||
'transport.title': 'Transporte',
|
'transport.title': 'Transporte',
|
||||||
'transport.addManual': 'Manuelles Transportmittel',
|
'transport.addManual': 'Manuelles Transportmittel',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'common.none': 'None',
|
'common.none': 'None',
|
||||||
'common.date': 'Date',
|
'common.date': 'Date',
|
||||||
'common.rename': 'Rename',
|
'common.rename': 'Rename',
|
||||||
|
'common.discardChanges': 'Discard Changes',
|
||||||
|
'common.discard': 'Discard',
|
||||||
'common.name': 'Name',
|
'common.name': 'Name',
|
||||||
'common.email': 'Email',
|
'common.email': 'Email',
|
||||||
'common.password': 'Password',
|
'common.password': 'Password',
|
||||||
@@ -122,6 +124,20 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'dashboard.toast.copied': 'Trip copied!',
|
'dashboard.toast.copied': 'Trip copied!',
|
||||||
'dashboard.toast.copyError': 'Failed to copy trip',
|
'dashboard.toast.copyError': 'Failed to copy trip',
|
||||||
'dashboard.confirm.delete': 'Delete trip "{title}"? All places and plans will be permanently deleted.',
|
'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.editTrip': 'Edit Trip',
|
||||||
'dashboard.createTrip': 'Create New Trip',
|
'dashboard.createTrip': 'Create New Trip',
|
||||||
'dashboard.tripTitle': 'Title',
|
'dashboard.tripTitle': 'Title',
|
||||||
@@ -1281,6 +1297,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'files.title': 'Files',
|
'files.title': 'Files',
|
||||||
'files.pageTitle': 'Files & Documents',
|
'files.pageTitle': 'Files & Documents',
|
||||||
'files.subtitle': '{count} files for {trip}',
|
'files.subtitle': '{count} files for {trip}',
|
||||||
|
'files.download': 'Download',
|
||||||
|
'files.openError': 'Could not open file',
|
||||||
'files.downloadPdf': 'Download PDF',
|
'files.downloadPdf': 'Download PDF',
|
||||||
'files.count': '{count} files',
|
'files.count': '{count} files',
|
||||||
'files.countSingular': '1 file',
|
'files.countSingular': '1 file',
|
||||||
@@ -1682,6 +1700,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'memories.immichAutoUpload': 'Mirror journey photos to Immich on upload',
|
'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.providerUrlHintSynology': 'Include the Photos app path in the URL, e.g. https://nas:5001/photo',
|
||||||
'memories.testConnection': 'Test connection',
|
'memories.testConnection': 'Test connection',
|
||||||
|
'memories.testShort': 'Test',
|
||||||
'memories.testFirst': 'Test connection first',
|
'memories.testFirst': 'Test connection first',
|
||||||
'memories.connected': 'Connected',
|
'memories.connected': 'Connected',
|
||||||
'memories.disconnected': 'Not connected',
|
'memories.disconnected': 'Not connected',
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ const es: Record<string, string> = {
|
|||||||
'common.none': 'Ninguno',
|
'common.none': 'Ninguno',
|
||||||
'common.date': 'Fecha',
|
'common.date': 'Fecha',
|
||||||
'common.rename': 'Renombrar',
|
'common.rename': 'Renombrar',
|
||||||
|
'common.discardChanges': 'Descartar cambios',
|
||||||
|
'common.discard': 'Descartar',
|
||||||
'common.name': 'Nombre',
|
'common.name': 'Nombre',
|
||||||
'common.email': 'Correo',
|
'common.email': 'Correo',
|
||||||
'common.password': 'Contraseña',
|
'common.password': 'Contraseña',
|
||||||
@@ -1168,6 +1170,8 @@ const es: Record<string, string> = {
|
|||||||
'files.title': 'Archivos',
|
'files.title': 'Archivos',
|
||||||
'files.pageTitle': 'Archivos y documentos',
|
'files.pageTitle': 'Archivos y documentos',
|
||||||
'files.subtitle': '{count} archivos para {trip}',
|
'files.subtitle': '{count} archivos para {trip}',
|
||||||
|
'files.download': 'Descargar',
|
||||||
|
'files.openError': 'No se pudo abrir el archivo',
|
||||||
'files.downloadPdf': 'Descargar PDF',
|
'files.downloadPdf': 'Descargar PDF',
|
||||||
'files.count': '{count} archivos',
|
'files.count': '{count} archivos',
|
||||||
'files.countSingular': '1 archivo',
|
'files.countSingular': '1 archivo',
|
||||||
@@ -1560,6 +1564,7 @@ const es: Record<string, string> = {
|
|||||||
'memories.immichAutoUpload': 'Duplicar las fotos del journey en Immich al subirlas',
|
'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.providerUrlHintSynology': 'Incluye la ruta de la aplicación Photos en la URL, p.ej. https://nas:5001/photo',
|
||||||
'memories.testConnection': 'Probar conexión',
|
'memories.testConnection': 'Probar conexión',
|
||||||
|
'memories.testShort': 'Probar',
|
||||||
'memories.testFirst': 'Probar conexión primero',
|
'memories.testFirst': 'Probar conexión primero',
|
||||||
'memories.connected': 'Conectado',
|
'memories.connected': 'Conectado',
|
||||||
'memories.disconnected': 'No conectado',
|
'memories.disconnected': 'No conectado',
|
||||||
@@ -2345,9 +2350,9 @@ const es: Record<string, string> = {
|
|||||||
// System notices — personal thank you
|
// System notices — personal thank you
|
||||||
'system_notice.v3_thankyou.title': 'Una nota personal de mi parte',
|
'system_notice.v3_thankyou.title': 'Una nota personal de mi parte',
|
||||||
'system_notice.v3_thankyou.body': 'Antes de seguir — quiero tomarme un momento.\n\nTREK empezó como un proyecto personal que construí para mis propios viajes. Nunca imaginé que crecería hasta convertirse en algo en lo que 4.000 de vosotros confían para planificar sus aventuras. Cada estrella, cada issue, cada solicitud de funcionalidad — los leo todos, y son lo que me mantiene en pie durante las noches largas entre un trabajo a jornada completa y la universidad.\n\nQuiero que sepáis: TREK siempre será open source, siempre self-hosted, siempre vuestro. Sin rastreo, sin suscripciones, sin letra pequeña. Solo una herramienta hecha por alguien que ama viajar tanto como vosotros.\n\nUn agradecimiento especial a [jubnl](https://github.com/jubnl) — te has convertido en un colaborador increíble. Mucho de lo que hace grande la versión 3.0 lleva tu huella. Gracias por creer en este proyecto cuando todavía era un borrador.\n\nY a cada uno de vosotros que reportó un bug, tradujo un texto, compartió TREK con un amigo o simplemente lo usó para planificar un viaje — **gracias**. Vosotros sois la razón de que esto exista.\n\nPor muchas más aventuras juntos.\n\n— Maurice\n\n---\n\n[Únete a la comunidad en Discord](https://discord.gg/7Q6M6jDwzf)\n\nSi TREK mejora tus viajes, un [pequeño café](https://ko-fi.com/mauriceboe) siempre mantiene las luces encendidas.',
|
'system_notice.v3_thankyou.body': 'Antes de seguir — quiero tomarme un momento.\n\nTREK empezó como un proyecto personal que construí para mis propios viajes. Nunca imaginé que crecería hasta convertirse en algo en lo que 4.000 de vosotros confían para planificar sus aventuras. Cada estrella, cada issue, cada solicitud de funcionalidad — los leo todos, y son lo que me mantiene en pie durante las noches largas entre un trabajo a jornada completa y la universidad.\n\nQuiero que sepáis: TREK siempre será open source, siempre self-hosted, siempre vuestro. Sin rastreo, sin suscripciones, sin letra pequeña. Solo una herramienta hecha por alguien que ama viajar tanto como vosotros.\n\nUn agradecimiento especial a [jubnl](https://github.com/jubnl) — te has convertido en un colaborador increíble. Mucho de lo que hace grande la versión 3.0 lleva tu huella. Gracias por creer en este proyecto cuando todavía era un borrador.\n\nY a cada uno de vosotros que reportó un bug, tradujo un texto, compartió TREK con un amigo o simplemente lo usó para planificar un viaje — **gracias**. Vosotros sois la razón de que esto exista.\n\nPor muchas más aventuras juntos.\n\n— Maurice\n\n---\n\n[Únete a la comunidad en Discord](https://discord.gg/7Q6M6jDwzf)\n\nSi TREK mejora tus viajes, un [pequeño café](https://ko-fi.com/mauriceboe) siempre mantiene las luces encendidas.',
|
||||||
'transport.addTransport': 'Add transport',
|
'transport.addTransport': 'Añadir transporte',
|
||||||
'transport.modalTitle.create': 'Add transport',
|
'transport.modalTitle.create': 'Añadir transporte',
|
||||||
'transport.modalTitle.edit': 'Edit transport',
|
'transport.modalTitle.edit': 'Editar transporte',
|
||||||
'transport.title': 'Transportes',
|
'transport.title': 'Transportes',
|
||||||
'transport.addManual': 'Transporte manual',
|
'transport.addManual': 'Transporte manual',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ const fr: Record<string, string> = {
|
|||||||
'common.none': 'Aucun',
|
'common.none': 'Aucun',
|
||||||
'common.date': 'Date',
|
'common.date': 'Date',
|
||||||
'common.rename': 'Renommer',
|
'common.rename': 'Renommer',
|
||||||
|
'common.discardChanges': 'Ignorer les modifications',
|
||||||
|
'common.discard': 'Ignorer',
|
||||||
'common.name': 'Nom',
|
'common.name': 'Nom',
|
||||||
'common.email': 'E-mail',
|
'common.email': 'E-mail',
|
||||||
'common.password': 'Mot de passe',
|
'common.password': 'Mot de passe',
|
||||||
@@ -1218,6 +1220,8 @@ const fr: Record<string, string> = {
|
|||||||
'files.title': 'Fichiers',
|
'files.title': 'Fichiers',
|
||||||
'files.pageTitle': 'Fichiers et documents',
|
'files.pageTitle': 'Fichiers et documents',
|
||||||
'files.subtitle': '{count} fichiers pour {trip}',
|
'files.subtitle': '{count} fichiers pour {trip}',
|
||||||
|
'files.download': 'Télécharger',
|
||||||
|
'files.openError': "Impossible d'ouvrir le fichier",
|
||||||
'files.downloadPdf': 'Télécharger le PDF',
|
'files.downloadPdf': 'Télécharger le PDF',
|
||||||
'files.count': '{count} fichiers',
|
'files.count': '{count} fichiers',
|
||||||
'files.countSingular': '1 fichier',
|
'files.countSingular': '1 fichier',
|
||||||
@@ -1617,6 +1621,7 @@ const fr: Record<string, string> = {
|
|||||||
'memories.immichAutoUpload': 'Répliquer les photos du journey vers Immich au téléversement',
|
'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.providerUrlHintSynology': 'Incluez le chemin de l\'application Photos dans l\'URL, ex. https://nas:5001/photo',
|
||||||
'memories.testConnection': 'Tester la connexion',
|
'memories.testConnection': 'Tester la connexion',
|
||||||
|
'memories.testShort': 'Tester',
|
||||||
'memories.testFirst': 'Testez la connexion avant de sauvegarder',
|
'memories.testFirst': 'Testez la connexion avant de sauvegarder',
|
||||||
'memories.connected': 'Connecté',
|
'memories.connected': 'Connecté',
|
||||||
'memories.disconnected': 'Non connecté',
|
'memories.disconnected': 'Non connecté',
|
||||||
@@ -2339,9 +2344,9 @@ const fr: Record<string, string> = {
|
|||||||
// System notices — personal thank you
|
// System notices — personal thank you
|
||||||
'system_notice.v3_thankyou.title': 'Un mot personnel de ma part',
|
'system_notice.v3_thankyou.title': 'Un mot personnel de ma part',
|
||||||
'system_notice.v3_thankyou.body': 'Avant de continuer — je veux prendre un instant.\n\nTREK a commencé comme un projet perso que j\'ai construit pour mes propres voyages. Je n\'aurais jamais imaginé qu\'il grandirait au point que 4 000 d\'entre vous lui fassent confiance pour planifier vos aventures. Chaque étoile, chaque issue, chaque demande de fonctionnalité — je les lis toutes, et ce sont elles qui me font tenir pendant les nuits blanches entre un travail à temps plein et l\'université.\n\nJe veux que vous sachiez : TREK sera toujours open source, toujours auto-hébergé, toujours à vous. Pas de tracking, pas d\'abonnements, pas de conditions cachées. Juste un outil construit par quelqu\'un qui aime voyager autant que vous.\n\nUn merci tout particulier à [jubnl](https://github.com/jubnl) — tu es devenu un collaborateur incroyable. Une grande partie de ce qui rend la 3.0 géniale porte ton empreinte. Merci d\'avoir cru en ce projet quand il était encore brut.\n\nEt à chacun d\'entre vous qui a signalé un bug, traduit une chaîne, partagé TREK avec un ami ou simplement l\'a utilisé pour planifier un voyage — **merci**. Vous êtes la raison pour laquelle tout ceci existe.\n\nÀ de nombreuses autres aventures ensemble.\n\n— Maurice\n\n---\n\n[Rejoins la communauté sur Discord](https://discord.gg/7Q6M6jDwzf)\n\nSi TREK rend tes voyages meilleurs, un [petit café](https://ko-fi.com/mauriceboe) aide toujours à garder les lumières allumées.',
|
'system_notice.v3_thankyou.body': 'Avant de continuer — je veux prendre un instant.\n\nTREK a commencé comme un projet perso que j\'ai construit pour mes propres voyages. Je n\'aurais jamais imaginé qu\'il grandirait au point que 4 000 d\'entre vous lui fassent confiance pour planifier vos aventures. Chaque étoile, chaque issue, chaque demande de fonctionnalité — je les lis toutes, et ce sont elles qui me font tenir pendant les nuits blanches entre un travail à temps plein et l\'université.\n\nJe veux que vous sachiez : TREK sera toujours open source, toujours auto-hébergé, toujours à vous. Pas de tracking, pas d\'abonnements, pas de conditions cachées. Juste un outil construit par quelqu\'un qui aime voyager autant que vous.\n\nUn merci tout particulier à [jubnl](https://github.com/jubnl) — tu es devenu un collaborateur incroyable. Une grande partie de ce qui rend la 3.0 géniale porte ton empreinte. Merci d\'avoir cru en ce projet quand il était encore brut.\n\nEt à chacun d\'entre vous qui a signalé un bug, traduit une chaîne, partagé TREK avec un ami ou simplement l\'a utilisé pour planifier un voyage — **merci**. Vous êtes la raison pour laquelle tout ceci existe.\n\nÀ de nombreuses autres aventures ensemble.\n\n— Maurice\n\n---\n\n[Rejoins la communauté sur Discord](https://discord.gg/7Q6M6jDwzf)\n\nSi TREK rend tes voyages meilleurs, un [petit café](https://ko-fi.com/mauriceboe) aide toujours à garder les lumières allumées.',
|
||||||
'transport.addTransport': 'Add transport',
|
'transport.addTransport': 'Ajouter un transport',
|
||||||
'transport.modalTitle.create': 'Add transport',
|
'transport.modalTitle.create': 'Ajouter un transport',
|
||||||
'transport.modalTitle.edit': 'Edit transport',
|
'transport.modalTitle.edit': 'Modifier le transport',
|
||||||
'transport.title': 'Transports',
|
'transport.title': 'Transports',
|
||||||
'transport.addManual': 'Transport manuel',
|
'transport.addManual': 'Transport manuel',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'common.none': 'Nincs',
|
'common.none': 'Nincs',
|
||||||
'common.date': 'Dátum',
|
'common.date': 'Dátum',
|
||||||
'common.rename': 'Átnevezés',
|
'common.rename': 'Átnevezés',
|
||||||
|
'common.discardChanges': 'Változtatások elvetése',
|
||||||
|
'common.discard': 'Elveti',
|
||||||
'common.name': 'Név',
|
'common.name': 'Név',
|
||||||
'common.email': 'E-mail',
|
'common.email': 'E-mail',
|
||||||
'common.password': 'Jelszó',
|
'common.password': 'Jelszó',
|
||||||
@@ -1219,6 +1221,8 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'files.title': 'Fájlok',
|
'files.title': 'Fájlok',
|
||||||
'files.pageTitle': 'Fájlok és dokumentumok',
|
'files.pageTitle': 'Fájlok és dokumentumok',
|
||||||
'files.subtitle': '{count} fájl a következőhöz: {trip}',
|
'files.subtitle': '{count} fájl a következőhöz: {trip}',
|
||||||
|
'files.download': 'Letöltés',
|
||||||
|
'files.openError': 'A fájl megnyitása sikertelen',
|
||||||
'files.downloadPdf': 'PDF letöltése',
|
'files.downloadPdf': 'PDF letöltése',
|
||||||
'files.count': '{count} fájl',
|
'files.count': '{count} fájl',
|
||||||
'files.countSingular': '1 fájl',
|
'files.countSingular': '1 fájl',
|
||||||
@@ -1688,6 +1692,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'memories.immichAutoUpload': 'Journey-fotók feltöltésekor másolat Immich-be is',
|
'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.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.testConnection': 'Kapcsolat tesztelése',
|
||||||
|
'memories.testShort': 'Teszt',
|
||||||
'memories.testFirst': 'Először teszteld a kapcsolatot',
|
'memories.testFirst': 'Először teszteld a kapcsolatot',
|
||||||
'memories.connected': 'Csatlakoztatva',
|
'memories.connected': 'Csatlakoztatva',
|
||||||
'memories.disconnected': 'Nincs csatlakoztatva',
|
'memories.disconnected': 'Nincs csatlakoztatva',
|
||||||
@@ -2340,9 +2345,9 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
// System notices — personal thank you
|
// System notices — personal thank you
|
||||||
'system_notice.v3_thankyou.title': 'Egy személyes gondolat tőlem',
|
'system_notice.v3_thankyou.title': 'Egy személyes gondolat tőlem',
|
||||||
'system_notice.v3_thankyou.body': 'Mielőtt továbbmennél — szeretnék egy pillanatra megállni.\n\nA TREK egy hobbiprojektként indult, amit a saját utazásaimhoz építettem. Sosem gondoltam volna, hogy valami olyanná nő, amire 4000-en bízzátok a kalandjaitok tervezését. Minden csillagot, minden issue-t, minden funkciókérést — mindet elolvasom, és ezek tartanak életben a késő éjszakákon a teljes állás és az egyetem között.\n\nSzeretnétek, ha tudnátok: a TREK mindig nyílt forráskódú marad, mindig self-hosted, mindig a tiétek. Nincs nyomkövetés, nincs előfizetés, nincsenek rejtett feltételek. Csak egy eszköz, amit valaki épített, aki ugyanúgy szereti az utazást, mint ti.\n\nKülönleges köszönet [jubnl](https://github.com/jubnl)-nek — hihetetlen társsá váltál. A 3.0 nagyszerűségének nagy része a te kézjegyedet viseli. Köszönöm, hogy hittél ebben a projektben, amikor még nyers volt.\n\nÉs mindannyiótoknak, akik hibát jelentettetek, szöveget fordítottatok, megosztottátok a TREK-et egy baráttal, vagy egyszerűen csak egy utazást terveztetek vele — **köszönöm**. Ti vagytok az ok, amiért ez létezik.\n\nSok további közös kalandért.\n\n— Maurice\n\n---\n\n[Csatlakozz a közösséghez a Discordon](https://discord.gg/7Q6M6jDwzf)\n\nHa a TREK jobbá teszi az utazásaidat, egy [kis kávé](https://ko-fi.com/mauriceboe) mindig segít, hogy égve maradjanak a fények.',
|
'system_notice.v3_thankyou.body': 'Mielőtt továbbmennél — szeretnék egy pillanatra megállni.\n\nA TREK egy hobbiprojektként indult, amit a saját utazásaimhoz építettem. Sosem gondoltam volna, hogy valami olyanná nő, amire 4000-en bízzátok a kalandjaitok tervezését. Minden csillagot, minden issue-t, minden funkciókérést — mindet elolvasom, és ezek tartanak életben a késő éjszakákon a teljes állás és az egyetem között.\n\nSzeretnétek, ha tudnátok: a TREK mindig nyílt forráskódú marad, mindig self-hosted, mindig a tiétek. Nincs nyomkövetés, nincs előfizetés, nincsenek rejtett feltételek. Csak egy eszköz, amit valaki épített, aki ugyanúgy szereti az utazást, mint ti.\n\nKülönleges köszönet [jubnl](https://github.com/jubnl)-nek — hihetetlen társsá váltál. A 3.0 nagyszerűségének nagy része a te kézjegyedet viseli. Köszönöm, hogy hittél ebben a projektben, amikor még nyers volt.\n\nÉs mindannyiótoknak, akik hibát jelentettetek, szöveget fordítottatok, megosztottátok a TREK-et egy baráttal, vagy egyszerűen csak egy utazást terveztetek vele — **köszönöm**. Ti vagytok az ok, amiért ez létezik.\n\nSok további közös kalandért.\n\n— Maurice\n\n---\n\n[Csatlakozz a közösséghez a Discordon](https://discord.gg/7Q6M6jDwzf)\n\nHa a TREK jobbá teszi az utazásaidat, egy [kis kávé](https://ko-fi.com/mauriceboe) mindig segít, hogy égve maradjanak a fények.',
|
||||||
'transport.addTransport': 'Add transport',
|
'transport.addTransport': 'Közlekedés hozzáadása',
|
||||||
'transport.modalTitle.create': 'Add transport',
|
'transport.modalTitle.create': 'Közlekedés hozzáadása',
|
||||||
'transport.modalTitle.edit': 'Edit transport',
|
'transport.modalTitle.edit': 'Közlekedés szerkesztése',
|
||||||
'transport.title': 'Közlekedés',
|
'transport.title': 'Közlekedés',
|
||||||
'transport.addManual': 'Kézi közlekedés',
|
'transport.addManual': 'Kézi közlekedés',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'common.none': 'Tidak ada',
|
'common.none': 'Tidak ada',
|
||||||
'common.date': 'Tanggal',
|
'common.date': 'Tanggal',
|
||||||
'common.rename': 'Ganti nama',
|
'common.rename': 'Ganti nama',
|
||||||
|
'common.discardChanges': 'Buang perubahan',
|
||||||
|
'common.discard': 'Buang',
|
||||||
'common.name': 'Nama',
|
'common.name': 'Nama',
|
||||||
'common.email': 'Email',
|
'common.email': 'Email',
|
||||||
'common.password': 'Kata sandi',
|
'common.password': 'Kata sandi',
|
||||||
@@ -1279,6 +1281,8 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'files.title': 'File',
|
'files.title': 'File',
|
||||||
'files.pageTitle': 'File & Dokumen',
|
'files.pageTitle': 'File & Dokumen',
|
||||||
'files.subtitle': '{count} file untuk {trip}',
|
'files.subtitle': '{count} file untuk {trip}',
|
||||||
|
'files.download': 'Unduh',
|
||||||
|
'files.openError': 'Tidak dapat membuka file',
|
||||||
'files.downloadPdf': 'Unduh PDF',
|
'files.downloadPdf': 'Unduh PDF',
|
||||||
'files.count': '{count} file',
|
'files.count': '{count} file',
|
||||||
'files.countSingular': '1 berkas',
|
'files.countSingular': '1 berkas',
|
||||||
@@ -1680,6 +1684,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'memories.immichAutoUpload': 'Salin foto journey ke Immich saat diunggah',
|
'memories.immichAutoUpload': 'Salin foto journey ke Immich saat diunggah',
|
||||||
'memories.providerUrlHintSynology': 'Sertakan path aplikasi Photos di URL, mis. https://nas:5001/photo',
|
'memories.providerUrlHintSynology': 'Sertakan path aplikasi Photos di URL, mis. https://nas:5001/photo',
|
||||||
'memories.testConnection': 'Uji koneksi',
|
'memories.testConnection': 'Uji koneksi',
|
||||||
|
'memories.testShort': 'Uji',
|
||||||
'memories.testFirst': 'Uji koneksi terlebih dahulu',
|
'memories.testFirst': 'Uji koneksi terlebih dahulu',
|
||||||
'memories.connected': 'Terhubung',
|
'memories.connected': 'Terhubung',
|
||||||
'memories.disconnected': 'Tidak terhubung',
|
'memories.disconnected': 'Tidak terhubung',
|
||||||
@@ -2381,9 +2386,9 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
// System notices — personal thank you
|
// System notices — personal thank you
|
||||||
'system_notice.v3_thankyou.title': 'Catatan pribadi dari saya',
|
'system_notice.v3_thankyou.title': 'Catatan pribadi dari saya',
|
||||||
'system_notice.v3_thankyou.body': 'Sebelum kamu lanjut — saya ingin berhenti sejenak.\n\nTREK dimulai sebagai proyek sampingan yang saya buat untuk perjalanan saya sendiri. Saya tidak pernah membayangkan ia akan tumbuh menjadi sesuatu yang dipercaya oleh 4.000 dari kalian untuk merencanakan petualangan. Setiap bintang, setiap issue, setiap permintaan fitur — saya membaca semuanya, dan itulah yang membuat saya terus bertahan di malam-malam larut antara pekerjaan penuh waktu dan kuliah.\n\nSaya ingin kalian tahu: TREK akan selalu open source, selalu self-hosted, selalu milik kalian. Tanpa pelacakan, tanpa langganan, tanpa syarat tersembunyi. Hanya sebuah alat yang dibuat oleh seseorang yang mencintai traveling sama seperti kalian.\n\nTerima kasih khusus untuk [jubnl](https://github.com/jubnl) — kamu telah menjadi kolaborator yang luar biasa. Begitu banyak hal yang membuat versi 3.0 hebat memiliki jejakmu. Terima kasih telah percaya pada proyek ini ketika masih kasar.\n\nDan untuk setiap dari kalian yang melaporkan bug, menerjemahkan string, membagikan TREK kepada teman, atau sekadar menggunakannya untuk merencanakan perjalanan — **terima kasih**. Kalianlah alasan semua ini ada.\n\nUntuk lebih banyak petualangan bersama.\n\n— Maurice\n\n---\n\n[Bergabunglah dengan komunitas di Discord](https://discord.gg/7Q6M6jDwzf)\n\nJika TREK membuat perjalananmu lebih baik, [secangkir kopi kecil](https://ko-fi.com/mauriceboe) selalu membantu menjaga lampu tetap menyala.',
|
'system_notice.v3_thankyou.body': 'Sebelum kamu lanjut — saya ingin berhenti sejenak.\n\nTREK dimulai sebagai proyek sampingan yang saya buat untuk perjalanan saya sendiri. Saya tidak pernah membayangkan ia akan tumbuh menjadi sesuatu yang dipercaya oleh 4.000 dari kalian untuk merencanakan petualangan. Setiap bintang, setiap issue, setiap permintaan fitur — saya membaca semuanya, dan itulah yang membuat saya terus bertahan di malam-malam larut antara pekerjaan penuh waktu dan kuliah.\n\nSaya ingin kalian tahu: TREK akan selalu open source, selalu self-hosted, selalu milik kalian. Tanpa pelacakan, tanpa langganan, tanpa syarat tersembunyi. Hanya sebuah alat yang dibuat oleh seseorang yang mencintai traveling sama seperti kalian.\n\nTerima kasih khusus untuk [jubnl](https://github.com/jubnl) — kamu telah menjadi kolaborator yang luar biasa. Begitu banyak hal yang membuat versi 3.0 hebat memiliki jejakmu. Terima kasih telah percaya pada proyek ini ketika masih kasar.\n\nDan untuk setiap dari kalian yang melaporkan bug, menerjemahkan string, membagikan TREK kepada teman, atau sekadar menggunakannya untuk merencanakan perjalanan — **terima kasih**. Kalianlah alasan semua ini ada.\n\nUntuk lebih banyak petualangan bersama.\n\n— Maurice\n\n---\n\n[Bergabunglah dengan komunitas di Discord](https://discord.gg/7Q6M6jDwzf)\n\nJika TREK membuat perjalananmu lebih baik, [secangkir kopi kecil](https://ko-fi.com/mauriceboe) selalu membantu menjaga lampu tetap menyala.',
|
||||||
'transport.addTransport': 'Add transport',
|
'transport.addTransport': 'Tambah transportasi',
|
||||||
'transport.modalTitle.create': 'Add transport',
|
'transport.modalTitle.create': 'Tambah transportasi',
|
||||||
'transport.modalTitle.edit': 'Edit transport',
|
'transport.modalTitle.edit': 'Edit transportasi',
|
||||||
'transport.title': 'Transportasi',
|
'transport.title': 'Transportasi',
|
||||||
'transport.addManual': 'Transportasi Manual',
|
'transport.addManual': 'Transportasi Manual',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'common.none': 'Nessuno',
|
'common.none': 'Nessuno',
|
||||||
'common.date': 'Data',
|
'common.date': 'Data',
|
||||||
'common.rename': 'Rinomina',
|
'common.rename': 'Rinomina',
|
||||||
|
'common.discardChanges': 'Scarta modifiche',
|
||||||
|
'common.discard': 'Scarta',
|
||||||
'common.name': 'Nome',
|
'common.name': 'Nome',
|
||||||
'common.email': 'Email',
|
'common.email': 'Email',
|
||||||
'common.password': 'Password',
|
'common.password': 'Password',
|
||||||
@@ -1219,6 +1221,8 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'files.title': 'File',
|
'files.title': 'File',
|
||||||
'files.pageTitle': 'File e documenti',
|
'files.pageTitle': 'File e documenti',
|
||||||
'files.subtitle': '{count} file per {trip}',
|
'files.subtitle': '{count} file per {trip}',
|
||||||
|
'files.download': 'Scarica',
|
||||||
|
'files.openError': 'Impossibile aprire il file',
|
||||||
'files.downloadPdf': 'Scarica PDF',
|
'files.downloadPdf': 'Scarica PDF',
|
||||||
'files.count': '{count} file',
|
'files.count': '{count} file',
|
||||||
'files.countSingular': '1 documento',
|
'files.countSingular': '1 documento',
|
||||||
@@ -1618,6 +1622,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'memories.immichAutoUpload': 'Rispecchia le foto del journey su Immich al caricamento',
|
'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.providerUrlHintSynology': 'Includi il percorso dell\'app Foto nell\'URL, es. https://nas:5001/photo',
|
||||||
'memories.testConnection': 'Test connessione',
|
'memories.testConnection': 'Test connessione',
|
||||||
|
'memories.testShort': 'Prova',
|
||||||
'memories.testFirst': 'Testa prima la connessione',
|
'memories.testFirst': 'Testa prima la connessione',
|
||||||
'memories.connected': 'Connesso',
|
'memories.connected': 'Connesso',
|
||||||
'memories.disconnected': 'Non connesso',
|
'memories.disconnected': 'Non connesso',
|
||||||
@@ -2340,9 +2345,9 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
// System notices — personal thank you
|
// System notices — personal thank you
|
||||||
'system_notice.v3_thankyou.title': 'Una nota personale da parte mia',
|
'system_notice.v3_thankyou.title': 'Una nota personale da parte mia',
|
||||||
'system_notice.v3_thankyou.body': 'Prima di andare avanti — voglio prendermi un momento.\n\nTREK è nato come un progetto secondario che ho costruito per i miei viaggi. Non avrei mai immaginato che sarebbe cresciuto fino a diventare qualcosa di cui 4.000 di voi si fidano per pianificare le proprie avventure. Ogni stella, ogni issue, ogni richiesta di funzionalità — le leggo tutte, e sono loro a tenermi in piedi nelle notti tarde tra un lavoro a tempo pieno e l\'università.\n\nVoglio che sappiate: TREK sarà sempre open source, sempre self-hosted, sempre vostro. Nessun tracciamento, nessun abbonamento, nessuna fregatura. Solo uno strumento creato da qualcuno che ama viaggiare tanto quanto voi.\n\nUn ringraziamento speciale a [jubnl](https://github.com/jubnl) — sei diventato un collaboratore incredibile. Molto di ciò che rende la 3.0 fantastica porta la tua impronta. Grazie per aver creduto in questo progetto quando era ancora acerbo.\n\nE a ognuno di voi che ha segnalato un bug, tradotto una stringa, condiviso TREK con un amico o semplicemente lo ha usato per pianificare un viaggio — **grazie**. Voi siete il motivo per cui tutto questo esiste.\n\nA molte altre avventure insieme.\n\n— Maurice\n\n---\n\n[Unisciti alla community su Discord](https://discord.gg/7Q6M6jDwzf)\n\nSe TREK rende i tuoi viaggi migliori, un [piccolo caffè](https://ko-fi.com/mauriceboe) aiuta sempre a tenere le luci accese.',
|
'system_notice.v3_thankyou.body': 'Prima di andare avanti — voglio prendermi un momento.\n\nTREK è nato come un progetto secondario che ho costruito per i miei viaggi. Non avrei mai immaginato che sarebbe cresciuto fino a diventare qualcosa di cui 4.000 di voi si fidano per pianificare le proprie avventure. Ogni stella, ogni issue, ogni richiesta di funzionalità — le leggo tutte, e sono loro a tenermi in piedi nelle notti tarde tra un lavoro a tempo pieno e l\'università.\n\nVoglio che sappiate: TREK sarà sempre open source, sempre self-hosted, sempre vostro. Nessun tracciamento, nessun abbonamento, nessuna fregatura. Solo uno strumento creato da qualcuno che ama viaggiare tanto quanto voi.\n\nUn ringraziamento speciale a [jubnl](https://github.com/jubnl) — sei diventato un collaboratore incredibile. Molto di ciò che rende la 3.0 fantastica porta la tua impronta. Grazie per aver creduto in questo progetto quando era ancora acerbo.\n\nE a ognuno di voi che ha segnalato un bug, tradotto una stringa, condiviso TREK con un amico o semplicemente lo ha usato per pianificare un viaggio — **grazie**. Voi siete il motivo per cui tutto questo esiste.\n\nA molte altre avventure insieme.\n\n— Maurice\n\n---\n\n[Unisciti alla community su Discord](https://discord.gg/7Q6M6jDwzf)\n\nSe TREK rende i tuoi viaggi migliori, un [piccolo caffè](https://ko-fi.com/mauriceboe) aiuta sempre a tenere le luci accese.',
|
||||||
'transport.addTransport': 'Add transport',
|
'transport.addTransport': 'Aggiungi trasporto',
|
||||||
'transport.modalTitle.create': 'Add transport',
|
'transport.modalTitle.create': 'Aggiungi trasporto',
|
||||||
'transport.modalTitle.edit': 'Edit transport',
|
'transport.modalTitle.edit': 'Modifica trasporto',
|
||||||
'transport.title': 'Trasporti',
|
'transport.title': 'Trasporti',
|
||||||
'transport.addManual': 'Trasporto manuale',
|
'transport.addManual': 'Trasporto manuale',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ const nl: Record<string, string> = {
|
|||||||
'common.none': 'Geen',
|
'common.none': 'Geen',
|
||||||
'common.date': 'Datum',
|
'common.date': 'Datum',
|
||||||
'common.rename': 'Hernoemen',
|
'common.rename': 'Hernoemen',
|
||||||
|
'common.discardChanges': 'Wijzigingen verwerpen',
|
||||||
|
'common.discard': 'Verwerpen',
|
||||||
'common.name': 'Naam',
|
'common.name': 'Naam',
|
||||||
'common.email': 'E-mail',
|
'common.email': 'E-mail',
|
||||||
'common.password': 'Wachtwoord',
|
'common.password': 'Wachtwoord',
|
||||||
@@ -612,8 +614,8 @@ const nl: Record<string, string> = {
|
|||||||
'admin.collab.chat.subtitle': 'Realtime berichten voor reissamenwerking',
|
'admin.collab.chat.subtitle': 'Realtime berichten voor reissamenwerking',
|
||||||
'admin.collab.notes.title': 'Notities',
|
'admin.collab.notes.title': 'Notities',
|
||||||
'admin.collab.notes.subtitle': 'Gedeelde notities en documenten',
|
'admin.collab.notes.subtitle': 'Gedeelde notities en documenten',
|
||||||
'admin.collab.polls.title': 'Peilingen',
|
'admin.collab.polls.title': 'Polls',
|
||||||
'admin.collab.polls.subtitle': 'Groepspeilingen en stemmen',
|
'admin.collab.polls.subtitle': 'Groepspolls en stemmen',
|
||||||
'admin.collab.whatsnext.title': 'Wat nu',
|
'admin.collab.whatsnext.title': 'Wat nu',
|
||||||
'admin.collab.whatsnext.subtitle': 'Activiteitssuggesties en volgende stappen',
|
'admin.collab.whatsnext.subtitle': 'Activiteitssuggesties en volgende stappen',
|
||||||
'admin.tabs.config': 'Personalisatie',
|
'admin.tabs.config': 'Personalisatie',
|
||||||
@@ -1218,6 +1220,8 @@ const nl: Record<string, string> = {
|
|||||||
'files.title': 'Bestanden',
|
'files.title': 'Bestanden',
|
||||||
'files.pageTitle': 'Bestanden en documenten',
|
'files.pageTitle': 'Bestanden en documenten',
|
||||||
'files.subtitle': '{count} bestanden voor {trip}',
|
'files.subtitle': '{count} bestanden voor {trip}',
|
||||||
|
'files.download': 'Downloaden',
|
||||||
|
'files.openError': 'Bestand kon niet worden geopend',
|
||||||
'files.downloadPdf': 'PDF downloaden',
|
'files.downloadPdf': 'PDF downloaden',
|
||||||
'files.count': '{count} bestanden',
|
'files.count': '{count} bestanden',
|
||||||
'files.countSingular': '1 bestand',
|
'files.countSingular': '1 bestand',
|
||||||
@@ -1617,6 +1621,7 @@ const nl: Record<string, string> = {
|
|||||||
'memories.immichAutoUpload': 'Journey-foto\'s bij upload ook naar Immich spiegelen',
|
'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.providerUrlHintSynology': 'Voeg het pad van de Photos-app toe aan de URL, bijv. https://nas:5001/photo',
|
||||||
'memories.testConnection': 'Verbinding testen',
|
'memories.testConnection': 'Verbinding testen',
|
||||||
|
'memories.testShort': 'Testen',
|
||||||
'memories.testFirst': 'Test eerst de verbinding',
|
'memories.testFirst': 'Test eerst de verbinding',
|
||||||
'memories.connected': 'Verbonden',
|
'memories.connected': 'Verbonden',
|
||||||
'memories.disconnected': 'Niet verbonden',
|
'memories.disconnected': 'Niet verbonden',
|
||||||
@@ -1656,7 +1661,7 @@ const nl: Record<string, string> = {
|
|||||||
// Collab Addon
|
// Collab Addon
|
||||||
'collab.tabs.chat': 'Chat',
|
'collab.tabs.chat': 'Chat',
|
||||||
'collab.tabs.notes': 'Notities',
|
'collab.tabs.notes': 'Notities',
|
||||||
'collab.tabs.polls': 'Peilingen',
|
'collab.tabs.polls': 'Polls',
|
||||||
'collab.whatsNext.title': 'Wat komt er',
|
'collab.whatsNext.title': 'Wat komt er',
|
||||||
'collab.whatsNext.today': 'Vandaag',
|
'collab.whatsNext.today': 'Vandaag',
|
||||||
'collab.whatsNext.tomorrow': 'Morgen',
|
'collab.whatsNext.tomorrow': 'Morgen',
|
||||||
@@ -1702,7 +1707,7 @@ const nl: Record<string, string> = {
|
|||||||
'collab.notes.attachFiles': 'Bestanden bijvoegen',
|
'collab.notes.attachFiles': 'Bestanden bijvoegen',
|
||||||
'collab.notes.noCategoriesYet': 'Nog geen categorieën',
|
'collab.notes.noCategoriesYet': 'Nog geen categorieën',
|
||||||
'collab.notes.emptyDesc': 'Maak een notitie om te beginnen',
|
'collab.notes.emptyDesc': 'Maak een notitie om te beginnen',
|
||||||
'collab.polls.title': 'Peilingen',
|
'collab.polls.title': 'Polls',
|
||||||
'collab.polls.new': 'Nieuwe poll',
|
'collab.polls.new': 'Nieuwe poll',
|
||||||
'collab.polls.empty': 'Nog geen polls',
|
'collab.polls.empty': 'Nog geen polls',
|
||||||
'collab.polls.emptyHint': 'Stel de groep een vraag en stem samen',
|
'collab.polls.emptyHint': 'Stel de groep een vraag en stem samen',
|
||||||
@@ -2339,9 +2344,9 @@ const nl: Record<string, string> = {
|
|||||||
// System notices — personal thank you
|
// System notices — personal thank you
|
||||||
'system_notice.v3_thankyou.title': 'Een persoonlijk woord van mij',
|
'system_notice.v3_thankyou.title': 'Een persoonlijk woord van mij',
|
||||||
'system_notice.v3_thankyou.body': 'Voordat je verdergaat — ik wil even stilstaan.\n\nTREK begon als een zijproject dat ik bouwde voor mijn eigen reizen. Ik had nooit gedacht dat het zou uitgroeien tot iets waar 4.000 van jullie op vertrouwen om avonturen te plannen. Elke ster, elke issue, elk functieverzoek — ik lees ze allemaal, en ze houden me op de been tijdens de late avonden tussen een fulltime baan en de universiteit.\n\nIk wil dat jullie weten: TREK zal altijd open source zijn, altijd self-hosted, altijd van jullie. Geen tracking, geen abonnementen, geen addertjes. Gewoon een tool gebouwd door iemand die net zo veel van reizen houdt als jullie.\n\nSpeciale dank aan [jubnl](https://github.com/jubnl) — je bent een ongelooflijke medewerker geworden. Zo veel van wat 3.0 geweldig maakt draagt jouw vingerafdruk. Bedankt dat je in dit project geloofde toen het nog ruw was.\n\nEn aan ieder van jullie die een bug meldde, een string vertaalde, TREK deelde met een vriend of het simpelweg gebruikte om een reis te plannen — **bedankt**. Jullie zijn de reden dat dit bestaat.\n\nOp nog vele avonturen samen.\n\n— Maurice\n\n---\n\n[Sluit je aan bij de community op Discord](https://discord.gg/7Q6M6jDwzf)\n\nAls TREK je reizen beter maakt, houdt een [klein kopje koffie](https://ko-fi.com/mauriceboe) altijd de lichten aan.',
|
'system_notice.v3_thankyou.body': 'Voordat je verdergaat — ik wil even stilstaan.\n\nTREK begon als een zijproject dat ik bouwde voor mijn eigen reizen. Ik had nooit gedacht dat het zou uitgroeien tot iets waar 4.000 van jullie op vertrouwen om avonturen te plannen. Elke ster, elke issue, elk functieverzoek — ik lees ze allemaal, en ze houden me op de been tijdens de late avonden tussen een fulltime baan en de universiteit.\n\nIk wil dat jullie weten: TREK zal altijd open source zijn, altijd self-hosted, altijd van jullie. Geen tracking, geen abonnementen, geen addertjes. Gewoon een tool gebouwd door iemand die net zo veel van reizen houdt als jullie.\n\nSpeciale dank aan [jubnl](https://github.com/jubnl) — je bent een ongelooflijke medewerker geworden. Zo veel van wat 3.0 geweldig maakt draagt jouw vingerafdruk. Bedankt dat je in dit project geloofde toen het nog ruw was.\n\nEn aan ieder van jullie die een bug meldde, een string vertaalde, TREK deelde met een vriend of het simpelweg gebruikte om een reis te plannen — **bedankt**. Jullie zijn de reden dat dit bestaat.\n\nOp nog vele avonturen samen.\n\n— Maurice\n\n---\n\n[Sluit je aan bij de community op Discord](https://discord.gg/7Q6M6jDwzf)\n\nAls TREK je reizen beter maakt, houdt een [klein kopje koffie](https://ko-fi.com/mauriceboe) altijd de lichten aan.',
|
||||||
'transport.addTransport': 'Add transport',
|
'transport.addTransport': 'Vervoer toevoegen',
|
||||||
'transport.modalTitle.create': 'Add transport',
|
'transport.modalTitle.create': 'Vervoer toevoegen',
|
||||||
'transport.modalTitle.edit': 'Edit transport',
|
'transport.modalTitle.edit': 'Vervoer bewerken',
|
||||||
'transport.title': 'Transport',
|
'transport.title': 'Transport',
|
||||||
'transport.addManual': 'Handmatig transport',
|
'transport.addManual': 'Handmatig transport',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'common.none': 'Brak',
|
'common.none': 'Brak',
|
||||||
'common.date': 'Data',
|
'common.date': 'Data',
|
||||||
'common.rename': 'Zmień nazwę',
|
'common.rename': 'Zmień nazwę',
|
||||||
|
'common.discardChanges': 'Odrzuć zmiany',
|
||||||
|
'common.discard': 'Odrzuć',
|
||||||
'common.name': 'Nazwa',
|
'common.name': 'Nazwa',
|
||||||
'common.email': 'E-mail',
|
'common.email': 'E-mail',
|
||||||
'common.password': 'Hasło',
|
'common.password': 'Hasło',
|
||||||
@@ -1170,6 +1172,8 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'files.title': 'Pliki',
|
'files.title': 'Pliki',
|
||||||
'files.pageTitle': 'Pliki i dokumenty',
|
'files.pageTitle': 'Pliki i dokumenty',
|
||||||
'files.subtitle': '{count} plików dla {trip}',
|
'files.subtitle': '{count} plików dla {trip}',
|
||||||
|
'files.download': 'Pobierz',
|
||||||
|
'files.openError': 'Nie można otworzyć pliku',
|
||||||
'files.downloadPdf': 'Pobierz PDF',
|
'files.downloadPdf': 'Pobierz PDF',
|
||||||
'files.count': '{count} plików',
|
'files.count': '{count} plików',
|
||||||
'files.countSingular': '1 plik',
|
'files.countSingular': '1 plik',
|
||||||
@@ -1569,6 +1573,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'memories.immichAutoUpload': 'Przy przesyłaniu kopiuj zdjęcia journey także do Immich',
|
'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.providerUrlHintSynology': 'Uwzględnij ścieżkę aplikacji Photos w URL, np. https://nas:5001/photo',
|
||||||
'memories.testConnection': 'Test',
|
'memories.testConnection': 'Test',
|
||||||
|
'memories.testShort': 'Test',
|
||||||
'memories.connected': 'Połączono',
|
'memories.connected': 'Połączono',
|
||||||
'memories.disconnected': 'Nie połączono',
|
'memories.disconnected': 'Nie połączono',
|
||||||
'memories.connectionSuccess': 'Połączono z Immich',
|
'memories.connectionSuccess': 'Połączono z Immich',
|
||||||
@@ -2332,9 +2337,9 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
// System notices — personal thank you
|
// System notices — personal thank you
|
||||||
'system_notice.v3_thankyou.title': 'Osobiste słowo ode mnie',
|
'system_notice.v3_thankyou.title': 'Osobiste słowo ode mnie',
|
||||||
'system_notice.v3_thankyou.body': 'Zanim pójdziesz dalej — chcę się na chwilę zatrzymać.\n\nTREK zaczął się jako poboczny projekt, który zbudowałem na własne podróże. Nigdy nie wyobrażałem sobie, że wyrośnie na coś, czemu 4000 z was ufa przy planowaniu swoich przygód. Każda gwiazdka, każdy issue, każda prośba o funkcję — czytam je wszystkie i to one trzymają mnie na nogach podczas późnych nocy między pracą na pełny etat a uczelnią.\n\nChcę, żebyście wiedzieli: TREK zawsze będzie open source, zawsze self-hosted, zawsze wasz. Bez śledzenia, bez subskrypcji, bez haczyków. Po prostu narzędzie zbudowane przez kogoś, kto kocha podróżowanie tak samo jak wy.\n\nSzczególne podziękowania dla [jubnl](https://github.com/jubnl) — stałeś się niesamowitym współpracownikiem. Tak wiele z tego, co czyni wersję 3.0 wspaniałą, nosi twój ślad. Dziękuję, że uwierzyłeś w ten projekt, gdy był jeszcze surowy.\n\nI każdemu z was, kto zgłosił błąd, przetłumaczył tekst, podzielił się TREK z przyjacielem lub po prostu użył go do zaplanowania podróży — **dziękuję**. To wy jesteście powodem, dla którego to istnieje.\n\nZa wiele kolejnych wspólnych przygód.\n\n— Maurice\n\n---\n\n[Dołącz do społeczności na Discordzie](https://discord.gg/7Q6M6jDwzf)\n\nJeśli TREK sprawia, że Twoje podróże są lepsze, [mała kawa](https://ko-fi.com/mauriceboe) zawsze pomaga utrzymać światła włączone.',
|
'system_notice.v3_thankyou.body': 'Zanim pójdziesz dalej — chcę się na chwilę zatrzymać.\n\nTREK zaczął się jako poboczny projekt, który zbudowałem na własne podróże. Nigdy nie wyobrażałem sobie, że wyrośnie na coś, czemu 4000 z was ufa przy planowaniu swoich przygód. Każda gwiazdka, każdy issue, każda prośba o funkcję — czytam je wszystkie i to one trzymają mnie na nogach podczas późnych nocy między pracą na pełny etat a uczelnią.\n\nChcę, żebyście wiedzieli: TREK zawsze będzie open source, zawsze self-hosted, zawsze wasz. Bez śledzenia, bez subskrypcji, bez haczyków. Po prostu narzędzie zbudowane przez kogoś, kto kocha podróżowanie tak samo jak wy.\n\nSzczególne podziękowania dla [jubnl](https://github.com/jubnl) — stałeś się niesamowitym współpracownikiem. Tak wiele z tego, co czyni wersję 3.0 wspaniałą, nosi twój ślad. Dziękuję, że uwierzyłeś w ten projekt, gdy był jeszcze surowy.\n\nI każdemu z was, kto zgłosił błąd, przetłumaczył tekst, podzielił się TREK z przyjacielem lub po prostu użył go do zaplanowania podróży — **dziękuję**. To wy jesteście powodem, dla którego to istnieje.\n\nZa wiele kolejnych wspólnych przygód.\n\n— Maurice\n\n---\n\n[Dołącz do społeczności na Discordzie](https://discord.gg/7Q6M6jDwzf)\n\nJeśli TREK sprawia, że Twoje podróże są lepsze, [mała kawa](https://ko-fi.com/mauriceboe) zawsze pomaga utrzymać światła włączone.',
|
||||||
'transport.addTransport': 'Add transport',
|
'transport.addTransport': 'Dodaj transport',
|
||||||
'transport.modalTitle.create': 'Add transport',
|
'transport.modalTitle.create': 'Dodaj transport',
|
||||||
'transport.modalTitle.edit': 'Edit transport',
|
'transport.modalTitle.edit': 'Edytuj transport',
|
||||||
'transport.title': 'Transport',
|
'transport.title': 'Transport',
|
||||||
'transport.addManual': 'Ręczny transport',
|
'transport.addManual': 'Ręczny transport',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ const ru: Record<string, string> = {
|
|||||||
'common.none': 'Нет',
|
'common.none': 'Нет',
|
||||||
'common.date': 'Дата',
|
'common.date': 'Дата',
|
||||||
'common.rename': 'Переименовать',
|
'common.rename': 'Переименовать',
|
||||||
|
'common.discardChanges': 'Отменить изменения',
|
||||||
|
'common.discard': 'Отменить',
|
||||||
'common.name': 'Имя',
|
'common.name': 'Имя',
|
||||||
'common.email': 'Эл. почта',
|
'common.email': 'Эл. почта',
|
||||||
'common.password': 'Пароль',
|
'common.password': 'Пароль',
|
||||||
@@ -1218,6 +1220,8 @@ const ru: Record<string, string> = {
|
|||||||
'files.title': 'Файлы',
|
'files.title': 'Файлы',
|
||||||
'files.pageTitle': 'Файлы и документы',
|
'files.pageTitle': 'Файлы и документы',
|
||||||
'files.subtitle': '{count} файлов для {trip}',
|
'files.subtitle': '{count} файлов для {trip}',
|
||||||
|
'files.download': 'Скачать',
|
||||||
|
'files.openError': 'Не удалось открыть файл',
|
||||||
'files.downloadPdf': 'Скачать PDF',
|
'files.downloadPdf': 'Скачать PDF',
|
||||||
'files.count': '{count} файлов',
|
'files.count': '{count} файлов',
|
||||||
'files.countSingular': '1 файл',
|
'files.countSingular': '1 файл',
|
||||||
@@ -1617,6 +1621,7 @@ const ru: Record<string, string> = {
|
|||||||
'memories.immichAutoUpload': 'Дублировать фото journey в Immich при загрузке',
|
'memories.immichAutoUpload': 'Дублировать фото journey в Immich при загрузке',
|
||||||
'memories.providerUrlHintSynology': 'Включите путь приложения Photos в URL, например https://nas:5001/photo',
|
'memories.providerUrlHintSynology': 'Включите путь приложения Photos в URL, например https://nas:5001/photo',
|
||||||
'memories.testConnection': 'Проверить подключение',
|
'memories.testConnection': 'Проверить подключение',
|
||||||
|
'memories.testShort': 'Проверить',
|
||||||
'memories.testFirst': 'Сначала проверьте подключение',
|
'memories.testFirst': 'Сначала проверьте подключение',
|
||||||
'memories.connected': 'Подключено',
|
'memories.connected': 'Подключено',
|
||||||
'memories.disconnected': 'Не подключено',
|
'memories.disconnected': 'Не подключено',
|
||||||
@@ -2339,9 +2344,9 @@ const ru: Record<string, string> = {
|
|||||||
// System notices — personal thank you
|
// System notices — personal thank you
|
||||||
'system_notice.v3_thankyou.title': 'Личное слово от меня',
|
'system_notice.v3_thankyou.title': 'Личное слово от меня',
|
||||||
'system_notice.v3_thankyou.body': 'Прежде чем продолжить — хочу остановиться на мгновение.\n\nTREK начинался как сторонний проект, который я создал для собственных поездок. Я никогда не думал, что он вырастет во что-то, чему 4 000 из вас доверяют планирование своих приключений. Каждая звёздочка, каждый issue, каждый запрос на фичу — я читаю их все, и именно они поддерживают меня в поздние ночи между основной работой и университетом.\n\nХочу, чтобы вы знали: TREK всегда будет open source, всегда self-hosted, всегда вашим. Никакого отслеживания, никаких подписок, никаких подвохов. Просто инструмент, созданный человеком, который любит путешествовать так же, как и вы.\n\nОсобая благодарность [jubnl](https://github.com/jubnl) — ты стал невероятным соратником. Многое из того, что делает версию 3.0 великолепной, несёт твой отпечаток. Спасибо, что поверил в этот проект, когда он был ещё сырым.\n\nИ каждому из вас, кто сообщил об ошибке, перевёл строку, поделился TREK с другом или просто использовал его для планирования поездки — **спасибо**. Вы — причина, по которой всё это существует.\n\nЗа множество новых приключений вместе.\n\n— Maurice\n\n---\n\n[Присоединяйся к сообществу в Discord](https://discord.gg/7Q6M6jDwzf)\n\nЕсли TREK делает твои путешествия лучше, [маленький кофе](https://ko-fi.com/mauriceboe) всегда помогает держать свет включённым.',
|
'system_notice.v3_thankyou.body': 'Прежде чем продолжить — хочу остановиться на мгновение.\n\nTREK начинался как сторонний проект, который я создал для собственных поездок. Я никогда не думал, что он вырастет во что-то, чему 4 000 из вас доверяют планирование своих приключений. Каждая звёздочка, каждый issue, каждый запрос на фичу — я читаю их все, и именно они поддерживают меня в поздние ночи между основной работой и университетом.\n\nХочу, чтобы вы знали: TREK всегда будет open source, всегда self-hosted, всегда вашим. Никакого отслеживания, никаких подписок, никаких подвохов. Просто инструмент, созданный человеком, который любит путешествовать так же, как и вы.\n\nОсобая благодарность [jubnl](https://github.com/jubnl) — ты стал невероятным соратником. Многое из того, что делает версию 3.0 великолепной, несёт твой отпечаток. Спасибо, что поверил в этот проект, когда он был ещё сырым.\n\nИ каждому из вас, кто сообщил об ошибке, перевёл строку, поделился TREK с другом или просто использовал его для планирования поездки — **спасибо**. Вы — причина, по которой всё это существует.\n\nЗа множество новых приключений вместе.\n\n— Maurice\n\n---\n\n[Присоединяйся к сообществу в Discord](https://discord.gg/7Q6M6jDwzf)\n\nЕсли TREK делает твои путешествия лучше, [маленький кофе](https://ko-fi.com/mauriceboe) всегда помогает держать свет включённым.',
|
||||||
'transport.addTransport': 'Add transport',
|
'transport.addTransport': 'Добавить транспорт',
|
||||||
'transport.modalTitle.create': 'Add transport',
|
'transport.modalTitle.create': 'Добавить транспорт',
|
||||||
'transport.modalTitle.edit': 'Edit transport',
|
'transport.modalTitle.edit': 'Изменить транспорт',
|
||||||
'transport.title': 'Транспорт',
|
'transport.title': 'Транспорт',
|
||||||
'transport.addManual': 'Ручной транспорт',
|
'transport.addManual': 'Ручной транспорт',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ const zh: Record<string, string> = {
|
|||||||
'common.none': '无',
|
'common.none': '无',
|
||||||
'common.date': '日期',
|
'common.date': '日期',
|
||||||
'common.rename': '重命名',
|
'common.rename': '重命名',
|
||||||
|
'common.discardChanges': '放弃更改',
|
||||||
|
'common.discard': '放弃',
|
||||||
'common.name': '名称',
|
'common.name': '名称',
|
||||||
'common.email': '邮箱',
|
'common.email': '邮箱',
|
||||||
'common.password': '密码',
|
'common.password': '密码',
|
||||||
@@ -1218,6 +1220,8 @@ const zh: Record<string, string> = {
|
|||||||
'files.title': '文件',
|
'files.title': '文件',
|
||||||
'files.pageTitle': '文件与文档',
|
'files.pageTitle': '文件与文档',
|
||||||
'files.subtitle': '{trip} 的 {count} 个文件',
|
'files.subtitle': '{trip} 的 {count} 个文件',
|
||||||
|
'files.download': '下载',
|
||||||
|
'files.openError': '无法打开文件',
|
||||||
'files.downloadPdf': '下载 PDF',
|
'files.downloadPdf': '下载 PDF',
|
||||||
'files.count': '{count} 个文件',
|
'files.count': '{count} 个文件',
|
||||||
'files.countSingular': '1 个文件',
|
'files.countSingular': '1 个文件',
|
||||||
@@ -1617,6 +1621,7 @@ const zh: Record<string, string> = {
|
|||||||
'memories.immichAutoUpload': '上传 Journey 照片时同步到 Immich',
|
'memories.immichAutoUpload': '上传 Journey 照片时同步到 Immich',
|
||||||
'memories.providerUrlHintSynology': '在 URL 中包含照片应用路径,例如 https://nas:5001/photo',
|
'memories.providerUrlHintSynology': '在 URL 中包含照片应用路径,例如 https://nas:5001/photo',
|
||||||
'memories.testConnection': '测试连接',
|
'memories.testConnection': '测试连接',
|
||||||
|
'memories.testShort': '测试',
|
||||||
'memories.testFirst': '请先测试连接',
|
'memories.testFirst': '请先测试连接',
|
||||||
'memories.connected': '已连接',
|
'memories.connected': '已连接',
|
||||||
'memories.disconnected': '未连接',
|
'memories.disconnected': '未连接',
|
||||||
@@ -2339,9 +2344,9 @@ const zh: Record<string, string> = {
|
|||||||
// System notices — personal thank you
|
// System notices — personal thank you
|
||||||
'system_notice.v3_thankyou.title': '来自我的一封私人信',
|
'system_notice.v3_thankyou.title': '来自我的一封私人信',
|
||||||
'system_notice.v3_thankyou.body': '在你继续之前——我想停下来说几句。\n\nTREK 最初只是我为自己的旅行而做的一个业余项目。我从未想过它会成长为 4,000 人信赖的冒险规划工具。每一颗星标、每一个 issue、每一个功能请求——我都会读,它们在全职工作和大学学业之间的深夜里支撑着我继续前行。\n\n我想让你们知道:TREK 将永远开源,永远可自托管,永远属于你们。没有追踪,没有订阅,没有任何附加条件。只是一个热爱旅行的人为同样热爱旅行的你们打造的工具。\n\n特别感谢 [jubnl](https://github.com/jubnl)——你已经成为一位不可思议的合作者。3.0 版本中许多精彩之处都留下了你的印记。感谢你在这个项目还很粗糙的时候就选择了相信它。\n\n也感谢你们每一位——报告了 bug、翻译了文本、向朋友分享了 TREK,或者只是用它规划了一次旅行——**谢谢你们**。你们是这一切存在的原因。\n\n愿我们一起踏上更多的冒险旅程。\n\n— Maurice\n\n---\n\n[加入 Discord 社区](https://discord.gg/7Q6M6jDwzf)\n\n如果 TREK 让你的旅行更美好,一杯[小小的咖啡](https://ko-fi.com/mauriceboe)能让这盏灯一直亮着。',
|
'system_notice.v3_thankyou.body': '在你继续之前——我想停下来说几句。\n\nTREK 最初只是我为自己的旅行而做的一个业余项目。我从未想过它会成长为 4,000 人信赖的冒险规划工具。每一颗星标、每一个 issue、每一个功能请求——我都会读,它们在全职工作和大学学业之间的深夜里支撑着我继续前行。\n\n我想让你们知道:TREK 将永远开源,永远可自托管,永远属于你们。没有追踪,没有订阅,没有任何附加条件。只是一个热爱旅行的人为同样热爱旅行的你们打造的工具。\n\n特别感谢 [jubnl](https://github.com/jubnl)——你已经成为一位不可思议的合作者。3.0 版本中许多精彩之处都留下了你的印记。感谢你在这个项目还很粗糙的时候就选择了相信它。\n\n也感谢你们每一位——报告了 bug、翻译了文本、向朋友分享了 TREK,或者只是用它规划了一次旅行——**谢谢你们**。你们是这一切存在的原因。\n\n愿我们一起踏上更多的冒险旅程。\n\n— Maurice\n\n---\n\n[加入 Discord 社区](https://discord.gg/7Q6M6jDwzf)\n\n如果 TREK 让你的旅行更美好,一杯[小小的咖啡](https://ko-fi.com/mauriceboe)能让这盏灯一直亮着。',
|
||||||
'transport.addTransport': 'Add transport',
|
'transport.addTransport': '添加交通',
|
||||||
'transport.modalTitle.create': 'Add transport',
|
'transport.modalTitle.create': '添加交通',
|
||||||
'transport.modalTitle.edit': 'Edit transport',
|
'transport.modalTitle.edit': '编辑交通',
|
||||||
'transport.title': '交通',
|
'transport.title': '交通',
|
||||||
'transport.addManual': '手动添加交通',
|
'transport.addManual': '手动添加交通',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ const zhTw: Record<string, string> = {
|
|||||||
'common.none': '無',
|
'common.none': '無',
|
||||||
'common.date': '日期',
|
'common.date': '日期',
|
||||||
'common.rename': '重新命名',
|
'common.rename': '重新命名',
|
||||||
|
'common.discardChanges': '捨棄變更',
|
||||||
|
'common.discard': '捨棄',
|
||||||
'common.name': '名稱',
|
'common.name': '名稱',
|
||||||
'common.email': '郵箱',
|
'common.email': '郵箱',
|
||||||
'common.password': '密碼',
|
'common.password': '密碼',
|
||||||
@@ -1278,6 +1280,8 @@ const zhTw: Record<string, string> = {
|
|||||||
'files.title': '檔案',
|
'files.title': '檔案',
|
||||||
'files.pageTitle': '檔案與文件',
|
'files.pageTitle': '檔案與文件',
|
||||||
'files.subtitle': '{trip} 的 {count} 個檔案',
|
'files.subtitle': '{trip} 的 {count} 個檔案',
|
||||||
|
'files.download': '下載',
|
||||||
|
'files.openError': '無法開啟檔案',
|
||||||
'files.downloadPdf': '下載 PDF',
|
'files.downloadPdf': '下載 PDF',
|
||||||
'files.count': '{count} 個檔案',
|
'files.count': '{count} 個檔案',
|
||||||
'files.countSingular': '1 個檔案',
|
'files.countSingular': '1 個檔案',
|
||||||
@@ -1677,6 +1681,7 @@ const zhTw: Record<string, string> = {
|
|||||||
'memories.immichAutoUpload': '上傳 Journey 照片時同步到 Immich',
|
'memories.immichAutoUpload': '上傳 Journey 照片時同步到 Immich',
|
||||||
'memories.providerUrlHintSynology': '在網址中包含照片應用程式路徑,例如 https://nas:5001/photo',
|
'memories.providerUrlHintSynology': '在網址中包含照片應用程式路徑,例如 https://nas:5001/photo',
|
||||||
'memories.testConnection': '測試連線',
|
'memories.testConnection': '測試連線',
|
||||||
|
'memories.testShort': '測試',
|
||||||
'memories.testFirst': '請先測試連線',
|
'memories.testFirst': '請先測試連線',
|
||||||
'memories.connected': '已連線',
|
'memories.connected': '已連線',
|
||||||
'memories.disconnected': '未連線',
|
'memories.disconnected': '未連線',
|
||||||
@@ -2340,9 +2345,9 @@ const zhTw: Record<string, string> = {
|
|||||||
// System notices — personal thank you
|
// System notices — personal thank you
|
||||||
'system_notice.v3_thankyou.title': '來自我的一封私人信',
|
'system_notice.v3_thankyou.title': '來自我的一封私人信',
|
||||||
'system_notice.v3_thankyou.body': '在你繼續之前——我想停下來說幾句。\n\nTREK 最初只是我為自己的旅行而做的一個業餘專案。我從未想過它會成長為 4,000 人信賴的冒險規劃工具。每一顆星標、每一個 issue、每一個功能請求——我都會讀,它們在全職工作和大學學業之間的深夜裡支撐著我繼續前行。\n\n我想讓你們知道:TREK 將永遠開源,永遠可自託管,永遠屬於你們。沒有追蹤,沒有訂閱,沒有任何附加條件。只是一個熱愛旅行的人為同樣熱愛旅行的你們打造的工具。\n\n特別感謝 [jubnl](https://github.com/jubnl)——你已經成為一位不可思議的合作者。3.0 版本中許多精彩之處都留下了你的印記。感謝你在這個專案還很粗糙的時候就選擇了相信它。\n\n也感謝你們每一位——回報了 bug、翻譯了文字、向朋友分享了 TREK,或者只是用它規劃了一次旅行——**謝謝你們**。你們是這一切存在的原因。\n\n願我們一起踏上更多的冒險旅程。\n\n— Maurice\n\n---\n\n[加入 Discord 社群](https://discord.gg/7Q6M6jDwzf)\n\n如果 TREK 讓你的旅行更美好,一杯[小小的咖啡](https://ko-fi.com/mauriceboe)能讓這盞燈一直亮著。',
|
'system_notice.v3_thankyou.body': '在你繼續之前——我想停下來說幾句。\n\nTREK 最初只是我為自己的旅行而做的一個業餘專案。我從未想過它會成長為 4,000 人信賴的冒險規劃工具。每一顆星標、每一個 issue、每一個功能請求——我都會讀,它們在全職工作和大學學業之間的深夜裡支撐著我繼續前行。\n\n我想讓你們知道:TREK 將永遠開源,永遠可自託管,永遠屬於你們。沒有追蹤,沒有訂閱,沒有任何附加條件。只是一個熱愛旅行的人為同樣熱愛旅行的你們打造的工具。\n\n特別感謝 [jubnl](https://github.com/jubnl)——你已經成為一位不可思議的合作者。3.0 版本中許多精彩之處都留下了你的印記。感謝你在這個專案還很粗糙的時候就選擇了相信它。\n\n也感謝你們每一位——回報了 bug、翻譯了文字、向朋友分享了 TREK,或者只是用它規劃了一次旅行——**謝謝你們**。你們是這一切存在的原因。\n\n願我們一起踏上更多的冒險旅程。\n\n— Maurice\n\n---\n\n[加入 Discord 社群](https://discord.gg/7Q6M6jDwzf)\n\n如果 TREK 讓你的旅行更美好,一杯[小小的咖啡](https://ko-fi.com/mauriceboe)能讓這盞燈一直亮著。',
|
||||||
'transport.addTransport': 'Add transport',
|
'transport.addTransport': '新增交通',
|
||||||
'transport.modalTitle.create': 'Add transport',
|
'transport.modalTitle.create': '新增交通',
|
||||||
'transport.modalTitle.edit': 'Edit transport',
|
'transport.modalTitle.edit': '編輯交通',
|
||||||
'transport.title': '交通',
|
'transport.title': '交通',
|
||||||
'transport.addManual': '手動新增交通',
|
'transport.addManual': '手動新增交通',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1240,6 +1240,15 @@ 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 {
|
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 { 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 bg = (o) => dark ? `rgba(255,255,255,${o})` : `rgba(0,0,0,${o})`
|
||||||
const tp = dark ? '#f1f5f9' : '#0f172a'
|
const tp = dark ? '#f1f5f9' : '#0f172a'
|
||||||
const tm = dark ? '#94a3b8' : '#64748b'
|
const tm = dark ? '#94a3b8' : '#64748b'
|
||||||
@@ -1290,7 +1299,7 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
|
|||||||
// Bucket list content
|
// Bucket list content
|
||||||
const bucketContent = (
|
const bucketContent = (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-stretch" style={{ overflowX: 'auto', padding: '0 8px' }}>
|
<div className="flex items-stretch" style={{ overflowX: 'auto', padding: '0 8px', maxWidth: statsWidth, width: '100%' }}>
|
||||||
{bucketList.map(item => (
|
{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 }}>
|
<div key={item.id} className="group flex flex-col items-center justify-center shrink-0" style={{ padding: '8px 14px', position: 'relative', minWidth: 80 }}>
|
||||||
{(() => {
|
{(() => {
|
||||||
@@ -1400,7 +1409,7 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
|
|||||||
{/* Both tabs always rendered so the wider one sets the panel width */}
|
{/* Both tabs always rendered so the wider one sets the panel width */}
|
||||||
<div style={{ display: 'grid' }}>
|
<div style={{ display: 'grid' }}>
|
||||||
<div style={bucketTab === 'bucket' ? { visibility: 'hidden' as const, gridArea: '1/1' } : { gridArea: '1/1' }}>
|
<div style={bucketTab === 'bucket' ? { visibility: 'hidden' as const, gridArea: '1/1' } : { gridArea: '1/1' }}>
|
||||||
<div className="flex items-stretch justify-center">
|
<div ref={statsContentRef} className="flex items-stretch justify-center">
|
||||||
|
|
||||||
{/* ═══ SECTION 1: Numbers ═══ */}
|
{/* ═══ SECTION 1: Numbers ═══ */}
|
||||||
{/* Countries hero */}
|
{/* Countries hero */}
|
||||||
|
|||||||
@@ -401,6 +401,10 @@ describe('DashboardPage', () => {
|
|||||||
const copyButtons = screen.getAllByRole('button', { name: /copy/i });
|
const copyButtons = screen.getAllByRole('button', { name: /copy/i });
|
||||||
await user.click(copyButtons[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(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getAllByText('Paris Adventure (Copy)')[0]).toBeInTheDocument();
|
expect(screen.getAllByText('Paris Adventure (Copy)')[0]).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -766,6 +770,10 @@ describe('DashboardPage', () => {
|
|||||||
expect(copyButtons.length).toBeGreaterThan(0);
|
expect(copyButtons.length).toBeGreaterThan(0);
|
||||||
await user.click(copyButtons[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(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getAllByText('Paris Adventure (Copy)').length).toBeGreaterThan(0);
|
expect(screen.getAllByText('Paris Adventure (Copy)').length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import CurrencyWidget from '../components/Dashboard/CurrencyWidget'
|
|||||||
import TimezoneWidget from '../components/Dashboard/TimezoneWidget'
|
import TimezoneWidget from '../components/Dashboard/TimezoneWidget'
|
||||||
import TripFormModal from '../components/Trips/TripFormModal'
|
import TripFormModal from '../components/Trips/TripFormModal'
|
||||||
import ConfirmDialog from '../components/shared/ConfirmDialog'
|
import ConfirmDialog from '../components/shared/ConfirmDialog'
|
||||||
|
import CopyTripDialog from '../components/shared/CopyTripDialog'
|
||||||
import { useToast } from '../components/shared/Toast'
|
import { useToast } from '../components/shared/Toast'
|
||||||
import { useCountUp } from '../hooks/useCountUp'
|
import { useCountUp } from '../hooks/useCountUp'
|
||||||
import {
|
import {
|
||||||
@@ -699,6 +700,7 @@ export default function DashboardPage(): React.ReactElement {
|
|||||||
const [showWidgetSettings, setShowWidgetSettings] = useState<boolean | 'mobile' | 'mobile-currency' | 'mobile-timezone'>(false)
|
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 [viewMode, setViewMode] = useState<'grid' | 'list'>(() => (localStorage.getItem('trek_dashboard_view') as 'grid' | 'list') || 'grid')
|
||||||
const [deleteTrip, setDeleteTrip] = useState<DashboardTrip | null>(null)
|
const [deleteTrip, setDeleteTrip] = useState<DashboardTrip | null>(null)
|
||||||
|
const [copyTrip, setCopyTrip] = useState<DashboardTrip | null>(null)
|
||||||
|
|
||||||
const toggleViewMode = () => {
|
const toggleViewMode = () => {
|
||||||
setViewMode(prev => {
|
setViewMode(prev => {
|
||||||
@@ -815,14 +817,18 @@ export default function DashboardPage(): React.ReactElement {
|
|||||||
setArchivedTrips(prev => prev.map(update))
|
setArchivedTrips(prev => prev.map(update))
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCopy = async (trip: DashboardTrip) => {
|
const handleCopy = (trip: DashboardTrip) => setCopyTrip(trip)
|
||||||
|
|
||||||
|
const confirmCopy = async () => {
|
||||||
|
if (!copyTrip) return
|
||||||
try {
|
try {
|
||||||
const data = await tripsApi.copy(trip.id, { title: `${trip.title} (${t('dashboard.copySuffix')})` })
|
const data = await tripsApi.copy(copyTrip.id, { title: `${copyTrip.title} (${t('dashboard.copySuffix')})` })
|
||||||
setTrips(prev => sortTrips([data.trip, ...prev]))
|
setTrips(prev => sortTrips([data.trip, ...prev]))
|
||||||
toast.success(t('dashboard.toast.copied'))
|
toast.success(t('dashboard.toast.copied'))
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t('dashboard.toast.copyError'))
|
toast.error(t('dashboard.toast.copyError'))
|
||||||
}
|
}
|
||||||
|
setCopyTrip(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const today = new Date().toISOString().split('T')[0]
|
const today = new Date().toISOString().split('T')[0]
|
||||||
@@ -1205,6 +1211,13 @@ export default function DashboardPage(): React.ReactElement {
|
|||||||
message={t('dashboard.confirm.delete', { title: deleteTrip?.title || '' })}
|
message={t('dashboard.confirm.delete', { title: deleteTrip?.title || '' })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<CopyTripDialog
|
||||||
|
isOpen={!!copyTrip}
|
||||||
|
tripTitle={copyTrip?.title || ''}
|
||||||
|
onClose={() => setCopyTrip(null)}
|
||||||
|
onConfirm={confirmCopy}
|
||||||
|
/>
|
||||||
|
|
||||||
<style>{`
|
<style>{`
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
0%, 100% { opacity: 1 }
|
0%, 100% { opacity: 1 }
|
||||||
|
|||||||
@@ -177,6 +177,24 @@ const mockJourneyDetail = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
stats: { entries: 2, photos: 1, places: 2 },
|
stats: { entries: 2, photos: 1, places: 2 },
|
||||||
|
gallery: [
|
||||||
|
{
|
||||||
|
id: 100,
|
||||||
|
journey_id: 1,
|
||||||
|
photo_id: 100,
|
||||||
|
provider: 'local',
|
||||||
|
file_path: 'photos/test.jpg',
|
||||||
|
asset_id: null,
|
||||||
|
owner_id: null,
|
||||||
|
thumbnail_path: null,
|
||||||
|
caption: 'Colosseum',
|
||||||
|
sort_order: 0,
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
shared: 1,
|
||||||
|
created_at: now,
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── MSW Handlers ─────────────────────────────────────────────────────────────
|
// ── MSW Handlers ─────────────────────────────────────────────────────────────
|
||||||
@@ -1468,7 +1486,7 @@ describe('JourneyDetailPage', () => {
|
|||||||
|
|
||||||
// ── FE-PAGE-JOURNEYDETAIL-074 ──────────────────────────────────────────
|
// ── FE-PAGE-JOURNEYDETAIL-074 ──────────────────────────────────────────
|
||||||
describe('FE-PAGE-JOURNEYDETAIL-074: Delete share link removes it', () => {
|
describe('FE-PAGE-JOURNEYDETAIL-074: Delete share link removes it', () => {
|
||||||
it('clicking "Remove share link" calls DELETE and returns to create state', async () => {
|
it('clicking "Delete link" calls DELETE and returns to create state', async () => {
|
||||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||||
let deleteCalled = false;
|
let deleteCalled = false;
|
||||||
|
|
||||||
@@ -1493,10 +1511,10 @@ describe('JourneyDetailPage', () => {
|
|||||||
await openSettingsDialog(user);
|
await openSettingsDialog(user);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Remove share link')).toBeInTheDocument();
|
expect(screen.getByText('Delete link')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
await user.click(screen.getByText('Remove share link'));
|
await user.click(screen.getByText('Delete link'));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(deleteCalled).toBe(true);
|
expect(deleteCalled).toBe(true);
|
||||||
@@ -1724,13 +1742,14 @@ describe('JourneyDetailPage', () => {
|
|||||||
it('renders the empty gallery state when journey has no photos', async () => {
|
it('renders the empty gallery state when journey has no photos', async () => {
|
||||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||||
|
|
||||||
// Override with entries that have no photos
|
// Override with entries that have no photos and empty gallery
|
||||||
const emptyEntry = {
|
const emptyEntry = {
|
||||||
...mockJourneyDetail.entries[0],
|
...mockJourneyDetail.entries[0],
|
||||||
photos: [],
|
photos: [],
|
||||||
};
|
};
|
||||||
setupDefaultHandlers({
|
setupDefaultHandlers({
|
||||||
entries: [emptyEntry],
|
entries: [emptyEntry],
|
||||||
|
gallery: [],
|
||||||
stats: { entries: 1, photos: 0, places: 1 },
|
stats: { entries: 1, photos: 0, places: 1 },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1981,10 +2000,9 @@ describe('JourneyDetailPage', () => {
|
|||||||
expect(screen.getByText(/1 photos/i)).toBeInTheDocument();
|
expect(screen.getByText(/1 photos/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
// The entry date '2026-03-15' is shown as a formatted overlay on each gallery photo
|
// Gallery photos render in a grid; each photo has a group container
|
||||||
// The component uses toLocaleDateString which produces "Mar 15, 2026" in en-US
|
const photos = document.querySelectorAll('[class*="aspect-square"]');
|
||||||
const dateOverlay = document.querySelector('[class*="opacity-0"]');
|
expect(photos.length).toBeGreaterThanOrEqual(1);
|
||||||
expect(dateOverlay).toBeTruthy();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2022,6 +2040,11 @@ describe('JourneyDetailPage', () => {
|
|||||||
setupDefaultHandlers({
|
setupDefaultHandlers({
|
||||||
entries: [immichEntry, mockJourneyDetail.entries[1]],
|
entries: [immichEntry, mockJourneyDetail.entries[1]],
|
||||||
stats: { entries: 2, photos: 1, places: 2 },
|
stats: { entries: 2, photos: 1, places: 2 },
|
||||||
|
gallery: [{
|
||||||
|
id: 200, journey_id: 1, photo_id: 200, provider: 'immich', file_path: null,
|
||||||
|
asset_id: 'asset-123', owner_id: 1, thumbnail_path: null,
|
||||||
|
caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now,
|
||||||
|
}],
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<JourneyDetailPage />);
|
render(<JourneyDetailPage />);
|
||||||
@@ -2056,6 +2079,11 @@ describe('JourneyDetailPage', () => {
|
|||||||
setupDefaultHandlers({
|
setupDefaultHandlers({
|
||||||
entries: [synologyEntry, mockJourneyDetail.entries[1]],
|
entries: [synologyEntry, mockJourneyDetail.entries[1]],
|
||||||
stats: { entries: 2, photos: 1, places: 2 },
|
stats: { entries: 2, photos: 1, places: 2 },
|
||||||
|
gallery: [{
|
||||||
|
id: 201, journey_id: 1, photo_id: 201, provider: 'synology', file_path: null,
|
||||||
|
asset_id: 'syn-456', owner_id: 1, thumbnail_path: null,
|
||||||
|
caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now,
|
||||||
|
}],
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<JourneyDetailPage />);
|
render(<JourneyDetailPage />);
|
||||||
@@ -2905,7 +2933,7 @@ describe('JourneyDetailPage', () => {
|
|||||||
|
|
||||||
// The permission toggles show Timeline, Gallery, Map labels within the share section
|
// The permission toggles show Timeline, Gallery, Map labels within the share section
|
||||||
// These reuse the same i18n keys as the main tab bar
|
// These reuse the same i18n keys as the main tab bar
|
||||||
expect(screen.getByText('Remove share link')).toBeInTheDocument();
|
expect(screen.getByText('Delete link')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Copy')).toBeInTheDocument();
|
expect(screen.getByText('Copy')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -3265,25 +3293,14 @@ describe('JourneyDetailPage', () => {
|
|||||||
|
|
||||||
// ── FE-PAGE-JOURNEYDETAIL-141 ──────────────────────────────────────────
|
// ── FE-PAGE-JOURNEYDETAIL-141 ──────────────────────────────────────────
|
||||||
describe('FE-PAGE-JOURNEYDETAIL-141: Gallery upload triggers file input and calls API', () => {
|
describe('FE-PAGE-JOURNEYDETAIL-141: Gallery upload triggers file input and calls API', () => {
|
||||||
it('uploading files in gallery creates an entry and uploads photos', async () => {
|
it('uploading files in gallery calls gallery upload API', async () => {
|
||||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||||
let createCalled = false;
|
|
||||||
let uploadCalled = false;
|
let uploadCalled = false;
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
http.post('/api/journeys/1/entries', () => {
|
http.post('/api/journeys/1/gallery/photos', () => {
|
||||||
createCalled = true;
|
|
||||||
return HttpResponse.json({
|
|
||||||
id: 99, journey_id: 1, author_id: 1, type: 'entry',
|
|
||||||
entry_date: '2026-04-11', title: 'Gallery', story: null, location_name: null,
|
|
||||||
location_lat: null, location_lng: null, mood: null, weather: null,
|
|
||||||
tags: [], pros_cons: null, visibility: 'private', sort_order: 0,
|
|
||||||
entry_time: null, photos: [], created_at: now, updated_at: now,
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
http.post('/api/journeys/entries/99/photos', () => {
|
|
||||||
uploadCalled = true;
|
uploadCalled = true;
|
||||||
return HttpResponse.json([]);
|
return HttpResponse.json({ photos: [] });
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -3304,9 +3321,6 @@ describe('JourneyDetailPage', () => {
|
|||||||
const testFile = new File(['fake-content'], 'test-photo.jpg', { type: 'image/jpeg' });
|
const testFile = new File(['fake-content'], 'test-photo.jpg', { type: 'image/jpeg' });
|
||||||
await user.upload(fileInput, testFile);
|
await user.upload(fileInput, testFile);
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(createCalled).toBe(true);
|
|
||||||
});
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(uploadCalled).toBe(true);
|
expect(uploadCalled).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -3320,9 +3334,9 @@ describe('JourneyDetailPage', () => {
|
|||||||
let deleteCalled = false;
|
let deleteCalled = false;
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
http.delete('/api/journeys/photos/100', () => {
|
http.delete('/api/journeys/1/gallery/100', () => {
|
||||||
deleteCalled = true;
|
deleteCalled = true;
|
||||||
return HttpResponse.json({ success: true });
|
return new HttpResponse(null, { status: 204 });
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
|
import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
|
||||||
|
import { formatLocationName } from '../utils/formatters'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
import { useJourneyStore } from '../store/journeyStore'
|
import { useJourneyStore } from '../store/journeyStore'
|
||||||
@@ -8,6 +9,7 @@ import { journeyApi, authApi, addonsApi, mapsApi } from '../api/client'
|
|||||||
import { addListener, removeListener } from '../api/websocket'
|
import { addListener, removeListener } from '../api/websocket'
|
||||||
import Navbar from '../components/Layout/Navbar'
|
import Navbar from '../components/Layout/Navbar'
|
||||||
import JourneyMap from '../components/Journey/JourneyMapAuto'
|
import JourneyMap from '../components/Journey/JourneyMapAuto'
|
||||||
|
import { DAY_COLORS } from '../components/Journey/dayColors'
|
||||||
import type { JourneyMapAutoHandle as JourneyMapHandle } from '../components/Journey/JourneyMapAuto'
|
import type { JourneyMapAutoHandle as JourneyMapHandle } from '../components/Journey/JourneyMapAuto'
|
||||||
import JournalBody from '../components/Journey/JournalBody'
|
import JournalBody from '../components/Journey/JournalBody'
|
||||||
import MarkdownToolbar from '../components/Journey/MarkdownToolbar'
|
import MarkdownToolbar from '../components/Journey/MarkdownToolbar'
|
||||||
@@ -25,7 +27,7 @@ import {
|
|||||||
import MobileMapTimeline from '../components/Journey/MobileMapTimeline'
|
import MobileMapTimeline from '../components/Journey/MobileMapTimeline'
|
||||||
import MobileEntryView from '../components/Journey/MobileEntryView'
|
import MobileEntryView from '../components/Journey/MobileEntryView'
|
||||||
import { useIsMobile } from '../hooks/useIsMobile'
|
import { useIsMobile } from '../hooks/useIsMobile'
|
||||||
import type { JourneyEntry, JourneyPhoto, JourneyDetail } from '../store/journeyStore'
|
import type { JourneyEntry, JourneyPhoto, GalleryPhoto, JourneyTrip, JourneyDetail } from '../store/journeyStore'
|
||||||
import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
|
import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
|
||||||
|
|
||||||
const GRADIENTS = [
|
const GRADIENTS = [
|
||||||
@@ -67,16 +69,18 @@ function groupByDate(entries: JourneyEntry[]): Map<string, JourneyEntry[]> {
|
|||||||
return groups
|
return groups
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(d: string): { weekday: string; month: string; day: number } {
|
function formatDate(d: string, locale?: string): { weekday: string; month: string; day: number } {
|
||||||
const date = new Date(d + 'T00:00:00')
|
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 {
|
return {
|
||||||
weekday: date.toLocaleDateString(undefined, { weekday: 'long' }),
|
weekday: date.toLocaleDateString(locale, { weekday: 'long' }),
|
||||||
month: date.toLocaleDateString(undefined, { month: 'long' }),
|
month: date.toLocaleDateString(locale, { month: 'long' }),
|
||||||
day: date.getDate(),
|
day: date.getDate(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'thumbnail'): string {
|
function photoUrl(p: { photo_id: number }, size: 'thumbnail' | 'original' = 'thumbnail'): string {
|
||||||
return `/api/photos/${p.photo_id}/${size}`
|
return `/api/photos/${p.photo_id}/${size}`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +88,7 @@ export default function JourneyDetailPage() {
|
|||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { t } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
const { current, loading, notFound, loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos, deletePhoto } = useJourneyStore()
|
const { current, loading, notFound, loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos, deletePhoto } = useJourneyStore()
|
||||||
const mapRef = useRef<JourneyMapHandle>(null)
|
const mapRef = useRef<JourneyMapHandle>(null)
|
||||||
const fullMapRef = useRef<JourneyMapHandle>(null)
|
const fullMapRef = useRef<JourneyMapHandle>(null)
|
||||||
@@ -186,7 +190,9 @@ export default function JourneyDetailPage() {
|
|||||||
const winner = lastPast || firstAhead
|
const winner = lastPast || firstAhead
|
||||||
if (winner) {
|
if (winner) {
|
||||||
setActiveEntryId(winner.id)
|
setActiveEntryId(winner.id)
|
||||||
mapRef.current?.highlightMarker(winner.id)
|
if (locatedEntryIdsRef.current.has(winner.id)) {
|
||||||
|
mapRef.current?.highlightMarker(winner.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const onScroll = () => {
|
const onScroll = () => {
|
||||||
@@ -277,16 +283,38 @@ export default function JourneyDetailPage() {
|
|||||||
[current?.entries]
|
[current?.entries]
|
||||||
)
|
)
|
||||||
|
|
||||||
const sidebarMapItems = useMemo(() => mapEntries.map(e => ({
|
const sidebarMapItems = useMemo(() => {
|
||||||
id: String(e.id),
|
const allDates = [...new Set(
|
||||||
lat: e.location_lat!,
|
(current?.entries || [])
|
||||||
lng: e.location_lng!,
|
.filter(e => e.title !== 'Gallery' && e.title !== '[Trip Photos]')
|
||||||
title: e.title || '',
|
.map(e => e.entry_date)
|
||||||
location_name: e.location_name || '',
|
.sort()
|
||||||
mood: e.mood,
|
)]
|
||||||
created_at: e.entry_date,
|
const sorted = [...mapEntries].sort((a, b) => a.entry_date.localeCompare(b.entry_date))
|
||||||
entry_date: e.entry_date,
|
const dayCounters = new Map<string, number>()
|
||||||
})), [mapEntries])
|
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 tripDates = useMemo(() => {
|
const tripDates = useMemo(() => {
|
||||||
const dates = new Set<string>()
|
const dates = new Set<string>()
|
||||||
@@ -313,7 +341,7 @@ export default function JourneyDetailPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const timelineEntries = current.entries.filter(e => e.title !== 'Gallery' && e.title !== '[Trip Photos]' && (!hideSkeletons || e.type !== 'skeleton'))
|
const timelineEntries = current.entries.filter(e => (!hideSkeletons || e.type !== 'skeleton'))
|
||||||
const dayGroups = groupByDate(timelineEntries)
|
const dayGroups = groupByDate(timelineEntries)
|
||||||
const sortedDates = [...dayGroups.keys()].sort()
|
const sortedDates = [...dayGroups.keys()].sort()
|
||||||
|
|
||||||
@@ -422,7 +450,7 @@ export default function JourneyDetailPage() {
|
|||||||
? 'max-w-[1440px] mx-auto px-0 pt-0'
|
? 'max-w-[1440px] mx-auto px-0 pt-0'
|
||||||
: 'flex w-full overflow-hidden'
|
: 'flex w-full overflow-hidden'
|
||||||
}
|
}
|
||||||
style={!isMobile ? { height: 'calc(100vh - var(--nav-h, 56px))' } : undefined}
|
style={!isMobile ? { height: 'calc(100dvh - var(--nav-h, 56px))' } : undefined}
|
||||||
>
|
>
|
||||||
{/* LEFT column (full width on mobile, scrollable feed on desktop) */}
|
{/* LEFT column (full width on mobile, scrollable feed on desktop) */}
|
||||||
<div
|
<div
|
||||||
@@ -430,7 +458,7 @@ export default function JourneyDetailPage() {
|
|||||||
className={
|
className={
|
||||||
isMobile
|
isMobile
|
||||||
? ''
|
? ''
|
||||||
: 'flex-1 overflow-y-auto journey-feed-scroll'
|
: 'flex-1 xl:max-w-[50%] overflow-y-auto journey-feed-scroll'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className={isMobile ? '' : 'w-full px-8 py-6'}>
|
<div className={isMobile ? '' : 'w-full px-8 py-6'}>
|
||||||
@@ -482,7 +510,7 @@ export default function JourneyDetailPage() {
|
|||||||
>
|
>
|
||||||
{hideSkeletons ? <EyeOff size={14} /> : <Eye size={14} />}
|
{hideSkeletons ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||||
</button>
|
</button>
|
||||||
<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">
|
<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">
|
||||||
{hideSkeletons ? t('journey.skeletons.show') : t('journey.skeletons.hide')}
|
{hideSkeletons ? t('journey.skeletons.show') : t('journey.skeletons.hide')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -575,14 +603,14 @@ export default function JourneyDetailPage() {
|
|||||||
|
|
||||||
{sortedDates.map((date, dayIdx) => {
|
{sortedDates.map((date, dayIdx) => {
|
||||||
const entries = dayGroups.get(date)!
|
const entries = dayGroups.get(date)!
|
||||||
const fd = formatDate(date)
|
const fd = formatDate(date, locale)
|
||||||
const locations = [...new Set(entries.map(e => e.location_name).filter(Boolean))]
|
const locations = [...new Set(entries.map(e => e.location_name).filter(Boolean))]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={date} className="flex flex-col gap-3 trek-stagger">
|
<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="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="flex items-center gap-3">
|
||||||
<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">
|
<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] }}>
|
||||||
{dayIdx + 1}
|
{dayIdx + 1}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -611,7 +639,7 @@ export default function JourneyDetailPage() {
|
|||||||
.catch(() => toast.error(t('common.errorOccurred')))
|
.catch(() => toast.error(t('common.errorOccurred')))
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div key={entry.id} data-entry-id={String(entry.id)} className={`relative ${canReorder ? 'flex items-stretch gap-2' : ''}`}>
|
<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}>
|
||||||
{canReorder && (
|
{canReorder && (
|
||||||
<div className="flex flex-col gap-1 justify-center flex-shrink-0 py-1">
|
<div className="flex flex-col gap-1 justify-center flex-shrink-0 py-1">
|
||||||
<button
|
<button
|
||||||
@@ -665,10 +693,11 @@ export default function JourneyDetailPage() {
|
|||||||
>
|
>
|
||||||
<GalleryView
|
<GalleryView
|
||||||
entries={current.entries}
|
entries={current.entries}
|
||||||
|
gallery={current.gallery || []}
|
||||||
journeyId={current.id}
|
journeyId={current.id}
|
||||||
userId={useAuthStore.getState().user?.id || 0}
|
userId={useAuthStore.getState().user?.id || 0}
|
||||||
trips={current.trips}
|
trips={current.trips}
|
||||||
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })}
|
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption ?? null, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })}
|
||||||
onRefresh={() => loadJourney(Number(id))}
|
onRefresh={() => loadJourney(Number(id))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -705,7 +734,7 @@ export default function JourneyDetailPage() {
|
|||||||
entry={editingEntry}
|
entry={editingEntry}
|
||||||
journeyId={current.id}
|
journeyId={current.id}
|
||||||
tripDates={tripDates}
|
tripDates={tripDates}
|
||||||
galleryPhotos={current.entries.flatMap(e => e.photos || [])}
|
galleryPhotos={current.gallery || []}
|
||||||
onClose={() => setEditingEntry(null)}
|
onClose={() => setEditingEntry(null)}
|
||||||
onSave={async (data) => {
|
onSave={async (data) => {
|
||||||
let entryId = editingEntry.id
|
let entryId = editingEntry.id
|
||||||
@@ -733,7 +762,8 @@ export default function JourneyDetailPage() {
|
|||||||
journey={current}
|
journey={current}
|
||||||
onClose={() => setShowSettings(false)}
|
onClose={() => setShowSettings(false)}
|
||||||
onSaved={() => { setShowSettings(false); loadJourney(Number(id)) }}
|
onSaved={() => { setShowSettings(false); loadJourney(Number(id)) }}
|
||||||
onOpenInvite={() => { setShowSettings(false); setShowInvite(true) }}
|
onOpenInvite={() => { setShowInvite(true) }}
|
||||||
|
onRefresh={() => loadJourney(Number(id))}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -816,7 +846,7 @@ function MapView({ entries, mapEntries, sortedDates, activeLocationId, fullMapRe
|
|||||||
fullMapRef: React.RefObject<JourneyMapHandle | null>
|
fullMapRef: React.RefObject<JourneyMapHandle | null>
|
||||||
onLocationClick: (id: string) => void
|
onLocationClick: (id: string) => void
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
// group map entries by date
|
// group map entries by date
|
||||||
const byDate = new Map<string, { entry: JourneyEntry; globalIdx: number }[]>()
|
const byDate = new Map<string, { entry: JourneyEntry; globalIdx: number }[]>()
|
||||||
mapEntries.forEach((e, i) => {
|
mapEntries.forEach((e, i) => {
|
||||||
@@ -872,7 +902,7 @@ function MapView({ entries, mapEntries, sortedDates, activeLocationId, fullMapRe
|
|||||||
<div className="px-5 pb-5">
|
<div className="px-5 pb-5">
|
||||||
{dates.map((date, dayIdx) => {
|
{dates.map((date, dayIdx) => {
|
||||||
const items = byDate.get(date)!
|
const items = byDate.get(date)!
|
||||||
const fd = formatDate(date)
|
const fd = formatDate(date, locale)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={date}>
|
<div key={date}>
|
||||||
@@ -915,7 +945,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>
|
<span className="text-[14px] font-semibold text-zinc-900 dark:text-white truncate">{e.title || e.location_name}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[11px] text-zinc-500 truncate">
|
<div className="text-[11px] text-zinc-500 truncate">
|
||||||
{e.location_name}{e.entry_time ? ` · ${e.entry_time}` : ''}
|
{formatLocationName(e.location_name)}{e.entry_time ? ` · ${e.entry_time}` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -942,12 +972,13 @@ function MapView({ entries, mapEntries, sortedDates, activeLocationId, fullMapRe
|
|||||||
|
|
||||||
// ── Gallery View ──────────────────────────────────────────────────────────
|
// ── Gallery View ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefresh }: {
|
function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick, onRefresh }: {
|
||||||
entries: JourneyEntry[]
|
entries: JourneyEntry[]
|
||||||
|
gallery: GalleryPhoto[]
|
||||||
journeyId: number
|
journeyId: number
|
||||||
userId: number
|
userId: number
|
||||||
trips: JourneyTrip[]
|
trips: JourneyTrip[]
|
||||||
onPhotoClick: (photos: JourneyPhoto[], index: number) => void
|
onPhotoClick: (photos: GalleryPhoto[], index: number) => void
|
||||||
onRefresh: () => void
|
onRefresh: () => void
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
@@ -980,12 +1011,7 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
|
|||||||
})()
|
})()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const allPhotos: { photo: JourneyPhoto; entry: JourneyEntry }[] = []
|
const allPhotos = gallery
|
||||||
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)
|
const entriesWithContent = entries.filter(e => e.type !== 'skeleton' || e.title)
|
||||||
|
|
||||||
@@ -1001,22 +1027,9 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
|
|||||||
if (!files?.length) return
|
if (!files?.length) return
|
||||||
setGalleryUploading(true)
|
setGalleryUploading(true)
|
||||||
try {
|
try {
|
||||||
// find existing "Gallery" entry or create one. The stored title is the
|
|
||||||
// literal 'Gallery' (server-side checks look for this exact string) —
|
|
||||||
// do not send a translated label here.
|
|
||||||
let galleryEntry = entries.find(e => e.title === 'Gallery' && e.type === 'entry')
|
|
||||||
let entryId = galleryEntry?.id
|
|
||||||
if (!entryId) {
|
|
||||||
const entry = await journeyApi.createEntry(journeyId, {
|
|
||||||
title: 'Gallery',
|
|
||||||
entry_date: new Date().toISOString().split('T')[0],
|
|
||||||
type: 'entry',
|
|
||||||
})
|
|
||||||
entryId = entry.id
|
|
||||||
}
|
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
for (const f of files) formData.append('photos', f)
|
for (const f of files) formData.append('photos', f)
|
||||||
await journeyApi.uploadPhotos(entryId, formData)
|
await journeyApi.uploadGalleryPhotos(journeyId, formData)
|
||||||
toast.success(t('journey.photosUploaded', { count: files.length }))
|
toast.success(t('journey.photosUploaded', { count: files.length }))
|
||||||
onRefresh()
|
onRefresh()
|
||||||
} catch {
|
} catch {
|
||||||
@@ -1027,24 +1040,27 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
|
|||||||
e.target.value = ''
|
e.target.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeletePhoto = async (photoId: number) => {
|
const handleDeletePhoto = async (galleryPhotoId: number) => {
|
||||||
// Optimistic update — remove photo from local state immediately
|
|
||||||
const store = useJourneyStore.getState()
|
const store = useJourneyStore.getState()
|
||||||
if (store.current) {
|
if (!store.current) return
|
||||||
const updated = {
|
|
||||||
|
// Optimistic update — remove from gallery and all entry photo lists
|
||||||
|
useJourneyStore.setState({
|
||||||
|
current: {
|
||||||
...store.current,
|
...store.current,
|
||||||
|
gallery: (store.current.gallery || []).filter(p => p.id !== galleryPhotoId),
|
||||||
entries: store.current.entries.map(e => ({
|
entries: store.current.entries.map(e => ({
|
||||||
...e,
|
...e,
|
||||||
photos: e.photos.filter(p => p.id !== photoId),
|
photos: e.photos.filter(p => p.id !== galleryPhotoId),
|
||||||
})).filter(e => e.type !== 'entry' || e.title !== 'Gallery' || e.photos.length > 0 || e.story),
|
})),
|
||||||
}
|
},
|
||||||
useJourneyStore.setState({ current: updated })
|
})
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
await journeyApi.deletePhoto(photoId)
|
await journeyApi.deleteGalleryPhoto(journeyId, galleryPhotoId)
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t('common.error'))
|
toast.error(t('common.error'))
|
||||||
onRefresh() // Revert on error
|
onRefresh()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1092,11 +1108,11 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5 pb-24 md:pb-6">
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5 pb-24 md:pb-6">
|
||||||
{allPhotos.map(({ photo, entry }, i) => (
|
{allPhotos.map((photo, i) => (
|
||||||
<div
|
<div
|
||||||
key={photo.id}
|
key={photo.id}
|
||||||
className="relative aspect-square rounded-lg overflow-hidden cursor-pointer group"
|
className="relative aspect-square rounded-lg overflow-hidden cursor-pointer group"
|
||||||
onClick={() => onPhotoClick(allPhotos.map(a => a.photo), i)}
|
onClick={() => onPhotoClick(allPhotos, i)}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={photoUrl(photo, 'thumbnail')}
|
src={photoUrl(photo, 'thumbnail')}
|
||||||
@@ -1125,11 +1141,6 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
|
|||||||
<p className="text-[10px] text-white truncate">{photo.caption}</p>
|
<p className="text-[10px] text-white truncate">{photo.caption}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="absolute bottom-1.5 left-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
<span className="text-[9px] font-medium px-1.5 py-0.5 rounded-full bg-black/50 backdrop-blur text-white">
|
|
||||||
{new Date(entry.entry_date + 'T12:00:00').toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' })}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -1142,25 +1153,19 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
|
|||||||
userId={userId}
|
userId={userId}
|
||||||
entries={entriesWithContent}
|
entries={entriesWithContent}
|
||||||
trips={trips}
|
trips={trips}
|
||||||
existingAssetIds={new Set(entries.flatMap(e => (e.photos || []).filter(p => p.asset_id).map(p => p.asset_id!)))}
|
existingAssetIds={new Set(gallery.filter(p => p.asset_id).map(p => p.asset_id!))}
|
||||||
onClose={() => setShowPicker(false)}
|
onClose={() => setShowPicker(false)}
|
||||||
onAdd={async (groups, entryId) => {
|
onAdd={async (groups, entryId) => {
|
||||||
let targetId = entryId
|
|
||||||
if (!targetId) {
|
|
||||||
try {
|
|
||||||
const entry = await journeyApi.createEntry(journeyId, {
|
|
||||||
title: 'Gallery',
|
|
||||||
entry_date: new Date().toISOString().split('T')[0],
|
|
||||||
type: 'entry',
|
|
||||||
})
|
|
||||||
targetId = entry.id
|
|
||||||
} catch { return }
|
|
||||||
}
|
|
||||||
let added = 0
|
let added = 0
|
||||||
for (const group of groups) {
|
for (const group of groups) {
|
||||||
try {
|
try {
|
||||||
const result = await journeyApi.addProviderPhotos(targetId, pickerProvider!, group.assetIds, undefined, group.passphrase)
|
if (entryId) {
|
||||||
added += result.added || 0
|
const result = await journeyApi.addProviderPhotos(entryId, pickerProvider!, group.assetIds, undefined, group.passphrase)
|
||||||
|
added += result.added || 0
|
||||||
|
} else {
|
||||||
|
const result = await journeyApi.addProviderPhotosToGallery(journeyId, pickerProvider!, group.assetIds, group.passphrase)
|
||||||
|
added += result.added || 0
|
||||||
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
if (added > 0) {
|
if (added > 0) {
|
||||||
@@ -1358,7 +1363,7 @@ function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: {
|
|||||||
{entry.location_name && (
|
{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">
|
<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" />
|
<MapPin size={10} className="flex-shrink-0" />
|
||||||
<span className="truncate">{entry.location_name}</span>
|
<span className="truncate">{formatLocationName(entry.location_name)}</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{entry.entry_time && (
|
{entry.entry_time && (
|
||||||
@@ -1401,7 +1406,7 @@ function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: {
|
|||||||
<div className="flex items-center gap-2 min-w-0 flex-1 mr-2">
|
<div className="flex items-center gap-2 min-w-0 flex-1 mr-2">
|
||||||
{entry.location_name && (
|
{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">
|
<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">{entry.location_name}</span>
|
<MapPin size={10} className="flex-shrink-0" /> <span className="truncate">{formatLocationName(entry.location_name)}</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{entry.entry_time && (
|
{entry.entry_time && (
|
||||||
@@ -1480,7 +1485,7 @@ function SkeletonCard({ entry, onClick }: { entry: JourneyEntry; onClick?: () =>
|
|||||||
{entry.title || t('journey.detail.newEntry')}
|
{entry.title || t('journey.detail.newEntry')}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[11px] text-zinc-500 mt-0.5">
|
<div className="text-[11px] text-zinc-500 mt-0.5">
|
||||||
{entry.location_name || ''}{entry.entry_time ? ` · ${entry.entry_time}` : ''}
|
{formatLocationName(entry.location_name)}{entry.entry_time ? ` · ${entry.entry_time}` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[11px] text-zinc-500 font-medium flex-shrink-0">
|
<div className="text-[11px] text-zinc-500 font-medium flex-shrink-0">
|
||||||
@@ -1764,11 +1769,11 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
|||||||
: t('journey.picker.newGallery')
|
: t('journey.picker.newGallery')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.75)' }}>
|
<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-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">
|
<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()}>
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700 flex-shrink-0">
|
||||||
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">
|
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">
|
||||||
{provider === 'immich' ? 'Immich' : 'Synology Photos'}
|
{provider === 'immich' ? 'Immich' : 'Synology Photos'}
|
||||||
</h2>
|
</h2>
|
||||||
@@ -1778,7 +1783,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filter bar */}
|
{/* Filter bar */}
|
||||||
<div className="px-6 py-3 border-b border-zinc-200 dark:border-zinc-700">
|
<div className="px-6 py-3 border-b border-zinc-200 dark:border-zinc-700 flex-shrink-0">
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="flex gap-1.5 mb-3">
|
<div className="flex gap-1.5 mb-3">
|
||||||
{[
|
{[
|
||||||
@@ -1864,7 +1869,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add-to entry selector */}
|
{/* 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">
|
<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="relative flex items-center gap-2">
|
<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>
|
<span className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500">{t('journey.picker.addTo')}</span>
|
||||||
<button
|
<button
|
||||||
@@ -1917,7 +1922,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
|||||||
const allSelected = selectable.length > 0 && selectable.every((a: any) => selected.has(a.id))
|
const allSelected = selectable.length > 0 && selectable.every((a: any) => selected.has(a.id))
|
||||||
if (selectable.length === 0) return null
|
if (selectable.length === 0) return null
|
||||||
return (
|
return (
|
||||||
<div className="px-4 py-2 border-b border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900">
|
<div className="px-4 py-2 border-b border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 flex-shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (allSelected) {
|
if (allSelected) {
|
||||||
@@ -1942,7 +1947,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
|||||||
})()}
|
})()}
|
||||||
|
|
||||||
{/* Photo grid */}
|
{/* Photo grid */}
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
<div className="flex-1 overflow-y-auto overscroll-contain p-4 min-h-0">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex justify-center py-12">
|
<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" />
|
<div className="w-6 h-6 border-2 border-zinc-300 border-t-zinc-900 rounded-full animate-spin" />
|
||||||
@@ -2015,7 +2020,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* 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">
|
<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">
|
||||||
<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 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="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>
|
<span className="leading-[18px]">{t('journey.picker.selected')}</span>
|
||||||
@@ -2161,7 +2166,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
|||||||
entry: JourneyEntry
|
entry: JourneyEntry
|
||||||
journeyId: number
|
journeyId: number
|
||||||
tripDates: Set<string>
|
tripDates: Set<string>
|
||||||
galleryPhotos: JourneyPhoto[]
|
galleryPhotos: GalleryPhoto[]
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSave: (data: Record<string, unknown>) => Promise<number>
|
onSave: (data: Record<string, unknown>) => Promise<number>
|
||||||
onUploadPhotos: (entryId: number, formData: FormData) => Promise<JourneyPhoto[]>
|
onUploadPhotos: (entryId: number, formData: FormData) => Promise<JourneyPhoto[]>
|
||||||
@@ -2187,7 +2192,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
|||||||
const [cons, setCons] = useState<string[]>(entry.pros_cons?.cons?.length ? entry.pros_cons.cons : [''])
|
const [cons, setCons] = useState<string[]>(entry.pros_cons?.cons?.length ? entry.pros_cons.cons : [''])
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [uploading, setUploading] = useState(false)
|
const [uploading, setUploading] = useState(false)
|
||||||
const [photos, setPhotos] = useState<JourneyPhoto[]>(entry.photos || [])
|
const [photos, setPhotos] = useState<(JourneyPhoto | GalleryPhoto)[]>(entry.photos || [])
|
||||||
const [pendingFiles, setPendingFiles] = useState<File[]>([])
|
const [pendingFiles, setPendingFiles] = useState<File[]>([])
|
||||||
const [pendingLinkIds, setPendingLinkIds] = useState<number[]>([])
|
const [pendingLinkIds, setPendingLinkIds] = useState<number[]>([])
|
||||||
const [showGalleryPick, setShowGalleryPick] = useState(false)
|
const [showGalleryPick, setShowGalleryPick] = useState(false)
|
||||||
@@ -2214,6 +2219,8 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
|||||||
pendingLinkIds.length > 0
|
pendingLinkIds.length > 0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const availableGalleryPhotos = galleryPhotos.filter(gp => !photos.some(p => p.id === gp.id))
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
if (isDirty && !window.confirm(t('journey.editor.discardChangesConfirm'))) return
|
if (isDirty && !window.confirm(t('journey.editor.discardChangesConfirm'))) return
|
||||||
onClose()
|
onClose()
|
||||||
@@ -2323,7 +2330,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
|||||||
{showGalleryPick && (
|
{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="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">
|
<div className="grid grid-cols-5 sm:grid-cols-6 gap-1.5 max-h-[160px] overflow-y-auto">
|
||||||
{galleryPhotos.filter(gp => !photos.some(p => p.id === gp.id)).map(gp => (
|
{availableGalleryPhotos.map(gp => (
|
||||||
<div
|
<div
|
||||||
key={gp.id}
|
key={gp.id}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
@@ -2343,7 +2350,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 }} />
|
<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>
|
</div>
|
||||||
))}
|
))}
|
||||||
{galleryPhotos.filter(gp => !photos.some(p => p.id === gp.id)).length === 0 && (
|
{availableGalleryPhotos.length === 0 && (
|
||||||
<div className="col-span-full text-center py-3 text-[11px] text-zinc-400">{t('journey.editor.allPhotosAdded')}</div>
|
<div className="col-span-full text-center py-3 text-[11px] text-zinc-400">{t('journey.editor.allPhotosAdded')}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -2378,8 +2385,13 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
|||||||
<button
|
<button
|
||||||
onClick={async (e) => {
|
onClick={async (e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
await journeyApi.deletePhoto(p.id)
|
|
||||||
setPhotos(prev => prev.filter(x => x.id !== p.id))
|
setPhotos(prev => prev.filter(x => x.id !== p.id))
|
||||||
|
if (entry.id > 0) {
|
||||||
|
// unlink from entry; gallery row is preserved
|
||||||
|
try { await journeyApi.unlinkPhoto(entry.id, p.id) } catch {}
|
||||||
|
} else {
|
||||||
|
setPendingLinkIds(prev => prev.filter(id => id !== p.id))
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
className="absolute top-1 right-1 w-5 h-5 rounded-full bg-black/60 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
className="absolute top-1 right-1 w-5 h-5 rounded-full bg-black/60 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
>
|
>
|
||||||
@@ -2952,7 +2964,7 @@ function JourneyShareSection({ journeyId }: { journeyId: number }) {
|
|||||||
onClick={deleteLink}
|
onClick={deleteLink}
|
||||||
className="text-[11px] font-medium text-red-500 hover:text-red-600 self-start"
|
className="text-[11px] font-medium text-red-500 hover:text-red-600 self-start"
|
||||||
>
|
>
|
||||||
Remove share link
|
{t('share.deleteLink')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -2960,11 +2972,12 @@ function JourneyShareSection({ journeyId }: { journeyId: number }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
|
function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite, onRefresh }: {
|
||||||
journey: JourneyDetail
|
journey: JourneyDetail
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSaved: () => void
|
onSaved: () => void
|
||||||
onOpenInvite: () => void
|
onOpenInvite: () => void
|
||||||
|
onRefresh: () => void
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [title, setTitle] = useState(journey.title)
|
const [title, setTitle] = useState(journey.title)
|
||||||
@@ -2972,6 +2985,10 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
|
|||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [showAddTrip, setShowAddTrip] = useState(false)
|
const [showAddTrip, setShowAddTrip] = useState(false)
|
||||||
const [unlinkTarget, setUnlinkTarget] = useState<{ trip_id: number; title: string } | null>(null)
|
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 coverRef = useRef<HTMLInputElement>(null)
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -3030,12 +3047,12 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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="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="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="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">
|
<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>
|
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">{t('journey.settings.title')}</h2>
|
||||||
<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">
|
<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">
|
||||||
<X size={16} />
|
<X size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -3131,7 +3148,7 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
|
|||||||
try {
|
try {
|
||||||
await journeyApi.removeContributor(journey.id, c.user_id)
|
await journeyApi.removeContributor(journey.id, c.user_id)
|
||||||
toast.success(t('journey.contributors.removed'))
|
toast.success(t('journey.contributors.removed'))
|
||||||
onSaved()
|
onRefresh()
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t('journey.contributors.removeFailed'))
|
toast.error(t('journey.contributors.removeFailed'))
|
||||||
}
|
}
|
||||||
@@ -3182,7 +3199,7 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
|
|||||||
{journey.status === 'archived' ? <ArchiveRestore size={14} /> : <Archive size={14} />}
|
{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>
|
<span className="hidden md:inline">{journey.status === 'archived' ? t('journey.settings.reopenJourney') : t('journey.settings.endJourney')}</span>
|
||||||
</button>
|
</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={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={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">
|
<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')}
|
{saving ? t('common.saving') : t('common.save')}
|
||||||
</button>
|
</button>
|
||||||
@@ -3229,6 +3246,16 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
|
|||||||
confirmLabel={t('common.delete')}
|
confirmLabel={t('common.delete')}
|
||||||
danger
|
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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,21 @@ vi.mock('../components/Journey/PhotoLightbox', () => ({
|
|||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../components/Journey/MobileMapTimeline', () => ({
|
||||||
|
default: ({ onEntryClick }: any) => (
|
||||||
|
<div data-testid="mobile-map-timeline">
|
||||||
|
<button onClick={() => onEntryClick({ id: 10, title: 'Shibuya Crossing', story: 'The most famous crossing in the world.', entry_date: '2026-03-15', entry_time: '14:00', location_name: 'Shibuya, Tokyo', photos: [] })}>
|
||||||
|
Open Entry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockIsMobile = { value: false };
|
||||||
|
vi.mock('../hooks/useIsMobile', () => ({
|
||||||
|
useIsMobile: () => mockIsMobile.value,
|
||||||
|
}));
|
||||||
|
|
||||||
import JourneyPublicPage from './JourneyPublicPage';
|
import JourneyPublicPage from './JourneyPublicPage';
|
||||||
|
|
||||||
// ── Fixtures ─────────────────────────────────────────────────────────────────
|
// ── Fixtures ─────────────────────────────────────────────────────────────────
|
||||||
@@ -106,6 +121,9 @@ const mockJourneyData = {
|
|||||||
share_gallery: true,
|
share_gallery: true,
|
||||||
share_map: true,
|
share_map: true,
|
||||||
},
|
},
|
||||||
|
gallery: [
|
||||||
|
{ id: 100, journey_id: 1, photo_id: 100, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/temple.jpg', caption: 'Temple entrance', shared: 1, sort_order: 0, created_at: 0 },
|
||||||
|
],
|
||||||
stats: {
|
stats: {
|
||||||
entries: 2,
|
entries: 2,
|
||||||
photos: 1,
|
photos: 1,
|
||||||
@@ -136,6 +154,7 @@ function setup404() {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetAllStores();
|
resetAllStores();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
mockIsMobile.value = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||||
@@ -234,28 +253,20 @@ describe('JourneyPublicPage', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-PAGE-PUBLICJOURNEY-009: map tab switches view', async () => {
|
it('FE-PAGE-PUBLICJOURNEY-009: map is always visible in desktop two-column layout', async () => {
|
||||||
setupSuccess();
|
setupSuccess();
|
||||||
render(<JourneyPublicPage />);
|
render(<JourneyPublicPage />);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Tokyo 2026')).toBeInTheDocument();
|
expect(screen.getByText('Tokyo 2026')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
const buttons = screen.getAllByRole('button');
|
// Desktop two-column: map sidebar is always rendered alongside the timeline;
|
||||||
const mapBtn = buttons.find(
|
// there is no standalone "Map" tab button on desktop.
|
||||||
btn => btn.textContent && /map/i.test(btn.textContent),
|
await waitFor(() => {
|
||||||
);
|
expect(screen.getByTestId('journey-map')).toBeInTheDocument();
|
||||||
expect(mapBtn).toBeDefined();
|
});
|
||||||
if (mapBtn) {
|
// Timeline entries remain visible (two-column shows both simultaneously)
|
||||||
fireEvent.click(mapBtn);
|
expect(screen.getByText('Shibuya Crossing')).toBeInTheDocument();
|
||||||
// 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 () => {
|
it('FE-PAGE-PUBLICJOURNEY-010: shows journey stats', async () => {
|
||||||
@@ -303,24 +314,18 @@ describe('JourneyPublicPage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// FE-PAGE-PUBLICJOURNEY-012
|
// FE-PAGE-PUBLICJOURNEY-012
|
||||||
it('FE-PAGE-PUBLICJOURNEY-012: tab switching from timeline to map shows map component', async () => {
|
it('FE-PAGE-PUBLICJOURNEY-012: map component renders with located entries in desktop two-column layout', async () => {
|
||||||
const user = userEvent.setup();
|
|
||||||
setupSuccess();
|
setupSuccess();
|
||||||
render(<JourneyPublicPage />);
|
render(<JourneyPublicPage />);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Tokyo 2026')).toBeInTheDocument();
|
expect(screen.getByText('Tokyo 2026')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapBtn = screen.getAllByRole('button').find(
|
// Desktop two-column: map sidebar is always rendered; no tab click required.
|
||||||
btn => btn.textContent && /map/i.test(btn.textContent),
|
|
||||||
);
|
|
||||||
expect(mapBtn).toBeDefined();
|
|
||||||
await user.click(mapBtn!);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId('journey-map')).toBeInTheDocument();
|
expect(screen.getByTestId('journey-map')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
// Map receives entries with lat/lng
|
// Both fixture entries have coordinates → map receives 2 located entries
|
||||||
expect(screen.getByTestId('journey-map').textContent).toContain('2');
|
expect(screen.getByTestId('journey-map').textContent).toContain('2');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -354,6 +359,11 @@ describe('JourneyPublicPage', () => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
gallery: [
|
||||||
|
{ id: 200, journey_id: 1, photo_id: 200, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/a.jpg', caption: 'Photo A', shared: 1, sort_order: 0, created_at: 0 },
|
||||||
|
{ id: 201, journey_id: 1, photo_id: 201, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/b.jpg', caption: 'Photo B', shared: 1, sort_order: 1, created_at: 0 },
|
||||||
|
{ id: 202, journey_id: 1, photo_id: 202, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/c.jpg', caption: 'Photo C', shared: 1, sort_order: 2, created_at: 0 },
|
||||||
|
],
|
||||||
stats: { entries: 1, photos: 3, places: 0 },
|
stats: { entries: 1, photos: 3, places: 0 },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -405,6 +415,40 @@ describe('JourneyPublicPage', () => {
|
|||||||
expect(statsContainer!.textContent).toContain('7');
|
expect(statsContainer!.textContent).toContain('7');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// FE-PAGE-PUBLICJOURNEY-019 — bug #828
|
||||||
|
it('FE-PAGE-PUBLICJOURNEY-019: mobile public share does not show standalone Map tab', async () => {
|
||||||
|
mockIsMobile.value = true;
|
||||||
|
setupSuccess();
|
||||||
|
render(<JourneyPublicPage />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Tokyo 2026')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const buttons = screen.getAllByRole('button');
|
||||||
|
const mapBtn = buttons.find(btn => btn.textContent && /^map$/i.test(btn.textContent.trim()));
|
||||||
|
expect(mapBtn).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// FE-PAGE-PUBLICJOURNEY-020 — bug #826
|
||||||
|
it('FE-PAGE-PUBLICJOURNEY-020: mobile public share opens entry details on card click', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
mockIsMobile.value = true;
|
||||||
|
setupSuccess();
|
||||||
|
render(<JourneyPublicPage />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Tokyo 2026')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The MobileMapTimeline mock fires onEntryClick when "Open Entry" is clicked
|
||||||
|
const openBtn = screen.getByText('Open Entry');
|
||||||
|
await user.click(openBtn);
|
||||||
|
|
||||||
|
// MobileEntryView should slide in with the entry title
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Shibuya Crossing')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// FE-PAGE-PUBLICJOURNEY-016
|
// FE-PAGE-PUBLICJOURNEY-016
|
||||||
it('FE-PAGE-PUBLICJOURNEY-016: language picker opens and switches language', async () => {
|
it('FE-PAGE-PUBLICJOURNEY-016: language picker opens and switches language', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|||||||
@@ -1,14 +1,23 @@
|
|||||||
import { useEffect, useState, useMemo } from 'react'
|
import { useEffect, useState, useMemo, useRef, useCallback } from 'react'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
import { journeyApi } from '../api/client'
|
import { journeyApi } from '../api/client'
|
||||||
import { useTranslation, SUPPORTED_LANGUAGES } from '../i18n'
|
import { useTranslation, SUPPORTED_LANGUAGES } from '../i18n'
|
||||||
import { useSettingsStore } from '../store/settingsStore'
|
import { useSettingsStore } from '../store/settingsStore'
|
||||||
import { List, Grid, MapPin, Camera, BookOpen, Image } from 'lucide-react'
|
import {
|
||||||
|
List, Grid, MapPin, Camera, BookOpen, Image, Clock,
|
||||||
|
Laugh, Smile, Meh, Frown,
|
||||||
|
Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake,
|
||||||
|
ThumbsUp, ThumbsDown,
|
||||||
|
} from 'lucide-react'
|
||||||
import JourneyMap from '../components/Journey/JourneyMap'
|
import JourneyMap from '../components/Journey/JourneyMap'
|
||||||
|
import type { JourneyMapHandle } from '../components/Journey/JourneyMap'
|
||||||
import JournalBody from '../components/Journey/JournalBody'
|
import JournalBody from '../components/Journey/JournalBody'
|
||||||
import PhotoLightbox from '../components/Journey/PhotoLightbox'
|
import PhotoLightbox from '../components/Journey/PhotoLightbox'
|
||||||
import MobileMapTimeline from '../components/Journey/MobileMapTimeline'
|
import MobileMapTimeline from '../components/Journey/MobileMapTimeline'
|
||||||
|
import MobileEntryView from '../components/Journey/MobileEntryView'
|
||||||
import { useIsMobile } from '../hooks/useIsMobile'
|
import { useIsMobile } from '../hooks/useIsMobile'
|
||||||
|
import { formatLocationName } from '../utils/formatters'
|
||||||
|
import { DAY_COLORS } from '../components/Journey/dayColors'
|
||||||
|
|
||||||
interface PublicEntry {
|
interface PublicEntry {
|
||||||
id: number
|
id: number
|
||||||
@@ -36,15 +45,42 @@ interface PublicPhoto {
|
|||||||
caption?: string | null
|
caption?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
function photoUrl(p: PublicPhoto, shareToken: string, kind: 'thumbnail' | 'original' = 'original'): string {
|
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 {
|
||||||
return `/api/public/journey/${shareToken}/photos/${p.photo_id}/${kind}`
|
return `/api/public/journey/${shareToken}/photos/${p.photo_id}/${kind}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(d: string): { weekday: string; month: string; day: number } {
|
function formatDate(d: string, locale?: string): { weekday: string; month: string; day: number } {
|
||||||
const date = new Date(d + 'T00:00:00')
|
const date = new Date(d + 'T00:00:00')
|
||||||
return {
|
return {
|
||||||
weekday: date.toLocaleDateString('en', { weekday: 'long' }),
|
weekday: date.toLocaleDateString(locale || 'en', { weekday: 'long' }),
|
||||||
month: date.toLocaleDateString('en', { month: 'long' }),
|
month: date.toLocaleDateString(locale || 'en', { month: 'long' }),
|
||||||
day: date.getDate(),
|
day: date.getDate(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,6 +106,16 @@ export default function JourneyPublicPage() {
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [showLangPicker, setShowLangPicker] = useState(false)
|
const [showLangPicker, setShowLangPicker] = useState(false)
|
||||||
const locale = useSettingsStore(s => s.settings.language) || 'en'
|
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(() => {
|
useEffect(() => {
|
||||||
if (!token) return
|
if (!token) return
|
||||||
@@ -80,25 +126,45 @@ export default function JourneyPublicPage() {
|
|||||||
}, [token])
|
}, [token])
|
||||||
|
|
||||||
const entries = (data?.entries || []) as PublicEntry[]
|
const entries = (data?.entries || []) as PublicEntry[]
|
||||||
|
const gallery = (data?.gallery || []) as PublicGalleryPhoto[]
|
||||||
const perms = data?.permissions || {}
|
const perms = data?.permissions || {}
|
||||||
const journey = data?.journey || {}
|
const journey = data?.journey || {}
|
||||||
const stats = data?.stats || {}
|
const stats = data?.stats || {}
|
||||||
|
|
||||||
// `[Trip Photos]` and `Gallery` are synthetic photo-only containers
|
const timelineEntries = useMemo(() => entries, [entries])
|
||||||
// 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 groupedEntries = useMemo(() => groupByDate(timelineEntries), [timelineEntries])
|
||||||
const sortedDates = useMemo(() => [...groupedEntries.keys()].sort(), [groupedEntries])
|
const sortedDates = useMemo(() => [...groupedEntries.keys()].sort(), [groupedEntries])
|
||||||
const mapEntries = useMemo(
|
const mapEntries = useMemo(
|
||||||
() => timelineEntries.filter(e => e.location_lat && e.location_lng),
|
() => timelineEntries.filter(e => e.location_lat && e.location_lng),
|
||||||
[timelineEntries],
|
[timelineEntries],
|
||||||
)
|
)
|
||||||
const allPhotos = useMemo(() => entries.flatMap(e => (e.photos || []).map(p => ({ photo: p, entry: e }))), [entries])
|
const allPhotos = gallery
|
||||||
|
|
||||||
|
// Map entries with day color/label for colored markers.
|
||||||
|
// 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
|
||||||
|
|
||||||
// Set default view based on permissions
|
// Set default view based on permissions
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -106,6 +172,11 @@ export default function JourneyPublicPage() {
|
|||||||
else if (!perms.share_timeline && !perms.share_gallery && perms.share_map) setView('map')
|
else if (!perms.share_timeline && !perms.share_gallery && perms.share_map) setView('map')
|
||||||
}, [perms])
|
}, [perms])
|
||||||
|
|
||||||
|
// When switching to desktop two-column, 'map' standalone tab no longer exists
|
||||||
|
useEffect(() => {
|
||||||
|
if (desktopTwoColumn && view === 'map') setView('timeline')
|
||||||
|
}, [desktopTwoColumn, view])
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950 flex items-center justify-center">
|
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950 flex items-center justify-center">
|
||||||
@@ -125,21 +196,262 @@ export default function JourneyPublicPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// In desktop two-column mode the map is always visible — exclude the standalone 'map' tab
|
||||||
const availableViews = [
|
const availableViews = [
|
||||||
perms.share_timeline && { id: 'timeline' as const, icon: List, label: t('journey.share.timeline') },
|
perms.share_timeline && { id: 'timeline' as const, icon: List, label: t('journey.share.timeline') },
|
||||||
perms.share_gallery && { id: 'gallery' as const, icon: Grid, label: t('journey.share.gallery') },
|
perms.share_gallery && { id: 'gallery' as const, icon: Grid, label: t('journey.share.gallery') },
|
||||||
perms.share_map && { id: 'map' as const, icon: MapPin, label: t('journey.share.map') },
|
!desktopTwoColumn && !isMobile && perms.share_map && { id: 'map' as const, icon: MapPin, label: t('journey.share.map') },
|
||||||
].filter(Boolean) as { id: 'timeline' | 'gallery' | 'map'; icon: any; label: string }[]
|
].filter(Boolean) as { id: 'timeline' | 'gallery' | 'map'; icon: any; label: string }[]
|
||||||
|
|
||||||
|
// Shared timeline renderer used in both layout modes
|
||||||
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
|
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
|
||||||
{/* Hero */}
|
{/* Hero */}
|
||||||
<div className="relative text-center text-white" style={{ background: 'linear-gradient(135deg, #000 0%, #0f172a 50%, #1e293b 100%)', padding: '32px 20px 28px' }}>
|
<div className="relative text-center text-white" style={{ background: 'linear-gradient(135deg, #000 0%, #0f172a 50%, #1e293b 100%)', padding: '32px 20px 28px', overflow: 'hidden' }}>
|
||||||
{/* Cover image background */}
|
|
||||||
{journey.cover_image && (
|
{journey.cover_image && (
|
||||||
<div style={{ position: 'absolute', inset: 0, backgroundImage: `url(/uploads/${journey.cover_image})`, backgroundSize: 'cover', backgroundPosition: 'center', opacity: 0.15 }} />
|
<div style={{ position: 'absolute', inset: 0, backgroundImage: `url(/uploads/${journey.cover_image})`, backgroundSize: 'cover', backgroundPosition: 'center', opacity: 0.15 }} />
|
||||||
)}
|
)}
|
||||||
{/* 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', 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)' }} />
|
<div style={{ position: 'absolute', bottom: -40, left: -40, width: 150, height: 150, borderRadius: '50%', background: 'rgba(255,255,255,0.02)' }} />
|
||||||
|
|
||||||
@@ -194,160 +506,98 @@ export default function JourneyPublicPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="max-w-[900px] mx-auto px-4 md:px-8 py-6">
|
{desktopTwoColumn ? (
|
||||||
|
// ── Desktop two-column: scrollable timeline feed + sticky map ──────────
|
||||||
{/* View tabs */}
|
<div className="max-w-[1440px] mx-auto flex" style={{ alignItems: 'flex-start' }}>
|
||||||
{availableViews.length > 1 && (
|
{/* Left: feed */}
|
||||||
<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">
|
<div className="flex-1 xl:max-w-[50%] min-w-0 px-8 py-6">
|
||||||
{availableViews.map(v => (
|
{renderTabs(availableViews)}
|
||||||
<button
|
{view === 'timeline' && perms.share_timeline && renderTimeline()}
|
||||||
key={v.id}
|
{view === 'gallery' && perms.share_gallery && renderGallery()}
|
||||||
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>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Mobile combined map+timeline (public, read-only) */}
|
{/* Right: sticky map — matches auth page aside proportions */}
|
||||||
{isMobile && view === 'timeline' && perms.share_timeline && perms.share_map && (
|
<aside
|
||||||
<MobileMapTimeline
|
className="flex-shrink-0"
|
||||||
entries={entries}
|
style={{
|
||||||
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 }))}
|
width: '44%', minWidth: 420, maxWidth: 760,
|
||||||
dark={document.documentElement.classList.contains('dark')}
|
position: 'sticky', top: 0, height: '100dvh',
|
||||||
readOnly
|
padding: '16px 16px 16px 0',
|
||||||
onEntryClick={() => {}}
|
alignSelf: 'flex-start',
|
||||||
publicPhotoUrl={(photoId) => `/api/public/journey/${token}/photos/${photoId}/original`}
|
}}
|
||||||
/>
|
>
|
||||||
)}
|
<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">
|
||||||
|
|
||||||
{/* Timeline (desktop, or mobile without map permission) */}
|
{/* Floating view toggle — visible above the fullscreen map on mobile */}
|
||||||
{(!isMobile || !perms.share_map) && view === 'timeline' && perms.share_timeline && (
|
{isMobile && view === 'timeline' && perms.share_timeline && perms.share_map && availableViews.length > 1 && (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="fixed left-0 right-0 z-50 flex justify-center px-4" style={{ top: 'calc(env(safe-area-inset-top, 0px) + 12px)' }}>
|
||||||
{sortedDates.map(date => {
|
<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">
|
||||||
const dayEntries = groupedEntries.get(date)!
|
{availableViews.map(v => (
|
||||||
const fd = formatDate(date)
|
<button
|
||||||
return (
|
key={v.id}
|
||||||
<div key={date}>
|
onClick={() => setView(v.id)}
|
||||||
<div className="flex items-center gap-3 mb-4">
|
className={`flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium ${
|
||||||
<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>
|
view === v.id
|
||||||
<div>
|
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
|
||||||
<div className="text-[14px] font-semibold text-zinc-900 dark:text-white">{fd.weekday}</div>
|
: 'text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
|
||||||
<div className="text-[11px] text-zinc-500">{fd.month} {fd.day}</div>
|
}`}
|
||||||
</div>
|
>
|
||||||
</div>
|
<v.icon size={13} />
|
||||||
<div className="flex flex-col gap-4 pl-[52px]">
|
{v.label}
|
||||||
{dayEntries.map(entry => (
|
</button>
|
||||||
<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>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Map */}
|
{renderTabs(availableViews)}
|
||||||
{view === 'map' && perms.share_map && (
|
|
||||||
<div className="rounded-2xl overflow-hidden border border-zinc-200 dark:border-zinc-700">
|
{/* Mobile combined map+timeline (public, read-only) */}
|
||||||
<JourneyMap
|
{isMobile && view === 'timeline' && perms.share_timeline && perms.share_map && (
|
||||||
checkins={[]}
|
<MobileMapTimeline
|
||||||
entries={mapEntries.map(e => ({
|
entries={timelineEntries}
|
||||||
id: String(e.id),
|
mapEntries={sidebarMapItems as any}
|
||||||
lat: e.location_lat!,
|
dark={document.documentElement.classList.contains('dark')}
|
||||||
lng: e.location_lng!,
|
readOnly
|
||||||
title: e.title || '',
|
onEntryClick={(entry) => setViewingEntry(entry as any)}
|
||||||
mood: e.mood,
|
publicPhotoUrl={(photoId) => `/api/public/journey/${token}/photos/${photoId}/original`}
|
||||||
created_at: e.entry_date,
|
carouselBottom="calc(env(safe-area-inset-bottom, 16px) + 8px)"
|
||||||
entry_date: e.entry_date,
|
|
||||||
})) as any}
|
|
||||||
height={500}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
</div>
|
{/* 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>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Powered by */}
|
{/* Powered by */}
|
||||||
<div className="flex flex-col items-center py-8 gap-2">
|
<div className="flex flex-col items-center py-8 gap-2">
|
||||||
@@ -368,6 +618,26 @@ export default function JourneyPublicPage() {
|
|||||||
onClose={() => setLightbox(null)}
|
onClose={() => setLightbox(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Mobile entry detail view (public share) */}
|
||||||
|
{viewingEntry && (
|
||||||
|
<MobileEntryView
|
||||||
|
entry={viewingEntry as any}
|
||||||
|
readOnly
|
||||||
|
publicPhotoUrl={(photoId) => `/api/public/journey/${token}/photos/${photoId}/original`}
|
||||||
|
onClose={() => setViewingEntry(null)}
|
||||||
|
onEdit={() => {}}
|
||||||
|
onDelete={() => {}}
|
||||||
|
onPhotoClick={(photos, idx) => setLightbox({
|
||||||
|
photos: photos.map(p => ({
|
||||||
|
id: String(p.id),
|
||||||
|
src: photoUrl(p as any, token!, 'original'),
|
||||||
|
caption: (p as any).caption ?? null,
|
||||||
|
})),
|
||||||
|
index: idx,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -642,6 +642,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
const r = await tripActions.updateReservation(tripId, editingReservation.id, { ...data, day_id: selectedDayId || null })
|
const r = await tripActions.updateReservation(tripId, editingReservation.id, { ...data, day_id: selectedDayId || null })
|
||||||
toast.success(t('trip.toast.reservationUpdated'))
|
toast.success(t('trip.toast.reservationUpdated'))
|
||||||
setShowReservationModal(false)
|
setShowReservationModal(false)
|
||||||
|
setEditingReservation(null)
|
||||||
if (data.type === 'hotel') {
|
if (data.type === 'hotel') {
|
||||||
accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
|
accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,24 @@ export interface JourneyPhoto {
|
|||||||
height?: number | null
|
height?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GalleryPhoto {
|
||||||
|
id: number
|
||||||
|
journey_id: number
|
||||||
|
photo_id: number
|
||||||
|
caption?: string | null
|
||||||
|
shared: number
|
||||||
|
sort_order: number
|
||||||
|
created_at: number
|
||||||
|
// Joined from trek_photos for display
|
||||||
|
provider?: string
|
||||||
|
asset_id?: string | null
|
||||||
|
owner_id?: number | null
|
||||||
|
file_path?: string | null
|
||||||
|
thumbnail_path?: string | null
|
||||||
|
width?: number | null
|
||||||
|
height?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface JourneyTrip {
|
export interface JourneyTrip {
|
||||||
trip_id: number
|
trip_id: number
|
||||||
added_at: number
|
added_at: number
|
||||||
@@ -79,6 +97,7 @@ export interface JourneyContributor {
|
|||||||
|
|
||||||
export interface JourneyDetail extends Journey {
|
export interface JourneyDetail extends Journey {
|
||||||
entries: JourneyEntry[]
|
entries: JourneyEntry[]
|
||||||
|
gallery: GalleryPhoto[]
|
||||||
trips: JourneyTrip[]
|
trips: JourneyTrip[]
|
||||||
contributors: JourneyContributor[]
|
contributors: JourneyContributor[]
|
||||||
stats: { entries: number; photos: number; places: number }
|
stats: { entries: number; photos: number; places: number }
|
||||||
@@ -103,6 +122,9 @@ interface JourneyState {
|
|||||||
reorderEntries: (journeyId: number, orderedIds: number[]) => Promise<void>
|
reorderEntries: (journeyId: number, orderedIds: number[]) => Promise<void>
|
||||||
|
|
||||||
uploadPhotos: (entryId: number, formData: FormData) => Promise<JourneyPhoto[]>
|
uploadPhotos: (entryId: number, formData: FormData) => Promise<JourneyPhoto[]>
|
||||||
|
uploadGalleryPhotos: (journeyId: number, formData: FormData) => Promise<GalleryPhoto[]>
|
||||||
|
unlinkPhoto: (entryId: number, journeyPhotoId: number) => Promise<void>
|
||||||
|
deleteGalleryPhoto: (journeyId: number, journeyPhotoId: number) => Promise<void>
|
||||||
deletePhoto: (photoId: number) => Promise<void>
|
deletePhoto: (photoId: number) => Promise<void>
|
||||||
|
|
||||||
clear: () => void
|
clear: () => void
|
||||||
@@ -228,12 +250,55 @@ export const useJourneyStore = create<JourneyState>((set, get) => ({
|
|||||||
entries: s.current.entries.map(e =>
|
entries: s.current.entries.map(e =>
|
||||||
e.id === entryId ? { ...e, photos: [...(e.photos || []), ...photos] } : e
|
e.id === entryId ? { ...e, photos: [...(e.photos || []), ...photos] } : e
|
||||||
),
|
),
|
||||||
|
gallery: [...(s.current.gallery || []), ...(data.gallery || [])],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return photos
|
return photos
|
||||||
},
|
},
|
||||||
|
|
||||||
|
uploadGalleryPhotos: async (journeyId, formData) => {
|
||||||
|
const data = await journeyApi.uploadGalleryPhotos(journeyId, formData)
|
||||||
|
const photos: GalleryPhoto[] = data.photos || []
|
||||||
|
set(s => {
|
||||||
|
if (!s.current || s.current.id !== journeyId) return s
|
||||||
|
return { current: { ...s.current, gallery: [...(s.current.gallery || []), ...photos] } }
|
||||||
|
})
|
||||||
|
return photos
|
||||||
|
},
|
||||||
|
|
||||||
|
unlinkPhoto: async (entryId, journeyPhotoId) => {
|
||||||
|
await journeyApi.unlinkPhoto(entryId, journeyPhotoId)
|
||||||
|
set(s => {
|
||||||
|
if (!s.current) return s
|
||||||
|
return {
|
||||||
|
current: {
|
||||||
|
...s.current,
|
||||||
|
entries: s.current.entries.map(e =>
|
||||||
|
e.id === entryId ? { ...e, photos: (e.photos || []).filter(p => p.id !== journeyPhotoId) } : e
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteGalleryPhoto: async (journeyId, journeyPhotoId) => {
|
||||||
|
await journeyApi.deleteGalleryPhoto(journeyId, journeyPhotoId)
|
||||||
|
set(s => {
|
||||||
|
if (!s.current) return s
|
||||||
|
return {
|
||||||
|
current: {
|
||||||
|
...s.current,
|
||||||
|
gallery: (s.current.gallery || []).filter(p => p.id !== journeyPhotoId),
|
||||||
|
entries: s.current.entries.map(e => ({
|
||||||
|
...e,
|
||||||
|
photos: (e.photos || []).filter(p => p.id !== journeyPhotoId),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
deletePhoto: async (photoId) => {
|
deletePhoto: async (photoId) => {
|
||||||
await journeyApi.deletePhoto(photoId)
|
await journeyApi.deletePhoto(photoId)
|
||||||
set(s => {
|
set(s => {
|
||||||
|
|||||||
@@ -171,6 +171,8 @@ export interface Reservation {
|
|||||||
place_id?: number | null
|
place_id?: number | null
|
||||||
assignment_id?: number | null
|
assignment_id?: number | null
|
||||||
accommodation_id?: number | null
|
accommodation_id?: number | null
|
||||||
|
accommodation_start_day_id?: number | null
|
||||||
|
accommodation_end_day_id?: number | null
|
||||||
day_plan_position?: number | null
|
day_plan_position?: number | null
|
||||||
metadata?: Record<string, string> | string | null
|
metadata?: Record<string, string> | string | null
|
||||||
needs_review?: number
|
needs_review?: number
|
||||||
|
|||||||
@@ -1,5 +1,38 @@
|
|||||||
import type { AssignmentsMap } from '../types'
|
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'])
|
const ZERO_DECIMAL_CURRENCIES = new Set(['JPY', 'KRW', 'VND', 'CLP', 'ISK', 'HUF'])
|
||||||
|
|
||||||
export function currencyDecimals(currency: string): number {
|
export function currencyDecimals(currency: string): number {
|
||||||
|
|||||||
@@ -64,11 +64,13 @@ class _MockIntersectionObserver {
|
|||||||
globalThis.IntersectionObserver = _MockIntersectionObserver as unknown as typeof IntersectionObserver;
|
globalThis.IntersectionObserver = _MockIntersectionObserver as unknown as typeof IntersectionObserver;
|
||||||
|
|
||||||
// ResizeObserver — used by resizable panels
|
// ResizeObserver — used by resizable panels
|
||||||
globalThis.ResizeObserver = vi.fn().mockImplementation(() => ({
|
class _MockResizeObserver {
|
||||||
observe: vi.fn(),
|
observe = vi.fn()
|
||||||
unobserve: vi.fn(),
|
unobserve = vi.fn()
|
||||||
disconnect: vi.fn(),
|
disconnect = vi.fn()
|
||||||
})) as unknown as typeof ResizeObserver;
|
constructor(_callback: ResizeObserverCallback) {}
|
||||||
|
}
|
||||||
|
globalThis.ResizeObserver = _MockResizeObserver as unknown as typeof ResizeObserver;
|
||||||
|
|
||||||
// URL.createObjectURL / revokeObjectURL — Node 22 URL.createObjectURL requires
|
// URL.createObjectURL / revokeObjectURL — Node 22 URL.createObjectURL requires
|
||||||
// a native node:buffer Blob; passing a jsdom Blob throws ERR_INVALID_ARG_TYPE.
|
// a native node:buffer Blob; passing a jsdom Blob throws ERR_INVALID_ARG_TYPE.
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 6.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.4 KiB |
Generated
+577
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "trek-server",
|
"name": "trek-server",
|
||||||
"version": "2.9.14",
|
"version": "3.0.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "trek-server",
|
"name": "trek-server",
|
||||||
"version": "2.9.14",
|
"version": "3.0.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.28.0",
|
"@modelcontextprotocol/sdk": "^1.28.0",
|
||||||
"archiver": "^6.0.1",
|
"archiver": "^6.0.1",
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
"otplib": "^12.0.1",
|
"otplib": "^12.0.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"semver": "^7.7.4",
|
"semver": "^7.7.4",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.2",
|
||||||
"undici": "^7.0.0",
|
"undici": "^7.0.0",
|
||||||
@@ -132,6 +133,16 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@emnapi/runtime": {
|
||||||
|
"version": "1.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||||
|
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.27.7",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
|
||||||
@@ -560,6 +571,519 @@
|
|||||||
"hono": "^4"
|
"hono": "^4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@img/colour": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-darwin-arm64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-darwin-arm64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-darwin-x64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-darwin-x64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-arm": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-arm": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-arm64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-arm64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-ppc64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-ppc64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-riscv64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-riscv64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-s390x": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-s390x": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-x64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-x64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-wasm32": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
|
||||||
|
"cpu": [
|
||||||
|
"wasm32"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/runtime": "^1.7.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-win32-arm64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-win32-ia32": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-win32-x64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@isaacs/cliui": {
|
"node_modules/@isaacs/cliui": {
|
||||||
"version": "8.0.2",
|
"version": "8.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||||
@@ -5083,6 +5607,50 @@
|
|||||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/sharp": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@img/colour": "^1.0.0",
|
||||||
|
"detect-libc": "^2.1.2",
|
||||||
|
"semver": "^7.7.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-darwin-arm64": "0.34.5",
|
||||||
|
"@img/sharp-darwin-x64": "0.34.5",
|
||||||
|
"@img/sharp-libvips-darwin-arm64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-darwin-x64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linux-arm": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linux-arm64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linux-ppc64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linux-riscv64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linux-s390x": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linux-x64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
|
||||||
|
"@img/sharp-linux-arm": "0.34.5",
|
||||||
|
"@img/sharp-linux-arm64": "0.34.5",
|
||||||
|
"@img/sharp-linux-ppc64": "0.34.5",
|
||||||
|
"@img/sharp-linux-riscv64": "0.34.5",
|
||||||
|
"@img/sharp-linux-s390x": "0.34.5",
|
||||||
|
"@img/sharp-linux-x64": "0.34.5",
|
||||||
|
"@img/sharp-linuxmusl-arm64": "0.34.5",
|
||||||
|
"@img/sharp-linuxmusl-x64": "0.34.5",
|
||||||
|
"@img/sharp-wasm32": "0.34.5",
|
||||||
|
"@img/sharp-win32-arm64": "0.34.5",
|
||||||
|
"@img/sharp-win32-ia32": "0.34.5",
|
||||||
|
"@img/sharp-win32-x64": "0.34.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/shebang-command": {
|
"node_modules/shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
@@ -5766,6 +6334,13 @@
|
|||||||
"nodetouch": "bin/nodetouch.js"
|
"nodetouch": "bin/nodetouch.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tslib": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
|
"license": "0BSD",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/tsx": {
|
"node_modules/tsx": {
|
||||||
"version": "4.21.0",
|
"version": "4.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "trek-server",
|
"name": "trek-server",
|
||||||
"version": "2.9.14",
|
"version": "3.0.2",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node --import tsx src/index.ts",
|
"start": "node --import tsx src/index.ts",
|
||||||
@@ -30,6 +30,7 @@
|
|||||||
"otplib": "^12.0.1",
|
"otplib": "^12.0.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"semver": "^7.7.4",
|
"semver": "^7.7.4",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.2",
|
||||||
"undici": "^7.0.0",
|
"undici": "^7.0.0",
|
||||||
|
|||||||
+4
-2
@@ -372,8 +372,10 @@ export function createApp(): express.Application {
|
|||||||
} else {
|
} else {
|
||||||
console.error('Unhandled error:', err);
|
console.error('Unhandled error:', err);
|
||||||
}
|
}
|
||||||
const status = err.statusCode || 500;
|
const status = err.statusCode || err.status || 500;
|
||||||
res.status(status).json({ error: 'Internal server error' });
|
// Expose the message for client errors (4xx); keep 'Internal server error' for 5xx.
|
||||||
|
const message = status < 500 ? err.message : 'Internal server error';
|
||||||
|
res.status(status).json({ error: message });
|
||||||
});
|
});
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
|
|||||||
@@ -1946,6 +1946,167 @@ function runMigrations(db: Database.Database): void {
|
|||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
},
|
},
|
||||||
|
// Migration 121: Journey gallery refactor — decouple photo ownership from
|
||||||
|
// entries. journey_photos becomes a per-journey gallery (one row per unique
|
||||||
|
// photo per journey). A new junction table journey_entry_photos links
|
||||||
|
// gallery photos to the entries that reference them, allowing the same
|
||||||
|
// photo to appear in multiple entries without duplication. Synthetic
|
||||||
|
// wrapper entries ('Gallery', '[Trip Photos]') created by the old model
|
||||||
|
// are removed — the gallery table replaces them.
|
||||||
|
() => {
|
||||||
|
const hasOld = db.prepare(
|
||||||
|
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'journey_photos'"
|
||||||
|
).get();
|
||||||
|
const hasBackup = db.prepare(
|
||||||
|
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'journey_photos_old'"
|
||||||
|
).get();
|
||||||
|
if (hasOld && !hasBackup) {
|
||||||
|
db.exec('ALTER TABLE journey_photos RENAME TO journey_photos_old');
|
||||||
|
}
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS journey_photos (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
journey_id INTEGER NOT NULL REFERENCES journeys(id) ON DELETE CASCADE,
|
||||||
|
photo_id INTEGER NOT NULL REFERENCES trek_photos(id) ON DELETE CASCADE,
|
||||||
|
caption TEXT,
|
||||||
|
shared INTEGER DEFAULT 0,
|
||||||
|
sort_order INTEGER DEFAULT 0,
|
||||||
|
provider TEXT,
|
||||||
|
asset_id TEXT,
|
||||||
|
owner_id INTEGER,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
UNIQUE(journey_id, photo_id)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS journey_entry_photos (
|
||||||
|
entry_id INTEGER NOT NULL REFERENCES journey_entries(id) ON DELETE CASCADE,
|
||||||
|
journey_photo_id INTEGER NOT NULL REFERENCES journey_photos(id) ON DELETE CASCADE,
|
||||||
|
sort_order INTEGER DEFAULT 0,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY(entry_id, journey_photo_id)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (hasOld || hasBackup) {
|
||||||
|
// Backfill gallery: deduplicate by (journey_id, photo_id), keeping
|
||||||
|
// the earliest row (MIN(id) = earliest created_at on AUTOINCREMENT).
|
||||||
|
db.exec(`
|
||||||
|
INSERT OR IGNORE INTO journey_photos
|
||||||
|
(journey_id, photo_id, caption, shared, sort_order, created_at)
|
||||||
|
SELECT
|
||||||
|
je.journey_id,
|
||||||
|
jpo.photo_id,
|
||||||
|
jpo.caption,
|
||||||
|
jpo.shared,
|
||||||
|
jpo.sort_order,
|
||||||
|
jpo.created_at
|
||||||
|
FROM journey_photos_old jpo
|
||||||
|
JOIN journey_entries je ON je.id = jpo.entry_id
|
||||||
|
WHERE jpo.id IN (
|
||||||
|
SELECT MIN(jpo2.id)
|
||||||
|
FROM journey_photos_old jpo2
|
||||||
|
JOIN journey_entries je2 ON je2.id = jpo2.entry_id
|
||||||
|
GROUP BY je2.journey_id, jpo2.photo_id
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Backfill junction: one row per (entry_id, photo_id), resolved to
|
||||||
|
// the new gallery ids.
|
||||||
|
db.exec(`
|
||||||
|
INSERT OR IGNORE INTO journey_entry_photos
|
||||||
|
(entry_id, journey_photo_id, sort_order, created_at)
|
||||||
|
SELECT
|
||||||
|
jpo.entry_id,
|
||||||
|
jp.id,
|
||||||
|
jpo.sort_order,
|
||||||
|
jpo.created_at
|
||||||
|
FROM journey_photos_old jpo
|
||||||
|
JOIN journey_entries je ON je.id = jpo.entry_id
|
||||||
|
JOIN journey_photos jp
|
||||||
|
ON jp.journey_id = je.journey_id
|
||||||
|
AND jp.photo_id = jpo.photo_id
|
||||||
|
`);
|
||||||
|
|
||||||
|
db.exec('DROP TABLE journey_photos_old');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove synthetic wrapper entries replaced by the gallery model.
|
||||||
|
// ON DELETE CASCADE on journey_entry_photos cleans up junction rows.
|
||||||
|
db.prepare(
|
||||||
|
"DELETE FROM journey_entries WHERE title IN ('Gallery', '[Trip Photos]')"
|
||||||
|
).run();
|
||||||
|
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS idx_journey_photos_journey ON journey_photos(journey_id)');
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS idx_journey_entry_photos_entry ON journey_entry_photos(entry_id)');
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS idx_journey_entry_photos_photo ON journey_entry_photos(journey_photo_id)');
|
||||||
|
},
|
||||||
|
// Migration 122: Correct stale day_id / end_day_id on non-transport
|
||||||
|
// reservations. Migration 110 only backfilled transport types; tours,
|
||||||
|
// restaurants, events and "other" bookings kept a stale day_id from
|
||||||
|
// older code paths that often defaulted to the first day of the trip.
|
||||||
|
// Starting with v3.0.0 the planner renders reservations by day_id
|
||||||
|
// instead of reservation_time, so those stale rows show up on the
|
||||||
|
// wrong day. This migration nulls out day_id / end_day_id values that
|
||||||
|
// don't match the reservation's time and then backfills them from
|
||||||
|
// reservation_time / reservation_end_time.
|
||||||
|
() => {
|
||||||
|
db.exec(`
|
||||||
|
UPDATE reservations
|
||||||
|
SET day_id = NULL
|
||||||
|
WHERE reservation_time IS NOT NULL
|
||||||
|
AND day_id IS NOT NULL
|
||||||
|
AND type != 'hotel'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM days d
|
||||||
|
WHERE d.id = reservations.day_id
|
||||||
|
AND d.date = substr(reservations.reservation_time, 1, 10)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
UPDATE reservations
|
||||||
|
SET end_day_id = NULL
|
||||||
|
WHERE reservation_end_time IS NOT NULL
|
||||||
|
AND end_day_id IS NOT NULL
|
||||||
|
AND type != 'hotel'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM days d
|
||||||
|
WHERE d.id = reservations.end_day_id
|
||||||
|
AND d.date = substr(reservations.reservation_end_time, 1, 10)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
UPDATE reservations
|
||||||
|
SET day_id = (
|
||||||
|
SELECT d.id FROM days d
|
||||||
|
WHERE d.trip_id = reservations.trip_id
|
||||||
|
AND d.date = substr(reservations.reservation_time, 1, 10)
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
WHERE type != 'hotel'
|
||||||
|
AND reservation_time IS NOT NULL
|
||||||
|
AND day_id IS NULL
|
||||||
|
`);
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
UPDATE reservations
|
||||||
|
SET end_day_id = (
|
||||||
|
SELECT d.id FROM days d
|
||||||
|
WHERE d.trip_id = reservations.trip_id
|
||||||
|
AND d.date = substr(reservations.reservation_end_time, 1, 10)
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
WHERE type != 'hotel'
|
||||||
|
AND reservation_end_time IS NOT NULL
|
||||||
|
AND end_day_id IS NULL
|
||||||
|
AND substr(reservations.reservation_end_time, 1, 10)
|
||||||
|
!= substr(reservations.reservation_time, 1, 10)
|
||||||
|
`);
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (currentVersion < migrations.length) {
|
if (currentVersion < migrations.length) {
|
||||||
|
|||||||
@@ -77,15 +77,11 @@ const upload = multer({
|
|||||||
// Routes
|
// Routes
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// Authenticated file download (supports Bearer header or ?token= query param)
|
// Authenticated file download (supports cookie, Bearer header, or ?token= query param)
|
||||||
router.get('/:id/download', (req: Request, res: Response) => {
|
router.get('/:id/download', (req: Request, res: Response) => {
|
||||||
const { tripId, id } = req.params;
|
const { tripId, id } = req.params;
|
||||||
|
|
||||||
const authHeader = req.headers['authorization'];
|
const auth = authenticateDownload(req);
|
||||||
const bearerToken = authHeader && authHeader.split(' ')[1];
|
|
||||||
const queryToken = req.query.token as string | undefined;
|
|
||||||
|
|
||||||
const auth = authenticateDownload(bearerToken, queryToken);
|
|
||||||
if ('error' in auth) return res.status(auth.status).json({ error: auth.error });
|
if ('error' in auth) return res.status(auth.status).json({ error: auth.error });
|
||||||
|
|
||||||
const trip = verifyTripAccess(tripId, auth.userId);
|
const trip = verifyTripAccess(tripId, auth.userId);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import * as svc from '../services/journeyService';
|
|||||||
import { db } from '../db/database';
|
import { db } from '../db/database';
|
||||||
import { createOrUpdateJourneyShareLink, getJourneyShareLink, deleteJourneyShareLink, getPublicJourney } from '../services/journeyShareService';
|
import { createOrUpdateJourneyShareLink, getJourneyShareLink, deleteJourneyShareLink, getPublicJourney } from '../services/journeyShareService';
|
||||||
import { uploadToImmich } from '../services/memories/immichService';
|
import { uploadToImmich } from '../services/memories/immichService';
|
||||||
|
import { getAllowedExtensions } from '../services/fileService';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -25,9 +26,26 @@ const storage = multer.diskStorage({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const imageFilter: multer.Options['fileFilter'] = (_req, file, cb) => {
|
||||||
|
if (!file.mimetype.startsWith('image/') || file.mimetype.includes('svg')) {
|
||||||
|
const err: Error & { statusCode?: number } = new Error('Only image files are allowed');
|
||||||
|
err.statusCode = 400;
|
||||||
|
return cb(err);
|
||||||
|
}
|
||||||
|
const ext = path.extname(file.originalname).toLowerCase().replace('.', '');
|
||||||
|
const allowed = getAllowedExtensions().split(',').map(e => e.trim().toLowerCase());
|
||||||
|
if (!allowed.includes('*') && !allowed.includes(ext)) {
|
||||||
|
const err: Error & { statusCode?: number } = new Error(`File type .${ext} is not allowed`);
|
||||||
|
err.statusCode = 400;
|
||||||
|
return cb(err);
|
||||||
|
}
|
||||||
|
cb(null, true);
|
||||||
|
};
|
||||||
|
|
||||||
const upload = multer({
|
const upload = multer({
|
||||||
storage,
|
storage,
|
||||||
limits: { fileSize: 20 * 1024 * 1024 },
|
limits: { fileSize: 20 * 1024 * 1024 },
|
||||||
|
fileFilter: imageFilter,
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Static prefix routes (MUST come before /:id) ─────────────────────────
|
// ── Static prefix routes (MUST come before /:id) ─────────────────────────
|
||||||
@@ -104,10 +122,11 @@ router.post('/entries/:entryId/photos', authenticate, upload.array('photos', 10)
|
|||||||
try {
|
try {
|
||||||
const immichId = await uploadToImmich(authReq.user.id, relativePath, file.originalname);
|
const immichId = await uploadToImmich(authReq.user.id, relativePath, file.originalname);
|
||||||
if (immichId) {
|
if (immichId) {
|
||||||
|
// photo.id is now the gallery photo id (journey_photos.id)
|
||||||
svc.setPhotoProvider(photo.id, 'immich', immichId, authReq.user.id);
|
svc.setPhotoProvider(photo.id, 'immich', immichId, authReq.user.id);
|
||||||
photo.provider = 'immich' as any;
|
(photo as any).provider = 'immich';
|
||||||
photo.asset_id = immichId;
|
(photo as any).asset_id = immichId;
|
||||||
photo.owner_id = authReq.user.id;
|
(photo as any).owner_id = authReq.user.id;
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
@@ -141,16 +160,25 @@ router.post('/entries/:entryId/provider-photos', authenticate, (req: Request, re
|
|||||||
res.status(201).json(photo);
|
res.status(201).json(photo);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Link an existing photo to a (different) entry
|
// Link a gallery photo to an entry
|
||||||
router.post('/entries/:entryId/link-photo', authenticate, (req: Request, res: Response) => {
|
router.post('/entries/:entryId/link-photo', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { photo_id } = req.body || {};
|
// Accept journey_photo_id (new) or photo_id (legacy alias) for backwards compat
|
||||||
if (!photo_id) return res.status(400).json({ error: 'photo_id required' });
|
const journeyPhotoId = (req.body || {}).journey_photo_id ?? (req.body || {}).photo_id;
|
||||||
const result = svc.linkPhotoToEntry(Number(req.params.entryId), Number(photo_id), authReq.user.id);
|
if (!journeyPhotoId) return res.status(400).json({ error: 'journey_photo_id required' });
|
||||||
|
const result = svc.linkPhotoToEntry(Number(req.params.entryId), Number(journeyPhotoId), authReq.user.id);
|
||||||
if (!result) return res.status(403).json({ error: 'Not allowed' });
|
if (!result) return res.status(403).json({ error: 'Not allowed' });
|
||||||
res.status(201).json(result);
|
res.status(201).json(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Unlink a photo from a specific entry (gallery row is preserved)
|
||||||
|
router.delete('/entries/:entryId/photos/:journeyPhotoId', authenticate, (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const ok = svc.unlinkPhotoFromEntry(Number(req.params.entryId), Number(req.params.journeyPhotoId), authReq.user.id);
|
||||||
|
if (!ok) return res.status(404).json({ error: 'Not found or not allowed' });
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
router.patch('/photos/:photoId', authenticate, (req: Request, res: Response) => {
|
router.patch('/photos/:photoId', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const result = svc.updatePhoto(Number(req.params.photoId), authReq.user.id, req.body || {});
|
const result = svc.updatePhoto(Number(req.params.photoId), authReq.user.id, req.body || {});
|
||||||
@@ -158,34 +186,65 @@ router.patch('/photos/:photoId', authenticate, (req: Request, res: Response) =>
|
|||||||
res.json(result);
|
res.json(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Hard-delete: removes gallery row + cascades to all entry links + deletes file if unreferenced
|
||||||
router.delete('/photos/:photoId', authenticate, async (req: Request, res: Response) => {
|
router.delete('/photos/:photoId', authenticate, async (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const photo = svc.deletePhoto(Number(req.params.photoId), authReq.user.id);
|
const photo = svc.deletePhoto(Number(req.params.photoId), authReq.user.id);
|
||||||
if (!photo) return res.status(404).json({ error: 'Photo not found' });
|
if (!photo) return res.status(404).json({ error: 'Photo not found' });
|
||||||
// delete local file
|
|
||||||
if (photo.file_path) {
|
if (photo.file_path) {
|
||||||
const fullPath = path.join(__dirname, '../../uploads', photo.file_path);
|
const fullPath = path.join(__dirname, '../../uploads', photo.file_path);
|
||||||
try { fs.unlinkSync(fullPath); } catch {}
|
try { fs.unlinkSync(fullPath); } catch {}
|
||||||
}
|
}
|
||||||
// only delete from Immich if the photo was UPLOADED through TREK (has local file)
|
|
||||||
// photos imported from Immich (no file_path) are just references — don't touch Immich
|
|
||||||
if (photo.provider === 'immich' && photo.asset_id && photo.file_path) {
|
|
||||||
try {
|
|
||||||
const { getImmichCredentials } = await import('../services/memories/immichService');
|
|
||||||
const creds = getImmichCredentials(authReq.user.id);
|
|
||||||
if (creds) {
|
|
||||||
const { safeFetch } = await import('../utils/ssrfGuard');
|
|
||||||
await safeFetch(`${creds.immich_url}/api/assets`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: { 'x-api-key': creds.immich_api_key, 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ ids: [photo.asset_id] }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Gallery (prefix /:id/gallery — before /:id) ──────────────────────────
|
||||||
|
|
||||||
|
// Upload photos directly to the journey gallery (no entry association)
|
||||||
|
router.post('/:id/gallery/photos', authenticate, upload.array('photos', 20), async (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const files = req.files as Express.Multer.File[];
|
||||||
|
if (!files?.length) return res.status(400).json({ error: 'No files uploaded' });
|
||||||
|
|
||||||
|
const filePaths = files.map(f => ({ path: `journey/${f.filename}` }));
|
||||||
|
const photos = svc.uploadGalleryPhotos(Number(req.params.id), authReq.user.id, filePaths);
|
||||||
|
if (!photos.length) return res.status(403).json({ error: 'Not allowed' });
|
||||||
|
res.status(201).json({ photos });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add provider photos to gallery only (no entry link)
|
||||||
|
router.post('/:id/gallery/provider-photos', authenticate, (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const { provider, asset_id, asset_ids, passphrase } = req.body || {};
|
||||||
|
const pp = passphrase && typeof passphrase === 'string' ? passphrase : undefined;
|
||||||
|
|
||||||
|
if (Array.isArray(asset_ids) && provider) {
|
||||||
|
const added: any[] = [];
|
||||||
|
for (const id of asset_ids) {
|
||||||
|
const photo = svc.addProviderPhotoToGallery(Number(req.params.id), authReq.user.id, provider, String(id), undefined, pp);
|
||||||
|
if (photo) added.push(photo);
|
||||||
|
}
|
||||||
|
return res.status(201).json({ photos: added, added: added.length });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!provider || !asset_id) return res.status(400).json({ error: 'provider and asset_id required' });
|
||||||
|
const photo = svc.addProviderPhotoToGallery(Number(req.params.id), authReq.user.id, provider, asset_id, undefined, pp);
|
||||||
|
if (!photo) return res.status(403).json({ error: 'Not allowed or duplicate' });
|
||||||
|
res.status(201).json(photo);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hard-delete a gallery photo (removes from all entries)
|
||||||
|
router.delete('/:id/gallery/:journeyPhotoId', authenticate, async (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const photo = svc.deleteGalleryPhoto(Number(req.params.journeyPhotoId), authReq.user.id);
|
||||||
|
if (!photo) return res.status(404).json({ error: 'Photo not found or not allowed' });
|
||||||
|
if (photo.file_path) {
|
||||||
|
const fullPath = path.join(__dirname, '../../uploads', photo.file_path);
|
||||||
|
try { fs.unlinkSync(fullPath); } catch {}
|
||||||
|
}
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
// ── Journeys /:id (parameterized routes AFTER static prefixes) ───────────
|
// ── Journeys /:id (parameterized routes AFTER static prefixes) ───────────
|
||||||
|
|
||||||
router.get('/:id', authenticate, (req: Request, res: Response) => {
|
router.get('/:id', authenticate, (req: Request, res: Response) => {
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export function parseAutoBackupBody(body: Record<string, unknown>): {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isValidBackupFilename(filename: string): boolean {
|
export function isValidBackupFilename(filename: string): boolean {
|
||||||
return /^backup-[\w\-]+\.zip$/.test(filename);
|
return /^(?:auto-)?backup-[\w-]+\.zip$/.test(filename);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function backupFilePath(filename: string): string {
|
export function backupFilePath(filename: string): string {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
import type { Request } from 'express';
|
||||||
import { db, canAccessTrip } from '../db/database';
|
import { db, canAccessTrip } from '../db/database';
|
||||||
import { consumeEphemeralToken } from './ephemeralTokens';
|
import { consumeEphemeralToken } from './ephemeralTokens';
|
||||||
import { verifyJwtAndLoadUser } from '../middleware/auth';
|
import { verifyJwtAndLoadUser } from '../middleware/auth';
|
||||||
@@ -72,23 +73,30 @@ export function resolveFilePath(filename: string): { resolved: string; safe: boo
|
|||||||
// Token-based download auth
|
// Token-based download auth
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export function authenticateDownload(bearerToken: string | undefined, queryToken: string | undefined): { userId: number } | { error: string; status: number } {
|
export function authenticateDownload(req: Request): { userId: number } | { error: string; status: number } {
|
||||||
if (!bearerToken && !queryToken) {
|
const cookieToken = (req as any).cookies?.trek_session as string | undefined;
|
||||||
return { error: 'Authentication required', status: 401 };
|
const authHeader = req.headers['authorization'];
|
||||||
}
|
const bearerToken = authHeader ? (authHeader.split(' ')[1] || undefined) : undefined;
|
||||||
|
const queryToken = req.query.token as string | undefined;
|
||||||
|
|
||||||
if (bearerToken) {
|
// Cookie and Bearer both carry a full JWT — try them first (cookie wins).
|
||||||
|
const jwtToken = cookieToken || bearerToken;
|
||||||
|
if (jwtToken) {
|
||||||
// Use the shared helper so the password_version gate applies here too;
|
// Use the shared helper so the password_version gate applies here too;
|
||||||
// previously this bypassed the check and stolen download tokens stayed
|
// previously this bypassed the check and stolen download tokens stayed
|
||||||
// valid across a password reset.
|
// valid across a password reset.
|
||||||
const user = verifyJwtAndLoadUser(bearerToken);
|
const user = verifyJwtAndLoadUser(jwtToken);
|
||||||
if (!user) return { error: 'Invalid or expired token', status: 401 };
|
if (!user) return { error: 'Invalid or expired token', status: 401 };
|
||||||
return { userId: user.id };
|
return { userId: user.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
const uid = consumeEphemeralToken(queryToken!, 'download');
|
if (queryToken) {
|
||||||
if (!uid) return { error: 'Invalid or expired token', status: 401 };
|
const uid = consumeEphemeralToken(queryToken, 'download');
|
||||||
return { userId: uid };
|
if (!uid) return { error: 'Invalid or expired token', status: 401 };
|
||||||
|
return { userId: uid };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { error: 'Authentication required', status: 401 };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -7,12 +7,22 @@ function ts(): number {
|
|||||||
return Date.now();
|
return Date.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Joined SELECT for journey_photos + trek_photos — returns fields matching JourneyPhoto interface
|
// Per-entry photo view: join journey_entry_photos → journey_photos (gallery) → trek_photos.
|
||||||
|
// id = gp.id (gallery photo id) — used by clients for linkPhoto/updatePhoto/unlink/delete.
|
||||||
const JP_SELECT = `
|
const JP_SELECT = `
|
||||||
jp.id, jp.entry_id, jp.photo_id, jp.caption, jp.sort_order, jp.shared, jp.created_at,
|
gp.id, jep.entry_id, gp.photo_id, gp.caption, jep.sort_order, gp.shared, gp.created_at,
|
||||||
tkp.provider, tkp.asset_id, tkp.owner_id, tkp.file_path, tkp.thumbnail_path, tkp.width, tkp.height
|
tp.provider, tp.asset_id, tp.owner_id, tp.file_path, tp.thumbnail_path, tp.width, tp.height
|
||||||
`;
|
`;
|
||||||
const JP_JOIN = 'journey_photos jp JOIN trek_photos tkp ON tkp.id = jp.photo_id';
|
const JP_JOIN = `journey_entry_photos jep
|
||||||
|
JOIN journey_photos gp ON gp.id = jep.journey_photo_id
|
||||||
|
JOIN trek_photos tp ON tp.id = gp.photo_id`;
|
||||||
|
|
||||||
|
// Per-journey gallery view: journey_photos → trek_photos (no entry context).
|
||||||
|
const GALLERY_SELECT = `
|
||||||
|
gp.id, gp.journey_id, gp.photo_id, gp.caption, gp.shared, gp.sort_order, gp.created_at,
|
||||||
|
tp.provider, tp.asset_id, tp.owner_id, tp.file_path, tp.thumbnail_path, tp.width, tp.height
|
||||||
|
`;
|
||||||
|
const GALLERY_JOIN = 'journey_photos gp JOIN trek_photos tp ON tp.id = gp.photo_id';
|
||||||
|
|
||||||
function broadcastJourneyEvent(journeyId: number, event: string, data: Record<string, unknown>, excludeSocketId?: string | number) {
|
function broadcastJourneyEvent(journeyId: number, event: string, data: Record<string, unknown>, excludeSocketId?: string | number) {
|
||||||
const contributors = db.prepare(
|
const contributors = db.prepare(
|
||||||
@@ -58,7 +68,7 @@ export function listJourneys(userId: number) {
|
|||||||
return db.prepare(`
|
return db.prepare(`
|
||||||
SELECT DISTINCT j.*,
|
SELECT DISTINCT j.*,
|
||||||
(SELECT COUNT(*) FROM journey_entries je WHERE je.journey_id = j.id AND je.type != 'skeleton') as entry_count,
|
(SELECT COUNT(*) FROM journey_entries je WHERE je.journey_id = j.id AND je.type != 'skeleton') as entry_count,
|
||||||
(SELECT COUNT(*) FROM journey_photos jp JOIN journey_entries je2 ON jp.entry_id = je2.id WHERE je2.journey_id = j.id) as photo_count,
|
(SELECT COUNT(*) FROM journey_photos jp WHERE jp.journey_id = j.id) as photo_count,
|
||||||
(SELECT COUNT(DISTINCT je3.location_name) FROM journey_entries je3 WHERE je3.journey_id = j.id AND je3.location_name IS NOT NULL AND je3.location_name != '') as place_count,
|
(SELECT COUNT(DISTINCT je3.location_name) FROM journey_entries je3 WHERE je3.journey_id = j.id AND je3.location_name IS NOT NULL AND je3.location_name != '') as place_count,
|
||||||
(SELECT MIN(t.start_date) FROM journey_trips jt JOIN trips t ON jt.trip_id = t.id WHERE jt.journey_id = j.id) as trip_date_min,
|
(SELECT MIN(t.start_date) FROM journey_trips jt JOIN trips t ON jt.trip_id = t.id WHERE jt.journey_id = j.id) as trip_date_min,
|
||||||
(SELECT MAX(t.end_date) FROM journey_trips jt JOIN trips t ON jt.trip_id = t.id WHERE jt.journey_id = j.id) as trip_date_max
|
(SELECT MAX(t.end_date) FROM journey_trips jt JOIN trips t ON jt.trip_id = t.id WHERE jt.journey_id = j.id) as trip_date_max
|
||||||
@@ -114,7 +124,7 @@ export function getJourneyFull(journeyId: number, userId: number) {
|
|||||||
).all(journeyId) as JourneyEntry[];
|
).all(journeyId) as JourneyEntry[];
|
||||||
|
|
||||||
const photos = db.prepare(
|
const photos = db.prepare(
|
||||||
`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.entry_id IN (SELECT id FROM journey_entries WHERE journey_id = ?) ORDER BY jp.sort_order ASC`
|
`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jep.entry_id IN (SELECT id FROM journey_entries WHERE journey_id = ?) ORDER BY jep.sort_order ASC`
|
||||||
).all(journeyId) as JourneyPhoto[];
|
).all(journeyId) as JourneyPhoto[];
|
||||||
|
|
||||||
// group photos by entry
|
// group photos by entry
|
||||||
@@ -123,12 +133,11 @@ export function getJourneyFull(journeyId: number, userId: number) {
|
|||||||
(photosByEntry[p.entry_id] ||= []).push(p);
|
(photosByEntry[p.entry_id] ||= []).push(p);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const gallery = db.prepare(
|
||||||
|
`SELECT ${GALLERY_SELECT} FROM ${GALLERY_JOIN} WHERE gp.journey_id = ? ORDER BY gp.sort_order ASC, gp.id ASC`
|
||||||
|
).all(journeyId);
|
||||||
|
|
||||||
const enrichedEntries = entries
|
const enrichedEntries = entries
|
||||||
.filter(e => {
|
|
||||||
// hide empty Gallery entries (no photos, no story)
|
|
||||||
if (e.title === 'Gallery' && !e.story && !(photosByEntry[e.id]?.length)) return false;
|
|
||||||
return true;
|
|
||||||
})
|
|
||||||
.map(e => ({
|
.map(e => ({
|
||||||
...e,
|
...e,
|
||||||
tags: e.tags ? JSON.parse(e.tags) : [],
|
tags: e.tags ? JSON.parse(e.tags) : [],
|
||||||
@@ -160,7 +169,7 @@ export function getJourneyFull(journeyId: number, userId: number) {
|
|||||||
|
|
||||||
// stats
|
// stats
|
||||||
const entryCount = entries.filter(e => e.type === 'entry').length;
|
const entryCount = entries.filter(e => e.type === 'entry').length;
|
||||||
const photoCount = photos.length;
|
const photoCount = (gallery as any[]).length;
|
||||||
const places = [...new Set(entries.map(e => e.location_name).filter(Boolean))];
|
const places = [...new Set(entries.map(e => e.location_name).filter(Boolean))];
|
||||||
|
|
||||||
const userPrefs = db.prepare(
|
const userPrefs = db.prepare(
|
||||||
@@ -183,6 +192,7 @@ export function getJourneyFull(journeyId: number, userId: number) {
|
|||||||
return {
|
return {
|
||||||
...journey,
|
...journey,
|
||||||
entries: enrichedEntries,
|
entries: enrichedEntries,
|
||||||
|
gallery,
|
||||||
trips,
|
trips,
|
||||||
contributors,
|
contributors,
|
||||||
stats: { entries: entryCount, photos: photoCount, places: places.length },
|
stats: { entries: entryCount, photos: photoCount, places: places.length },
|
||||||
@@ -315,46 +325,22 @@ export function syncTripPlaces(journeyId: number, tripId: number, authorId: numb
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// import trip_photos into journey when a trip is linked
|
// import trip_photos into journey gallery when a trip is linked
|
||||||
function syncTripPhotos(journeyId: number, tripId: number) {
|
function syncTripPhotos(journeyId: number, tripId: number) {
|
||||||
const tripPhotos = db.prepare(
|
const tripPhotos = db.prepare(
|
||||||
'SELECT tp.photo_id, tp.user_id, tp.shared FROM trip_photos tp WHERE tp.trip_id = ?'
|
'SELECT tp.photo_id, tp.shared FROM trip_photos tp WHERE tp.trip_id = ?'
|
||||||
).all(tripId) as { photo_id: number; user_id: number; shared: number }[];
|
).all(tripId) as { photo_id: number; shared: number }[];
|
||||||
if (!tripPhotos.length) return;
|
if (!tripPhotos.length) return;
|
||||||
|
|
||||||
const now = ts();
|
const now = ts();
|
||||||
|
const maxOrderRow = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE journey_id = ?').get(journeyId) as { m: number | null };
|
||||||
|
let nextOrder = (maxOrderRow?.m ?? -1) + 1;
|
||||||
|
|
||||||
// find or create a "Photos" entry for this trip's photos
|
|
||||||
let photoEntry = db.prepare(`
|
|
||||||
SELECT id FROM journey_entries
|
|
||||||
WHERE journey_id = ? AND source_trip_id = ? AND title = '[Trip Photos]' AND type = 'entry'
|
|
||||||
`).get(journeyId, tripId) as { id: number } | undefined;
|
|
||||||
|
|
||||||
if (!photoEntry) {
|
|
||||||
const trip = db.prepare('SELECT start_date FROM trips WHERE id = ?').get(tripId) as { start_date: string } | undefined;
|
|
||||||
const entryDate = trip?.start_date || new Date().toISOString().split('T')[0];
|
|
||||||
const owner = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(journeyId) as { user_id: number };
|
|
||||||
|
|
||||||
const res = db.prepare(`
|
|
||||||
INSERT INTO journey_entries (journey_id, source_trip_id, author_id, type, title, entry_date, sort_order, created_at, updated_at)
|
|
||||||
VALUES (?, ?, ?, 'entry', '[Trip Photos]', ?, 999, ?, ?)
|
|
||||||
`).run(journeyId, tripId, owner.user_id, entryDate, now, now);
|
|
||||||
photoEntry = { id: Number(res.lastInsertRowid) };
|
|
||||||
}
|
|
||||||
|
|
||||||
// import each trip photo, skip duplicates (by photo_id)
|
|
||||||
for (const tp of tripPhotos) {
|
for (const tp of tripPhotos) {
|
||||||
const exists = db.prepare(
|
|
||||||
'SELECT 1 FROM journey_photos WHERE entry_id = ? AND photo_id = ?'
|
|
||||||
).get(photoEntry.id, tp.photo_id);
|
|
||||||
if (exists) continue;
|
|
||||||
|
|
||||||
const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE entry_id = ?').get(photoEntry.id) as { m: number | null };
|
|
||||||
|
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
INSERT INTO journey_photos (entry_id, photo_id, shared, sort_order, created_at)
|
INSERT OR IGNORE INTO journey_photos (journey_id, photo_id, shared, sort_order, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?)
|
||||||
`).run(photoEntry.id, tp.photo_id, tp.shared, (maxOrder?.m ?? -1) + 1, now);
|
`).run(journeyId, tp.photo_id, tp.shared, nextOrder++, now);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -444,7 +430,7 @@ export function onPlaceDeleted(placeId: number) {
|
|||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (entry.type === 'skeleton') {
|
if (entry.type === 'skeleton') {
|
||||||
// no content: just delete
|
// no content: just delete
|
||||||
const hasPhotos = db.prepare('SELECT 1 FROM journey_photos WHERE entry_id = ?').get(entry.id);
|
const hasPhotos = db.prepare('SELECT 1 FROM journey_entry_photos WHERE entry_id = ?').get(entry.id);
|
||||||
if (!hasPhotos && !entry.story) {
|
if (!hasPhotos && !entry.story) {
|
||||||
db.prepare('DELETE FROM journey_entries WHERE id = ?').run(entry.id);
|
db.prepare('DELETE FROM journey_entries WHERE id = ?').run(entry.id);
|
||||||
continue;
|
continue;
|
||||||
@@ -469,7 +455,7 @@ export function listEntries(journeyId: number, userId: number) {
|
|||||||
).all(journeyId) as JourneyEntry[];
|
).all(journeyId) as JourneyEntry[];
|
||||||
|
|
||||||
const photos = db.prepare(
|
const photos = db.prepare(
|
||||||
`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.entry_id IN (SELECT id FROM journey_entries WHERE journey_id = ?) ORDER BY jp.sort_order ASC`
|
`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jep.entry_id IN (SELECT id FROM journey_entries WHERE journey_id = ?) ORDER BY jep.sort_order ASC`
|
||||||
).all(journeyId) as JourneyPhoto[];
|
).all(journeyId) as JourneyPhoto[];
|
||||||
|
|
||||||
const photosByEntry: Record<number, JourneyPhoto[]> = {};
|
const photosByEntry: Record<number, JourneyPhoto[]> = {};
|
||||||
@@ -628,9 +614,6 @@ export function deleteEntry(entryId: number, userId: number, sid?: string): bool
|
|||||||
if (!entry) return false;
|
if (!entry) return false;
|
||||||
if (!canEdit(entry.journey_id, userId)) return false;
|
if (!canEdit(entry.journey_id, userId)) return false;
|
||||||
|
|
||||||
// delete photos along with the entry — no more orphan Gallery entries
|
|
||||||
db.prepare('DELETE FROM journey_photos WHERE entry_id = ?').run(entryId);
|
|
||||||
|
|
||||||
if (entry.source_trip_id && entry.source_place_id && entry.type !== 'skeleton') {
|
if (entry.source_trip_id && entry.source_place_id && entry.type !== 'skeleton') {
|
||||||
// Revert filled entry back to skeleton instead of deleting
|
// Revert filled entry back to skeleton instead of deleting
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
@@ -645,12 +628,6 @@ export function deleteEntry(entryId: number, userId: number, sid?: string): bool
|
|||||||
broadcastJourneyEvent(entry.journey_id, 'journey:entry:deleted', { entryId }, sid);
|
broadcastJourneyEvent(entry.journey_id, 'journey:entry:deleted', { entryId }, sid);
|
||||||
}
|
}
|
||||||
|
|
||||||
// clean up any empty Gallery entries in this journey
|
|
||||||
db.prepare(`
|
|
||||||
DELETE FROM journey_entries WHERE journey_id = ? AND title = 'Gallery'
|
|
||||||
AND id NOT IN (SELECT DISTINCT entry_id FROM journey_photos)
|
|
||||||
`).run(entry.journey_id);
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -664,23 +641,40 @@ function promoteSkeletonIfNeeded(entry: JourneyEntry): void {
|
|||||||
db.prepare('UPDATE journey_entries SET type = ?, updated_at = ? WHERE id = ?').run('entry', ts(), entry.id);
|
db.prepare('UPDATE journey_entries SET type = ?, updated_at = ? WHERE id = ?').run('entry', ts(), entry.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure a trek_photo_id is in the journey gallery; return its gallery row id.
|
||||||
|
function ensureInGallery(journeyId: number, trekPhotoId: number, caption?: string, shared?: number): number {
|
||||||
|
const now = ts();
|
||||||
|
const maxOrderRow = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE journey_id = ?').get(journeyId) as { m: number | null };
|
||||||
|
db.prepare(`
|
||||||
|
INSERT OR IGNORE INTO journey_photos (journey_id, photo_id, caption, shared, sort_order, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(journeyId, trekPhotoId, caption || null, shared ?? 0, (maxOrderRow?.m ?? -1) + 1, now);
|
||||||
|
const row = db.prepare('SELECT id FROM journey_photos WHERE journey_id = ? AND photo_id = ?').get(journeyId, trekPhotoId) as { id: number };
|
||||||
|
return row.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link a gallery photo to an entry (idempotent). Returns the junction JP_SELECT row.
|
||||||
|
function linkGalleryPhotoToEntry(galleryId: number, entryId: number): JourneyPhoto | null {
|
||||||
|
const now = ts();
|
||||||
|
const maxOrderRow = db.prepare('SELECT MAX(sort_order) as m FROM journey_entry_photos WHERE entry_id = ?').get(entryId) as { m: number | null };
|
||||||
|
db.prepare(`
|
||||||
|
INSERT OR IGNORE INTO journey_entry_photos (entry_id, journey_photo_id, sort_order, created_at)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
`).run(entryId, galleryId, (maxOrderRow?.m ?? -1) + 1, now);
|
||||||
|
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jep.entry_id = ? AND jep.journey_photo_id = ?`)
|
||||||
|
.get(entryId, galleryId) as JourneyPhoto | null;
|
||||||
|
}
|
||||||
|
|
||||||
export function addPhoto(entryId: number, userId: number, filePath: string, thumbnailPath?: string, caption?: string): JourneyPhoto | null {
|
export function addPhoto(entryId: number, userId: number, filePath: string, thumbnailPath?: string, caption?: string): JourneyPhoto | null {
|
||||||
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
|
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
|
||||||
if (!entry) return null;
|
if (!entry) return null;
|
||||||
if (!canEdit(entry.journey_id, userId)) return null;
|
if (!canEdit(entry.journey_id, userId)) return null;
|
||||||
|
|
||||||
const trekPhotoId = getOrCreateLocalTrekPhoto(filePath, thumbnailPath);
|
const trekPhotoId = getOrCreateLocalTrekPhoto(filePath, thumbnailPath);
|
||||||
const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE entry_id = ?').get(entryId) as { m: number | null };
|
const galleryId = db.transaction(() => ensureInGallery(entry.journey_id, trekPhotoId, caption))();
|
||||||
const now = ts();
|
const result = linkGalleryPhotoToEntry(galleryId, entryId);
|
||||||
|
|
||||||
const res = db.prepare(`
|
|
||||||
INSERT INTO journey_photos (entry_id, photo_id, caption, sort_order, created_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?)
|
|
||||||
`).run(entryId, trekPhotoId, caption || null, (maxOrder?.m ?? -1) + 1, now);
|
|
||||||
|
|
||||||
promoteSkeletonIfNeeded(entry);
|
promoteSkeletonIfNeeded(entry);
|
||||||
|
return result;
|
||||||
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(Number(res.lastInsertRowid)) as JourneyPhoto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addProviderPhoto(entryId: number, userId: number, provider: string, assetId: string, caption?: string, passphrase?: string): JourneyPhoto | null {
|
export function addProviderPhoto(entryId: number, userId: number, provider: string, assetId: string, caption?: string, passphrase?: string): JourneyPhoto | null {
|
||||||
@@ -690,119 +684,127 @@ export function addProviderPhoto(entryId: number, userId: number, provider: stri
|
|||||||
|
|
||||||
const trekPhotoId = getOrCreateTrekPhoto(provider, assetId, userId, passphrase);
|
const trekPhotoId = getOrCreateTrekPhoto(provider, assetId, userId, passphrase);
|
||||||
|
|
||||||
// skip if already added
|
// skip if this photo is already linked to this entry
|
||||||
const exists = db.prepare('SELECT 1 FROM journey_photos WHERE entry_id = ? AND photo_id = ?').get(entryId, trekPhotoId);
|
const alreadyLinked = db.prepare(`
|
||||||
if (exists) return null;
|
SELECT 1 FROM journey_entry_photos jep
|
||||||
|
JOIN journey_photos gp ON gp.id = jep.journey_photo_id
|
||||||
const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE entry_id = ?').get(entryId) as { m: number | null };
|
WHERE jep.entry_id = ? AND gp.photo_id = ?
|
||||||
const now = ts();
|
`).get(entryId, trekPhotoId);
|
||||||
|
if (alreadyLinked) return null;
|
||||||
const res = db.prepare(`
|
|
||||||
INSERT INTO journey_photos (entry_id, photo_id, caption, sort_order, created_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?)
|
|
||||||
`).run(entryId, trekPhotoId, caption || null, (maxOrder?.m ?? -1) + 1, now);
|
|
||||||
|
|
||||||
|
const galleryId = db.transaction(() => ensureInGallery(entry.journey_id, trekPhotoId, caption))();
|
||||||
|
const result = linkGalleryPhotoToEntry(galleryId, entryId);
|
||||||
promoteSkeletonIfNeeded(entry);
|
promoteSkeletonIfNeeded(entry);
|
||||||
|
return result;
|
||||||
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(Number(res.lastInsertRowid)) as JourneyPhoto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function linkPhotoToEntry(entryId: number, photoId: number, userId: number): JourneyPhoto | null {
|
// Link a gallery photo (by its journey_photos.id) to an entry — idempotent.
|
||||||
|
export function linkPhotoToEntry(entryId: number, journeyPhotoId: number, userId: number): JourneyPhoto | null {
|
||||||
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
|
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
|
||||||
if (!entry) return null;
|
if (!entry) return null;
|
||||||
if (!canEdit(entry.journey_id, userId)) return null;
|
if (!canEdit(entry.journey_id, userId)) return null;
|
||||||
|
|
||||||
const source = db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(photoId) as JourneyPhoto | undefined;
|
// Verify the gallery photo belongs to this journey
|
||||||
if (!source) return null;
|
const galleryRow = db.prepare('SELECT id, journey_id FROM journey_photos WHERE id = ?').get(journeyPhotoId) as { id: number; journey_id: number } | undefined;
|
||||||
|
if (!galleryRow || galleryRow.journey_id !== entry.journey_id) return null;
|
||||||
if (source.entry_id === entryId) return source;
|
|
||||||
|
|
||||||
const oldEntry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(source.entry_id) as JourneyEntry | undefined;
|
|
||||||
const sourceIsGallery = oldEntry?.title === 'Gallery';
|
|
||||||
|
|
||||||
// skip if target already has this photo (by trek_photo_id)
|
|
||||||
const dupe = db.prepare('SELECT id FROM journey_photos WHERE entry_id = ? AND photo_id = ?').get(entryId, source.photo_id) as { id: number } | undefined;
|
|
||||||
if (dupe) return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(dupe.id) as JourneyPhoto;
|
|
||||||
|
|
||||||
const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE entry_id = ?').get(entryId) as { m: number | null };
|
|
||||||
let resultId: number;
|
|
||||||
|
|
||||||
if (sourceIsGallery) {
|
|
||||||
// Copy so the photo stays in the gallery even after being used in an entry.
|
|
||||||
const res = db.prepare(`
|
|
||||||
INSERT INTO journey_photos (entry_id, photo_id, caption, sort_order, created_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?)
|
|
||||||
`).run(entryId, source.photo_id, source.caption || null, (maxOrder?.m ?? -1) + 1, ts());
|
|
||||||
resultId = Number(res.lastInsertRowid);
|
|
||||||
} else {
|
|
||||||
// Non-gallery source: keep existing move behavior.
|
|
||||||
db.prepare('UPDATE journey_photos SET entry_id = ? WHERE id = ?').run(entryId, photoId);
|
|
||||||
resultId = photoId;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const result = linkGalleryPhotoToEntry(galleryRow.id, entryId);
|
||||||
promoteSkeletonIfNeeded(entry);
|
promoteSkeletonIfNeeded(entry);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
// If we moved out of a Gallery entry (shouldn't happen with the guard above,
|
// Upload photos to the journey gallery only (no entry association).
|
||||||
// but kept for any legacy data), clean up the Gallery wrapper if emptied.
|
export function uploadGalleryPhotos(journeyId: number, userId: number, filePaths: { path: string; thumbnail?: string }[]): JourneyPhoto[] {
|
||||||
if (!sourceIsGallery && oldEntry && oldEntry.title === 'Gallery') {
|
if (!canEdit(journeyId, userId)) return [];
|
||||||
const remaining = db.prepare('SELECT COUNT(*) as c FROM journey_photos WHERE entry_id = ?').get(source.entry_id) as { c: number };
|
const results: any[] = [];
|
||||||
if (remaining.c === 0) {
|
const now = ts();
|
||||||
db.prepare('DELETE FROM journey_entries WHERE id = ?').run(source.entry_id);
|
const maxOrderRow = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE journey_id = ?').get(journeyId) as { m: number | null };
|
||||||
}
|
let nextOrder = (maxOrderRow?.m ?? -1) + 1;
|
||||||
|
|
||||||
|
for (const f of filePaths) {
|
||||||
|
const trekPhotoId = getOrCreateLocalTrekPhoto(f.path, f.thumbnail);
|
||||||
|
db.prepare(`
|
||||||
|
INSERT OR IGNORE INTO journey_photos (journey_id, photo_id, shared, sort_order, created_at)
|
||||||
|
VALUES (?, ?, 0, ?, ?)
|
||||||
|
`).run(journeyId, trekPhotoId, nextOrder++, now);
|
||||||
|
const row = db.prepare(`SELECT ${GALLERY_SELECT} FROM ${GALLERY_JOIN} WHERE gp.journey_id = ? AND gp.photo_id = ?`).get(journeyId, trekPhotoId);
|
||||||
|
if (row) results.push(row);
|
||||||
}
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(resultId) as JourneyPhoto;
|
// Add a provider photo to the gallery only (no entry link).
|
||||||
|
export function addProviderPhotoToGallery(journeyId: number, userId: number, provider: string, assetId: string, caption?: string, passphrase?: string): any | null {
|
||||||
|
if (!canEdit(journeyId, userId)) return null;
|
||||||
|
const trekPhotoId = getOrCreateTrekPhoto(provider, assetId, userId, passphrase);
|
||||||
|
const galleryId = db.transaction(() => ensureInGallery(journeyId, trekPhotoId, caption))();
|
||||||
|
return db.prepare(`SELECT ${GALLERY_SELECT} FROM ${GALLERY_JOIN} WHERE gp.id = ?`).get(galleryId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlink a photo from a specific entry; gallery row is preserved.
|
||||||
|
export function unlinkPhotoFromEntry(entryId: number, journeyPhotoId: number, userId: number): boolean {
|
||||||
|
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
|
||||||
|
if (!entry) return false;
|
||||||
|
if (!canEdit(entry.journey_id, userId)) return false;
|
||||||
|
|
||||||
|
const result = db.prepare('DELETE FROM journey_entry_photos WHERE entry_id = ? AND journey_photo_id = ?').run(entryId, journeyPhotoId);
|
||||||
|
return result.changes > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hard-delete a gallery photo (removes from all entries and the gallery).
|
||||||
|
export function deleteGalleryPhoto(journeyPhotoId: number, userId: number): { photo_id: number; file_path?: string | null } | null {
|
||||||
|
const row = db.prepare('SELECT * FROM journey_photos WHERE id = ?').get(journeyPhotoId) as { id: number; journey_id: number; photo_id: number } | undefined;
|
||||||
|
if (!row) return null;
|
||||||
|
if (!canEdit(row.journey_id, userId)) return null;
|
||||||
|
|
||||||
|
const trekRow = db.prepare('SELECT file_path, provider FROM trek_photos WHERE id = ?').get(row.photo_id) as { file_path?: string; provider?: string } | undefined;
|
||||||
|
|
||||||
|
// cascade on journey_entry_photos.journey_photo_id handles junction cleanup
|
||||||
|
db.prepare('DELETE FROM journey_photos WHERE id = ?').run(journeyPhotoId);
|
||||||
|
deleteTrekPhotoIfOrphan(row.photo_id);
|
||||||
|
|
||||||
|
return { photo_id: row.photo_id, file_path: trekRow?.file_path ?? null };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setPhotoProvider(photoId: number, provider: string, assetId: string, ownerId: number) {
|
export function setPhotoProvider(photoId: number, provider: string, assetId: string, ownerId: number) {
|
||||||
// Get the trek_photo_id from the journey_photo, then update the central registry
|
// photoId = journey_photos.id (gallery row); look up the trek_photo_id
|
||||||
const jp = db.prepare('SELECT photo_id FROM journey_photos WHERE id = ?').get(photoId) as { photo_id: number } | undefined;
|
const jp = db.prepare('SELECT photo_id FROM journey_photos WHERE id = ?').get(photoId) as { photo_id: number } | undefined;
|
||||||
if (!jp) return;
|
if (!jp) return;
|
||||||
setTrekPhotoProvider(jp.photo_id, provider, assetId, ownerId);
|
setTrekPhotoProvider(jp.photo_id, provider, assetId, ownerId);
|
||||||
|
// also denorm on gallery row for fast reads
|
||||||
|
db.prepare('UPDATE journey_photos SET provider = ?, asset_id = ?, owner_id = ? WHERE id = ?').run(provider, assetId, ownerId, photoId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updatePhoto(photoId: number, userId: number, data: { caption?: string; sort_order?: number }): JourneyPhoto | null {
|
export function updatePhoto(photoId: number, userId: number, data: { caption?: string; sort_order?: number }): JourneyPhoto | null {
|
||||||
const photo = db.prepare(`
|
// photoId = journey_photos.id (gallery row)
|
||||||
SELECT ${JP_SELECT}, je.journey_id FROM ${JP_JOIN}
|
const row = db.prepare('SELECT id, journey_id FROM journey_photos WHERE id = ?').get(photoId) as { id: number; journey_id: number } | undefined;
|
||||||
JOIN journey_entries je ON jp.entry_id = je.id
|
if (!row) return null;
|
||||||
WHERE jp.id = ?
|
if (!canEdit(row.journey_id, userId)) return null;
|
||||||
`).get(photoId) as (JourneyPhoto & { journey_id: number }) | undefined;
|
|
||||||
if (!photo) return null;
|
|
||||||
if (!canEdit(photo.journey_id, userId)) return null;
|
|
||||||
|
|
||||||
const fields: string[] = [];
|
// caption lives on the gallery row; sort_order lives on the junction table
|
||||||
const values: unknown[] = [];
|
// (JP_SELECT reads jep.sort_order, so updating journey_photos.sort_order
|
||||||
if (data.caption !== undefined) { fields.push('caption = ?'); values.push(data.caption); }
|
// would not be reflected in the returned row).
|
||||||
if (data.sort_order !== undefined) { fields.push('sort_order = ?'); values.push(data.sort_order); }
|
if (data.caption !== undefined) {
|
||||||
if (!fields.length) return photo;
|
db.prepare('UPDATE journey_photos SET caption = ? WHERE id = ?').run(data.caption, photoId);
|
||||||
|
}
|
||||||
values.push(photoId);
|
if (data.sort_order !== undefined) {
|
||||||
db.prepare(`UPDATE journey_photos SET ${fields.join(', ')} WHERE id = ?`).run(...values);
|
db.prepare('UPDATE journey_entry_photos SET sort_order = ? WHERE journey_photo_id = ?').run(data.sort_order, photoId);
|
||||||
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(photoId) as JourneyPhoto;
|
}
|
||||||
|
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE gp.id = ? LIMIT 1`).get(photoId) as JourneyPhoto | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deletePhoto(photoId: number, userId: number): (JourneyPhoto & { journey_id: number }) | null {
|
// deletePhoto: hard-delete (backwards compat name used by old route).
|
||||||
const photo = db.prepare(`
|
export function deletePhoto(photoId: number, userId: number): { id: number; photo_id: number; file_path?: string | null; journey_id: number } | null {
|
||||||
SELECT ${JP_SELECT}, je.journey_id FROM ${JP_JOIN}
|
const row = db.prepare('SELECT id, journey_id, photo_id FROM journey_photos WHERE id = ?').get(photoId) as { id: number; journey_id: number; photo_id: number } | undefined;
|
||||||
JOIN journey_entries je ON jp.entry_id = je.id
|
if (!row) return null;
|
||||||
WHERE jp.id = ?
|
if (!canEdit(row.journey_id, userId)) return null;
|
||||||
`).get(photoId) as (JourneyPhoto & { journey_id: number }) | undefined;
|
|
||||||
if (!photo) return null;
|
const trekRow = db.prepare('SELECT file_path, provider FROM trek_photos WHERE id = ?').get(row.photo_id) as { file_path?: string; provider?: string } | undefined;
|
||||||
if (!canEdit(photo.journey_id, userId)) return null;
|
|
||||||
|
|
||||||
db.prepare('DELETE FROM journey_photos WHERE id = ?').run(photoId);
|
db.prepare('DELETE FROM journey_photos WHERE id = ?').run(photoId);
|
||||||
deleteTrekPhotoIfOrphan(photo.photo_id);
|
deleteTrekPhotoIfOrphan(row.photo_id);
|
||||||
|
|
||||||
// clean up empty Gallery entries left behind
|
return { id: row.id, photo_id: row.photo_id, file_path: trekRow?.file_path ?? null, journey_id: row.journey_id };
|
||||||
const remaining = db.prepare('SELECT 1 FROM journey_photos WHERE entry_id = ?').get(photo.entry_id);
|
|
||||||
if (!remaining) {
|
|
||||||
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(photo.entry_id) as JourneyEntry | undefined;
|
|
||||||
if (entry && entry.title === 'Gallery' && !entry.story) {
|
|
||||||
db.prepare('DELETE FROM journey_entries WHERE id = ?').run(photo.entry_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return photo;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Contributors ─────────────────────────────────────────────────────────
|
// ── Contributors ─────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -66,11 +66,10 @@ export function validateShareTokenForPhoto(token: string, photoId: number): { jo
|
|||||||
const row = db.prepare('SELECT journey_id FROM journey_share_tokens WHERE token = ?').get(token) as any;
|
const row = db.prepare('SELECT journey_id FROM journey_share_tokens WHERE token = ?').get(token) as any;
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
const photo = db.prepare(`
|
const photo = db.prepare(`
|
||||||
SELECT jp.photo_id, tkp.owner_id, je.journey_id
|
SELECT gp.photo_id, tkp.owner_id, gp.journey_id
|
||||||
FROM journey_photos jp
|
FROM journey_photos gp
|
||||||
JOIN trek_photos tkp ON tkp.id = jp.photo_id
|
JOIN trek_photos tkp ON tkp.id = gp.photo_id
|
||||||
JOIN journey_entries je ON jp.entry_id = je.id
|
WHERE gp.photo_id = ? AND gp.journey_id = ?
|
||||||
WHERE jp.photo_id = ? AND je.journey_id = ?
|
|
||||||
`).get(photoId, row.journey_id) as any;
|
`).get(photoId, row.journey_id) as any;
|
||||||
if (!photo) return null;
|
if (!photo) return null;
|
||||||
const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(row.journey_id) as any;
|
const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(row.journey_id) as any;
|
||||||
@@ -81,10 +80,9 @@ export function validateShareTokenForAsset(token: string, assetId: string): { ow
|
|||||||
const row = db.prepare('SELECT journey_id FROM journey_share_tokens WHERE token = ?').get(token) as any;
|
const row = db.prepare('SELECT journey_id FROM journey_share_tokens WHERE token = ?').get(token) as any;
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
const photo = db.prepare(`
|
const photo = db.prepare(`
|
||||||
SELECT tkp.owner_id FROM journey_photos jp
|
SELECT tkp.owner_id FROM journey_photos gp
|
||||||
JOIN trek_photos tkp ON tkp.id = jp.photo_id
|
JOIN trek_photos tkp ON tkp.id = gp.photo_id
|
||||||
JOIN journey_entries je ON jp.entry_id = je.id
|
WHERE tkp.asset_id = ? AND gp.journey_id = ?
|
||||||
WHERE tkp.asset_id = ? AND je.journey_id = ?
|
|
||||||
`).get(assetId, row.journey_id) as any;
|
`).get(assetId, row.journey_id) as any;
|
||||||
if (!photo) {
|
if (!photo) {
|
||||||
const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(row.journey_id) as any;
|
const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(row.journey_id) as any;
|
||||||
@@ -108,13 +106,13 @@ export function getPublicJourney(token: string) {
|
|||||||
`).all(row.journey_id) as any[];
|
`).all(row.journey_id) as any[];
|
||||||
|
|
||||||
const photos = db.prepare(`
|
const photos = db.prepare(`
|
||||||
SELECT jp.id, jp.entry_id, jp.photo_id, jp.caption, jp.sort_order, jp.shared, jp.created_at,
|
SELECT gp.id, jep.entry_id, gp.photo_id, gp.caption, jep.sort_order, gp.shared, gp.created_at,
|
||||||
tkp.provider, tkp.asset_id, tkp.owner_id, tkp.file_path, tkp.thumbnail_path, tkp.width, tkp.height
|
tkp.provider, tkp.asset_id, tkp.owner_id, tkp.file_path, tkp.thumbnail_path, tkp.width, tkp.height
|
||||||
FROM journey_photos jp
|
FROM journey_entry_photos jep
|
||||||
JOIN trek_photos tkp ON tkp.id = jp.photo_id
|
JOIN journey_photos gp ON gp.id = jep.journey_photo_id
|
||||||
JOIN journey_entries je ON jp.entry_id = je.id
|
JOIN trek_photos tkp ON tkp.id = gp.photo_id
|
||||||
WHERE je.journey_id = ?
|
WHERE gp.journey_id = ?
|
||||||
ORDER BY jp.sort_order
|
ORDER BY jep.sort_order
|
||||||
`).all(row.journey_id) as any[];
|
`).all(row.journey_id) as any[];
|
||||||
|
|
||||||
const photosByEntry: Record<number, any[]> = {};
|
const photosByEntry: Record<number, any[]> = {};
|
||||||
@@ -122,12 +120,16 @@ export function getPublicJourney(token: string) {
|
|||||||
(photosByEntry[p.entry_id] ||= []).push(p);
|
(photosByEntry[p.entry_id] ||= []).push(p);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const gallery = db.prepare(`
|
||||||
|
SELECT gp.id, gp.journey_id, gp.photo_id, gp.caption, gp.shared, gp.sort_order, gp.created_at,
|
||||||
|
tkp.provider, tkp.asset_id, tkp.owner_id, tkp.file_path, tkp.thumbnail_path, tkp.width, tkp.height
|
||||||
|
FROM journey_photos gp
|
||||||
|
JOIN trek_photos tkp ON tkp.id = gp.photo_id
|
||||||
|
WHERE gp.journey_id = ?
|
||||||
|
ORDER BY gp.sort_order
|
||||||
|
`).all(row.journey_id) as any[];
|
||||||
|
|
||||||
const enrichedEntries = entries
|
const enrichedEntries = entries
|
||||||
.filter(e => {
|
|
||||||
// hide empty Gallery entries (no photos, no story)
|
|
||||||
if (e.title === 'Gallery' && !e.story && !(photosByEntry[e.id]?.length)) return false;
|
|
||||||
return true;
|
|
||||||
})
|
|
||||||
.map(e => ({
|
.map(e => ({
|
||||||
...e,
|
...e,
|
||||||
tags: e.tags ? JSON.parse(e.tags) : [],
|
tags: e.tags ? JSON.parse(e.tags) : [],
|
||||||
@@ -138,7 +140,7 @@ export function getPublicJourney(token: string) {
|
|||||||
// Stats
|
// Stats
|
||||||
const stats = {
|
const stats = {
|
||||||
entries: entries.length,
|
entries: entries.length,
|
||||||
photos: photos.length,
|
photos: gallery.length,
|
||||||
places: new Set(entries.filter(e => e.location_name).map(e => e.location_name)).size,
|
places: new Set(entries.filter(e => e.location_name).map(e => e.location_name)).size,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -150,6 +152,7 @@ export function getPublicJourney(token: string) {
|
|||||||
status: journey.status,
|
status: journey.status,
|
||||||
},
|
},
|
||||||
entries: enrichedEntries,
|
entries: enrichedEntries,
|
||||||
|
gallery,
|
||||||
stats,
|
stats,
|
||||||
permissions: {
|
permissions: {
|
||||||
share_timeline: !!row.share_timeline,
|
share_timeline: !!row.share_timeline,
|
||||||
|
|||||||
@@ -129,15 +129,14 @@ export function canAccessUserPhoto(requestingUserId: number, ownerUserId: number
|
|||||||
// Journey photos use tripId=0 — check journey_photos + journey_contributors
|
// Journey photos use tripId=0 — check journey_photos + journey_contributors
|
||||||
if (tripId === '0') {
|
if (tripId === '0') {
|
||||||
const journeyPhoto = db.prepare(`
|
const journeyPhoto = db.prepare(`
|
||||||
SELECT jp.entry_id, je.journey_id
|
SELECT gp.journey_id
|
||||||
FROM journey_photos jp
|
FROM journey_photos gp
|
||||||
JOIN trek_photos tkp ON tkp.id = jp.photo_id
|
JOIN trek_photos tkp ON tkp.id = gp.photo_id
|
||||||
JOIN journey_entries je ON je.id = jp.entry_id
|
|
||||||
WHERE tkp.asset_id = ?
|
WHERE tkp.asset_id = ?
|
||||||
AND tkp.provider = ?
|
AND tkp.provider = ?
|
||||||
AND tkp.owner_id = ?
|
AND tkp.owner_id = ?
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`).get(assetId, provider, ownerUserId) as { entry_id: number; journey_id: number } | undefined;
|
`).get(assetId, provider, ownerUserId) as { journey_id: number } | undefined;
|
||||||
if (!journeyPhoto) return false;
|
if (!journeyPhoto) return false;
|
||||||
|
|
||||||
const access = db.prepare(`
|
const access = db.prepare(`
|
||||||
@@ -194,13 +193,12 @@ export function canAccessTrekPhoto(requestingUserId: number, trekPhotoId: number
|
|||||||
|
|
||||||
// Check journey_photos — is this photo in a journey the user can access?
|
// Check journey_photos — is this photo in a journey the user can access?
|
||||||
const journeyAccess = db.prepare(`
|
const journeyAccess = db.prepare(`
|
||||||
SELECT 1 FROM journey_photos jp
|
SELECT 1 FROM journey_photos gp
|
||||||
JOIN journey_entries je ON je.id = jp.entry_id
|
WHERE gp.photo_id = ?
|
||||||
WHERE jp.photo_id = ?
|
|
||||||
AND EXISTS (
|
AND EXISTS (
|
||||||
SELECT 1 FROM journeys j WHERE j.id = je.journey_id AND j.user_id = ?
|
SELECT 1 FROM journeys j WHERE j.id = gp.journey_id AND j.user_id = ?
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 1 FROM journey_contributors jc WHERE jc.journey_id = je.journey_id AND jc.user_id = ?
|
SELECT 1 FROM journey_contributors jc WHERE jc.journey_id = gp.journey_id AND jc.user_id = ?
|
||||||
)
|
)
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`).get(trekPhotoId, requestingUserId, requestingUserId);
|
`).get(trekPhotoId, requestingUserId, requestingUserId);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type { ServiceResult, AssetInfo } from './helpersService';
|
|||||||
import { fail, success } from './helpersService';
|
import { fail, success } from './helpersService';
|
||||||
import { encrypt_api_key, decrypt_api_key } from '../apiKeyCrypto';
|
import { encrypt_api_key, decrypt_api_key } from '../apiKeyCrypto';
|
||||||
import * as photoCache from './trekPhotoCache';
|
import * as photoCache from './trekPhotoCache';
|
||||||
|
import { ensureLocalThumbnail } from './thumbnailService';
|
||||||
|
|
||||||
// ── Lookup / Register ────────────────────────────────────────────────────
|
// ── Lookup / Register ────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -101,7 +102,31 @@ export async function streamPhoto(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (photo.file_path) {
|
if (photo.file_path) {
|
||||||
const localPath = path.join(__dirname, '../../../uploads', photo.file_path);
|
const uploadsRoot = path.join(__dirname, '../../../uploads');
|
||||||
|
|
||||||
|
if (kind === 'thumbnail') {
|
||||||
|
let thumbRel = photo.thumbnail_path ?? null;
|
||||||
|
if (!thumbRel) {
|
||||||
|
const result = await ensureLocalThumbnail(uploadsRoot, photo.file_path);
|
||||||
|
if (result) {
|
||||||
|
thumbRel = result.thumbnailRelPath;
|
||||||
|
db.prepare(
|
||||||
|
'UPDATE trek_photos SET thumbnail_path = ?, width = COALESCE(width, ?), height = COALESCE(height, ?) WHERE id = ?'
|
||||||
|
).run(thumbRel, result.width, result.height, photo.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (thumbRel) {
|
||||||
|
const thumbAbs = path.join(uploadsRoot, thumbRel);
|
||||||
|
if (fs.existsSync(thumbAbs)) {
|
||||||
|
res.set('Cache-Control', 'public, max-age=86400, immutable');
|
||||||
|
res.sendFile(thumbAbs);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fall through to original if thumbnail unavailable.
|
||||||
|
}
|
||||||
|
|
||||||
|
const localPath = path.join(uploadsRoot, photo.file_path);
|
||||||
if (fs.existsSync(localPath)) {
|
if (fs.existsSync(localPath)) {
|
||||||
res.set('Cache-Control', 'public, max-age=86400');
|
res.set('Cache-Control', 'public, max-age=86400');
|
||||||
res.sendFile(localPath);
|
res.sendFile(localPath);
|
||||||
|
|||||||
@@ -627,7 +627,9 @@ export async function fetchSynologyThumbnailBytes(
|
|||||||
mode: 'download',
|
mode: 'download',
|
||||||
id: parsedId.id,
|
id: parsedId.id,
|
||||||
type: 'unit',
|
type: 'unit',
|
||||||
size: 'sm',
|
// Match the uncached streamSynologyAsset default — 'sm' (240px) looked
|
||||||
|
// pixelated on retina.
|
||||||
|
size: 'm',
|
||||||
cache_key: parsedId.cacheKey,
|
cache_key: parsedId.cacheKey,
|
||||||
_sid: sid.data,
|
_sid: sid.data,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import sharp from 'sharp'
|
||||||
|
import path from 'path'
|
||||||
|
import fs from 'fs/promises'
|
||||||
|
import crypto from 'crypto'
|
||||||
|
|
||||||
|
const THUMB_MAX = 800
|
||||||
|
const THUMB_QUALITY = 80
|
||||||
|
|
||||||
|
export async function ensureLocalThumbnail(
|
||||||
|
uploadsRoot: string,
|
||||||
|
originalRelPath: string,
|
||||||
|
): Promise<{ thumbnailRelPath: string; width: number; height: number } | null> {
|
||||||
|
const originalAbs = path.join(uploadsRoot, originalRelPath)
|
||||||
|
try { await fs.access(originalAbs) } catch { return null }
|
||||||
|
|
||||||
|
// Deterministic name so concurrent requests don't race on the same photo.
|
||||||
|
const hash = crypto.createHash('sha1').update(originalRelPath).digest('hex').slice(0, 16)
|
||||||
|
const thumbRel = `journey/thumbs/${hash}.webp`
|
||||||
|
const thumbAbs = path.join(uploadsRoot, thumbRel)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [srcStat, dstStat] = await Promise.all([
|
||||||
|
fs.stat(originalAbs),
|
||||||
|
fs.stat(thumbAbs).catch(() => null),
|
||||||
|
])
|
||||||
|
if (dstStat && dstStat.mtimeMs >= srcStat.mtimeMs) {
|
||||||
|
const meta = await sharp(thumbAbs).metadata()
|
||||||
|
return { thumbnailRelPath: thumbRel, width: meta.width ?? 0, height: meta.height ?? 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.mkdir(path.dirname(thumbAbs), { recursive: true })
|
||||||
|
await sharp(originalAbs)
|
||||||
|
.rotate()
|
||||||
|
.resize({ width: THUMB_MAX, height: THUMB_MAX, fit: 'inside', withoutEnlargement: true })
|
||||||
|
.webp({ quality: THUMB_QUALITY })
|
||||||
|
.toFile(thumbAbs)
|
||||||
|
const meta = await sharp(thumbAbs).metadata()
|
||||||
|
return { thumbnailRelPath: thumbRel, width: meta.width ?? 0, height: meta.height ?? 0 }
|
||||||
|
} catch {
|
||||||
|
// Unsupported format, corrupt file, etc. — fall back to original in caller.
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -143,7 +143,7 @@ export async function discover(issuer: string, discoveryUrl?: string | null): Pr
|
|||||||
// Validate that the discovery doc's issuer matches the operator-configured
|
// Validate that the discovery doc's issuer matches the operator-configured
|
||||||
// one. A MITM or compromised doc could otherwise supply a crafted issuer
|
// one. A MITM or compromised doc could otherwise supply a crafted issuer
|
||||||
// that passes jwt.verify() because we used doc.issuer as the expected value.
|
// that passes jwt.verify() because we used doc.issuer as the expected value.
|
||||||
if (doc.issuer && doc.issuer !== issuer) {
|
if (doc.issuer && doc.issuer.replace(/\/+$/, '') !== issuer) {
|
||||||
throw new Error(`OIDC discovery issuer mismatch: expected "${issuer}", got "${doc.issuer}"`);
|
throw new Error(`OIDC discovery issuer mismatch: expected "${issuer}", got "${doc.issuer}"`);
|
||||||
}
|
}
|
||||||
doc._issuer = url;
|
doc._issuer = url;
|
||||||
|
|||||||
@@ -43,6 +43,24 @@ function loadEndpoints(reservationId: number): ReservationEndpoint[] {
|
|||||||
).all(reservationId) as ReservationEndpoint[];
|
).all(reservationId) as ReservationEndpoint[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve the day row whose date matches the date portion of an ISO-ish
|
||||||
|
// timestamp. Used to keep `day_id` / `end_day_id` in sync with
|
||||||
|
// `reservation_time` / `reservation_end_time` so non-transport bookings
|
||||||
|
// (tours, restaurants, events, ...) end up on the right day in the UI,
|
||||||
|
// which now filters by day_id instead of reservation_time.
|
||||||
|
function resolveDayIdFromTime(
|
||||||
|
tripId: string | number,
|
||||||
|
time: string | null | undefined,
|
||||||
|
): number | null {
|
||||||
|
if (!time) return null;
|
||||||
|
const datePart = time.slice(0, 10);
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(datePart)) return null;
|
||||||
|
const row = db
|
||||||
|
.prepare('SELECT id FROM days WHERE trip_id = ? AND date = ? LIMIT 1')
|
||||||
|
.get(tripId, datePart) as { id: number } | undefined;
|
||||||
|
return row?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
const saveEndpoints = db.transaction((reservationId: number, endpoints: EndpointInput[]) => {
|
const saveEndpoints = db.transaction((reservationId: number, endpoints: EndpointInput[]) => {
|
||||||
db.prepare('DELETE FROM reservation_endpoints WHERE reservation_id = ?').run(reservationId);
|
db.prepare('DELETE FROM reservation_endpoints WHERE reservation_id = ?').run(reservationId);
|
||||||
const insert = db.prepare(`
|
const insert = db.prepare(`
|
||||||
@@ -57,7 +75,8 @@ const saveEndpoints = db.transaction((reservationId: number, endpoints: Endpoint
|
|||||||
export function listReservations(tripId: string | number) {
|
export function listReservations(tripId: string | number) {
|
||||||
const reservations = db.prepare(`
|
const reservations = db.prepare(`
|
||||||
SELECT r.*, d.day_number, p.name as place_name, r.assignment_id,
|
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.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
|
||||||
FROM reservations r
|
FROM reservations r
|
||||||
LEFT JOIN days d ON r.day_id = d.id
|
LEFT JOIN days d ON r.day_id = d.id
|
||||||
LEFT JOIN places p ON r.place_id = p.id
|
LEFT JOIN places p ON r.place_id = p.id
|
||||||
@@ -93,7 +112,8 @@ export function listReservations(tripId: string | number) {
|
|||||||
export function getReservationWithJoins(id: string | number) {
|
export function getReservationWithJoins(id: string | number) {
|
||||||
const row = db.prepare(`
|
const row = db.prepare(`
|
||||||
SELECT r.*, d.day_number, p.name as place_name, r.assignment_id,
|
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.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
|
||||||
FROM reservations r
|
FROM reservations r
|
||||||
LEFT JOIN days d ON r.day_id = d.id
|
LEFT JOIN days d ON r.day_id = d.id
|
||||||
LEFT JOIN places p ON r.place_id = p.id
|
LEFT JOIN places p ON r.place_id = p.id
|
||||||
@@ -158,13 +178,26 @@ export function createReservation(tripId: string | number, data: CreateReservati
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Derive day_id / end_day_id from reservation_time when the client
|
||||||
|
// didn't explicitly set them (non-hotel bookings only — hotels store
|
||||||
|
// their date range on the linked day_accommodation).
|
||||||
|
const resolvedType = type || 'other';
|
||||||
|
let resolvedDayId: number | null = day_id ?? null;
|
||||||
|
if (resolvedDayId == null && resolvedType !== 'hotel' && reservation_time) {
|
||||||
|
resolvedDayId = resolveDayIdFromTime(tripId, reservation_time);
|
||||||
|
}
|
||||||
|
let resolvedEndDayId: number | null = end_day_id ?? null;
|
||||||
|
if (resolvedEndDayId == null && resolvedType !== 'hotel' && reservation_end_time) {
|
||||||
|
resolvedEndDayId = resolveDayIdFromTime(tripId, reservation_end_time);
|
||||||
|
}
|
||||||
|
|
||||||
const result = db.prepare(`
|
const result = db.prepare(`
|
||||||
INSERT INTO reservations (trip_id, day_id, end_day_id, place_id, assignment_id, title, reservation_time, reservation_end_time, location, confirmation_number, notes, status, type, accommodation_id, metadata, needs_review)
|
INSERT INTO reservations (trip_id, day_id, end_day_id, place_id, assignment_id, title, reservation_time, reservation_end_time, location, confirmation_number, notes, status, type, accommodation_id, metadata, needs_review)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(
|
`).run(
|
||||||
tripId,
|
tripId,
|
||||||
day_id || null,
|
resolvedDayId,
|
||||||
end_day_id ?? null,
|
resolvedEndDayId,
|
||||||
place_id || null,
|
place_id || null,
|
||||||
assignment_id || null,
|
assignment_id || null,
|
||||||
title,
|
title,
|
||||||
@@ -174,7 +207,7 @@ export function createReservation(tripId: string | number, data: CreateReservati
|
|||||||
confirmation_number || null,
|
confirmation_number || null,
|
||||||
notes || null,
|
notes || null,
|
||||||
status || 'pending',
|
status || 'pending',
|
||||||
type || 'other',
|
resolvedType,
|
||||||
resolvedAccommodationId,
|
resolvedAccommodationId,
|
||||||
metadata ? JSON.stringify(metadata) : null,
|
metadata ? JSON.stringify(metadata) : null,
|
||||||
needs_review ? 1 : 0
|
needs_review ? 1 : 0
|
||||||
@@ -276,13 +309,8 @@ 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;
|
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 (start_day_id && end_day_id) {
|
||||||
if (resolvedAccId) {
|
if (resolvedAccId) {
|
||||||
if (accPlaceId) {
|
db.prepare('UPDATE day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, check_in = ?, check_out = ?, confirmation = ? WHERE id = ?')
|
||||||
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);
|
||||||
.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) {
|
} else if (accPlaceId) {
|
||||||
const accResult = db.prepare(
|
const accResult = db.prepare(
|
||||||
'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||||
@@ -293,6 +321,35 @@ export function updateReservation(id: string | number, tripId: string | number,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resolvedType = (type ?? current.type) || 'other';
|
||||||
|
const nextReservationTime = resolvedType === 'hotel'
|
||||||
|
? null
|
||||||
|
: (reservation_time !== undefined ? (reservation_time || null) : current.reservation_time);
|
||||||
|
const nextReservationEndTime = resolvedType === 'hotel'
|
||||||
|
? null
|
||||||
|
: (reservation_end_time !== undefined ? (reservation_end_time || null) : current.reservation_end_time);
|
||||||
|
|
||||||
|
// day_id / end_day_id: honour an explicit value from the client,
|
||||||
|
// otherwise derive from the (possibly updated) reservation_time so the
|
||||||
|
// planner renders the booking on the correct day.
|
||||||
|
let nextDayId: number | null;
|
||||||
|
if (day_id !== undefined) {
|
||||||
|
nextDayId = day_id || null;
|
||||||
|
} else if (reservation_time !== undefined && resolvedType !== 'hotel') {
|
||||||
|
nextDayId = resolveDayIdFromTime(tripId, nextReservationTime);
|
||||||
|
} else {
|
||||||
|
nextDayId = current.day_id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextEndDayId: number | null;
|
||||||
|
if (end_day_id !== undefined) {
|
||||||
|
nextEndDayId = end_day_id ?? null;
|
||||||
|
} else if (reservation_end_time !== undefined && resolvedType !== 'hotel') {
|
||||||
|
nextEndDayId = resolveDayIdFromTime(tripId, nextReservationEndTime);
|
||||||
|
} else {
|
||||||
|
nextEndDayId = (current as any).end_day_id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE reservations SET
|
UPDATE reservations SET
|
||||||
title = COALESCE(?, title),
|
title = COALESCE(?, title),
|
||||||
@@ -313,13 +370,13 @@ export function updateReservation(id: string | number, tripId: string | number,
|
|||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`).run(
|
`).run(
|
||||||
title || null,
|
title || null,
|
||||||
(type ?? current.type) === 'hotel' ? null : (reservation_time !== undefined ? (reservation_time || null) : current.reservation_time),
|
nextReservationTime,
|
||||||
(type ?? current.type) === 'hotel' ? null : (reservation_end_time !== undefined ? (reservation_end_time || null) : current.reservation_end_time),
|
nextReservationEndTime,
|
||||||
location !== undefined ? (location || null) : current.location,
|
location !== undefined ? (location || null) : current.location,
|
||||||
confirmation_number !== undefined ? (confirmation_number || null) : current.confirmation_number,
|
confirmation_number !== undefined ? (confirmation_number || null) : current.confirmation_number,
|
||||||
notes !== undefined ? (notes || null) : current.notes,
|
notes !== undefined ? (notes || null) : current.notes,
|
||||||
day_id !== undefined ? (day_id || null) : current.day_id,
|
nextDayId,
|
||||||
end_day_id !== undefined ? (end_day_id ?? null) : (current as any).end_day_id ?? null,
|
nextEndDayId,
|
||||||
place_id !== undefined ? (place_id || null) : current.place_id,
|
place_id !== undefined ? (place_id || null) : current.place_id,
|
||||||
assignment_id !== undefined ? (assignment_id || null) : current.assignment_id,
|
assignment_id !== undefined ? (assignment_id || null) : current.assignment_id,
|
||||||
status || null,
|
status || null,
|
||||||
|
|||||||
@@ -681,6 +681,24 @@ export function copyTripById(sourceTripId: string | number, newOwnerId: number,
|
|||||||
if (newDayId) insertNote.run(newDayId, newTripId, n.text, n.time, n.icon, n.sort_order);
|
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);
|
return Number(newTripId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -389,6 +389,24 @@ export interface JourneyPhoto {
|
|||||||
height?: number | null;
|
height?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GalleryPhoto {
|
||||||
|
id: number;
|
||||||
|
journey_id: number;
|
||||||
|
photo_id: number;
|
||||||
|
caption?: string | null;
|
||||||
|
shared: number;
|
||||||
|
sort_order: number;
|
||||||
|
created_at: number;
|
||||||
|
// Joined from trek_photos for API responses
|
||||||
|
provider?: string;
|
||||||
|
asset_id?: string | null;
|
||||||
|
owner_id?: number | null;
|
||||||
|
file_path?: string | null;
|
||||||
|
thumbnail_path?: string | null;
|
||||||
|
width?: number | null;
|
||||||
|
height?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface JourneyTrip {
|
export interface JourneyTrip {
|
||||||
journey_id: number;
|
journey_id: number;
|
||||||
trip_id: number;
|
trip_id: number;
|
||||||
|
|||||||
@@ -365,13 +365,12 @@ describe('File download', () => {
|
|||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FILE-008 — GET /:id/download with Bearer JWT downloads or 404s (no physical file in tests)', async () => {
|
it('FILE-008 — GET /:id/download with Bearer JWT downloads file', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
const trip = createTrip(testDb, user.id);
|
const trip = createTrip(testDb, user.id);
|
||||||
const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
|
const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
|
||||||
const fileId = upload.body.file.id;
|
const fileId = upload.body.file.id;
|
||||||
|
|
||||||
// authenticateDownload accepts a signed JWT as Bearer token
|
|
||||||
const token = generateToken(user.id);
|
const token = generateToken(user.id);
|
||||||
|
|
||||||
const dl = await request(app)
|
const dl = await request(app)
|
||||||
@@ -380,4 +379,18 @@ describe('File download', () => {
|
|||||||
// multer stores the file to disk during uploadFile — physical file exists
|
// multer stores the file to disk during uploadFile — physical file exists
|
||||||
expect(dl.status).toBe(200);
|
expect(dl.status).toBe(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('FILE-011 — GET /:id/download with trek_session cookie downloads file', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
|
||||||
|
const fileId = upload.body.file.id;
|
||||||
|
|
||||||
|
const token = generateToken(user.id);
|
||||||
|
|
||||||
|
const dl = await request(app)
|
||||||
|
.get(`/api/trips/${trip.id}/files/${fileId}/download`)
|
||||||
|
.set('Cookie', `trek_session=${token}`);
|
||||||
|
expect(dl.status).toBe(200);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -649,7 +649,7 @@ describe('Link photo to entry', () => {
|
|||||||
.send({});
|
.send({});
|
||||||
|
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
expect(res.body.error).toBe('photo_id required');
|
expect(res.body.error).toBe('journey_photo_id required');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -84,8 +84,9 @@ describe('GET /api/system-notices/active', () => {
|
|||||||
|
|
||||||
it('returns empty array for non-first-login user with no applicable notices', async () => {
|
it('returns empty array for non-first-login user with no applicable notices', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
// login_count > 1 means firstLogin condition does not match for any notice
|
// login_count > 1 means firstLogin condition does not match for any notice;
|
||||||
testDb.prepare('UPDATE users SET login_count = 5 WHERE id = ?').run(user.id);
|
// first_seen_version >= 3.0.0 means existingUserBeforeVersion('3.0.0') also does not match
|
||||||
|
testDb.prepare('UPDATE users SET login_count = 5, first_seen_version = ? WHERE id = ?').run('3.0.0', user.id);
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.get('/api/system-notices/active')
|
.get('/api/system-notices/active')
|
||||||
.set('Cookie', authCookie(user.id));
|
.set('Cookie', authCookie(user.id));
|
||||||
@@ -122,7 +123,7 @@ describe('GET /api/system-notices/active', () => {
|
|||||||
SYSTEM_NOTICES.push(TEST_NOTICE);
|
SYSTEM_NOTICES.push(TEST_NOTICE);
|
||||||
try {
|
try {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
testDb.prepare('UPDATE users SET login_count = 5 WHERE id = ?').run(user.id);
|
testDb.prepare('UPDATE users SET login_count = 5, first_seen_version = ? WHERE id = ?').run('3.0.0', user.id);
|
||||||
|
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.get('/api/system-notices/active')
|
.get('/api/system-notices/active')
|
||||||
|
|||||||
@@ -950,6 +950,52 @@ describe('Copy trip with data', () => {
|
|||||||
expect(newNotes).toHaveLength(1);
|
expect(newNotes).toHaveLength(1);
|
||||||
expect(newNotes[0].text).toBe('Pack early!');
|
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 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -234,6 +234,22 @@ describe('BACKUP-034 isValidBackupFilename', () => {
|
|||||||
it('accepts filename with hyphens and underscores', () => {
|
it('accepts filename with hyphens and underscores', () => {
|
||||||
expect(isValidBackupFilename('backup-my_trek-2026.zip')).toBe(true);
|
expect(isValidBackupFilename('backup-my_trek-2026.zip')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('accepts auto-backup filename', () => {
|
||||||
|
expect(isValidBackupFilename('auto-backup-2026-04-21T00-00-00.zip')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects auto-backup with empty body', () => {
|
||||||
|
expect(isValidBackupFilename('auto-backup-.zip')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects backup with empty body', () => {
|
||||||
|
expect(isValidBackupFilename('backup-.zip')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects arbitrary auto- prefix that is not auto-backup', () => {
|
||||||
|
expect(isValidBackupFilename('auto-notbackup-2026.zip')).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1325,9 +1325,10 @@ describe('Edge cases', () => {
|
|||||||
const result = deleteEntry(entry.id, user.id);
|
const result = deleteEntry(entry.id, user.id);
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
|
|
||||||
// Photo should be deleted with the entry
|
// Junction row must be gone (ON DELETE CASCADE from journey_entries).
|
||||||
const deletedPhoto = testDb.prepare('SELECT * FROM journey_photos WHERE id = ?').get(photo!.id) as any;
|
// Gallery row (journey_photos) is preserved — photo may belong to other entries.
|
||||||
expect(deletedPhoto).toBeUndefined();
|
const junctionRow = testDb.prepare('SELECT * FROM journey_entry_photos WHERE entry_id = ?').get(entry.id) as any;
|
||||||
|
expect(junctionRow).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('JOURNEY-SVC-082: updateJourney can set cover_gradient', () => {
|
it('JOURNEY-SVC-082: updateJourney can set cover_gradient', () => {
|
||||||
@@ -1395,17 +1396,12 @@ describe('Edge cases', () => {
|
|||||||
|
|
||||||
addTripToJourney(journey.id, trip.id, user.id);
|
addTripToJourney(journey.id, trip.id, user.id);
|
||||||
|
|
||||||
// Should have a [Trip Photos] entry with the imported photo
|
// Trip photos now go straight into the journey gallery (no wrapper entry).
|
||||||
const photoEntry = testDb.prepare(
|
|
||||||
"SELECT * FROM journey_entries WHERE journey_id = ? AND title = '[Trip Photos]'"
|
|
||||||
).get(journey.id) as any;
|
|
||||||
expect(photoEntry).toBeDefined();
|
|
||||||
|
|
||||||
const photos = testDb.prepare(`
|
const photos = testDb.prepare(`
|
||||||
SELECT jp.*, tkp.asset_id FROM journey_photos jp
|
SELECT jp.*, tkp.asset_id FROM journey_photos jp
|
||||||
JOIN trek_photos tkp ON tkp.id = jp.photo_id
|
JOIN trek_photos tkp ON tkp.id = jp.photo_id
|
||||||
WHERE jp.entry_id = ?
|
WHERE jp.journey_id = ?
|
||||||
`).all(photoEntry.id);
|
`).all(journey.id);
|
||||||
expect(photos.length).toBe(1);
|
expect(photos.length).toBe(1);
|
||||||
expect((photos[0] as any).asset_id).toBe('immich-photo-1');
|
expect((photos[0] as any).asset_id).toBe('immich-photo-1');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ afterAll(() => {
|
|||||||
|
|
||||||
// -- Helpers ------------------------------------------------------------------
|
// -- Helpers ------------------------------------------------------------------
|
||||||
|
|
||||||
/** Insert a trek_photos + journey_photos row and return the trek_photos id (used as photoId in public URLs). */
|
/** Insert a trek_photos + journey_photos (gallery) + journey_entry_photos row and return the trek_photos id (used as photoId in public URLs). */
|
||||||
function insertJourneyPhoto(
|
function insertJourneyPhoto(
|
||||||
entryId: number,
|
entryId: number,
|
||||||
opts: { filePath?: string; assetId?: string; ownerId?: number } = {}
|
opts: { filePath?: string; assetId?: string; ownerId?: number } = {}
|
||||||
@@ -70,10 +70,24 @@ function insertJourneyPhoto(
|
|||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?)
|
||||||
`).run(provider, opts.assetId ?? null, filePath, opts.ownerId ?? null, Date.now());
|
`).run(provider, opts.assetId ?? null, filePath, opts.ownerId ?? null, Date.now());
|
||||||
const trekId = trekResult.lastInsertRowid as number;
|
const trekId = trekResult.lastInsertRowid as number;
|
||||||
|
|
||||||
|
// Look up journey_id from entry so gallery row is keyed to the journey (not entry).
|
||||||
|
const entryRow = testDb.prepare('SELECT journey_id FROM journey_entries WHERE id = ?').get(entryId) as { journey_id: number };
|
||||||
|
const journeyId = entryRow.journey_id;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
testDb.prepare(`
|
testDb.prepare(`
|
||||||
INSERT INTO journey_photos (entry_id, photo_id, caption, sort_order, created_at)
|
INSERT OR IGNORE INTO journey_photos (journey_id, photo_id, caption, sort_order, created_at)
|
||||||
VALUES (?, ?, NULL, 0, ?)
|
VALUES (?, ?, NULL, 0, ?)
|
||||||
`).run(entryId, trekId, Date.now());
|
`).run(journeyId, trekId, now);
|
||||||
|
|
||||||
|
const galleryRow = testDb.prepare('SELECT id FROM journey_photos WHERE journey_id = ? AND photo_id = ?').get(journeyId, trekId) as { id: number };
|
||||||
|
|
||||||
|
testDb.prepare(`
|
||||||
|
INSERT OR IGNORE INTO journey_entry_photos (entry_id, journey_photo_id, sort_order, created_at)
|
||||||
|
VALUES (?, ?, 0, ?)
|
||||||
|
`).run(entryId, galleryRow.id, now);
|
||||||
|
|
||||||
// Return trek_photos.id — this is p.photo_id in the public API response
|
// Return trek_photos.id — this is p.photo_id in the public API response
|
||||||
// and the value the client sends to /api/public/journey/:token/photos/:photoId/:kind
|
// and the value the client sends to /api/public/journey/:token/photos/:photoId/:kind
|
||||||
return trekId;
|
return trekId;
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ If a toggle fails (e.g., network error), it rolls back to its previous state.
|
|||||||
|
|
||||||
Some addons require credentials or environment variables before they are functional:
|
Some addons require credentials or environment variables before they are functional:
|
||||||
|
|
||||||
- **Journey** — requires photo provider credentials (Immich or Synology Photos) configured per-user in their personal Settings. See [Photo-Providers](Photo-Providers).
|
- **Journey** — works without any external integration. To embed photos from Immich or Synology Photos, enable the corresponding photo-provider toggle listed under Journey, then configure credentials per-user in **Settings → Integrations**. See [Photo-Providers](Photo-Providers).
|
||||||
- **MCP** — requires `APP_URL` to be set so OAuth redirect URIs resolve correctly.
|
- **MCP** — requires `APP_URL` to be set so OAuth redirect URIs resolve correctly.
|
||||||
|
|
||||||
## Related pages
|
## Related pages
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
Thanks for your interest in contributing to TREK! Here are the guidelines for submitting pull requests.
|
||||||
|
|
||||||
|
## Before You Start
|
||||||
|
|
||||||
|
- **Ask in Discord first** — Before writing any code, pitch your idea in the `#github-pr` channel on our [Discord server](https://discord.gg/NhZBDSd4qW). We'll let you know if the PR is wanted and give direction. PRs without prior approval will be closed
|
||||||
|
- **Check existing issues** — Look for open issues or discussions before starting work
|
||||||
|
- **Target the `dev` branch** — All PRs must be opened against `dev`, not `main`
|
||||||
|
- **One thing per PR** — Keep PRs focused on a single change. Don't bundle unrelated fixes
|
||||||
|
|
||||||
|
## Pull Request Guidelines
|
||||||
|
|
||||||
|
### Code Quality
|
||||||
|
|
||||||
|
- Write clean, readable code that matches the existing style
|
||||||
|
- No unnecessary abstractions or over-engineering
|
||||||
|
- Don't add features beyond what was discussed in the issue
|
||||||
|
- Don't add comments unless the logic isn't self-evident
|
||||||
|
- Don't add error handling for scenarios that can't happen
|
||||||
|
|
||||||
|
### What We Look For
|
||||||
|
|
||||||
|
- **Does it solve the stated problem?** — The PR should match the issue it addresses
|
||||||
|
- **Is it minimal?** — No extra refactoring, no "while I'm here" changes
|
||||||
|
- **Does it break anything?** — Breaking changes are not acceptable
|
||||||
|
- **Is the code clean?** — Consistent style, no debug logs, no dead code
|
||||||
|
|
||||||
|
### Commit Messages
|
||||||
|
|
||||||
|
Use conventional commits:
|
||||||
|
```
|
||||||
|
fix(component): short description of what was fixed
|
||||||
|
feat(component): short description of new feature
|
||||||
|
```
|
||||||
|
|
||||||
|
### PR Description
|
||||||
|
|
||||||
|
Follow the template provided by default (.github/PULL_REQUEST_TEMPLATE.md).
|
||||||
|
|
||||||
|
### What Will Get Your PR Closed
|
||||||
|
|
||||||
|
- PRs that weren't discussed and approved in `#github-pr` on Discord first
|
||||||
|
- PRs that add unnecessary complexity (e.g. a redo button when undo already exists)
|
||||||
|
- PRs with breaking changes
|
||||||
|
- PRs that change code style or formatting across unrelated files
|
||||||
|
- PRs that add dependencies without justification
|
||||||
|
|
||||||
|
## Development Setup
|
||||||
|
|
||||||
|
See the [[Development Environment|Development-environment]] page for the full setup guide, including forking, remote configuration, branch conventions, and available scripts.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
|---|---------------------------------------------------------------------------------|
|
||||||
|
| Frontend | React 18, TypeScript, Zustand, Leaflet, Tailwind CSS, Vite |
|
||||||
|
| Backend | Express, TypeScript, better-sqlite3 |
|
||||||
|
| Real-time | WebSocket (ws) |
|
||||||
|
| Database | SQLite with WAL mode |
|
||||||
|
| Auth | JWT (HS256), bcrypt, TOTP MFA, OIDC |
|
||||||
|
| Maps | Leaflet + react-leaflet, OSRM, Nominatim, CartoDB tiles |
|
||||||
|
| i18n | 15 languages (EN, DE, ES, FR, NL, IT, PT-BR, CS, PL, HU, RU, ZH, ZH-TW, AR, ID) |
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
# Developer Setup Guide
|
||||||
|
|
||||||
|
> Before anything else, please read the [[Contributing]] guidelines.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Node.js 22+
|
||||||
|
- npm
|
||||||
|
- Git
|
||||||
|
- A GitHub account
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Fork & Clone the Repository
|
||||||
|
|
||||||
|
Go to the [TREK repository](https://github.com/mauriceboe/TREK) and click **Fork** to create your own copy.
|
||||||
|
|
||||||
|
Then clone your fork locally:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone your fork, checking out the dev branch
|
||||||
|
git clone -b dev git@github.com:your-username/TREK.git
|
||||||
|
cd TREK
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Configure Git Remotes
|
||||||
|
|
||||||
|
Add the original repository as `upstream` so you can pull in future updates:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git remote add upstream git@github.com:mauriceboe/TREK.git
|
||||||
|
```
|
||||||
|
|
||||||
|
You should now have two remotes:
|
||||||
|
|
||||||
|
| Remote | URL | Purpose |
|
||||||
|
|------------|----------------------------------------------|--------------------------------|
|
||||||
|
| `origin` | `git@github.com:your-username/TREK.git` | Your fork — push changes here |
|
||||||
|
| `upstream` | `git@github.com:mauriceboe/TREK.git` | Main repo — pull updates from here |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Keep Your Fork Up to Date
|
||||||
|
|
||||||
|
Before starting any work, make sure your local `dev` branch is in sync with upstream:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git fetch upstream
|
||||||
|
git rebase upstream/dev # or: git merge upstream/dev
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Create a Feature Branch
|
||||||
|
|
||||||
|
Working on a dedicated branch keeps your changes isolated and makes PRs easier to review:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout -b fix/my-changes origin/dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Branch naming conventions:
|
||||||
|
- `feat/short-description` for new features
|
||||||
|
- `fix/short-description` for bug fixes
|
||||||
|
- `chore/short-description` for maintenance tasks
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Install Dependencies
|
||||||
|
|
||||||
|
Install dependencies for both the client and server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Client
|
||||||
|
cd client
|
||||||
|
npm i
|
||||||
|
|
||||||
|
# Server
|
||||||
|
cd ../server
|
||||||
|
npm i
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Available Scripts
|
||||||
|
|
||||||
|
### Server (`/server`)
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|----------------------------|------------------------------------------|
|
||||||
|
| `npm start` | Start the server (production) |
|
||||||
|
| `npm run dev` | Start the server in watch mode (tsx) |
|
||||||
|
| `npm test` | Run all tests |
|
||||||
|
| `npm run test:unit` | Run unit tests only |
|
||||||
|
| `npm run test:integration` | Run integration tests |
|
||||||
|
| `npm run test:ws` | Run WebSocket tests |
|
||||||
|
| `npm run test:watch` | Run tests in watch mode |
|
||||||
|
| `npm run test:coverage` | Run tests with coverage report |
|
||||||
|
|
||||||
|
### Client (`/client`)
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|--------------------------|------------------------------------------------------|
|
||||||
|
| `npm run dev` | Start the Vite dev server |
|
||||||
|
| `npm run build` | Build for production (runs icon generation first) |
|
||||||
|
| `npm run preview` | Preview the production build locally |
|
||||||
|
| `npm test` | Run all tests |
|
||||||
|
| `npm run test:unit` | Run unit tests only |
|
||||||
|
| `npm run test:integration` | Run integration tests |
|
||||||
|
| `npm run test:watch` | Run tests in watch mode |
|
||||||
|
| `npm run test:coverage` | Run tests with coverage report |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Commit & Push Your Changes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .
|
||||||
|
git commit -m "fix: describe your change"
|
||||||
|
|
||||||
|
# Push to your fork's dev branch
|
||||||
|
git push origin fix/my-changes:dev
|
||||||
|
|
||||||
|
# Or if working directly on dev
|
||||||
|
git push origin dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open a Pull Request from your fork to `mauriceboe/TREK` targeting the `dev` branch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tips
|
||||||
|
|
||||||
|
- Always branch off from an up-to-date `dev` — run `git fetch upstream && git rebase upstream/dev` before starting new work.
|
||||||
|
- Run tests before pushing: `npm run test` in both `client/` and `server/`.
|
||||||
|
- Follow the commit message conventions described in the [[Contributing]] guidelines.
|
||||||
@@ -48,7 +48,7 @@ Verified in `server/src/config.ts` (line 107):
|
|||||||
|
|
||||||
## HTTPS / Proxy
|
## HTTPS / Proxy
|
||||||
|
|
||||||
These three variables work together behind a TLS-terminating reverse proxy. See [Reverse-Proxy] for the full explanation.
|
These three variables work together behind a TLS-terminating reverse proxy. See [Reverse-Proxy](Reverse-Proxy) for the full explanation.
|
||||||
|
|
||||||
| Variable | Description | Default |
|
| Variable | Description | Default |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -62,7 +62,7 @@ These three variables work together behind a TLS-terminating reverse proxy. See
|
|||||||
|
|
||||||
## OIDC / SSO
|
## OIDC / SSO
|
||||||
|
|
||||||
For setup instructions, see [OIDC-SSO].
|
For setup instructions, see [OIDC-SSO](OIDC-SSO).
|
||||||
|
|
||||||
| Variable | Description | Default |
|
| Variable | Description | Default |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -110,7 +110,7 @@ Both variables must be set together. If either is omitted, the account is create
|
|||||||
|
|
||||||
## MCP
|
## MCP
|
||||||
|
|
||||||
For setup instructions, see [MCP-Overview].
|
For setup instructions, see [MCP-Overview](MCP-Overview).
|
||||||
|
|
||||||
| Variable | Description | Default |
|
| Variable | Description | Default |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -129,7 +129,7 @@ For setup instructions, see [MCP-Overview].
|
|||||||
|
|
||||||
## Related Pages
|
## Related Pages
|
||||||
|
|
||||||
- [Reverse-Proxy] — HTTPS proxy setup and the `FORCE_HTTPS` / `TRUST_PROXY` / `COOKIE_SECURE` trio
|
- [Reverse-Proxy](Reverse-Proxy) — HTTPS proxy setup and the `FORCE_HTTPS` / `TRUST_PROXY` / `COOKIE_SECURE` trio
|
||||||
- [OIDC-SSO] — complete OIDC configuration guide
|
- [OIDC-SSO](OIDC-SSO) — complete OIDC configuration guide
|
||||||
- [MCP-Overview] — MCP server setup and rate limiting
|
- [MCP-Overview](MCP-Overview) — MCP server setup and rate limiting
|
||||||
- [Encryption-Key-Rotation] — rotating the `ENCRYPTION_KEY` without losing data
|
- [Encryption-Key-Rotation](Encryption-Key-Rotation) — rotating the `ENCRYPTION_KEY` without losing data
|
||||||
|
|||||||
+15
-7
@@ -30,17 +30,23 @@ TREK is a self-hosted, real-time collaborative travel planner licensed under AGP
|
|||||||
- **Public Share Links** — share a read-only view of any trip
|
- **Public Share Links** — share a read-only view of any trip
|
||||||
|
|
||||||
### Addons _(admin-toggleable)_
|
### Addons _(admin-toggleable)_
|
||||||
|
- **Lists** — packing lists and to-dos with templates, member assignments, optional bag tracking
|
||||||
|
- **Budget Planner** — expense tracker with category breakdown, splits, multi-currency
|
||||||
|
- **Documents** — file manager for trips, places, and reservations
|
||||||
|
- **Collab** — group chat, shared notes, polls, day-by-day attendance
|
||||||
- **Vacay** — personal vacation day planner with calendar view, public holidays, and carry-over tracking
|
- **Vacay** — personal vacation day planner with calendar view, public holidays, and carry-over tracking
|
||||||
- **Atlas** — interactive world map, bucket list, travel stats, continent breakdown
|
- **Atlas** — interactive world map, bucket list, travel stats, continent breakdown
|
||||||
- **Journey** — travel journal linking entries to trips, with contributor roles
|
- **Journey** — magazine-style travel journal with entries, photos (via Immich/Synology Photos), maps, and moods
|
||||||
- **Memories** — photo-focused trip memories
|
- **Naver List Import** — import places from shared Naver Maps lists
|
||||||
- **Collab** — group chat, shared notes, polls, and activity sign-ups
|
- **MCP** — expose TREK to AI assistants via the Model Context Protocol (OAuth 2.1)
|
||||||
- **Dashboard Widgets** — currency converter and timezone clock, toggled per user
|
|
||||||
|
> Dashboard widgets (currency converter and timezone clock) are per-user preferences, not an admin-toggleable addon — see [Dashboard-Widgets](Dashboard-Widgets).
|
||||||
|
|
||||||
### AI / MCP Integration
|
### AI / MCP Integration
|
||||||
- **MCP Server** — built-in Model Context Protocol server with OAuth 2.1 authentication
|
- **MCP Server** — built-in Model Context Protocol server with OAuth 2.1 authentication
|
||||||
- **80+ Tools** — create trips, plan itineraries, manage budgets, send messages, and more
|
- **150+ Tools** — create trips, plan itineraries, manage budgets, send messages, and more
|
||||||
- **24 OAuth Scopes** — granular permissions across 13 permission groups
|
- **30 Resources** — read-only `trek://` URIs for trips, days, places, budget, packing, journeys, and more
|
||||||
|
- **27 OAuth Scopes** — granular permissions across 13 permission groups
|
||||||
- **Pre-built Prompts** — `trip-summary`, `packing-list`, and `budget-overview` context loaders
|
- **Pre-built Prompts** — `trip-summary`, `packing-list`, and `budget-overview` context loaders
|
||||||
|
|
||||||
### Admin
|
### Admin
|
||||||
@@ -48,7 +54,7 @@ TREK is a self-hosted, real-time collaborative travel planner licensed under AGP
|
|||||||
- Addon management, API key storage, scheduled auto-backups
|
- Addon management, API key storage, scheduled auto-backups
|
||||||
- System notices for onboarding and announcements
|
- System notices for onboarding and announcements
|
||||||
|
|
||||||
> **Admin:** Most configuration lives in the Admin Panel. The first user to register becomes the admin automatically.
|
> **Admin:** Most configuration lives in the Admin Panel. On first boot TREK seeds an admin account automatically — credentials come from `ADMIN_EMAIL` / `ADMIN_PASSWORD` if set, otherwise a random password is printed to the container log.
|
||||||
|
|
||||||
## Get Started
|
## Get Started
|
||||||
|
|
||||||
@@ -58,3 +64,5 @@ TREK is a self-hosted, real-time collaborative travel planner licensed under AGP
|
|||||||
| [My Trips Dashboard](My-Trips-Dashboard) | Start planning your first trip |
|
| [My Trips Dashboard](My-Trips-Dashboard) | Start planning your first trip |
|
||||||
| [Admin Panel](Admin-Panel-Overview) | Configure your instance |
|
| [Admin Panel](Admin-Panel-Overview) | Configure your instance |
|
||||||
| [MCP / AI Integration](MCP-Overview) | Connect Claude, Cursor, or any MCP client |
|
| [MCP / AI Integration](MCP-Overview) | Connect Claude, Cursor, or any MCP client |
|
||||||
|
| [Contributing](Contributing) | Guidelines for submitting pull requests |
|
||||||
|
| [Development Environment](Development-environment) | Set up a local dev environment |
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ ALLOWED_ORIGINS=https://trek.example.com
|
|||||||
APP_URL=https://trek.example.com
|
APP_URL=https://trek.example.com
|
||||||
```
|
```
|
||||||
|
|
||||||
Uncomment and fill in the OIDC, initial setup, or MCP variables as needed. For a full description of every variable, see [Environment-Variables].
|
Uncomment and fill in the OIDC, initial setup, or MCP variables as needed. For a full description of every variable, see [Environment-Variables](Environment-Variables).
|
||||||
|
|
||||||
## Start TREK
|
## Start TREK
|
||||||
|
|
||||||
@@ -111,10 +111,10 @@ docker compose logs -f
|
|||||||
|
|
||||||
This compose file is designed for deployments where a reverse proxy (nginx, Caddy, Traefik) terminates TLS in front of TREK. To enable HTTPS redirects and secure cookies, uncomment `FORCE_HTTPS=true` and `TRUST_PROXY=1`.
|
This compose file is designed for deployments where a reverse proxy (nginx, Caddy, Traefik) terminates TLS in front of TREK. To enable HTTPS redirects and secure cookies, uncomment `FORCE_HTTPS=true` and `TRUST_PROXY=1`.
|
||||||
|
|
||||||
See [Reverse-Proxy] for complete proxy configuration examples.
|
See [Reverse-Proxy](Reverse-Proxy) for complete proxy configuration examples.
|
||||||
|
|
||||||
## Next Steps
|
## Next Steps
|
||||||
|
|
||||||
- [Environment-Variables] — full variable reference
|
- [Environment-Variables](Environment-Variables) — full variable reference
|
||||||
- [Reverse-Proxy] — HTTPS configuration
|
- [Reverse-Proxy](Reverse-Proxy) — HTTPS configuration
|
||||||
- [Updating] — how to pull a new image
|
- [Updating](Updating) — how to pull a new image
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ Pass additional `-e` flags for timezone and CORS/email link support:
|
|||||||
-e ALLOWED_ORIGINS=https://trek.example.com \
|
-e ALLOWED_ORIGINS=https://trek.example.com \
|
||||||
```
|
```
|
||||||
|
|
||||||
See [Environment-Variables] for the full list.
|
See [Environment-Variables](Environment-Variables) for the full list.
|
||||||
|
|
||||||
## Volume Reference
|
## Volume Reference
|
||||||
|
|
||||||
@@ -66,11 +66,11 @@ docker logs trek
|
|||||||
|
|
||||||
## Limitations of `docker run`
|
## Limitations of `docker run`
|
||||||
|
|
||||||
A bare `docker run` command has no built-in secret management and is harder to reproduce after a system reboot. For production, see [Install-Docker-Compose], which adds security hardening (`read_only`, `cap_drop`, `cap_add`, `no-new-privileges`, `tmpfs`) and makes it easy to manage environment variables through a `.env` file.
|
A bare `docker run` command has no built-in secret management and is harder to reproduce after a system reboot. For production, see [Install-Docker-Compose](Install-Docker-Compose), which adds security hardening (`read_only`, `cap_drop`, `cap_add`, `no-new-privileges`, `tmpfs`) and makes it easy to manage environment variables through a `.env` file.
|
||||||
|
|
||||||
## Next Steps
|
## Next Steps
|
||||||
|
|
||||||
- [Reverse-Proxy] — HTTPS is required for PWA install and the `trek_session` cookie `secure` flag
|
- [Reverse-Proxy](Reverse-Proxy) — HTTPS is required for PWA install and the `trek_session` cookie `secure` flag
|
||||||
- [Install-Docker-Compose] — recommended for production
|
- [Install-Docker-Compose](Install-Docker-Compose) — recommended for production
|
||||||
- [Environment-Variables] — full list of configurable variables
|
- [Environment-Variables](Environment-Variables) — full list of configurable variables
|
||||||
- [Updating] — how to pull a new image without losing data
|
- [Updating](Updating) — how to pull a new image without losing data
|
||||||
|
|||||||
@@ -191,5 +191,5 @@ See the [`charts/README.md`](https://github.com/mauriceboe/TREK/blob/main/charts
|
|||||||
|
|
||||||
## Next Steps
|
## Next Steps
|
||||||
|
|
||||||
- [Environment-Variables] — full variable reference
|
- [Environment-Variables](Environment-Variables) — full variable reference
|
||||||
- [Reverse-Proxy] — proxy configuration for non-Kubernetes deployments
|
- [Reverse-Proxy](Reverse-Proxy) — proxy configuration for non-Kubernetes deployments
|
||||||
|
|||||||
@@ -69,5 +69,5 @@ On first boot, TREK automatically creates an admin account. The credentials are
|
|||||||
|
|
||||||
## Next Steps
|
## Next Steps
|
||||||
|
|
||||||
- [Environment-Variables] — complete variable reference
|
- [Environment-Variables](Environment-Variables) — complete variable reference
|
||||||
- [Updating] — how to pull a new image on Unraid
|
- [Updating](Updating) — how to pull a new image on Unraid
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user