fix(journey): improve video thumbnails and playback layout (#2043)

* Revert "chore(deps): bump dompurify from 3.4.11 to 3.4.12 (#1681)" (#1694)

This reverts commit 94736112cf.

* feat: add PhotoGrid and PhotoImg components for journey photo gallery display

* feat: implement JourneyPublicPage to display shared journey data with timeline, map, and gallery views

* feat: create MobileEntryView component for displaying detailed journey entries on mobile devices

* feat: add docker-compose configuration for video testing environment

* feat: add gallery view components with support for local photo/video uploads and external provider integration

* fix: green up the video layout branch

Four things stood between this and a merge.

index.css carried committed merge conflict markers. A format-on-save had run
over the whole file and collapsed `=======` and `>>>>>>>` onto the line that
starts the popup-tip rule, which makes .trek-map-popup .mapboxgl-popup-tip part
of a malformed selector list - so the parser drops the whole rule and the map
popup's tail comes back as the stray notch its comment describes. Nothing in CI
catches that; invalid CSS just disappears. The file is back to the dev version
with only the video block added, 1301 -> 1323 lines instead of 2057, and the
same for the lockfile, which had only picked up "peer": true annotations from a
different npm run.

PhotoImg referenced an onClick it never took as a prop - the click already sits
on the button wrapping it.

PublicPhoto did not declare media_type even though journey-share.service.ts
selects it in both queries, which is what the `as any` casts around it were
working around. Declared once, the way JourneyPhoto does, and the casts are gone.

The single-photo hero had moved to a thumbnail. Right for a video, whose
/original is the video file and leaves an <img> blank - but a photo hero is
deliberately full size, which FE-JRN-PUBWIRE-008 pins. It now picks per media
type.

---------

Co-authored-by: jubnl <66769052+jubnl@users.noreply.github.com>
Co-authored-by: Maurice <mauriceboe@icloud.com>
This commit is contained in:
Danette10
2026-08-25 23:42:11 +02:00
committed by GitHub
parent 5b9aaab847
commit f605b730d8
7 changed files with 214 additions and 73 deletions
@@ -44,10 +44,10 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
try { try {
const status = await memoriesApi.status(p.id) const status = await memoriesApi.status(p.id)
if (status.connected) connected.push({ id: p.id, name: p.name }) if (status.connected) connected.push({ id: p.id, name: p.name })
} catch {} } catch { }
} }
setAvailableProviders(connected) setAvailableProviders(connected)
} catch {} } catch { }
})() })()
}, []) }, [])
@@ -158,9 +158,10 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
onClick={() => onPhotoClick(allPhotos, i)} onClick={() => onPhotoClick(allPhotos, i)}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onPhotoClick(allPhotos, i) } }} onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onPhotoClick(allPhotos, i) } }}
> >
{photo.media_type === 'video' && !photo.thumbnail_path ? ( {photo.media_type === 'video' &&
// Poster-less video (capture failed / unsupported codec): show a photo.provider === 'local' &&
// neutral tile rather than a broken 404 thumbnail (#823). !photo.thumbnail_path ? (
// Poster-less local video: show a neutral tile.
<div className="w-full h-full bg-zinc-200 dark:bg-zinc-800" /> <div className="w-full h-full bg-zinc-200 dark:bg-zinc-800" />
) : ( ) : (
<img <img
@@ -1,17 +1,31 @@
import { Image } from 'lucide-react' import { Image, Play } from 'lucide-react'
import type { JourneyPhoto } from '../../store/journeyStore' import type { JourneyPhoto } from '../../store/journeyStore'
import { photoUrl } from '../../pages/journeyDetail/JourneyDetailPage.helpers' import { photoUrl } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
export function PhotoImg({ photo, className, style }: { photo: JourneyPhoto; className?: string; style?: React.CSSProperties }) { export function PhotoImg({ photo, className, style }: { photo: JourneyPhoto; className?: string; style?: React.CSSProperties }) {
const src = photoUrl(photo, 'thumbnail') const src = photoUrl(photo, 'thumbnail')
const isVideo = photo.media_type === 'video'
return ( return (
<img <div
src={src} className={`relative overflow-hidden ${isVideo ? 'bg-black' : ''} ${className || ''}`}
alt={photo.caption || ''}
className={className}
style={style} style={style}
loading="lazy" >
/> <img
src={src}
alt=""
className={`w-full h-full ${isVideo ? 'object-contain' : 'object-cover'}`}
loading="lazy"
/>
{isVideo && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<span className="w-11 h-11 rounded-full bg-black/60 backdrop-blur-sm flex items-center justify-center text-white">
<Play size={20} className="ml-0.5" fill="currentColor" />
</span>
</div>
)}
</div>
) )
} }
@@ -60,4 +74,4 @@ export function PhotoGrid({ photos, onClick }: { photos: JourneyPhoto[]; onClick
</div> </div>
</div> </div>
) )
} }
@@ -1,5 +1,5 @@
import { import {
X, Pencil, Trash2, MapPin, Clock, Camera, X, Pencil, Trash2, MapPin, Clock, Camera, Play,
Laugh, Smile, Meh, Frown, Laugh, Smile, Meh, Frown,
Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake, Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake,
ThumbsUp, ThumbsDown, ThumbsUp, ThumbsDown,
@@ -14,30 +14,37 @@ import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
// local because the desktop variant carries raw hex values and no dark-mode // local because the desktop variant carries raw hex values and no dark-mode
// classes, so the two cannot share one table without changing how this looks. // classes, so the two cannot share one table without changing how this looks.
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: 'journey.mood.amazing', bg: 'bg-pink-50 dark:bg-pink-900/20', text: 'text-pink-600 dark:text-pink-400' }, amazing: { icon: Laugh, label: 'journey.mood.amazing', bg: 'bg-pink-50 dark:bg-pink-900/20', text: 'text-pink-600 dark:text-pink-400' },
good: { icon: Smile, label: 'journey.mood.good', bg: 'bg-amber-50 dark:bg-amber-900/20', text: 'text-amber-600 dark:text-amber-400' }, good: { icon: Smile, label: 'journey.mood.good', bg: 'bg-amber-50 dark:bg-amber-900/20', text: 'text-amber-600 dark:text-amber-400' },
neutral: { icon: Meh, label: 'journey.mood.neutral', bg: 'bg-zinc-100 dark:bg-zinc-800', text: 'text-zinc-500 dark:text-zinc-400' }, neutral: { icon: Meh, label: 'journey.mood.neutral', bg: 'bg-zinc-100 dark:bg-zinc-800', text: 'text-zinc-500 dark:text-zinc-400' },
rough: { icon: Frown, label: 'journey.mood.rough', bg: 'bg-violet-50 dark:bg-violet-900/20', text: 'text-violet-600 dark:text-violet-400' }, rough: { icon: Frown, label: 'journey.mood.rough', bg: 'bg-violet-50 dark:bg-violet-900/20', text: 'text-violet-600 dark:text-violet-400' },
} }
const WEATHER_CONFIG: Record<string, { icon: typeof Sun; label: string }> = { const WEATHER_CONFIG: Record<string, { icon: typeof Sun; label: string }> = {
sunny: { icon: Sun, label: 'journey.weather.sunny' }, sunny: { icon: Sun, label: 'journey.weather.sunny' },
partly: { icon: CloudSun, label: 'journey.weather.partly' }, partly: { icon: CloudSun, label: 'journey.weather.partly' },
cloudy: { icon: Cloud, label: 'journey.weather.cloudy' }, cloudy: { icon: Cloud, label: 'journey.weather.cloudy' },
rainy: { icon: CloudRain, label: 'journey.weather.rainy' }, rainy: { icon: CloudRain, label: 'journey.weather.rainy' },
stormy: { icon: CloudLightning, label: 'journey.weather.stormy' }, stormy: { icon: CloudLightning, label: 'journey.weather.stormy' },
cold: { icon: Snowflake, label: 'journey.weather.cold' }, cold: { icon: Snowflake, label: 'journey.weather.cold' },
} }
function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'original', builder?: (id: number) => string): string { function photoUrl(
if (builder) return builder(p.photo_id) p: JourneyPhoto,
size: 'thumbnail' | 'original' = 'original',
builder?: (id: number, size?: 'thumbnail' | 'original') => string
): string {
if (builder) return builder(p.photo_id, size)
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 publicPhotoUrl?: (
photoId: number,
size?: 'thumbnail' | 'original'
) => string
onClose: () => void onClose: () => void
onEdit: () => void onEdit: () => void
onDelete: () => void onDelete: () => void
@@ -94,21 +101,33 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
<button <button
type="button" type="button"
aria-label={photos[0].caption || t('journey.photos')} aria-label={photos[0].caption || t('journey.photos')}
className="block w-full bg-transparent border-0 p-0 cursor-pointer" className={`relative block w-full max-h-[50vh] cursor-pointer border-0 p-0 ${photos[0].media_type === 'video' ? 'bg-black' : 'bg-transparent'
}`}
onClick={() => onPhotoClick(photos, 0)} onClick={() => onPhotoClick(photos, 0)}
> >
<img <img
src={photoUrl(photos[0], 'original', publicPhotoUrl)} src={photoUrl(photos[0], 'original', publicPhotoUrl)}
alt="" alt=""
className="w-full max-h-[50vh] object-cover" className={`w-full max-h-[50vh] ${photos[0].media_type === 'video' ? 'object-contain' : 'object-cover'
}`}
/> />
{photos[0].media_type === 'video' && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-black/60 text-white backdrop-blur">
<Play size={20} className="ml-0.5" fill="currentColor" />
</span>
</div>
)}
</button> </button>
{photos.length > 1 && ( {photos.length > 1 && (
<div className="absolute bottom-3 right-3 flex items-center gap-1 bg-black/60 backdrop-blur-sm text-white rounded-full px-2.5 py-1 text-[11px] font-medium"> <div className="absolute bottom-3 right-3 flex items-center gap-1 bg-black/60 backdrop-blur-sm text-white rounded-full px-2.5 py-1 text-[11px] font-medium">
<Camera size={12} /> <Camera size={12} />
{t('mobileJourney.photosCount', { count: photos.length })} {t('mobileJourney.photosCount', { count: photos.length })}
</div> </div>
)} )}
{/* Photo strip for multiple photos */} {/* Photo strip for multiple photos */}
{photos.length > 1 && ( {photos.length > 1 && (
<div className="flex gap-1 px-4 py-2 overflow-x-auto bg-zinc-50 dark:bg-zinc-900"> <div className="flex gap-1 px-4 py-2 overflow-x-auto bg-zinc-50 dark:bg-zinc-900">
@@ -117,21 +136,32 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
key={p.id || i} key={p.id || i}
type="button" type="button"
aria-label={p.caption || t('journey.photos')} aria-label={p.caption || t('journey.photos')}
className="flex-shrink-0 bg-transparent border-0 p-0 cursor-pointer" className={`relative h-16 w-16 flex-shrink-0 cursor-pointer overflow-hidden rounded-lg border-0 p-0 hover:ring-2 ring-zinc-900/30 dark:ring-white/30 transition-all ${p.media_type === 'video' ? 'bg-black' : 'bg-transparent'
}`}
onClick={() => onPhotoClick(photos, i)} onClick={() => onPhotoClick(photos, i)}
> >
<img <img
src={photoUrl(p, 'thumbnail', publicPhotoUrl)} src={photoUrl(p, 'thumbnail', publicPhotoUrl)}
alt="" alt=""
className="w-16 h-16 rounded-lg object-cover hover:ring-2 ring-zinc-900/30 dark:ring-white/30 transition-all" className={`h-full w-full ${p.media_type === 'video' ? 'object-contain' : 'object-cover'
}`}
/> />
{p.media_type === 'video' && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<Play
size={16}
className="text-white drop-shadow"
fill="currentColor"
/>
</div>
)}
</button> </button>
))} ))}
</div> </div>
)} )}
</div> </div>
)} )}
{/* Content */} {/* Content */}
<div className="px-5 py-5 pb-32"> <div className="px-5 py-5 pb-32">
@@ -236,6 +266,6 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
)} )}
</div> </div>
</div> </div>
</div> </div >
) )
} }
+43 -6
View File
@@ -25,30 +25,67 @@ export default function VideoPlayer({
useEffect(() => { useEffect(() => {
const el = videoRef.current const el = videoRef.current
if (!el) return if (!el) return
const player = new Plyr(el, { const player = new Plyr(el, {
controls: ['play-large', 'play', 'progress', 'current-time', 'mute', 'volume', 'fullscreen'], controls: [
'play-large',
'play',
'progress',
'current-time',
'duration',
'mute',
'volume',
'fullscreen',
],
autoplay: autoPlay, autoplay: autoPlay,
// Keep playback inline so the lightbox stays in control on mobile.
clickToPlay: true, clickToPlay: true,
hideControls: false,
}) })
return () => { try { player.destroy() } catch { /* already torn down */ } }
return () => {
try {
player.destroy()
} catch {
/* already torn down */
}
}
}, [src, autoPlay]) }, [src, autoPlay])
return ( return (
<div <div
className="trek-video-player"
style={{ style={{
width: 'min(92vw, 1100px)', width: 'min(92vw, 1100px)',
maxHeight: '92vh', height: 'min(88vh, 900px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#000',
borderRadius: 4, borderRadius: 4,
overflow: 'hidden', overflow: 'hidden',
animation: 'fadeIn 0.15s ease', animation: 'fadeIn 0.15s ease',
...style, ...style,
}} }}
> >
<video ref={videoRef} src={src} poster={poster} playsInline controls preload="metadata"> <video
ref={videoRef}
src={src}
poster={poster}
playsInline
controls
preload="metadata"
style={{
width: '100%',
height: '100%',
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
background: '#000',
}}
>
{/* Gallery uploads carry no caption file; the empty track keeps the element valid. */} {/* Gallery uploads carry no caption file; the empty track keeps the element valid. */}
<track kind="captions" /> <track kind="captions" />
</video> </video>
</div> </div>
) )
} }
+22
View File
@@ -35,6 +35,28 @@ body { height: 100%; overflow: auto; overscroll-behavior: none; -webkit-overflow
.journey-feed-scroll { scrollbar-width: none; -ms-overflow-style: none; } .journey-feed-scroll { scrollbar-width: none; -ms-overflow-style: none; }
.journey-feed-scroll::-webkit-scrollbar { display: none; } .journey-feed-scroll::-webkit-scrollbar { display: none; }
/* Journey video: let the player fill the box the lightbox gives it.
*
* Plyr sizes its wrapper from the video's intrinsic aspect ratio via a
* padding-bottom hack, which fights a fixed-height container — a portrait clip
* then overflowed and pushed its own controls out of reach (#2041). Pinning the
* wrapper to the box and letting the video letterbox inside it keeps any aspect
* ratio whole and the control bar on screen. */
.trek-video-player .plyr,
.trek-video-player .plyr__video-wrapper {
width: 100%;
height: 100%;
background: #000;
}
.trek-video-player .plyr__video-wrapper {
padding-bottom: 0 !important;
}
.trek-video-player video {
width: 100% !important;
height: 100% !important;
object-fit: contain !important;
}
/* Leaflet Popups — Enter-Animation vom Anchor-Tip */ /* Leaflet Popups — Enter-Animation vom Anchor-Tip */
.leaflet-popup { .leaflet-popup {
animation: trek-popover-enter 220ms cubic-bezier(0.23, 1, 0.32, 1); animation: trek-popover-enter 220ms cubic-bezier(0.23, 1, 0.32, 1);
+66 -35
View File
@@ -145,19 +145,19 @@ export default function JourneyPublicPage() {
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 && !desktopTwoColumn &&
!isMobile && !isMobile &&
perms.share_map && { id: 'map' as const, icon: MapPin, label: t('journey.share.map') }, perms.share_map && { id: 'map' as const, icon: MapPin, label: t('journey.share.map') },
].filter(Boolean) as { id: 'timeline' | 'gallery' | 'map'; icon: any; label: string }[]; ].filter(Boolean) as { id: 'timeline' | 'gallery' | 'map'; icon: any; label: string }[];
// The hook cannot build these: the thumbnail URL needs the share token, which is // The hook cannot build these: the thumbnail URL needs the share token, which is
// the credential for the byte proxy. // the credential for the byte proxy.
const photoLayer = token const photoLayer = token
? mapPhotos.map((p: { id: string; lat: number; lng: number; photoId: number }) => ({ ? mapPhotos.map((p: { id: string; lat: number; lng: number; photoId: number }) => ({
id: p.id, id: p.id,
lat: p.lat, lat: p.lat,
lng: p.lng, lng: p.lng,
thumbUrl: photoUrl({ photo_id: p.photoId }, token, 'thumbnail'), thumbUrl: photoUrl({ photo_id: p.photoId }, token, 'thumbnail'),
})) }))
: []; : [];
// Shared timeline renderer used in both layout modes // Shared timeline renderer used in both layout modes
@@ -219,7 +219,7 @@ export default function JourneyPublicPage() {
id: String(p.id), id: String(p.id),
src: photoUrl(p, token!, 'original'), src: photoUrl(p, token!, 'original'),
caption: p.caption, caption: p.caption,
mediaType: (p as any).media_type, mediaType: p.media_type,
})); }));
const isActive = activeEntryId === String(entry.id); const isActive = activeEntryId === String(entry.id);
@@ -246,7 +246,22 @@ export default function JourneyPublicPage() {
className="relative block w-full cursor-pointer text-left" className="relative block w-full cursor-pointer text-left"
onClick={() => setLightbox({ photos: lightboxPhotos, index: 0 })} onClick={() => setLightbox({ photos: lightboxPhotos, index: 0 })}
> >
<img src={photoUrl(photos[0], token!)} className="h-64 w-full object-cover" alt="" /> <div className={`relative h-64 w-full ${photos[0].media_type === 'video' ? 'bg-black' : ''}`}>
<img
src={photoUrl(photos[0], token!, photos[0].media_type === 'video' ? 'thumbnail' : 'original')}
className={`h-full w-full ${photos[0].media_type === 'video' ? 'object-contain' : 'object-cover'
}`}
alt=""
/>
{photos[0].media_type === 'video' && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-black/60 text-white backdrop-blur">
<Play size={20} className="ml-0.5" fill="currentColor" />
</span>
</div>
)}
</div>
<div <div
className="pointer-events-none absolute inset-x-0 bottom-0" className="pointer-events-none absolute inset-x-0 bottom-0"
style={{ style={{
@@ -272,21 +287,30 @@ export default function JourneyPublicPage() {
)} )}
</button> </button>
)} )}
{photos.length === 2 && ( {photos.length === 2 && (
<div className="grid grid-cols-2 gap-0.5 overflow-hidden"> <div className="grid grid-cols-2 gap-0.5 overflow-hidden">
{photos.slice(0, 2).map((p, i) => ( {photos.slice(0, 2).map((p, i) => (
<button <button
key={p.id} key={p.id}
type="button" type="button"
className="block h-52 w-full cursor-pointer overflow-hidden" className={`relative block h-52 w-full cursor-pointer overflow-hidden ${p.media_type === 'video' ? 'bg-black' : ''
}`}
onClick={() => setLightbox({ photos: lightboxPhotos, index: i })} onClick={() => setLightbox({ photos: lightboxPhotos, index: i })}
> >
<img <img
src={photoUrl(p, token!, 'thumbnail')} src={photoUrl(p, token!, 'thumbnail')}
alt="" alt=""
className="h-full w-full object-cover" className={`h-full w-full ${p.media_type === 'video' ? 'object-contain' : 'object-cover'
}`}
/> />
{p.media_type === 'video' && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<span className="flex h-10 w-10 items-center justify-center rounded-full bg-black/60 text-white backdrop-blur">
<Play size={17} className="ml-0.5" fill="currentColor" />
</span>
</div>
)}
</button> </button>
))} ))}
</div> </div>
@@ -302,7 +326,8 @@ export default function JourneyPublicPage() {
<img <img
src={photoUrl(photos[0], token!, 'thumbnail')} src={photoUrl(photos[0], token!, 'thumbnail')}
alt="" alt=""
className="h-full w-full object-cover" className={`h-full w-full ${photos[0].media_type === 'video' ? 'object-contain bg-black' : 'object-cover'
}`}
/> />
</button> </button>
<div className="flex min-w-0 flex-1 flex-col" style={{ gap: 2 }}> <div className="flex min-w-0 flex-1 flex-col" style={{ gap: 2 }}>
@@ -314,7 +339,8 @@ export default function JourneyPublicPage() {
<img <img
src={photoUrl(photos[1], token!, 'thumbnail')} src={photoUrl(photos[1], token!, 'thumbnail')}
alt="" alt=""
className="h-full w-full object-cover" className={`h-full w-full ${photos[1].media_type === 'video' ? 'object-contain bg-black' : 'object-cover'
}`}
/> />
</button> </button>
<button <button
@@ -325,7 +351,8 @@ export default function JourneyPublicPage() {
<img <img
src={photoUrl(photos[2], token!, 'thumbnail')} src={photoUrl(photos[2], token!, 'thumbnail')}
alt="" alt=""
className="h-full w-full object-cover" className={`h-full w-full ${photos[2].media_type === 'video' ? 'object-contain bg-black' : 'object-cover'
}`}
/> />
{photos.length > 3 && ( {photos.length > 3 && (
<div className="absolute inset-0 flex items-center justify-center bg-black/40"> <div className="absolute inset-0 flex items-center justify-center bg-black/40">
@@ -337,7 +364,8 @@ export default function JourneyPublicPage() {
</button> </button>
</div> </div>
</div> </div>
)} )
}
{/* Content */} {/* Content */}
{/* Stays a div: the story renders user markdown, links included, {/* Stays a div: the story renders user markdown, links included,
@@ -471,10 +499,10 @@ export default function JourneyPublicPage() {
); );
})} })}
</div> </div>
</div> </div >
); );
})} })}
</div> </div >
); );
// Shared gallery renderer // Shared gallery renderer
@@ -491,7 +519,7 @@ export default function JourneyPublicPage() {
id: String(p.id), id: String(p.id),
src: photoUrl(p, token!, 'original'), src: photoUrl(p, token!, 'original'),
caption: p.caption, caption: p.caption,
mediaType: (p as any).media_type, mediaType: p.media_type,
})), })),
index: idx, index: idx,
}) })
@@ -499,11 +527,14 @@ export default function JourneyPublicPage() {
> >
<img <img
src={photoUrl(photo, token!, 'thumbnail')} src={photoUrl(photo, token!, 'thumbnail')}
className="h-full w-full object-cover transition-transform hover:scale-105" className={`h-full w-full transition-transform hover:scale-105 ${photo.media_type === 'video'
? 'object-contain bg-black'
: 'object-cover'
}`}
alt="" alt=""
loading="lazy" loading="lazy"
/> />
{(photo as any).media_type === 'video' && ( {photo.media_type === 'video' && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center"> <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-black/55 text-white backdrop-blur"> <span className="flex h-9 w-9 items-center justify-center rounded-full bg-black/55 text-white backdrop-blur">
<Play size={16} className="ml-0.5" fill="currentColor" /> <Play size={16} className="ml-0.5" fill="currentColor" />
@@ -523,11 +554,10 @@ export default function JourneyPublicPage() {
<button type="button" <button type="button"
key={v.id} key={v.id}
onClick={() => setView(v.id)} onClick={() => setView(v.id)}
className={`flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium ${ className={`flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium ${view === v.id
view === v.id ? 'bg-zinc-900 text-white dark:bg-white dark:text-zinc-900'
? 'bg-zinc-900 text-white dark:bg-white dark:text-zinc-900' : 'text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
: 'text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300' }`}
}`}
> >
<v.icon size={13} /> <v.icon size={13} />
{v.label} {v.label}
@@ -830,11 +860,10 @@ export default function JourneyPublicPage() {
<button type="button" <button type="button"
key={v.id} key={v.id}
onClick={() => setView(v.id)} onClick={() => setView(v.id)}
className={`flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium ${ className={`flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium ${view === v.id
view === v.id ? 'bg-zinc-900 text-white dark:bg-white dark:text-zinc-900'
? 'bg-zinc-900 text-white dark:bg-white dark:text-zinc-900' : 'text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
: 'text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300' }`}
}`}
> >
<v.icon size={13} /> <v.icon size={13} />
{v.label} {v.label}
@@ -911,17 +940,19 @@ export default function JourneyPublicPage() {
<MobileEntryView <MobileEntryView
entry={viewingEntry as any} entry={viewingEntry as any}
readOnly readOnly
publicPhotoUrl={(photoId) => `/api/public/journey/${token}/photos/${photoId}/original`} publicPhotoUrl={(photoId, size = 'original') =>
`/api/public/journey/${token}/photos/${photoId}/${size}`
}
onClose={() => setViewingEntry(null)} onClose={() => setViewingEntry(null)}
onEdit={() => {}} onEdit={() => { }}
onDelete={() => {}} onDelete={() => { }}
onPhotoClick={(photos, idx) => onPhotoClick={(photos, idx) =>
setLightbox({ setLightbox({
photos: photos.map((p) => ({ photos: photos.map((p) => ({
id: String(p.id), id: String(p.id),
src: photoUrl(p as any, token!, 'original'), src: photoUrl(p as any, token!, 'original'),
caption: (p as any).caption ?? null, caption: (p as any).caption ?? null,
mediaType: (p as any).media_type, mediaType: p.media_type,
})), })),
index: idx, index: idx,
}) })
@@ -28,6 +28,9 @@ export interface PublicPhoto {
owner_id?: number | null owner_id?: number | null
file_path?: string | null file_path?: string | null
caption?: string | null caption?: string | null
// 'image' (default) or 'video' (#823)
media_type?: string | null
duration_ms?: number | null
} }
export interface PublicGalleryPhoto { export interface PublicGalleryPhoto {
@@ -39,6 +42,9 @@ export interface PublicGalleryPhoto {
owner_id?: number | null owner_id?: number | null
file_path?: string | null file_path?: string | null
caption?: string | null caption?: string | null
// 'image' (default) or 'video' (#823)
media_type?: string | null
duration_ms?: number | null
/** Where and when the picture was taken (#1614). Absent for most photos, and the /** Where and when the picture was taken (#1614). Absent for most photos, and the
* coordinates are withheld entirely unless the owner shared the map. */ * coordinates are withheld entirely unless the owner shared the map. */
taken_at?: string | null taken_at?: string | null