Compare commits

..

13 Commits

Author SHA1 Message Date
sss3978 00a7ac0341 Merge d30132197e into 71aa8f8051 2026-04-22 14:00:59 +00:00
sss3978 d30132197e Update client/src/i18n/translations/ja.ts
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-22 23:00:56 +09:00
jubnl 71aa8f8051 feat: journey gallery 1-to-N model with M:N entry-photo junction table
Replaces the old model where journey_photos was keyed per-entry with a
per-journey gallery table (one row per unique photo per journey) and a new
junction table journey_entry_photos that links gallery photos to entries.

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

streamPhoto now branches on kind for local file_path rows — thumbnail requests
use the stored/generated thumb path; original requests (and fallback when thumb
generation fails) continue to serve the full file. Remote providers (Immich,
Synology) are unaffected.
2026-04-22 15:56:34 +02:00
jubnl f6b3931bc4 fix: mobile public share — remove map tab (#828), cap timeline width (#827), wire entry click (#826)
- #828: exclude 'map' from availableViews on mobile; MobileMapTimeline already
  shows combined map+timeline so the standalone map tab is redundant
- #827: cap timeline feed column at xl:max-w-[50%] on ≥1280px viewports so the
  map aside is not dwarfed on wide monitors; applies to both desktop two-column
  layouts (JourneyPublicPage)
- #826: wire MobileMapTimeline onEntryClick to setViewingEntry; render
  MobileEntryView with readOnly + public photo URL builder so photos load via
  the share token endpoint; add publicPhotoUrl prop to MobileEntryView so
  photo URLs are routable for both authenticated and public-share contexts
2026-04-22 15:56:20 +02:00
sss3978 9bf4220054 Update ja.ts 2026-04-22 21:59:16 +09:00
sss3978 0d0ab5080c Update supportedLanguages.ts 2026-04-22 21:56:52 +09:00
sss3978 1084d40685 Update TranslationContext.tsx 2026-04-22 21:55:47 +09:00
sss3978 75ef928264 Create ja.ts 2026-04-22 21:54:10 +09:00
Maurice 9e3041305c docs: remove badge icons + Roadmap board->view 2026-04-22 00:00:46 +02:00
Maurice 78fc557143 docs: remove icons from badges 2026-04-22 00:00:27 +02:00
Maurice 8a2fec8de0 docs: shorten badge labels (Demo/Try, Discord/Join, Ko-fi/Support, BMAC/Support) 2026-04-21 23:58:49 +02:00
Maurice e109dc0b51 docs: subtitle onto its own line under the logo + Ko-fi/BMAC badges
- <br /> between the TREK logo and the subtitle picture so the
  subtitle sits below the logo instead of rendering next to it.
- New badge row with Ko-fi and Buy Me a Coffee in the same
  for-the-badge style as Live Demo / Docker / Discord / Roadmap.
2026-04-21 23:39:54 +02:00
21 changed files with 3672 additions and 320 deletions
+10 -4
View File
@@ -6,6 +6,8 @@
<img src="docs/logo-trek-dark.gif" alt="TREK" height="96" /> <img src="docs/logo-trek-dark.gif" alt="TREK" height="96" />
</picture> </picture>
<br />
<picture> <picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/subtitle-light.png" /> <source media="(prefers-color-scheme: dark)" srcset="docs/subtitle-light.png" />
<source media="(prefers-color-scheme: light)" srcset="docs/subtitle-dark.png" /> <source media="(prefers-color-scheme: light)" srcset="docs/subtitle-dark.png" />
@@ -16,13 +18,17 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
<br /> <br />
<a href="https://demo-nomad.pakulat.org"><img alt="Live Demo" src="https://img.shields.io/badge/Live_Demo-try_it_now-111827?style=for-the-badge" /></a> <a href="https://demo-nomad.pakulat.org"><img alt="Demo" src="https://img.shields.io/badge/Demo-try-111827?style=for-the-badge" /></a>
&nbsp; &nbsp;
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=for-the-badge&logo=docker&logoColor=white" /></a> <a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=for-the-badge" /></a>
&nbsp; &nbsp;
<a href="https://discord.gg/NhZBDSd4qW"><img alt="Discord" src="https://img.shields.io/badge/Discord-community-5865F2?style=for-the-badge&logo=discord&logoColor=white" /></a> <a href="https://discord.gg/NhZBDSd4qW"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?style=for-the-badge" /></a>
&nbsp; &nbsp;
<a href="https://kanban.pakulat.org/shared/I4wxF6inOOMB0C6hH6kQm3efyNxFjwyI"><img alt="Roadmap" src="https://img.shields.io/badge/Roadmap-board-0EA5E9?style=for-the-badge&logo=trello&logoColor=white" /></a> <a href="https://kanban.pakulat.org/shared/I4wxF6inOOMB0C6hH6kQm3efyNxFjwyI"><img alt="Roadmap" src="https://img.shields.io/badge/Roadmap-view-0EA5E9?style=for-the-badge" /></a>
<br />
<a href="https://ko-fi.com/mauriceboe"><img alt="Ko-fi" src="https://img.shields.io/badge/Ko--fi-support-FF5E5B?style=for-the-badge" /></a>
&nbsp;
<a href="https://www.buymeacoffee.com/mauriceboe"><img alt="BMAC" src="https://img.shields.io/badge/BMAC-support-FFDD00?style=for-the-badge" /></a>
<br /> <br />
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-AGPL_v3-6B7280?style=flat-square" /></a> <a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-AGPL_v3-6B7280?style=flat-square" /></a>
<a href="https://github.com/mauriceboe/TREK/releases"><img alt="Latest Release" src="https://img.shields.io/github/v/release/mauriceboe/TREK?include_prereleases&style=flat-square&color=6B7280" /></a> <a href="https://github.com/mauriceboe/TREK/releases"><img alt="Latest Release" src="https://img.shields.io/github/v/release/mauriceboe/TREK?include_prereleases&style=flat-square&color=6B7280" /></a>
+5 -1
View File
@@ -356,9 +356,13 @@ export const journeyApi = {
// Photos // Photos
uploadPhotos: (entryId: number, formData: FormData) => apiClient.post(`/journeys/entries/${entryId}/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data), uploadPhotos: (entryId: number, formData: FormData) => apiClient.post(`/journeys/entries/${entryId}/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
uploadGalleryPhotos: (journeyId: number, formData: FormData) => apiClient.post(`/journeys/${journeyId}/gallery/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data), addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data), addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
linkPhoto: (entryId: number, photoId: number) => apiClient.post(`/journeys/entries/${entryId}/link-photo`, { photo_id: photoId }).then(r => r.data), linkPhoto: (entryId: number, journeyPhotoId: number) => apiClient.post(`/journeys/entries/${entryId}/link-photo`, { journey_photo_id: journeyPhotoId }).then(r => r.data),
unlinkPhoto: (entryId: number, journeyPhotoId: number) => apiClient.delete(`/journeys/entries/${entryId}/photos/${journeyPhotoId}`).then(r => r.data),
deleteGalleryPhoto: (journeyId: number, journeyPhotoId: number) => apiClient.delete(`/journeys/${journeyId}/gallery/${journeyPhotoId}`).then(r => r.data),
updatePhoto: (photoId: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/photos/${photoId}`, data).then(r => r.data), updatePhoto: (photoId: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/photos/${photoId}`, data).then(r => r.data),
deletePhoto: (photoId: number) => apiClient.delete(`/journeys/photos/${photoId}`).then(r => r.data), deletePhoto: (photoId: number) => apiClient.delete(`/journeys/photos/${photoId}`).then(r => r.data),
@@ -25,20 +25,22 @@ const WEATHER_CONFIG: Record<string, { icon: typeof Sun; label: string }> = {
cold: { icon: Snowflake, label: 'Cold' }, cold: { icon: Snowflake, label: 'Cold' },
} }
function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'original'): string { function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'original', builder?: (id: number) => string): string {
if (builder) return builder(p.photo_id)
return `/api/photos/${p.photo_id}/${size}` return `/api/photos/${p.photo_id}/${size}`
} }
interface Props { interface Props {
entry: JourneyEntry entry: JourneyEntry
readOnly?: boolean readOnly?: boolean
publicPhotoUrl?: (photoId: number) => string
onClose: () => void onClose: () => void
onEdit: () => void onEdit: () => void
onDelete: () => void onDelete: () => void
onPhotoClick: (photos: JourneyPhoto[], index: number) => void onPhotoClick: (photos: JourneyPhoto[], index: number) => void
} }
export default function MobileEntryView({ entry, readOnly, onClose, onEdit, onDelete, onPhotoClick }: Props) { export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClose, onEdit, onDelete, onPhotoClick }: Props) {
const photos = entry.photos || [] const photos = entry.photos || []
const mood = entry.mood ? MOOD_CONFIG[entry.mood] : null const mood = entry.mood ? MOOD_CONFIG[entry.mood] : null
const weather = entry.weather ? WEATHER_CONFIG[entry.weather] : null const weather = entry.weather ? WEATHER_CONFIG[entry.weather] : null
@@ -85,7 +87,7 @@ export default function MobileEntryView({ entry, readOnly, onClose, onEdit, onDe
{photos.length > 0 && ( {photos.length > 0 && (
<div className="relative"> <div className="relative">
<img <img
src={photoUrl(photos[0])} src={photoUrl(photos[0], 'original', publicPhotoUrl)}
alt="" alt=""
className="w-full max-h-[50vh] object-cover cursor-pointer" className="w-full max-h-[50vh] object-cover cursor-pointer"
onClick={() => onPhotoClick(photos, 0)} onClick={() => onPhotoClick(photos, 0)}
@@ -102,7 +104,7 @@ export default function MobileEntryView({ entry, readOnly, onClose, onEdit, onDe
{photos.map((p, i) => ( {photos.map((p, i) => (
<img <img
key={p.id || i} key={p.id || i}
src={photoUrl(p, 'thumbnail')} src={photoUrl(p, 'thumbnail', publicPhotoUrl)}
alt="" alt=""
className="w-16 h-16 rounded-lg object-cover flex-shrink-0 cursor-pointer hover:ring-2 ring-zinc-900/30 dark:ring-white/30 transition-all" className="w-16 h-16 rounded-lg object-cover flex-shrink-0 cursor-pointer hover:ring-2 ring-zinc-900/30 dark:ring-white/30 transition-all"
onClick={() => onPhotoClick(photos, i)} onClick={() => onPhotoClick(photos, i)}
+3 -2
View File
@@ -15,6 +15,7 @@ import ar from './translations/ar'
import br from './translations/br' import br from './translations/br'
import cs from './translations/cs' import cs from './translations/cs'
import pl from './translations/pl' import pl from './translations/pl'
import ja from './translations/ja'
import { SUPPORTED_LANGUAGES, SupportedLanguageCode } from './supportedLanguages' import { SUPPORTED_LANGUAGES, SupportedLanguageCode } from './supportedLanguages'
export { SUPPORTED_LANGUAGES } export { SUPPORTED_LANGUAGES }
@@ -23,7 +24,7 @@ type TranslationStrings = Record<string, string | { name: string; category: stri
// Keyed by SupportedLanguageCode so TypeScript enforces all languages have a translation. // Keyed by SupportedLanguageCode so TypeScript enforces all languages have a translation.
const translations: Record<SupportedLanguageCode, TranslationStrings> = { const translations: Record<SupportedLanguageCode, TranslationStrings> = {
de, en, es, fr, hu, it, ru, zh, 'zh-TW': zhTw, nl, id, ar, br, cs, pl, de, en, es, fr, hu, it, ru, zh, 'zh-TW': zhTw, nl, id, ar, br, cs, pl, ja,
} }
// Derived from SUPPORTED_LANGUAGES — add new languages there, not here. // Derived from SUPPORTED_LANGUAGES — add new languages there, not here.
@@ -38,7 +39,7 @@ export function getLocaleForLanguage(language: string): string {
export function getIntlLanguage(language: string): string { export function getIntlLanguage(language: string): string {
if (language === 'br') return 'pt-BR' if (language === 'br') return 'pt-BR'
return ['de', 'es', 'fr', 'hu', 'it', 'ru', 'zh', 'zh-TW', 'nl', 'ar', 'cs', 'pl', 'id'].includes(language) ? language : 'en' return ['de', 'es', 'fr', 'hu', 'it', 'ru', 'zh', 'zh-TW', 'nl', 'ar', 'cs', 'pl', 'id', 'ja' ].includes(language) ? language : 'en'
} }
export function isRtlLanguage(language: string): boolean { export function isRtlLanguage(language: string): boolean {
+2 -1
View File
@@ -12,8 +12,9 @@ export const SUPPORTED_LANGUAGES = [
{ value: 'zh', label: '简体中文', locale: 'zh-CN' }, { value: 'zh', label: '简体中文', locale: 'zh-CN' },
{ value: 'zh-TW', label: '繁體中文', locale: 'zh-TW' }, { value: 'zh-TW', label: '繁體中文', locale: 'zh-TW' },
{ value: 'it', label: 'Italiano', locale: 'it-IT' }, { value: 'it', label: 'Italiano', locale: 'it-IT' },
{ value: 'ar', label: 'العربية', locale: 'ar-SA' }, { value: 'ar', label: 'العربية', locale: 'ar-SA' },
{ value: 'id', label: 'Bahasa Indonesia', locale: 'id-ID' }, { value: 'id', label: 'Bahasa Indonesia', locale: 'id-ID' },
{ value: 'ja', label: '日本語', locale: 'ja-JP' },
] as const ] as const
export type SupportedLanguageCode = typeof SUPPORTED_LANGUAGES[number]['value'] export type SupportedLanguageCode = typeof SUPPORTED_LANGUAGES[number]['value']
File diff suppressed because it is too large Load Diff
+38 -24
View File
@@ -177,6 +177,24 @@ const mockJourneyDetail = {
}, },
], ],
stats: { entries: 2, photos: 1, places: 2 }, stats: { entries: 2, photos: 1, places: 2 },
gallery: [
{
id: 100,
journey_id: 1,
photo_id: 100,
provider: 'local',
file_path: 'photos/test.jpg',
asset_id: null,
owner_id: null,
thumbnail_path: null,
caption: 'Colosseum',
sort_order: 0,
width: 800,
height: 600,
shared: 1,
created_at: now,
},
],
}; };
// ── MSW Handlers ───────────────────────────────────────────────────────────── // ── MSW Handlers ─────────────────────────────────────────────────────────────
@@ -1724,13 +1742,14 @@ describe('JourneyDetailPage', () => {
it('renders the empty gallery state when journey has no photos', async () => { it('renders the empty gallery state when journey has no photos', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
// Override with entries that have no photos // Override with entries that have no photos and empty gallery
const emptyEntry = { const emptyEntry = {
...mockJourneyDetail.entries[0], ...mockJourneyDetail.entries[0],
photos: [], photos: [],
}; };
setupDefaultHandlers({ setupDefaultHandlers({
entries: [emptyEntry], entries: [emptyEntry],
gallery: [],
stats: { entries: 1, photos: 0, places: 1 }, stats: { entries: 1, photos: 0, places: 1 },
}); });
@@ -1981,10 +2000,9 @@ describe('JourneyDetailPage', () => {
expect(screen.getByText(/1 photos/i)).toBeInTheDocument(); expect(screen.getByText(/1 photos/i)).toBeInTheDocument();
}); });
// The entry date '2026-03-15' is shown as a formatted overlay on each gallery photo // Gallery photos render in a grid; each photo has a group container
// The component uses toLocaleDateString which produces "Mar 15, 2026" in en-US const photos = document.querySelectorAll('[class*="aspect-square"]');
const dateOverlay = document.querySelector('[class*="opacity-0"]'); expect(photos.length).toBeGreaterThanOrEqual(1);
expect(dateOverlay).toBeTruthy();
}); });
}); });
@@ -2022,6 +2040,11 @@ describe('JourneyDetailPage', () => {
setupDefaultHandlers({ setupDefaultHandlers({
entries: [immichEntry, mockJourneyDetail.entries[1]], entries: [immichEntry, mockJourneyDetail.entries[1]],
stats: { entries: 2, photos: 1, places: 2 }, stats: { entries: 2, photos: 1, places: 2 },
gallery: [{
id: 200, journey_id: 1, photo_id: 200, provider: 'immich', file_path: null,
asset_id: 'asset-123', owner_id: 1, thumbnail_path: null,
caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now,
}],
}); });
render(<JourneyDetailPage />); render(<JourneyDetailPage />);
@@ -2056,6 +2079,11 @@ describe('JourneyDetailPage', () => {
setupDefaultHandlers({ setupDefaultHandlers({
entries: [synologyEntry, mockJourneyDetail.entries[1]], entries: [synologyEntry, mockJourneyDetail.entries[1]],
stats: { entries: 2, photos: 1, places: 2 }, stats: { entries: 2, photos: 1, places: 2 },
gallery: [{
id: 201, journey_id: 1, photo_id: 201, provider: 'synology', file_path: null,
asset_id: 'syn-456', owner_id: 1, thumbnail_path: null,
caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now,
}],
}); });
render(<JourneyDetailPage />); render(<JourneyDetailPage />);
@@ -3265,25 +3293,14 @@ describe('JourneyDetailPage', () => {
// ── FE-PAGE-JOURNEYDETAIL-141 ────────────────────────────────────────── // ── FE-PAGE-JOURNEYDETAIL-141 ──────────────────────────────────────────
describe('FE-PAGE-JOURNEYDETAIL-141: Gallery upload triggers file input and calls API', () => { describe('FE-PAGE-JOURNEYDETAIL-141: Gallery upload triggers file input and calls API', () => {
it('uploading files in gallery creates an entry and uploads photos', async () => { it('uploading files in gallery calls gallery upload API', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
let createCalled = false;
let uploadCalled = false; let uploadCalled = false;
server.use( server.use(
http.post('/api/journeys/1/entries', () => { http.post('/api/journeys/1/gallery/photos', () => {
createCalled = true;
return HttpResponse.json({
id: 99, journey_id: 1, author_id: 1, type: 'entry',
entry_date: '2026-04-11', title: 'Gallery', story: null, location_name: null,
location_lat: null, location_lng: null, mood: null, weather: null,
tags: [], pros_cons: null, visibility: 'private', sort_order: 0,
entry_time: null, photos: [], created_at: now, updated_at: now,
});
}),
http.post('/api/journeys/entries/99/photos', () => {
uploadCalled = true; uploadCalled = true;
return HttpResponse.json([]); return HttpResponse.json({ photos: [] });
}), }),
); );
@@ -3304,9 +3321,6 @@ describe('JourneyDetailPage', () => {
const testFile = new File(['fake-content'], 'test-photo.jpg', { type: 'image/jpeg' }); const testFile = new File(['fake-content'], 'test-photo.jpg', { type: 'image/jpeg' });
await user.upload(fileInput, testFile); await user.upload(fileInput, testFile);
await waitFor(() => {
expect(createCalled).toBe(true);
});
await waitFor(() => { await waitFor(() => {
expect(uploadCalled).toBe(true); expect(uploadCalled).toBe(true);
}); });
@@ -3320,9 +3334,9 @@ describe('JourneyDetailPage', () => {
let deleteCalled = false; let deleteCalled = false;
server.use( server.use(
http.delete('/api/journeys/photos/100', () => { http.delete('/api/journeys/1/gallery/100', () => {
deleteCalled = true; deleteCalled = true;
return HttpResponse.json({ success: true }); return new HttpResponse(null, { status: 204 });
}), }),
); );
+44 -75
View File
@@ -27,7 +27,7 @@ import {
import MobileMapTimeline from '../components/Journey/MobileMapTimeline' import MobileMapTimeline from '../components/Journey/MobileMapTimeline'
import MobileEntryView from '../components/Journey/MobileEntryView' import MobileEntryView from '../components/Journey/MobileEntryView'
import { useIsMobile } from '../hooks/useIsMobile' import { useIsMobile } from '../hooks/useIsMobile'
import type { JourneyEntry, JourneyPhoto, JourneyDetail } from '../store/journeyStore' import type { JourneyEntry, JourneyPhoto, GalleryPhoto, JourneyTrip, JourneyDetail } from '../store/journeyStore'
import { computeJourneyLifecycle } from '../utils/journeyLifecycle' import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
const GRADIENTS = [ const GRADIENTS = [
@@ -80,7 +80,7 @@ function formatDate(d: string, locale?: string): { weekday: string; month: strin
} }
} }
function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'thumbnail'): string { function photoUrl(p: { photo_id: number }, size: 'thumbnail' | 'original' = 'thumbnail'): string {
return `/api/photos/${p.photo_id}/${size}` return `/api/photos/${p.photo_id}/${size}`
} }
@@ -341,7 +341,7 @@ export default function JourneyDetailPage() {
) )
} }
const timelineEntries = current.entries.filter(e => e.title !== 'Gallery' && e.title !== '[Trip Photos]' && (!hideSkeletons || e.type !== 'skeleton')) const timelineEntries = current.entries.filter(e => (!hideSkeletons || e.type !== 'skeleton'))
const dayGroups = groupByDate(timelineEntries) const dayGroups = groupByDate(timelineEntries)
const sortedDates = [...dayGroups.keys()].sort() const sortedDates = [...dayGroups.keys()].sort()
@@ -458,7 +458,7 @@ export default function JourneyDetailPage() {
className={ className={
isMobile isMobile
? '' ? ''
: 'flex-1 overflow-y-auto journey-feed-scroll' : 'flex-1 xl:max-w-[50%] overflow-y-auto journey-feed-scroll'
} }
> >
<div className={isMobile ? '' : 'w-full px-8 py-6'}> <div className={isMobile ? '' : 'w-full px-8 py-6'}>
@@ -693,10 +693,11 @@ export default function JourneyDetailPage() {
> >
<GalleryView <GalleryView
entries={current.entries} entries={current.entries}
gallery={current.gallery || []}
journeyId={current.id} journeyId={current.id}
userId={useAuthStore.getState().user?.id || 0} userId={useAuthStore.getState().user?.id || 0}
trips={current.trips} trips={current.trips}
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })} onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption ?? null, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })}
onRefresh={() => loadJourney(Number(id))} onRefresh={() => loadJourney(Number(id))}
/> />
</div> </div>
@@ -733,7 +734,7 @@ export default function JourneyDetailPage() {
entry={editingEntry} entry={editingEntry}
journeyId={current.id} journeyId={current.id}
tripDates={tripDates} tripDates={tripDates}
galleryPhotos={current.entries.flatMap(e => e.photos || [])} galleryPhotos={current.gallery || []}
onClose={() => setEditingEntry(null)} onClose={() => setEditingEntry(null)}
onSave={async (data) => { onSave={async (data) => {
let entryId = editingEntry.id let entryId = editingEntry.id
@@ -971,12 +972,13 @@ function MapView({ entries, mapEntries, sortedDates, activeLocationId, fullMapRe
// ── Gallery View ────────────────────────────────────────────────────────── // ── Gallery View ──────────────────────────────────────────────────────────
function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefresh }: { function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick, onRefresh }: {
entries: JourneyEntry[] entries: JourneyEntry[]
gallery: GalleryPhoto[]
journeyId: number journeyId: number
userId: number userId: number
trips: JourneyTrip[] trips: JourneyTrip[]
onPhotoClick: (photos: JourneyPhoto[], index: number) => void onPhotoClick: (photos: GalleryPhoto[], index: number) => void
onRefresh: () => void onRefresh: () => void
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
@@ -1009,19 +1011,7 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
})() })()
}, []) }, [])
const allPhotos: { photo: JourneyPhoto; entry: JourneyEntry }[] = [] const allPhotos = gallery
const seenPhotoIds = new Map<number, number>() // photo_id → index in allPhotos
for (const e of entries) {
for (const p of e.photos) {
const existing = seenPhotoIds.get(p.photo_id)
if (existing === undefined) {
seenPhotoIds.set(p.photo_id, allPhotos.length)
allPhotos.push({ photo: p, entry: e })
} else if (e.title === 'Gallery' && allPhotos[existing].entry.title !== 'Gallery') {
allPhotos[existing] = { photo: p, entry: e }
}
}
}
const entriesWithContent = entries.filter(e => e.type !== 'skeleton' || e.title) const entriesWithContent = entries.filter(e => e.type !== 'skeleton' || e.title)
@@ -1037,22 +1027,9 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
if (!files?.length) return if (!files?.length) return
setGalleryUploading(true) setGalleryUploading(true)
try { try {
// find existing "Gallery" entry or create one. The stored title is the
// literal 'Gallery' (server-side checks look for this exact string) —
// do not send a translated label here.
let galleryEntry = entries.find(e => e.title === 'Gallery' && e.type === 'entry')
let entryId = galleryEntry?.id
if (!entryId) {
const entry = await journeyApi.createEntry(journeyId, {
title: 'Gallery',
entry_date: new Date().toISOString().split('T')[0],
type: 'entry',
})
entryId = entry.id
}
const formData = new FormData() const formData = new FormData()
for (const f of files) formData.append('photos', f) for (const f of files) formData.append('photos', f)
await journeyApi.uploadPhotos(entryId, formData) await journeyApi.uploadGalleryPhotos(journeyId, formData)
toast.success(t('journey.photosUploaded', { count: files.length })) toast.success(t('journey.photosUploaded', { count: files.length }))
onRefresh() onRefresh()
} catch { } catch {
@@ -1063,25 +1040,24 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
e.target.value = '' e.target.value = ''
} }
const handleDeletePhoto = async (photoId: number) => { const handleDeletePhoto = async (galleryPhotoId: number) => {
const store = useJourneyStore.getState() const store = useJourneyStore.getState()
if (!store.current) return if (!store.current) return
const target = store.current.entries.flatMap(e => e.photos).find(p => p.id === photoId)
if (!target) return
const siblingIds = store.current.entries.flatMap(e => e.photos).filter(p => p.photo_id === target.photo_id).map(p => p.id)
// Optimistic update — remove every row with this photo_id // Optimistic update — remove from gallery and all entry photo lists
const updated = { useJourneyStore.setState({
...store.current, current: {
entries: store.current.entries.map(e => ({ ...store.current,
...e, gallery: (store.current.gallery || []).filter(p => p.id !== galleryPhotoId),
photos: e.photos.filter(p => p.photo_id !== target.photo_id), entries: store.current.entries.map(e => ({
})).filter(e => e.type !== 'entry' || e.title !== 'Gallery' || e.photos.length > 0 || e.story), ...e,
} photos: e.photos.filter(p => p.id !== galleryPhotoId),
useJourneyStore.setState({ current: updated }) })),
},
})
try { try {
await Promise.all(siblingIds.map(id => journeyApi.deletePhoto(id))) await journeyApi.deleteGalleryPhoto(journeyId, galleryPhotoId)
} catch { } catch {
toast.error(t('common.error')) toast.error(t('common.error'))
onRefresh() onRefresh()
@@ -1132,11 +1108,11 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
</div> </div>
) : ( ) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5 pb-24 md:pb-6"> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5 pb-24 md:pb-6">
{allPhotos.map(({ photo, entry }, i) => ( {allPhotos.map((photo, i) => (
<div <div
key={photo.id} key={photo.id}
className="relative aspect-square rounded-lg overflow-hidden cursor-pointer group" className="relative aspect-square rounded-lg overflow-hidden cursor-pointer group"
onClick={() => onPhotoClick(allPhotos.map(a => a.photo), i)} onClick={() => onPhotoClick(allPhotos, i)}
> >
<img <img
src={photoUrl(photo, 'thumbnail')} src={photoUrl(photo, 'thumbnail')}
@@ -1165,11 +1141,6 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
<p className="text-[10px] text-white truncate">{photo.caption}</p> <p className="text-[10px] text-white truncate">{photo.caption}</p>
</div> </div>
)} )}
<div className="absolute bottom-1.5 left-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
<span className="text-[9px] font-medium px-1.5 py-0.5 rounded-full bg-black/50 backdrop-blur text-white">
{new Date(entry.entry_date + 'T12:00:00').toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' })}
</span>
</div>
</div> </div>
))} ))}
</div> </div>
@@ -1182,25 +1153,19 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
userId={userId} userId={userId}
entries={entriesWithContent} entries={entriesWithContent}
trips={trips} trips={trips}
existingAssetIds={new Set(entries.flatMap(e => (e.photos || []).filter(p => p.asset_id).map(p => p.asset_id!)))} existingAssetIds={new Set(gallery.filter(p => p.asset_id).map(p => p.asset_id!))}
onClose={() => setShowPicker(false)} onClose={() => setShowPicker(false)}
onAdd={async (groups, entryId) => { onAdd={async (groups, entryId) => {
let targetId = entryId
if (!targetId) {
try {
const entry = await journeyApi.createEntry(journeyId, {
title: 'Gallery',
entry_date: new Date().toISOString().split('T')[0],
type: 'entry',
})
targetId = entry.id
} catch { return }
}
let added = 0 let added = 0
for (const group of groups) { for (const group of groups) {
try { try {
const result = await journeyApi.addProviderPhotos(targetId, pickerProvider!, group.assetIds, undefined, group.passphrase) if (entryId) {
added += result.added || 0 const result = await journeyApi.addProviderPhotos(entryId, pickerProvider!, group.assetIds, undefined, group.passphrase)
added += result.added || 0
} else {
const result = await journeyApi.addProviderPhotosToGallery(journeyId, pickerProvider!, group.assetIds, group.passphrase)
added += result.added || 0
}
} catch {} } catch {}
} }
if (added > 0) { if (added > 0) {
@@ -2201,7 +2166,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
entry: JourneyEntry entry: JourneyEntry
journeyId: number journeyId: number
tripDates: Set<string> tripDates: Set<string>
galleryPhotos: JourneyPhoto[] galleryPhotos: GalleryPhoto[]
onClose: () => void onClose: () => void
onSave: (data: Record<string, unknown>) => Promise<number> onSave: (data: Record<string, unknown>) => Promise<number>
onUploadPhotos: (entryId: number, formData: FormData) => Promise<JourneyPhoto[]> onUploadPhotos: (entryId: number, formData: FormData) => Promise<JourneyPhoto[]>
@@ -2227,7 +2192,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
const [cons, setCons] = useState<string[]>(entry.pros_cons?.cons?.length ? entry.pros_cons.cons : ['']) const [cons, setCons] = useState<string[]>(entry.pros_cons?.cons?.length ? entry.pros_cons.cons : [''])
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
const [photos, setPhotos] = useState<JourneyPhoto[]>(entry.photos || []) const [photos, setPhotos] = useState<(JourneyPhoto | GalleryPhoto)[]>(entry.photos || [])
const [pendingFiles, setPendingFiles] = useState<File[]>([]) const [pendingFiles, setPendingFiles] = useState<File[]>([])
const [pendingLinkIds, setPendingLinkIds] = useState<number[]>([]) const [pendingLinkIds, setPendingLinkIds] = useState<number[]>([])
const [showGalleryPick, setShowGalleryPick] = useState(false) const [showGalleryPick, setShowGalleryPick] = useState(false)
@@ -2254,8 +2219,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
pendingLinkIds.length > 0 pendingLinkIds.length > 0
) )
const uniqueGalleryPhotos = Array.from(new Map(galleryPhotos.map(gp => [gp.photo_id, gp])).values()) const availableGalleryPhotos = galleryPhotos.filter(gp => !photos.some(p => p.id === gp.id))
const availableGalleryPhotos = uniqueGalleryPhotos.filter(gp => !photos.some(p => p.photo_id === gp.photo_id))
const handleClose = () => { const handleClose = () => {
if (isDirty && !window.confirm(t('journey.editor.discardChangesConfirm'))) return if (isDirty && !window.confirm(t('journey.editor.discardChangesConfirm'))) return
@@ -2421,8 +2385,13 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
<button <button
onClick={async (e) => { onClick={async (e) => {
e.stopPropagation() e.stopPropagation()
await journeyApi.deletePhoto(p.id)
setPhotos(prev => prev.filter(x => x.id !== p.id)) setPhotos(prev => prev.filter(x => x.id !== p.id))
if (entry.id > 0) {
// unlink from entry; gallery row is preserved
try { await journeyApi.unlinkPhoto(entry.id, p.id) } catch {}
} else {
setPendingLinkIds(prev => prev.filter(id => id !== p.id))
}
}} }}
className="absolute top-1 right-1 w-5 h-5 rounded-full bg-black/60 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity" className="absolute top-1 right-1 w-5 h-5 rounded-full bg-black/60 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
> >
@@ -56,6 +56,21 @@ vi.mock('../components/Journey/PhotoLightbox', () => ({
), ),
})); }));
vi.mock('../components/Journey/MobileMapTimeline', () => ({
default: ({ onEntryClick }: any) => (
<div data-testid="mobile-map-timeline">
<button onClick={() => onEntryClick({ id: 10, title: 'Shibuya Crossing', story: 'The most famous crossing in the world.', entry_date: '2026-03-15', entry_time: '14:00', location_name: 'Shibuya, Tokyo', photos: [] })}>
Open Entry
</button>
</div>
),
}));
const mockIsMobile = { value: false };
vi.mock('../hooks/useIsMobile', () => ({
useIsMobile: () => mockIsMobile.value,
}));
import JourneyPublicPage from './JourneyPublicPage'; import JourneyPublicPage from './JourneyPublicPage';
// ── Fixtures ───────────────────────────────────────────────────────────────── // ── Fixtures ─────────────────────────────────────────────────────────────────
@@ -106,6 +121,9 @@ const mockJourneyData = {
share_gallery: true, share_gallery: true,
share_map: true, share_map: true,
}, },
gallery: [
{ id: 100, journey_id: 1, photo_id: 100, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/temple.jpg', caption: 'Temple entrance', shared: 1, sort_order: 0, created_at: 0 },
],
stats: { stats: {
entries: 2, entries: 2,
photos: 1, photos: 1,
@@ -136,6 +154,7 @@ function setup404() {
beforeEach(() => { beforeEach(() => {
resetAllStores(); resetAllStores();
vi.clearAllMocks(); vi.clearAllMocks();
mockIsMobile.value = false;
}); });
// ── Tests ──────────────────────────────────────────────────────────────────── // ── Tests ────────────────────────────────────────────────────────────────────
@@ -340,6 +359,11 @@ describe('JourneyPublicPage', () => {
], ],
}, },
], ],
gallery: [
{ id: 200, journey_id: 1, photo_id: 200, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/a.jpg', caption: 'Photo A', shared: 1, sort_order: 0, created_at: 0 },
{ id: 201, journey_id: 1, photo_id: 201, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/b.jpg', caption: 'Photo B', shared: 1, sort_order: 1, created_at: 0 },
{ id: 202, journey_id: 1, photo_id: 202, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/c.jpg', caption: 'Photo C', shared: 1, sort_order: 2, created_at: 0 },
],
stats: { entries: 1, photos: 3, places: 0 }, stats: { entries: 1, photos: 3, places: 0 },
}; };
@@ -391,6 +415,40 @@ describe('JourneyPublicPage', () => {
expect(statsContainer!.textContent).toContain('7'); expect(statsContainer!.textContent).toContain('7');
}); });
// FE-PAGE-PUBLICJOURNEY-019 — bug #828
it('FE-PAGE-PUBLICJOURNEY-019: mobile public share does not show standalone Map tab', async () => {
mockIsMobile.value = true;
setupSuccess();
render(<JourneyPublicPage />);
await waitFor(() => {
expect(screen.getByText('Tokyo 2026')).toBeInTheDocument();
});
const buttons = screen.getAllByRole('button');
const mapBtn = buttons.find(btn => btn.textContent && /^map$/i.test(btn.textContent.trim()));
expect(mapBtn).toBeUndefined();
});
// FE-PAGE-PUBLICJOURNEY-020 — bug #826
it('FE-PAGE-PUBLICJOURNEY-020: mobile public share opens entry details on card click', async () => {
const user = userEvent.setup();
mockIsMobile.value = true;
setupSuccess();
render(<JourneyPublicPage />);
await waitFor(() => {
expect(screen.getByText('Tokyo 2026')).toBeInTheDocument();
});
// The MobileMapTimeline mock fires onEntryClick when "Open Entry" is clicked
const openBtn = screen.getByText('Open Entry');
await user.click(openBtn);
// MobileEntryView should slide in with the entry title
await waitFor(() => {
expect(screen.getByText('Shibuya Crossing')).toBeInTheDocument();
});
});
// FE-PAGE-PUBLICJOURNEY-016 // FE-PAGE-PUBLICJOURNEY-016
it('FE-PAGE-PUBLICJOURNEY-016: language picker opens and switches language', async () => { it('FE-PAGE-PUBLICJOURNEY-016: language picker opens and switches language', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
+42 -11
View File
@@ -14,6 +14,7 @@ import type { JourneyMapHandle } from '../components/Journey/JourneyMap'
import JournalBody from '../components/Journey/JournalBody' import JournalBody from '../components/Journey/JournalBody'
import PhotoLightbox from '../components/Journey/PhotoLightbox' import PhotoLightbox from '../components/Journey/PhotoLightbox'
import MobileMapTimeline from '../components/Journey/MobileMapTimeline' import MobileMapTimeline from '../components/Journey/MobileMapTimeline'
import MobileEntryView from '../components/Journey/MobileEntryView'
import { useIsMobile } from '../hooks/useIsMobile' import { useIsMobile } from '../hooks/useIsMobile'
import { formatLocationName } from '../utils/formatters' import { formatLocationName } from '../utils/formatters'
import { DAY_COLORS } from '../components/Journey/dayColors' import { DAY_COLORS } from '../components/Journey/dayColors'
@@ -44,6 +45,17 @@ interface PublicPhoto {
caption?: string | null caption?: string | null
} }
interface PublicGalleryPhoto {
id: number
journey_id: number
photo_id: number
provider?: string
asset_id?: string | null
owner_id?: number | null
file_path?: string | null
caption?: string | null
}
const MOOD_CONFIG: Record<string, { icon: typeof Smile; label: string; bg: string; text: string }> = { const MOOD_CONFIG: Record<string, { icon: typeof Smile; label: string; bg: string; text: string }> = {
amazing: { icon: Laugh, label: 'Amazing', bg: 'bg-pink-50 dark:bg-pink-900/20', text: 'text-pink-600 dark:text-pink-400' }, amazing: { icon: Laugh, label: 'Amazing', bg: 'bg-pink-50 dark:bg-pink-900/20', text: 'text-pink-600 dark:text-pink-400' },
good: { icon: Smile, label: 'Good', bg: 'bg-amber-50 dark:bg-amber-900/20', text: 'text-amber-600 dark:text-amber-400' }, good: { icon: Smile, label: 'Good', bg: 'bg-amber-50 dark:bg-amber-900/20', text: 'text-amber-600 dark:text-amber-400' },
@@ -60,7 +72,7 @@ const WEATHER_CONFIG: Record<string, { icon: typeof Sun; label: string }> = {
cold: { icon: Snowflake, label: 'Cold' }, cold: { icon: Snowflake, label: 'Cold' },
} }
function photoUrl(p: PublicPhoto, shareToken: string, kind: 'thumbnail' | 'original' = 'original'): string { function photoUrl(p: { photo_id: number }, shareToken: string, kind: 'thumbnail' | 'original' = 'original'): string {
return `/api/public/journey/${shareToken}/photos/${p.photo_id}/${kind}` return `/api/public/journey/${shareToken}/photos/${p.photo_id}/${kind}`
} }
@@ -96,6 +108,7 @@ export default function JourneyPublicPage() {
const locale = useSettingsStore(s => s.settings.language) || 'en' const locale = useSettingsStore(s => s.settings.language) || 'en'
const mapRef = useRef<JourneyMapHandle>(null) const mapRef = useRef<JourneyMapHandle>(null)
const [activeEntryId, setActiveEntryId] = useState<string | null>(null) const [activeEntryId, setActiveEntryId] = useState<string | null>(null)
const [viewingEntry, setViewingEntry] = useState<PublicEntry | null>(null)
const handleMarkerClick = useCallback((entryId: string) => { const handleMarkerClick = useCallback((entryId: string) => {
setActiveEntryId(entryId) setActiveEntryId(entryId)
@@ -113,21 +126,19 @@ export default function JourneyPublicPage() {
}, [token]) }, [token])
const entries = (data?.entries || []) as PublicEntry[] const entries = (data?.entries || []) as PublicEntry[]
const gallery = (data?.gallery || []) as PublicGalleryPhoto[]
const perms = data?.permissions || {} const perms = data?.permissions || {}
const journey = data?.journey || {} const journey = data?.journey || {}
const stats = data?.stats || {} const stats = data?.stats || {}
const timelineEntries = useMemo( const timelineEntries = useMemo(() => entries, [entries])
() => entries.filter(e => e.title !== '[Trip Photos]' && e.title !== 'Gallery'),
[entries],
)
const groupedEntries = useMemo(() => groupByDate(timelineEntries), [timelineEntries]) const groupedEntries = useMemo(() => groupByDate(timelineEntries), [timelineEntries])
const sortedDates = useMemo(() => [...groupedEntries.keys()].sort(), [groupedEntries]) const sortedDates = useMemo(() => [...groupedEntries.keys()].sort(), [groupedEntries])
const mapEntries = useMemo( const mapEntries = useMemo(
() => timelineEntries.filter(e => e.location_lat && e.location_lng), () => timelineEntries.filter(e => e.location_lat && e.location_lng),
[timelineEntries], [timelineEntries],
) )
const allPhotos = useMemo(() => entries.flatMap(e => (e.photos || []).map(p => ({ photo: p, entry: e }))), [entries]) const allPhotos = gallery
// Map entries with day color/label for colored markers. // Map entries with day color/label for colored markers.
// dayIdx is derived from sortedDates (ALL timeline dates) so marker colors // dayIdx is derived from sortedDates (ALL timeline dates) so marker colors
@@ -189,7 +200,7 @@ export default function JourneyPublicPage() {
const availableViews = [ const availableViews = [
perms.share_timeline && { id: 'timeline' as const, icon: List, label: t('journey.share.timeline') }, perms.share_timeline && { id: 'timeline' as const, icon: List, label: t('journey.share.timeline') },
perms.share_gallery && { id: 'gallery' as const, icon: Grid, label: t('journey.share.gallery') }, perms.share_gallery && { id: 'gallery' as const, icon: Grid, label: t('journey.share.gallery') },
!desktopTwoColumn && perms.share_map && { id: 'map' as const, icon: MapPin, label: t('journey.share.map') }, !desktopTwoColumn && !isMobile && perms.share_map && { id: 'map' as const, icon: MapPin, label: t('journey.share.map') },
].filter(Boolean) as { id: 'timeline' | 'gallery' | 'map'; icon: any; label: string }[] ].filter(Boolean) as { id: 'timeline' | 'gallery' | 'map'; icon: any; label: string }[]
// Shared timeline renderer used in both layout modes // Shared timeline renderer used in both layout modes
@@ -402,11 +413,11 @@ export default function JourneyPublicPage() {
// Shared gallery renderer // Shared gallery renderer
const renderGallery = () => ( const renderGallery = () => (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5"> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5">
{allPhotos.map(({ photo }, idx) => ( {allPhotos.map((photo, idx) => (
<div <div
key={photo.id} key={photo.id}
className="aspect-square rounded-lg overflow-hidden cursor-pointer" className="aspect-square rounded-lg overflow-hidden cursor-pointer"
onClick={() => setLightbox({ photos: allPhotos.map(({ photo: p }) => ({ id: String(p.id), src: photoUrl(p, token!, 'original'), caption: p.caption })), index: idx })} onClick={() => setLightbox({ photos: allPhotos.map(p => ({ id: String(p.id), src: photoUrl(p, token!, 'original'), caption: p.caption })), index: idx })}
> >
<img src={photoUrl(photo, token!, 'thumbnail')} className="w-full h-full object-cover hover:scale-105 transition-transform" alt="" loading="lazy" /> <img src={photoUrl(photo, token!, 'thumbnail')} className="w-full h-full object-cover hover:scale-105 transition-transform" alt="" loading="lazy" />
</div> </div>
@@ -499,7 +510,7 @@ export default function JourneyPublicPage() {
// ── Desktop two-column: scrollable timeline feed + sticky map ────────── // ── Desktop two-column: scrollable timeline feed + sticky map ──────────
<div className="max-w-[1440px] mx-auto flex" style={{ alignItems: 'flex-start' }}> <div className="max-w-[1440px] mx-auto flex" style={{ alignItems: 'flex-start' }}>
{/* Left: feed */} {/* Left: feed */}
<div className="flex-1 min-w-0 px-8 py-6"> <div className="flex-1 xl:max-w-[50%] min-w-0 px-8 py-6">
{renderTabs(availableViews)} {renderTabs(availableViews)}
{view === 'timeline' && perms.share_timeline && renderTimeline()} {view === 'timeline' && perms.share_timeline && renderTimeline()}
{view === 'gallery' && perms.share_gallery && renderGallery()} {view === 'gallery' && perms.share_gallery && renderGallery()}
@@ -563,7 +574,7 @@ export default function JourneyPublicPage() {
mapEntries={sidebarMapItems as any} mapEntries={sidebarMapItems as any}
dark={document.documentElement.classList.contains('dark')} dark={document.documentElement.classList.contains('dark')}
readOnly readOnly
onEntryClick={() => {}} onEntryClick={(entry) => setViewingEntry(entry as any)}
publicPhotoUrl={(photoId) => `/api/public/journey/${token}/photos/${photoId}/original`} publicPhotoUrl={(photoId) => `/api/public/journey/${token}/photos/${photoId}/original`}
carouselBottom="calc(env(safe-area-inset-bottom, 16px) + 8px)" carouselBottom="calc(env(safe-area-inset-bottom, 16px) + 8px)"
/> />
@@ -607,6 +618,26 @@ export default function JourneyPublicPage() {
onClose={() => setLightbox(null)} onClose={() => setLightbox(null)}
/> />
)} )}
{/* Mobile entry detail view (public share) */}
{viewingEntry && (
<MobileEntryView
entry={viewingEntry as any}
readOnly
publicPhotoUrl={(photoId) => `/api/public/journey/${token}/photos/${photoId}/original`}
onClose={() => setViewingEntry(null)}
onEdit={() => {}}
onDelete={() => {}}
onPhotoClick={(photos, idx) => setLightbox({
photos: photos.map(p => ({
id: String(p.id),
src: photoUrl(p as any, token!, 'original'),
caption: (p as any).caption ?? null,
})),
index: idx,
})}
/>
)}
</div> </div>
) )
} }
+65
View File
@@ -57,6 +57,24 @@ export interface JourneyPhoto {
height?: number | null height?: number | null
} }
export interface GalleryPhoto {
id: number
journey_id: number
photo_id: number
caption?: string | null
shared: number
sort_order: number
created_at: number
// Joined from trek_photos for display
provider?: string
asset_id?: string | null
owner_id?: number | null
file_path?: string | null
thumbnail_path?: string | null
width?: number | null
height?: number | null
}
export interface JourneyTrip { export interface JourneyTrip {
trip_id: number trip_id: number
added_at: number added_at: number
@@ -79,6 +97,7 @@ export interface JourneyContributor {
export interface JourneyDetail extends Journey { export interface JourneyDetail extends Journey {
entries: JourneyEntry[] entries: JourneyEntry[]
gallery: GalleryPhoto[]
trips: JourneyTrip[] trips: JourneyTrip[]
contributors: JourneyContributor[] contributors: JourneyContributor[]
stats: { entries: number; photos: number; places: number } stats: { entries: number; photos: number; places: number }
@@ -103,6 +122,9 @@ interface JourneyState {
reorderEntries: (journeyId: number, orderedIds: number[]) => Promise<void> reorderEntries: (journeyId: number, orderedIds: number[]) => Promise<void>
uploadPhotos: (entryId: number, formData: FormData) => Promise<JourneyPhoto[]> uploadPhotos: (entryId: number, formData: FormData) => Promise<JourneyPhoto[]>
uploadGalleryPhotos: (journeyId: number, formData: FormData) => Promise<GalleryPhoto[]>
unlinkPhoto: (entryId: number, journeyPhotoId: number) => Promise<void>
deleteGalleryPhoto: (journeyId: number, journeyPhotoId: number) => Promise<void>
deletePhoto: (photoId: number) => Promise<void> deletePhoto: (photoId: number) => Promise<void>
clear: () => void clear: () => void
@@ -228,12 +250,55 @@ export const useJourneyStore = create<JourneyState>((set, get) => ({
entries: s.current.entries.map(e => entries: s.current.entries.map(e =>
e.id === entryId ? { ...e, photos: [...(e.photos || []), ...photos] } : e e.id === entryId ? { ...e, photos: [...(e.photos || []), ...photos] } : e
), ),
gallery: [...(s.current.gallery || []), ...(data.gallery || [])],
}, },
} }
}) })
return photos return photos
}, },
uploadGalleryPhotos: async (journeyId, formData) => {
const data = await journeyApi.uploadGalleryPhotos(journeyId, formData)
const photos: GalleryPhoto[] = data.photos || []
set(s => {
if (!s.current || s.current.id !== journeyId) return s
return { current: { ...s.current, gallery: [...(s.current.gallery || []), ...photos] } }
})
return photos
},
unlinkPhoto: async (entryId, journeyPhotoId) => {
await journeyApi.unlinkPhoto(entryId, journeyPhotoId)
set(s => {
if (!s.current) return s
return {
current: {
...s.current,
entries: s.current.entries.map(e =>
e.id === entryId ? { ...e, photos: (e.photos || []).filter(p => p.id !== journeyPhotoId) } : e
),
},
}
})
},
deleteGalleryPhoto: async (journeyId, journeyPhotoId) => {
await journeyApi.deleteGalleryPhoto(journeyId, journeyPhotoId)
set(s => {
if (!s.current) return s
return {
current: {
...s.current,
gallery: (s.current.gallery || []).filter(p => p.id !== journeyPhotoId),
entries: s.current.entries.map(e => ({
...e,
photos: (e.photos || []).filter(p => p.id !== journeyPhotoId),
})),
},
}
})
},
deletePhoto: async (photoId) => { deletePhoto: async (photoId) => {
await journeyApi.deletePhoto(photoId) await journeyApi.deletePhoto(photoId)
set(s => { set(s => {
+575
View File
@@ -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",
+1
View File
@@ -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",
+97
View File
@@ -1946,6 +1946,103 @@ 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)');
},
]; ];
if (currentVersion < migrations.length) { if (currentVersion < migrations.length) {
+65 -24
View File
@@ -104,10 +104,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 +142,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 +168,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) => {
+146 -142
View File
@@ -7,12 +7,22 @@ function ts(): number {
return Date.now(); return Date.now();
} }
// Joined SELECT for journey_photos + trek_photos — returns fields matching JourneyPhoto interface // Per-entry photo view: join journey_entry_photos → journey_photos (gallery) → trek_photos.
// id = gp.id (gallery photo id) — used by clients for linkPhoto/updatePhoto/unlink/delete.
const JP_SELECT = ` const JP_SELECT = `
jp.id, jp.entry_id, jp.photo_id, jp.caption, jp.sort_order, jp.shared, jp.created_at, gp.id, jep.entry_id, gp.photo_id, gp.caption, jep.sort_order, gp.shared, gp.created_at,
tkp.provider, tkp.asset_id, tkp.owner_id, tkp.file_path, tkp.thumbnail_path, tkp.width, tkp.height tp.provider, tp.asset_id, tp.owner_id, tp.file_path, tp.thumbnail_path, tp.width, tp.height
`; `;
const JP_JOIN = 'journey_photos jp JOIN trek_photos tkp ON tkp.id = jp.photo_id'; const JP_JOIN = `journey_entry_photos jep
JOIN journey_photos gp ON gp.id = jep.journey_photo_id
JOIN trek_photos tp ON tp.id = gp.photo_id`;
// Per-journey gallery view: journey_photos → trek_photos (no entry context).
const GALLERY_SELECT = `
gp.id, gp.journey_id, gp.photo_id, gp.caption, gp.shared, gp.sort_order, gp.created_at,
tp.provider, tp.asset_id, tp.owner_id, tp.file_path, tp.thumbnail_path, tp.width, tp.height
`;
const GALLERY_JOIN = 'journey_photos gp JOIN trek_photos tp ON tp.id = gp.photo_id';
function broadcastJourneyEvent(journeyId: number, event: string, data: Record<string, unknown>, excludeSocketId?: string | number) { function broadcastJourneyEvent(journeyId: number, event: string, data: Record<string, unknown>, excludeSocketId?: string | number) {
const contributors = db.prepare( const contributors = db.prepare(
@@ -58,7 +68,7 @@ export function listJourneys(userId: number) {
return db.prepare(` return db.prepare(`
SELECT DISTINCT j.*, SELECT DISTINCT j.*,
(SELECT COUNT(*) FROM journey_entries je WHERE je.journey_id = j.id AND je.type != 'skeleton') as entry_count, (SELECT COUNT(*) FROM journey_entries je WHERE je.journey_id = j.id AND je.type != 'skeleton') as entry_count,
(SELECT COUNT(DISTINCT jp.photo_id) FROM journey_photos jp JOIN journey_entries je2 ON jp.entry_id = je2.id WHERE je2.journey_id = j.id) as photo_count, (SELECT COUNT(*) FROM journey_photos jp WHERE jp.journey_id = j.id) as photo_count,
(SELECT COUNT(DISTINCT je3.location_name) FROM journey_entries je3 WHERE je3.journey_id = j.id AND je3.location_name IS NOT NULL AND je3.location_name != '') as place_count, (SELECT COUNT(DISTINCT je3.location_name) FROM journey_entries je3 WHERE je3.journey_id = j.id AND je3.location_name IS NOT NULL AND je3.location_name != '') as place_count,
(SELECT MIN(t.start_date) FROM journey_trips jt JOIN trips t ON jt.trip_id = t.id WHERE jt.journey_id = j.id) as trip_date_min, (SELECT MIN(t.start_date) FROM journey_trips jt JOIN trips t ON jt.trip_id = t.id WHERE jt.journey_id = j.id) as trip_date_min,
(SELECT MAX(t.end_date) FROM journey_trips jt JOIN trips t ON jt.trip_id = t.id WHERE jt.journey_id = j.id) as trip_date_max (SELECT MAX(t.end_date) FROM journey_trips jt JOIN trips t ON jt.trip_id = t.id WHERE jt.journey_id = j.id) as trip_date_max
@@ -114,7 +124,7 @@ export function getJourneyFull(journeyId: number, userId: number) {
).all(journeyId) as JourneyEntry[]; ).all(journeyId) as JourneyEntry[];
const photos = db.prepare( const photos = db.prepare(
`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.entry_id IN (SELECT id FROM journey_entries WHERE journey_id = ?) ORDER BY jp.sort_order ASC` `SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jep.entry_id IN (SELECT id FROM journey_entries WHERE journey_id = ?) ORDER BY jep.sort_order ASC`
).all(journeyId) as JourneyPhoto[]; ).all(journeyId) as JourneyPhoto[];
// group photos by entry // group photos by entry
@@ -123,12 +133,11 @@ export function getJourneyFull(journeyId: number, userId: number) {
(photosByEntry[p.entry_id] ||= []).push(p); (photosByEntry[p.entry_id] ||= []).push(p);
} }
const gallery = db.prepare(
`SELECT ${GALLERY_SELECT} FROM ${GALLERY_JOIN} WHERE gp.journey_id = ? ORDER BY gp.sort_order ASC, gp.id ASC`
).all(journeyId);
const enrichedEntries = entries const enrichedEntries = entries
.filter(e => {
// hide empty Gallery entries (no photos, no story)
if (e.title === 'Gallery' && !e.story && !(photosByEntry[e.id]?.length)) return false;
return true;
})
.map(e => ({ .map(e => ({
...e, ...e,
tags: e.tags ? JSON.parse(e.tags) : [], tags: e.tags ? JSON.parse(e.tags) : [],
@@ -160,7 +169,7 @@ export function getJourneyFull(journeyId: number, userId: number) {
// stats // stats
const entryCount = entries.filter(e => e.type === 'entry').length; const entryCount = entries.filter(e => e.type === 'entry').length;
const photoCount = new Set(photos.map(p => p.photo_id)).size; const photoCount = (gallery as any[]).length;
const places = [...new Set(entries.map(e => e.location_name).filter(Boolean))]; const places = [...new Set(entries.map(e => e.location_name).filter(Boolean))];
const userPrefs = db.prepare( const userPrefs = db.prepare(
@@ -183,6 +192,7 @@ export function getJourneyFull(journeyId: number, userId: number) {
return { return {
...journey, ...journey,
entries: enrichedEntries, entries: enrichedEntries,
gallery,
trips, trips,
contributors, contributors,
stats: { entries: entryCount, photos: photoCount, places: places.length }, stats: { entries: entryCount, photos: photoCount, places: places.length },
@@ -315,46 +325,22 @@ export function syncTripPlaces(journeyId: number, tripId: number, authorId: numb
} }
} }
// import trip_photos into journey when a trip is linked // import trip_photos into journey gallery when a trip is linked
function syncTripPhotos(journeyId: number, tripId: number) { function syncTripPhotos(journeyId: number, tripId: number) {
const tripPhotos = db.prepare( const tripPhotos = db.prepare(
'SELECT tp.photo_id, tp.user_id, tp.shared FROM trip_photos tp WHERE tp.trip_id = ?' 'SELECT tp.photo_id, tp.shared FROM trip_photos tp WHERE tp.trip_id = ?'
).all(tripId) as { photo_id: number; user_id: number; shared: number }[]; ).all(tripId) as { photo_id: number; shared: number }[];
if (!tripPhotos.length) return; if (!tripPhotos.length) return;
const now = ts(); const now = ts();
const maxOrderRow = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE journey_id = ?').get(journeyId) as { m: number | null };
let nextOrder = (maxOrderRow?.m ?? -1) + 1;
// find or create a "Photos" entry for this trip's photos
let photoEntry = db.prepare(`
SELECT id FROM journey_entries
WHERE journey_id = ? AND source_trip_id = ? AND title = '[Trip Photos]' AND type = 'entry'
`).get(journeyId, tripId) as { id: number } | undefined;
if (!photoEntry) {
const trip = db.prepare('SELECT start_date FROM trips WHERE id = ?').get(tripId) as { start_date: string } | undefined;
const entryDate = trip?.start_date || new Date().toISOString().split('T')[0];
const owner = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(journeyId) as { user_id: number };
const res = db.prepare(`
INSERT INTO journey_entries (journey_id, source_trip_id, author_id, type, title, entry_date, sort_order, created_at, updated_at)
VALUES (?, ?, ?, 'entry', '[Trip Photos]', ?, 999, ?, ?)
`).run(journeyId, tripId, owner.user_id, entryDate, now, now);
photoEntry = { id: Number(res.lastInsertRowid) };
}
// import each trip photo, skip duplicates (by photo_id)
for (const tp of tripPhotos) { for (const tp of tripPhotos) {
const exists = db.prepare(
'SELECT 1 FROM journey_photos WHERE entry_id = ? AND photo_id = ?'
).get(photoEntry.id, tp.photo_id);
if (exists) continue;
const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE entry_id = ?').get(photoEntry.id) as { m: number | null };
db.prepare(` db.prepare(`
INSERT INTO journey_photos (entry_id, photo_id, shared, sort_order, created_at) INSERT OR IGNORE INTO journey_photos (journey_id, photo_id, shared, sort_order, created_at)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
`).run(photoEntry.id, tp.photo_id, tp.shared, (maxOrder?.m ?? -1) + 1, now); `).run(journeyId, tp.photo_id, tp.shared, nextOrder++, now);
} }
} }
@@ -444,7 +430,7 @@ export function onPlaceDeleted(placeId: number) {
for (const entry of entries) { for (const entry of entries) {
if (entry.type === 'skeleton') { if (entry.type === 'skeleton') {
// no content: just delete // no content: just delete
const hasPhotos = db.prepare('SELECT 1 FROM journey_photos WHERE entry_id = ?').get(entry.id); const hasPhotos = db.prepare('SELECT 1 FROM journey_entry_photos WHERE entry_id = ?').get(entry.id);
if (!hasPhotos && !entry.story) { if (!hasPhotos && !entry.story) {
db.prepare('DELETE FROM journey_entries WHERE id = ?').run(entry.id); db.prepare('DELETE FROM journey_entries WHERE id = ?').run(entry.id);
continue; continue;
@@ -469,7 +455,7 @@ export function listEntries(journeyId: number, userId: number) {
).all(journeyId) as JourneyEntry[]; ).all(journeyId) as JourneyEntry[];
const photos = db.prepare( const photos = db.prepare(
`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.entry_id IN (SELECT id FROM journey_entries WHERE journey_id = ?) ORDER BY jp.sort_order ASC` `SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jep.entry_id IN (SELECT id FROM journey_entries WHERE journey_id = ?) ORDER BY jep.sort_order ASC`
).all(journeyId) as JourneyPhoto[]; ).all(journeyId) as JourneyPhoto[];
const photosByEntry: Record<number, JourneyPhoto[]> = {}; const photosByEntry: Record<number, JourneyPhoto[]> = {};
@@ -628,9 +614,6 @@ export function deleteEntry(entryId: number, userId: number, sid?: string): bool
if (!entry) return false; if (!entry) return false;
if (!canEdit(entry.journey_id, userId)) return false; if (!canEdit(entry.journey_id, userId)) return false;
// delete photos along with the entry — no more orphan Gallery entries
db.prepare('DELETE FROM journey_photos WHERE entry_id = ?').run(entryId);
if (entry.source_trip_id && entry.source_place_id && entry.type !== 'skeleton') { if (entry.source_trip_id && entry.source_place_id && entry.type !== 'skeleton') {
// Revert filled entry back to skeleton instead of deleting // Revert filled entry back to skeleton instead of deleting
db.prepare(` db.prepare(`
@@ -645,12 +628,6 @@ export function deleteEntry(entryId: number, userId: number, sid?: string): bool
broadcastJourneyEvent(entry.journey_id, 'journey:entry:deleted', { entryId }, sid); broadcastJourneyEvent(entry.journey_id, 'journey:entry:deleted', { entryId }, sid);
} }
// clean up any empty Gallery entries in this journey
db.prepare(`
DELETE FROM journey_entries WHERE journey_id = ? AND title = 'Gallery'
AND id NOT IN (SELECT DISTINCT entry_id FROM journey_photos)
`).run(entry.journey_id);
return true; return true;
} }
@@ -664,23 +641,40 @@ function promoteSkeletonIfNeeded(entry: JourneyEntry): void {
db.prepare('UPDATE journey_entries SET type = ?, updated_at = ? WHERE id = ?').run('entry', ts(), entry.id); db.prepare('UPDATE journey_entries SET type = ?, updated_at = ? WHERE id = ?').run('entry', ts(), entry.id);
} }
// Ensure a trek_photo_id is in the journey gallery; return its gallery row id.
function ensureInGallery(journeyId: number, trekPhotoId: number, caption?: string, shared?: number): number {
const now = ts();
const maxOrderRow = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE journey_id = ?').get(journeyId) as { m: number | null };
db.prepare(`
INSERT OR IGNORE INTO journey_photos (journey_id, photo_id, caption, shared, sort_order, created_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(journeyId, trekPhotoId, caption || null, shared ?? 0, (maxOrderRow?.m ?? -1) + 1, now);
const row = db.prepare('SELECT id FROM journey_photos WHERE journey_id = ? AND photo_id = ?').get(journeyId, trekPhotoId) as { id: number };
return row.id;
}
// Link a gallery photo to an entry (idempotent). Returns the junction JP_SELECT row.
function linkGalleryPhotoToEntry(galleryId: number, entryId: number): JourneyPhoto | null {
const now = ts();
const maxOrderRow = db.prepare('SELECT MAX(sort_order) as m FROM journey_entry_photos WHERE entry_id = ?').get(entryId) as { m: number | null };
db.prepare(`
INSERT OR IGNORE INTO journey_entry_photos (entry_id, journey_photo_id, sort_order, created_at)
VALUES (?, ?, ?, ?)
`).run(entryId, galleryId, (maxOrderRow?.m ?? -1) + 1, now);
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jep.entry_id = ? AND jep.journey_photo_id = ?`)
.get(entryId, galleryId) as JourneyPhoto | null;
}
export function addPhoto(entryId: number, userId: number, filePath: string, thumbnailPath?: string, caption?: string): JourneyPhoto | null { export function addPhoto(entryId: number, userId: number, filePath: string, thumbnailPath?: string, caption?: string): JourneyPhoto | null {
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined; const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
if (!entry) return null; if (!entry) return null;
if (!canEdit(entry.journey_id, userId)) return null; if (!canEdit(entry.journey_id, userId)) return null;
const trekPhotoId = getOrCreateLocalTrekPhoto(filePath, thumbnailPath); const trekPhotoId = getOrCreateLocalTrekPhoto(filePath, thumbnailPath);
const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE entry_id = ?').get(entryId) as { m: number | null }; const galleryId = db.transaction(() => ensureInGallery(entry.journey_id, trekPhotoId, caption))();
const now = ts(); const result = linkGalleryPhotoToEntry(galleryId, entryId);
const res = db.prepare(`
INSERT INTO journey_photos (entry_id, photo_id, caption, sort_order, created_at)
VALUES (?, ?, ?, ?, ?)
`).run(entryId, trekPhotoId, caption || null, (maxOrder?.m ?? -1) + 1, now);
promoteSkeletonIfNeeded(entry); promoteSkeletonIfNeeded(entry);
return result;
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(Number(res.lastInsertRowid)) as JourneyPhoto;
} }
export function addProviderPhoto(entryId: number, userId: number, provider: string, assetId: string, caption?: string, passphrase?: string): JourneyPhoto | null { export function addProviderPhoto(entryId: number, userId: number, provider: string, assetId: string, caption?: string, passphrase?: string): JourneyPhoto | null {
@@ -690,119 +684,129 @@ 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[] = []; const fields: string[] = [];
const values: unknown[] = []; const values: unknown[] = [];
if (data.caption !== undefined) { fields.push('caption = ?'); values.push(data.caption); } if (data.caption !== undefined) { fields.push('caption = ?'); values.push(data.caption); }
if (data.sort_order !== undefined) { fields.push('sort_order = ?'); values.push(data.sort_order); } if (data.sort_order !== undefined) { fields.push('sort_order = ?'); values.push(data.sort_order); }
if (!fields.length) return photo; if (!fields.length) {
// no-op: return some photo row for this gallery item (first entry link)
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE gp.id = ? LIMIT 1`).get(photoId) as JourneyPhoto | null;
}
values.push(photoId); values.push(photoId);
db.prepare(`UPDATE journey_photos SET ${fields.join(', ')} WHERE id = ?`).run(...values); db.prepare(`UPDATE journey_photos SET ${fields.join(', ')} WHERE id = ?`).run(...values);
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(photoId) as JourneyPhoto; 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): { 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 { photo_id: row.photo_id, file_path: trekRow?.file_path ?? null, journey_id: row.journey_id };
const remaining = db.prepare('SELECT 1 FROM journey_photos WHERE entry_id = ?').get(photo.entry_id);
if (!remaining) {
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(photo.entry_id) as JourneyEntry | undefined;
if (entry && entry.title === 'Gallery' && !entry.story) {
db.prepare('DELETE FROM journey_entries WHERE id = ?').run(photo.entry_id);
}
}
return photo;
} }
// ── Contributors ───────────────────────────────────────────────────────── // ── Contributors ─────────────────────────────────────────────────────────
+24 -21
View File
@@ -66,11 +66,10 @@ export function validateShareTokenForPhoto(token: string, photoId: number): { jo
const row = db.prepare('SELECT journey_id FROM journey_share_tokens WHERE token = ?').get(token) as any; const row = db.prepare('SELECT journey_id FROM journey_share_tokens WHERE token = ?').get(token) as any;
if (!row) return null; if (!row) return null;
const photo = db.prepare(` const photo = db.prepare(`
SELECT jp.photo_id, tkp.owner_id, je.journey_id SELECT gp.photo_id, tkp.owner_id, gp.journey_id
FROM journey_photos jp FROM journey_photos gp
JOIN trek_photos tkp ON tkp.id = jp.photo_id JOIN trek_photos tkp ON tkp.id = gp.photo_id
JOIN journey_entries je ON jp.entry_id = je.id WHERE gp.photo_id = ? AND gp.journey_id = ?
WHERE jp.photo_id = ? AND je.journey_id = ?
`).get(photoId, row.journey_id) as any; `).get(photoId, row.journey_id) as any;
if (!photo) return null; if (!photo) return null;
const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(row.journey_id) as any; const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(row.journey_id) as any;
@@ -81,10 +80,9 @@ export function validateShareTokenForAsset(token: string, assetId: string): { ow
const row = db.prepare('SELECT journey_id FROM journey_share_tokens WHERE token = ?').get(token) as any; const row = db.prepare('SELECT journey_id FROM journey_share_tokens WHERE token = ?').get(token) as any;
if (!row) return null; if (!row) return null;
const photo = db.prepare(` const photo = db.prepare(`
SELECT tkp.owner_id FROM journey_photos jp SELECT tkp.owner_id FROM journey_photos gp
JOIN trek_photos tkp ON tkp.id = jp.photo_id JOIN trek_photos tkp ON tkp.id = gp.photo_id
JOIN journey_entries je ON jp.entry_id = je.id WHERE tkp.asset_id = ? AND gp.journey_id = ?
WHERE tkp.asset_id = ? AND je.journey_id = ?
`).get(assetId, row.journey_id) as any; `).get(assetId, row.journey_id) as any;
if (!photo) { if (!photo) {
const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(row.journey_id) as any; const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(row.journey_id) as any;
@@ -108,13 +106,13 @@ export function getPublicJourney(token: string) {
`).all(row.journey_id) as any[]; `).all(row.journey_id) as any[];
const photos = db.prepare(` const photos = db.prepare(`
SELECT jp.id, jp.entry_id, jp.photo_id, jp.caption, jp.sort_order, jp.shared, jp.created_at, SELECT gp.id, jep.entry_id, gp.photo_id, gp.caption, jep.sort_order, gp.shared, gp.created_at,
tkp.provider, tkp.asset_id, tkp.owner_id, tkp.file_path, tkp.thumbnail_path, tkp.width, tkp.height tkp.provider, tkp.asset_id, tkp.owner_id, tkp.file_path, tkp.thumbnail_path, tkp.width, tkp.height
FROM journey_photos jp FROM journey_entry_photos jep
JOIN trek_photos tkp ON tkp.id = jp.photo_id JOIN journey_photos gp ON gp.id = jep.journey_photo_id
JOIN journey_entries je ON jp.entry_id = je.id JOIN trek_photos tkp ON tkp.id = gp.photo_id
WHERE je.journey_id = ? WHERE gp.journey_id = ?
ORDER BY jp.sort_order ORDER BY jep.sort_order
`).all(row.journey_id) as any[]; `).all(row.journey_id) as any[];
const photosByEntry: Record<number, any[]> = {}; const photosByEntry: Record<number, any[]> = {};
@@ -122,12 +120,16 @@ export function getPublicJourney(token: string) {
(photosByEntry[p.entry_id] ||= []).push(p); (photosByEntry[p.entry_id] ||= []).push(p);
} }
const gallery = db.prepare(`
SELECT gp.id, gp.journey_id, gp.photo_id, gp.caption, gp.shared, gp.sort_order, gp.created_at,
tkp.provider, tkp.asset_id, tkp.owner_id, tkp.file_path, tkp.thumbnail_path, tkp.width, tkp.height
FROM journey_photos gp
JOIN trek_photos tkp ON tkp.id = gp.photo_id
WHERE gp.journey_id = ?
ORDER BY gp.sort_order
`).all(row.journey_id) as any[];
const enrichedEntries = entries const enrichedEntries = entries
.filter(e => {
// hide empty Gallery entries (no photos, no story)
if (e.title === 'Gallery' && !e.story && !(photosByEntry[e.id]?.length)) return false;
return true;
})
.map(e => ({ .map(e => ({
...e, ...e,
tags: e.tags ? JSON.parse(e.tags) : [], tags: e.tags ? JSON.parse(e.tags) : [],
@@ -138,7 +140,7 @@ export function getPublicJourney(token: string) {
// Stats // Stats
const stats = { const stats = {
entries: entries.length, entries: entries.length,
photos: photos.length, photos: gallery.length,
places: new Set(entries.filter(e => e.location_name).map(e => e.location_name)).size, places: new Set(entries.filter(e => e.location_name).map(e => e.location_name)).size,
}; };
@@ -150,6 +152,7 @@ export function getPublicJourney(token: string) {
status: journey.status, status: journey.status,
}, },
entries: enrichedEntries, entries: enrichedEntries,
gallery,
stats, stats,
permissions: { permissions: {
share_timeline: !!row.share_timeline, share_timeline: !!row.share_timeline,
+8 -10
View File
@@ -129,15 +129,14 @@ export function canAccessUserPhoto(requestingUserId: number, ownerUserId: number
// Journey photos use tripId=0 — check journey_photos + journey_contributors // Journey photos use tripId=0 — check journey_photos + journey_contributors
if (tripId === '0') { if (tripId === '0') {
const journeyPhoto = db.prepare(` const journeyPhoto = db.prepare(`
SELECT jp.entry_id, je.journey_id SELECT gp.journey_id
FROM journey_photos jp FROM journey_photos gp
JOIN trek_photos tkp ON tkp.id = jp.photo_id JOIN trek_photos tkp ON tkp.id = gp.photo_id
JOIN journey_entries je ON je.id = jp.entry_id
WHERE tkp.asset_id = ? WHERE tkp.asset_id = ?
AND tkp.provider = ? AND tkp.provider = ?
AND tkp.owner_id = ? AND tkp.owner_id = ?
LIMIT 1 LIMIT 1
`).get(assetId, provider, ownerUserId) as { entry_id: number; journey_id: number } | undefined; `).get(assetId, provider, ownerUserId) as { journey_id: number } | undefined;
if (!journeyPhoto) return false; if (!journeyPhoto) return false;
const access = db.prepare(` const access = db.prepare(`
@@ -194,13 +193,12 @@ export function canAccessTrekPhoto(requestingUserId: number, trekPhotoId: number
// Check journey_photos — is this photo in a journey the user can access? // Check journey_photos — is this photo in a journey the user can access?
const journeyAccess = db.prepare(` const journeyAccess = db.prepare(`
SELECT 1 FROM journey_photos jp SELECT 1 FROM journey_photos gp
JOIN journey_entries je ON je.id = jp.entry_id WHERE gp.photo_id = ?
WHERE jp.photo_id = ?
AND EXISTS ( AND EXISTS (
SELECT 1 FROM journeys j WHERE j.id = je.journey_id AND j.user_id = ? SELECT 1 FROM journeys j WHERE j.id = gp.journey_id AND j.user_id = ?
UNION ALL UNION ALL
SELECT 1 FROM journey_contributors jc WHERE jc.journey_id = je.journey_id AND jc.user_id = ? SELECT 1 FROM journey_contributors jc WHERE jc.journey_id = gp.journey_id AND jc.user_id = ?
) )
LIMIT 1 LIMIT 1
`).get(trekPhotoId, requestingUserId, requestingUserId); `).get(trekPhotoId, requestingUserId, requestingUserId);
@@ -9,6 +9,7 @@ import type { ServiceResult, AssetInfo } from './helpersService';
import { fail, success } from './helpersService'; import { fail, success } from './helpersService';
import { encrypt_api_key, decrypt_api_key } from '../apiKeyCrypto'; import { encrypt_api_key, decrypt_api_key } from '../apiKeyCrypto';
import * as photoCache from './trekPhotoCache'; import * as photoCache from './trekPhotoCache';
import { ensureLocalThumbnail } from './thumbnailService';
// ── Lookup / Register ──────────────────────────────────────────────────── // ── Lookup / Register ────────────────────────────────────────────────────
@@ -101,7 +102,31 @@ export async function streamPhoto(
} }
if (photo.file_path) { if (photo.file_path) {
const localPath = path.join(__dirname, '../../../uploads', photo.file_path); const uploadsRoot = path.join(__dirname, '../../../uploads');
if (kind === 'thumbnail') {
let thumbRel = photo.thumbnail_path ?? null;
if (!thumbRel) {
const result = await ensureLocalThumbnail(uploadsRoot, photo.file_path);
if (result) {
thumbRel = result.thumbnailRelPath;
db.prepare(
'UPDATE trek_photos SET thumbnail_path = ?, width = COALESCE(width, ?), height = COALESCE(height, ?) WHERE id = ?'
).run(thumbRel, result.width, result.height, photo.id);
}
}
if (thumbRel) {
const thumbAbs = path.join(uploadsRoot, thumbRel);
if (fs.existsSync(thumbAbs)) {
res.set('Cache-Control', 'public, max-age=86400, immutable');
res.sendFile(thumbAbs);
return;
}
}
// Fall through to original if thumbnail unavailable.
}
const localPath = path.join(uploadsRoot, photo.file_path);
if (fs.existsSync(localPath)) { if (fs.existsSync(localPath)) {
res.set('Cache-Control', 'public, max-age=86400'); res.set('Cache-Control', 'public, max-age=86400');
res.sendFile(localPath); res.sendFile(localPath);
@@ -0,0 +1,40 @@
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 }
}
} catch { /* regenerate */ }
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 }
}
+18
View File
@@ -389,6 +389,24 @@ export interface JourneyPhoto {
height?: number | null; height?: number | null;
} }
export interface GalleryPhoto {
id: number;
journey_id: number;
photo_id: number;
caption?: string | null;
shared: number;
sort_order: number;
created_at: number;
// Joined from trek_photos for API responses
provider?: string;
asset_id?: string | null;
owner_id?: number | null;
file_path?: string | null;
thumbnail_path?: string | null;
width?: number | null;
height?: number | null;
}
export interface JourneyTrip { export interface JourneyTrip {
journey_id: number; journey_id: number;
trip_id: number; trip_id: number;