mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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 (
|
||||
<img
|
||||
src={src}
|
||||
alt={photo.caption || ''}
|
||||
className={className}
|
||||
<div
|
||||
className={`relative overflow-hidden ${isVideo ? 'bg-black' : ''} ${className || ''}`}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -14,30 +14,37 @@ import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
|
||||
// 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.
|
||||
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' },
|
||||
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' },
|
||||
rough: { icon: Frown, label: 'journey.mood.rough', bg: 'bg-violet-50 dark:bg-violet-900/20', text: 'text-violet-600 dark:text-violet-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' },
|
||||
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' },
|
||||
}
|
||||
|
||||
const WEATHER_CONFIG: Record<string, { icon: typeof Sun; label: string }> = {
|
||||
sunny: { icon: Sun, label: 'journey.weather.sunny' },
|
||||
partly: { icon: CloudSun, label: 'journey.weather.partly' },
|
||||
cloudy: { icon: Cloud, label: 'journey.weather.cloudy' },
|
||||
rainy: { icon: CloudRain, label: 'journey.weather.rainy' },
|
||||
sunny: { icon: Sun, label: 'journey.weather.sunny' },
|
||||
partly: { icon: CloudSun, label: 'journey.weather.partly' },
|
||||
cloudy: { icon: Cloud, label: 'journey.weather.cloudy' },
|
||||
rainy: { icon: CloudRain, label: 'journey.weather.rainy' },
|
||||
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 {
|
||||
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 >
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -145,19 +145,19 @@ export default function JourneyPublicPage() {
|
||||
perms.share_timeline && { id: 'timeline' as const, icon: List, label: t('journey.share.timeline') },
|
||||
perms.share_gallery && { id: 'gallery' as const, icon: Grid, label: t('journey.share.gallery') },
|
||||
!desktopTwoColumn &&
|
||||
!isMobile &&
|
||||
perms.share_map && { id: 'map' as const, icon: MapPin, label: t('journey.share.map') },
|
||||
!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 }[];
|
||||
|
||||
// The hook cannot build these: the thumbnail URL needs the share token, which is
|
||||
// the credential for the byte proxy.
|
||||
const photoLayer = token
|
||||
? mapPhotos.map((p: { id: string; lat: number; lng: number; photoId: number }) => ({
|
||||
id: p.id,
|
||||
lat: p.lat,
|
||||
lng: p.lng,
|
||||
thumbUrl: photoUrl({ photo_id: p.photoId }, token, 'thumbnail'),
|
||||
}))
|
||||
id: p.id,
|
||||
lat: p.lat,
|
||||
lng: p.lng,
|
||||
thumbUrl: photoUrl({ photo_id: p.photoId }, token, 'thumbnail'),
|
||||
}))
|
||||
: [];
|
||||
|
||||
// Shared timeline renderer used in both layout modes
|
||||
@@ -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,11 +554,10 @@ 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
|
||||
? 'bg-zinc-900 text-white dark:bg-white dark:text-zinc-900'
|
||||
: 'text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
|
||||
}`}
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
<v.icon size={13} />
|
||||
{v.label}
|
||||
@@ -830,11 +860,10 @@ 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
|
||||
? 'bg-zinc-900 text-white dark:bg-white dark:text-zinc-900'
|
||||
: 'text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
|
||||
}`}
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
<v.icon size={13} />
|
||||
{v.label}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user