mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
Keep a picked place image and its credit visible everywhere
Accommodation cards read the place from page-local state, so a new thumbnail only showed up there after a reload. place:updated now asks the planner to reload them, but only when the image actually changed - renaming a place must not trigger a refetch. The inspector shows the stored credit under the avatar. Crediting a CC BY-SA picture only in the moment it is chosen is not enough; the obligation travels with the picture. Also lets a GIF hero through to the PDF. Uploads accept gif, the PDF extension check did not, so one silently fell back to the category icon.
This commit is contained in:
@@ -93,7 +93,9 @@ function safeImg(url) {
|
|||||||
// (it ends in …/bytes), so the extension check below would wrongly reject it —
|
// (it ends in …/bytes), so the extension check below would wrongly reject it —
|
||||||
// which is why persisted place photos showed as category icons in the PDF.
|
// which is why persisted place photos showed as category icons in the PDF.
|
||||||
if (url.startsWith('/api/maps/place-photo/')) return absUrl(url)
|
if (url.startsWith('/api/maps/place-photo/')) return absUrl(url)
|
||||||
return /\.(jpe?g|png|webp|bmp|tiff?)(\?.*)?$/i.test(url) ? absUrl(url) : null
|
// gif is in the list because place-image-upload.ts accepts it; without it an
|
||||||
|
// uploaded GIF hero silently fell back to the category icon in the PDF.
|
||||||
|
return /\.(jpe?g|png|gif|webp|bmp|tiff?)(\?.*)?$/i.test(url) ? absUrl(url) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate SVG string from Lucide icon name (for category thumbnails)
|
// Generate SVG string from Lucide icon name (for category thumbnails)
|
||||||
|
|||||||
@@ -44,6 +44,49 @@ function setSessionCache(key, value) {
|
|||||||
try { sessionStorage.setItem(key, JSON.stringify(value)) } catch {}
|
try { sessionStorage.setItem(key, JSON.stringify(value)) } catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const creditCache = new Map()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Names whoever made the picture shown in the avatar.
|
||||||
|
*
|
||||||
|
* Only cached provider photos carry a credit, and their proxy URL embeds the
|
||||||
|
* cache key. Anything else (an uploaded image, a legacy remote URL) renders
|
||||||
|
* nothing. Commons pictures are largely CC BY-SA, so this is an obligation
|
||||||
|
* rather than a nicety — the picker credits them while choosing, this keeps the
|
||||||
|
* credit visible afterwards.
|
||||||
|
*/
|
||||||
|
function PhotoCredit({ imageUrl }) {
|
||||||
|
const [credit, setCredit] = useState(null)
|
||||||
|
const key = useMemo(() => {
|
||||||
|
const match = /^\/api\/maps\/place-photo\/(.+)\/bytes$/.exec(imageUrl || '')
|
||||||
|
return match ? decodeURIComponent(match[1]) : null
|
||||||
|
}, [imageUrl])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!key) { setCredit(null); return }
|
||||||
|
if (creditCache.has(key)) { setCredit(creditCache.get(key)); return }
|
||||||
|
let alive = true
|
||||||
|
mapsApi.placePhotoCredit(key).then(data => {
|
||||||
|
creditCache.set(key, data.credit)
|
||||||
|
if (alive) setCredit(data.credit)
|
||||||
|
}).catch(() => {})
|
||||||
|
return () => { alive = false }
|
||||||
|
}, [key])
|
||||||
|
|
||||||
|
if (!credit) return null
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="text-content-faint"
|
||||||
|
title={credit}
|
||||||
|
style={{
|
||||||
|
display: 'block', marginTop: 4, maxWidth: 72,
|
||||||
|
fontSize: 'calc(9px * var(--fs-scale-caption, 1))', lineHeight: 1.2,
|
||||||
|
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>{credit}</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function usePlaceDetails(googlePlaceId, osmId, language) {
|
function usePlaceDetails(googlePlaceId, osmId, language) {
|
||||||
const [details, setDetails] = useState(null)
|
const [details, setDetails] = useState(null)
|
||||||
const detailId = googlePlaceId || osmId
|
const detailId = googlePlaceId || osmId
|
||||||
@@ -684,6 +727,7 @@ function PlaceInspectorHeader({ openNow, place, category, t, editingName, nameIn
|
|||||||
onRemove={() => onUpdatePlace(place.id, { image_url: null })} />
|
onRemove={() => onUpdatePlace(place.id, { image_url: null })} />
|
||||||
: <PlaceAvatar place={place} category={category} size={52} />}
|
: <PlaceAvatar place={place} category={category} size={52} />}
|
||||||
</div>
|
</div>
|
||||||
|
{openNow === null && <PhotoCredit imageUrl={place.image_url} />}
|
||||||
{openNow !== null && (
|
{openNow !== null && (
|
||||||
<span style={{
|
<span style={{
|
||||||
position: 'absolute', bottom: -7, left: '50%', transform: 'translateX(-50%)',
|
position: 'absolute', bottom: -7, left: '50%', transform: 'translateX(-50%)',
|
||||||
|
|||||||
@@ -477,9 +477,25 @@ export function handleRemoteEvent(set: SetState, get: GetState, event: WebSocket
|
|||||||
// Snapshot before set(): the trip:updated case below replaces state.trip, so a
|
// Snapshot before set(): the trip:updated case below replaces state.trip, so a
|
||||||
// date-change check made after it would compare the new trip against itself.
|
// date-change check made after it would compare the new trip against itself.
|
||||||
const prevTrip = get().trip
|
const prevTrip = get().trip
|
||||||
|
// Same reason, for the image check below — the applier overwrites the row.
|
||||||
|
const prevPlaceImage =
|
||||||
|
type === 'place:updated'
|
||||||
|
? get().places.find(p => p.id === (payload.place as Place | undefined)?.id)?.image_url
|
||||||
|
: undefined
|
||||||
|
|
||||||
set(state => STATE_APPLIERS[type as TrekWsTripEventName]?.(payload, state) ?? {})
|
set(state => STATE_APPLIERS[type as TrekWsTripEventName]?.(payload, state) ?? {})
|
||||||
|
|
||||||
|
// Accommodation cards read their thumbnail from a page-local copy of the
|
||||||
|
// place, not from this store, so a new hero image would only appear there
|
||||||
|
// after a reload. Narrow to an actual image change: every other place edit
|
||||||
|
// (renaming, recategorising) must not trigger a refetch.
|
||||||
|
if (type === 'place:updated') {
|
||||||
|
const nextImage = (payload.place as Place | undefined)?.image_url
|
||||||
|
if (nextImage !== prevPlaceImage) {
|
||||||
|
window.dispatchEvent(new CustomEvent('accommodations:refresh'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A reorder/insert re-pins dates and re-stamps booking times server-side, so
|
// A reorder/insert re-pins dates and re-stamps booking times server-side, so
|
||||||
// pull the authoritative days + reservations for collaborators.
|
// pull the authoritative days + reservations for collaborators.
|
||||||
if (type === 'day:reordered') {
|
if (type === 'day:reordered') {
|
||||||
|
|||||||
@@ -475,5 +475,37 @@ describe('tripStore', () => {
|
|||||||
|
|
||||||
expect(useTripStore.getState().places[0].name).toBe('After');
|
expect(useTripStore.getState().places[0].name).toBe('After');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('FE-TSTORE-019b: asks the planner to reload accommodations when a place image changes', () => {
|
||||||
|
// Accommodation cards keep their own copy of the place, so a new hero
|
||||||
|
// image would otherwise only show up after a reload.
|
||||||
|
const place = buildPlace({ id: 500, trip_id: 1, image_url: null });
|
||||||
|
seedStore(useTripStore, { places: [place] });
|
||||||
|
const listener = vi.fn();
|
||||||
|
window.addEventListener('accommodations:refresh', listener);
|
||||||
|
|
||||||
|
useTripStore.getState().handleRemoteEvent({
|
||||||
|
type: 'place:updated',
|
||||||
|
place: { ...place, image_url: '/api/maps/place-photo/way%3A1~p0/bytes' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(listener).toHaveBeenCalledTimes(1);
|
||||||
|
window.removeEventListener('accommodations:refresh', listener);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-TSTORE-019c: does not reload accommodations for an edit that leaves the image alone', () => {
|
||||||
|
const place = buildPlace({ id: 500, trip_id: 1, name: 'Before', image_url: '/uploads/places/a.jpg' });
|
||||||
|
seedStore(useTripStore, { places: [place] });
|
||||||
|
const listener = vi.fn();
|
||||||
|
window.addEventListener('accommodations:refresh', listener);
|
||||||
|
|
||||||
|
useTripStore.getState().handleRemoteEvent({
|
||||||
|
type: 'place:updated',
|
||||||
|
place: { ...place, name: 'After' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
window.removeEventListener('accommodations:refresh', listener);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user