= {
- 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
+
{photos.length > 1 && (
{t('mobileJourney.photosCount', { count: photos.length })}
)}
+
{/* Photo strip for multiple photos */}
{photos.length > 1 && (
@@ -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)}
>

+
+ {p.media_type === 'video' && (
+
+ )}
))}
)}
)}
-
{/* Content */}
@@ -236,6 +266,6 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
)}
-
+
)
}
diff --git a/client/src/components/Journey/VideoPlayer.tsx b/client/src/components/Journey/VideoPlayer.tsx
index e66765b99..f5dc79e06 100644
--- a/client/src/components/Journey/VideoPlayer.tsx
+++ b/client/src/components/Journey/VideoPlayer.tsx
@@ -25,30 +25,67 @@ 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 (
-
)
-}
+}
\ No newline at end of file
diff --git a/client/src/index.css b/client/src/index.css
index 2111be73a..a0416cc5c 100644
--- a/client/src/index.css
+++ b/client/src/index.css
@@ -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);
diff --git a/client/src/pages/JourneyPublicPage.tsx b/client/src/pages/JourneyPublicPage.tsx
index 370e27817..0f9a4aa2a 100644
--- a/client/src/pages/JourneyPublicPage.tsx
+++ b/client/src/pages/JourneyPublicPage.tsx
@@ -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 })}
>
-
+
+

+
+ {photos[0].media_type === 'video' && (
+
+ )}
+
)}
-
{photos.length === 2 && (
{photos.slice(0, 2).map((p, i) => (
))}
@@ -302,7 +326,8 @@ export default function JourneyPublicPage() {
@@ -314,7 +339,8 @@ export default function JourneyPublicPage() {
{photos.length > 3 && (
@@ -337,7 +364,8 @@ export default function JourneyPublicPage() {
- )}
+ )
+ }
{/* Content */}
{/* Stays a div: the story renders user markdown, links included,
@@ -471,10 +499,10 @@ export default function JourneyPublicPage() {
);
})}
-
+
);
})}
-
+
);
// 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() {
>
- {(photo as any).media_type === 'video' && (
+ {photo.media_type === 'video' && (
@@ -523,11 +554,10 @@ export default function JourneyPublicPage() {