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 {
const status = await memoriesApi.status(p.id)
if (status.connected) connected.push({ id: p.id, name: p.name })
} catch {}
} catch { }
}
setAvailableProviders(connected)
} catch {}
} catch { }
})()
}, [])
@@ -158,9 +158,10 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
onClick={() => onPhotoClick(allPhotos, i)}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onPhotoClick(allPhotos, i) } }}
>
{photo.media_type === 'video' && !photo.thumbnail_path ? (
// Poster-less video (capture failed / unsupported codec): show a
// neutral tile rather than a broken 404 thumbnail (#823).
{photo.media_type === 'video' &&
photo.provider === 'local' &&
!photo.thumbnail_path ? (
// Poster-less local video: show a neutral tile.
<div className="w-full h-full bg-zinc-200 dark:bg-zinc-800" />
) : (
<img
@@ -1,17 +1,31 @@
import { Image } from 'lucide-react'
import { Image, Play } from 'lucide-react'
import type { JourneyPhoto } from '../../store/journeyStore'
import { photoUrl } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
export function PhotoImg({ photo, className, style }: { photo: JourneyPhoto; className?: string; style?: React.CSSProperties }) {
const src = photoUrl(photo, 'thumbnail')
const isVideo = photo.media_type === 'video'
return (
<div
className={`relative overflow-hidden ${isVideo ? 'bg-black' : ''} ${className || ''}`}
style={style}
>
<img
src={src}
alt={photo.caption || ''}
className={className}
style={style}
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>
)
}
@@ -1,5 +1,5 @@
import {
X, Pencil, Trash2, MapPin, Clock, Camera,
X, Pencil, Trash2, MapPin, Clock, Camera, Play,
Laugh, Smile, Meh, Frown,
Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake,
ThumbsUp, ThumbsDown,
@@ -29,15 +29,22 @@ const WEATHER_CONFIG: Record<string, { icon: typeof Sun; label: string }> = {
cold: { icon: Snowflake, label: 'journey.weather.cold' },
}
function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'original', builder?: (id: number) => string): string {
if (builder) return builder(p.photo_id)
function photoUrl(
p: JourneyPhoto,
size: 'thumbnail' | 'original' = 'original',
builder?: (id: number, size?: 'thumbnail' | 'original') => string
): string {
if (builder) return builder(p.photo_id, size)
return `/api/photos/${p.photo_id}/${size}`
}
interface Props {
entry: JourneyEntry
readOnly?: boolean
publicPhotoUrl?: (photoId: number) => string
publicPhotoUrl?: (
photoId: number,
size?: 'thumbnail' | 'original'
) => string
onClose: () => void
onEdit: () => void
onDelete: () => void
@@ -94,21 +101,33 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
<button
type="button"
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)}
>
<img
src={photoUrl(photos[0], 'original', publicPhotoUrl)}
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>
{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">
<Camera size={12} />
{t('mobileJourney.photosCount', { count: photos.length })}
</div>
)}
{/* Photo strip for multiple photos */}
{photos.length > 1 && (
<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}
type="button"
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)}
>
<img
src={photoUrl(p, 'thumbnail', publicPhotoUrl)}
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>
))}
</div>
)}
</div>
)}
{/* Content */}
<div className="px-5 py-5 pb-32">
@@ -236,6 +266,6 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
)}
</div>
</div>
</div>
</div >
)
}
+42 -5
View File
@@ -25,27 +25,64 @@ export default function VideoPlayer({
useEffect(() => {
const el = videoRef.current
if (!el) return
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,
// Keep playback inline so the lightbox stays in control on mobile.
clickToPlay: true,
hideControls: false,
})
return () => { try { player.destroy() } catch { /* already torn down */ } }
return () => {
try {
player.destroy()
} catch {
/* already torn down */
}
}
}, [src, autoPlay])
return (
<div
className="trek-video-player"
style={{
width: 'min(92vw, 1100px)',
maxHeight: '92vh',
height: 'min(88vh, 900px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#000',
borderRadius: 4,
overflow: 'hidden',
animation: 'fadeIn 0.15s ease',
...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. */}
<track kind="captions" />
</video>
+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::-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-popup {
animation: trek-popover-enter 220ms cubic-bezier(0.23, 1, 0.32, 1);
+53 -22
View File
@@ -219,7 +219,7 @@ export default function JourneyPublicPage() {
id: String(p.id),
src: photoUrl(p, token!, 'original'),
caption: p.caption,
mediaType: (p as any).media_type,
mediaType: p.media_type,
}));
const isActive = activeEntryId === String(entry.id);
@@ -246,7 +246,22 @@ export default function JourneyPublicPage() {
className="relative block w-full cursor-pointer text-left"
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
className="pointer-events-none absolute inset-x-0 bottom-0"
style={{
@@ -272,21 +287,30 @@ export default function JourneyPublicPage() {
)}
</button>
)}
{photos.length === 2 && (
<div className="grid grid-cols-2 gap-0.5 overflow-hidden">
{photos.slice(0, 2).map((p, i) => (
<button
key={p.id}
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 })}
>
<img
src={photoUrl(p, token!, 'thumbnail')}
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>
))}
</div>
@@ -302,7 +326,8 @@ export default function JourneyPublicPage() {
<img
src={photoUrl(photos[0], token!, 'thumbnail')}
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>
<div className="flex min-w-0 flex-1 flex-col" style={{ gap: 2 }}>
@@ -314,7 +339,8 @@ export default function JourneyPublicPage() {
<img
src={photoUrl(photos[1], token!, 'thumbnail')}
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
@@ -325,7 +351,8 @@ export default function JourneyPublicPage() {
<img
src={photoUrl(photos[2], token!, 'thumbnail')}
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 && (
<div className="absolute inset-0 flex items-center justify-center bg-black/40">
@@ -337,7 +364,8 @@ export default function JourneyPublicPage() {
</button>
</div>
</div>
)}
)
}
{/* Content */}
{/* Stays a div: the story renders user markdown, links included,
@@ -471,10 +499,10 @@ export default function JourneyPublicPage() {
);
})}
</div>
</div>
</div >
);
})}
</div>
</div >
);
// Shared gallery renderer
@@ -491,7 +519,7 @@ export default function JourneyPublicPage() {
id: String(p.id),
src: photoUrl(p, token!, 'original'),
caption: p.caption,
mediaType: (p as any).media_type,
mediaType: p.media_type,
})),
index: idx,
})
@@ -499,11 +527,14 @@ export default function JourneyPublicPage() {
>
<img
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=""
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">
<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" />
@@ -523,8 +554,7 @@ export default function JourneyPublicPage() {
<button type="button"
key={v.id}
onClick={() => setView(v.id)}
className={`flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium ${
view === v.id
className={`flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium ${view === v.id
? 'bg-zinc-900 text-white dark:bg-white dark:text-zinc-900'
: 'text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
}`}
@@ -830,8 +860,7 @@ export default function JourneyPublicPage() {
<button type="button"
key={v.id}
onClick={() => setView(v.id)}
className={`flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium ${
view === v.id
className={`flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium ${view === v.id
? 'bg-zinc-900 text-white dark:bg-white dark:text-zinc-900'
: 'text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
}`}
@@ -911,17 +940,19 @@ export default function JourneyPublicPage() {
<MobileEntryView
entry={viewingEntry as any}
readOnly
publicPhotoUrl={(photoId) => `/api/public/journey/${token}/photos/${photoId}/original`}
publicPhotoUrl={(photoId, size = 'original') =>
`/api/public/journey/${token}/photos/${photoId}/${size}`
}
onClose={() => setViewingEntry(null)}
onEdit={() => {}}
onDelete={() => {}}
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,
mediaType: (p as any).media_type,
mediaType: p.media_type,
})),
index: idx,
})
@@ -28,6 +28,9 @@ export interface PublicPhoto {
owner_id?: number | null
file_path?: string | null
caption?: string | null
// 'image' (default) or 'video' (#823)
media_type?: string | null
duration_ms?: number | null
}
export interface PublicGalleryPhoto {
@@ -39,6 +42,9 @@ export interface PublicGalleryPhoto {
owner_id?: number | null
file_path?: 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
* coordinates are withheld entirely unless the owner shared the map. */
taken_at?: string | null