mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +00:00
3678761a2d
* feat(places): upload a custom place image on the server (#1136) * i18n(places): custom place image labels * feat(places): click a place thumbnail to set a custom image * feat(places): custom place image on mobile place and collection sheets * test(places): cover custom place and collection image upload * docs(places): document custom place images * fix(places): carry osm_id on the embedded place so day-plan thumbnails auto-fetch (#1136) * feat(places): custom tooltip on the place image controls * fix(places): show a custom place image on map markers, skip the redundant photo fetch (#1136)
112 lines
4.3 KiB
TypeScript
112 lines
4.3 KiB
TypeScript
import { useRef, useState } from 'react'
|
|
import { Camera, Loader2, X } from 'lucide-react'
|
|
import PlaceAvatar from './PlaceAvatar'
|
|
import { Tooltip } from './Tooltip'
|
|
import { normalizeImageFile } from '../../utils/convertHeic'
|
|
import { useToast } from './Toast'
|
|
import { useTranslation, translateApiError } from '../../i18n'
|
|
import type { Place } from '../../types'
|
|
|
|
interface Category {
|
|
color?: string
|
|
icon?: string
|
|
}
|
|
|
|
interface PlaceAvatarUploadProps {
|
|
place: Pick<Place, 'id' | 'name' | 'image_url' | 'google_place_id' | 'osm_id' | 'lat' | 'lng'>
|
|
category?: Category | null
|
|
size?: number
|
|
onUpload: (file: File) => Promise<void>
|
|
/** Clears the custom image; the auto-fetched default thumbnail then returns (#1136). */
|
|
onRemove: () => Promise<void> | void
|
|
}
|
|
|
|
/**
|
|
* A PlaceAvatar the user can click to set a custom thumbnail (#1136): hover reveals
|
|
* a camera overlay with a tooltip, clicking opens the file picker, and when a custom
|
|
* upload is present a small corner button removes it (falling back to the default).
|
|
*/
|
|
export default function PlaceAvatarUpload({ place, category, size = 52, onUpload, onRemove }: PlaceAvatarUploadProps) {
|
|
const { t } = useTranslation()
|
|
const toast = useToast()
|
|
const fileRef = useRef<HTMLInputElement>(null)
|
|
const [busy, setBusy] = useState(false)
|
|
const hasCustom = Boolean(place.image_url)
|
|
|
|
const handleFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0]
|
|
e.target.value = ''
|
|
if (!file) return
|
|
setBusy(true)
|
|
try {
|
|
await onUpload(await normalizeImageFile(file))
|
|
} catch (err: unknown) {
|
|
toast.error(translateApiError(t, err, 'places.imageUploadError'))
|
|
} finally {
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
const handleRemove = async (e: React.MouseEvent) => {
|
|
e.stopPropagation()
|
|
setBusy(true)
|
|
try {
|
|
await onRemove()
|
|
} catch (err: unknown) {
|
|
toast.error(translateApiError(t, err, 'places.imageUploadError'))
|
|
} finally {
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div style={{ position: 'relative', width: size, height: size }}>
|
|
<Tooltip label={hasCustom ? t('places.changeImage') : t('places.uploadImage')} placement="top">
|
|
<div
|
|
className="group"
|
|
style={{ position: 'relative', width: size, height: size, borderRadius: '50%', cursor: busy ? 'default' : 'pointer' }}
|
|
onClick={() => { if (!busy) fileRef.current?.click() }}
|
|
role="button"
|
|
tabIndex={0}
|
|
onKeyDown={e => { if ((e.key === 'Enter' || e.key === ' ') && !busy) { e.preventDefault(); fileRef.current?.click() } }}
|
|
aria-label={hasCustom ? t('places.changeImage') : t('places.uploadImage')}
|
|
>
|
|
<PlaceAvatar place={place} category={category} size={size} />
|
|
|
|
{/* Hover overlay — a camera cue that this thumbnail is editable. */}
|
|
<div
|
|
className="opacity-0 group-hover:opacity-100"
|
|
style={{
|
|
position: 'absolute', inset: 0, borderRadius: '50%',
|
|
background: 'rgba(0,0,0,0.45)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
transition: 'opacity 0.15s', pointerEvents: 'none',
|
|
}}
|
|
>
|
|
{busy ? <Loader2 size={Math.round(size * 0.34)} className="animate-spin" color="#fff" /> : <Camera size={Math.round(size * 0.34)} color="#fff" />}
|
|
</div>
|
|
</div>
|
|
</Tooltip>
|
|
|
|
{/* Remove button — only when a custom upload is set. */}
|
|
{hasCustom && !busy && (
|
|
<Tooltip label={t('places.removeImage')} placement="top">
|
|
<button
|
|
type="button"
|
|
onClick={handleRemove}
|
|
aria-label={t('places.removeImage')}
|
|
style={{
|
|
position: 'absolute', top: -3, right: -3, width: 18, height: 18, borderRadius: '50%',
|
|
background: '#ef4444', color: '#fff', border: '2px solid var(--bg-elevated, #fff)',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', padding: 0, zIndex: 1,
|
|
}}
|
|
>
|
|
<X size={10} strokeWidth={3} />
|
|
</button>
|
|
</Tooltip>
|
|
)}
|
|
|
|
<input ref={fileRef} type="file" accept="image/jpeg,image/png,image/gif,image/webp,.heic,.heif" style={{ display: 'none' }} onChange={handleFile} />
|
|
</div>
|
|
)
|
|
}
|