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:
Maurice
2026-08-09 22:32:00 +02:00
committed by Maurice
parent 4843ebc937
commit 5d99886b04
4 changed files with 95 additions and 1 deletions
+3 -1
View File
@@ -93,7 +93,9 @@ function safeImg(url) {
// (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.
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)
@@ -44,6 +44,49 @@ function setSessionCache(key, value) {
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) {
const [details, setDetails] = useState(null)
const detailId = googlePlaceId || osmId
@@ -684,6 +727,7 @@ function PlaceInspectorHeader({ openNow, place, category, t, editingName, nameIn
onRemove={() => onUpdatePlace(place.id, { image_url: null })} />
: <PlaceAvatar place={place} category={category} size={52} />}
</div>
{openNow === null && <PhotoCredit imageUrl={place.image_url} />}
{openNow !== null && (
<span style={{
position: 'absolute', bottom: -7, left: '50%', transform: 'translateX(-50%)',