mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 22:16:26 +00:00
fix(journey): let the browser read a picked clip, so it previews and gets a poster
A clip picked in the entry editor is previewed through a <video> on an object URL, and its poster frame is grabbed the same way before anything is uploaded. The CSP never named media-src, so the browser fell back to default-src, which does not admit blob: at all. The preview stayed empty and the poster capture failed silently, so every clip landed without a poster and its thumbnail route answered 404 (issue #2341, the part that survived 4.3.0). None of it showed in development, where Vite serves the document and helmet never sees it. media-src now carries 'self' and blob:. For the clips that still come up without a poster, because the browser cannot decode them, the entry card and the editor tile draw the play badge on a black tile instead of asking for a thumbnail that is 404 by design, the way the gallery already did. Closes #2341
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState, useRef } from 'react'
|
||||
import { localIsoDate } from '../../utils/localDate'
|
||||
import { X, Plus, Image, Minus, Check, MapPin, Locate, Camera } from 'lucide-react'
|
||||
import { X, Plus, Image, Minus, Check, MapPin, Locate, Camera, Play } from 'lucide-react'
|
||||
import { normalizeImageFiles } from '../../utils/convertHeic'
|
||||
import { isVideoFile } from '../../utils/videoPoster'
|
||||
import { type ResilientResult, type UploadProgress } from '../../utils/uploadQueue'
|
||||
@@ -11,7 +11,7 @@ import { getCurrentPositionOnce } from '../../hooks/useGeolocation'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
import type { JourneyEntry, JourneyPhoto, GalleryPhoto, JourneyTrip } from '../../store/journeyStore'
|
||||
import { MOOD_CONFIG, WEATHER_CONFIG } from '../../pages/journeyDetail/JourneyDetailPage.constants'
|
||||
import { photoUrl, isValidGeoPoint, geoOnceErrorKey } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||
import { photoUrl, posterlessVideo, isValidGeoPoint, geoOnceErrorKey } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||
import MarkdownToolbar from './MarkdownToolbar'
|
||||
import { DatePicker } from './JourneyDetailPageDatePicker'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
@@ -527,7 +527,15 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{photos.map((p, idx) => (
|
||||
<div key={p.id} className={`w-20 h-20 rounded-xl overflow-hidden relative group ${idx === 0 && photos.length > 1 ? 'ring-2 ring-zinc-900 dark:ring-white ring-offset-1 dark:ring-offset-zinc-900' : ''}`}>
|
||||
<img src={photoUrl(p)} className="w-full h-full object-cover" alt="" onError={e => { const img = e.currentTarget; const orig = photoUrl(p, 'original'); if (!img.src.includes('/original')) img.src = orig }} />
|
||||
{posterlessVideo(p) ? (
|
||||
// No poster to show and falling back to /original would hand
|
||||
// an <img> the clip itself, so this tile stays a play badge.
|
||||
<div className="w-full h-full bg-black flex items-center justify-center text-white">
|
||||
<Play size={18} className="ml-0.5" fill="currentColor" />
|
||||
</div>
|
||||
) : (
|
||||
<img src={photoUrl(p)} className="w-full h-full object-cover" alt="" onError={e => { const img = e.currentTarget; const orig = photoUrl(p, 'original'); if (!img.src.includes('/original')) img.src = orig }} />
|
||||
)}
|
||||
{idx === 0 && photos.length > 1 && (
|
||||
<span className="absolute bottom-0.5 left-0.5 px-1 py-px rounded text-[8px] font-bold bg-zinc-900/70 text-white">{t('journey.editor.photoFirst')}</span>
|
||||
)}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { journeyApi, addonsApi, memoriesApi } from '../../api/client'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
import type { JourneyEntry, GalleryPhoto, JourneyTrip } from '../../store/journeyStore'
|
||||
import { photoUrl } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||
import { photoUrl, posterlessVideo } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||
import { ProviderPicker } from './JourneyDetailPageProviderPicker'
|
||||
import { ScrollTrigger } from './JourneyDetailPageScrollTrigger'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
@@ -151,9 +151,7 @@ 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.provider === 'local' &&
|
||||
!photo.thumbnail_path ? (
|
||||
{posterlessVideo(photo) ? (
|
||||
// Poster-less local video: show a neutral tile.
|
||||
<div className="w-full h-full bg-zinc-200 dark:bg-zinc-800" />
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render } from '../../../tests/helpers/render'
|
||||
import { PhotoImg } from './JourneyDetailPagePhotoGrid'
|
||||
import type { JourneyPhoto } from '../../store/journeyStore'
|
||||
|
||||
function buildPhoto(overrides: Partial<JourneyPhoto> = {}): JourneyPhoto {
|
||||
return {
|
||||
id: 1,
|
||||
entry_id: 10,
|
||||
photo_id: 515,
|
||||
sort_order: 0,
|
||||
shared: 1,
|
||||
created_at: 0,
|
||||
provider: 'local',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('PhotoImg', () => {
|
||||
it('asks for the thumbnail of a photo', () => {
|
||||
const { container } = render(<PhotoImg photo={buildPhoto()} />)
|
||||
|
||||
expect(container.querySelector('img')).toHaveAttribute('src', '/api/photos/515/thumbnail')
|
||||
})
|
||||
|
||||
it('shows the poster of a clip behind the play badge', () => {
|
||||
const { container } = render(<PhotoImg photo={buildPhoto({ media_type: 'video', thumbnail_path: 'journey/poster.jpg' })} />)
|
||||
|
||||
expect(container.querySelector('img')).toHaveAttribute('src', '/api/photos/515/thumbnail')
|
||||
expect(container.querySelector('svg')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('draws no image for a clip without a poster, whose thumbnail route answers 404 (#2341)', () => {
|
||||
// The tile stays black with the play badge on it. An <img> here would only
|
||||
// add the broken-image glyph the reporter saw.
|
||||
const { container } = render(<PhotoImg photo={buildPhoto({ media_type: 'video', thumbnail_path: null })} />)
|
||||
|
||||
expect(container.querySelector('img')).toBeNull()
|
||||
expect(container.querySelector('svg')).not.toBeNull()
|
||||
expect(container.firstElementChild?.className).toContain('bg-black')
|
||||
})
|
||||
|
||||
it('still asks the server for a provider clip, which serves its own poster', () => {
|
||||
const { container } = render(<PhotoImg photo={buildPhoto({ media_type: 'video', provider: 'immich', thumbnail_path: null })} />)
|
||||
|
||||
expect(container.querySelector('img')).toHaveAttribute('src', '/api/photos/515/thumbnail')
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Image, Play } from 'lucide-react'
|
||||
import type { JourneyPhoto } from '../../store/journeyStore'
|
||||
import { photoUrl } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||
import { photoUrl, posterlessVideo } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||
|
||||
export function PhotoImg({ photo, className, style }: { photo: JourneyPhoto; className?: string; style?: React.CSSProperties }) {
|
||||
const src = photoUrl(photo, 'thumbnail')
|
||||
@@ -11,12 +11,16 @@ export function PhotoImg({ photo, className, style }: { photo: JourneyPhoto; cla
|
||||
className={`relative overflow-hidden ${isVideo ? 'bg-black' : ''} ${className || ''}`}
|
||||
style={style}
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
className={`w-full h-full ${isVideo ? 'object-contain' : 'object-cover'}`}
|
||||
loading="lazy"
|
||||
/>
|
||||
{/* A clip without a poster keeps the black tile and the play badge; asking
|
||||
for its thumbnail would only draw the broken-image glyph over them. */}
|
||||
{!posterlessVideo(photo) && (
|
||||
<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">
|
||||
|
||||
Reference in New Issue
Block a user