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 {
|
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 >
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user