mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
0df82185bd
A saved place always had price, currency, website and phone columns. Saving from a trip, the file import and the copy into a trip all carried them, but neither editor showed them and the update contract did not know them, so the validation pipe stripped them from a PATCH and the MCP tool refused them as unknown keys (#2471). - shared: collectionPlaceUpdateRequestSchema takes price (not negative, nullable), currency (three letters, trimmed and upper-cased), website (http or https, as on save) and phone (trimmed, capped at 60 like the file import). The save schema is unchanged. - server: updatePlace writes the four fields inside the existing transaction. update_collection_place spreads the same shape, so REST and MCP take and refuse the same values; its description now names the price and asks for the currency with it. - client: the desktop detail sheet and the phone sheet edit price with a currency picker, website and phone, and show them in read mode (price only above zero, phone as a call link, website as a link chip through safeHttpUrl). Both shells share one hook, useCollectionPlaceExtras, which sends only the fields the user changed and always pairs a price with its currency, since a list has no base currency. An amount that does not parse marks the field and holds the save. - i18n: collections.price, collections.priceHint and collections.phone in all 23 locales; the website label reuses places.formWebsite. - wiki: Collections and MCP-Addon-Tools name the new fields. Tests: schema spec, COLLECTIONS-SVC-128 to 131, controller pipe parity, update_collection_place with the new fields, COLLECTIONS-E2E-085 to 087, FE-COMP-COLEXTRAS-001 to 012, FE-COMP-COLDETAIL-049 to 054 and FE-MOB-CPLSH-042 to 046.
425 lines
23 KiB
TypeScript
425 lines
23 KiB
TypeScript
import React, { useEffect, useRef, useState } from 'react'
|
|
import Markdown from 'react-markdown'
|
|
import remarkGfm from 'remark-gfm'
|
|
import remarkBreaks from 'remark-breaks'
|
|
import { markdownLinkComponents } from '../shared/markdownLink'
|
|
import { X, Pencil, Copy, Trash2, MapPin, Link2, Plus, ExternalLink, Check, Tag, Tags, Camera, Loader2, Navigation, Banknote, Globe, Phone } from 'lucide-react'
|
|
import type { CollectionPlace, CollectionStatus, CollectionLink, CollectionLabel } from '@trek/shared'
|
|
import type { Category, TranslationFn } from '../../types'
|
|
import MarkdownToolbar from '../Journey/MarkdownToolbar'
|
|
import { NumericInput } from '../shared/NumericInput'
|
|
import CustomSelect from '../shared/CustomSelect'
|
|
import { mapsApi } from '../../api/client'
|
|
import { entityGradient } from '../../utils/gradients'
|
|
import { getCategoryIcon } from '../shared/categoryIcons'
|
|
import { NavigationMenu } from '../shared/NavigationMenu'
|
|
import { getNavigationTargets, openNavigationTarget } from '../Planner/placeNavigation'
|
|
import { STATUS_META, STATUS_ORDER, normalizeLinkUrl } from '../../pages/collections/collectionsModel'
|
|
import { useToast } from '../shared/Toast'
|
|
import { Tooltip } from '../shared/Tooltip'
|
|
import PlaceRating from '../shared/StarRating'
|
|
import { normalizeImageFile } from '../../utils/convertHeic'
|
|
import { getApiErrorMessage } from '../../utils/apiError'
|
|
import { useCollectionPlaceExtras, type CollectionPlaceExtrasPatch } from './useCollectionPlaceExtras'
|
|
|
|
function linkHost(url: string): string {
|
|
try { return new URL(url).hostname.replace(/^www\./, '') } catch { return url }
|
|
}
|
|
|
|
interface CollectionPlaceDetailProps {
|
|
place: CollectionPlace
|
|
canEdit: boolean
|
|
canDelete: boolean
|
|
categories: Category[]
|
|
/** The active list's custom labels, for the assign chips. */
|
|
labels: CollectionLabel[]
|
|
/** When set, dock the sheet over that column (desktop split) instead of centred. */
|
|
anchorRect?: { left: number; width: number } | null
|
|
onClose: () => void
|
|
onSetStatus: (status: CollectionStatus) => void
|
|
onSave: (patch: { name?: string; description?: string | null; links?: CollectionLink[]; category_id?: number | null; label_ids?: number[]; image_url?: string | null; lat?: number | null; lng?: number | null; address?: string | null } & CollectionPlaceExtrasPatch) => Promise<void>
|
|
/** Upload a custom cover image (#1136); enables the cover change/remove controls. */
|
|
onUploadImage?: (file: File) => Promise<void>
|
|
onCopyToTrip: () => void
|
|
onRemove: () => void
|
|
/** Cast/clear the current user's star vote (#1435); every member may vote. */
|
|
onRate?: (rating: number | null) => Promise<void> | void
|
|
t: TranslationFn
|
|
}
|
|
|
|
function StatusSegment({ status, onSet, t }: { status: CollectionStatus; onSet: (s: CollectionStatus) => void; t: TranslationFn }): React.ReactElement {
|
|
return (
|
|
<div className="col-detail-seg" role="group">
|
|
{STATUS_ORDER.map(s => {
|
|
const Icon = STATUS_META[s].icon
|
|
const on = status === s
|
|
return (
|
|
<button key={s} type="button" aria-pressed={on} onClick={() => onSet(s)} className={on ? 'on' : ''}>
|
|
<Icon size={14} style={{ color: on ? STATUS_META[s].color : undefined }} /> {t(STATUS_META[s].labelKey)}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Bottom detail sheet for a saved place — an opaque, clearly-sectioned card
|
|
* (cover → meta → status → description → links) docked over the list column.
|
|
* Read mode renders the description as markdown + link chips; edit mode swaps in
|
|
* name / category / price / website / phone / markdown description / links,
|
|
* saving via updatePlace. Status is an always-live segmented control (auto-saves).
|
|
*/
|
|
export default function CollectionPlaceDetail({
|
|
place, canEdit, canDelete, categories, labels, anchorRect, onClose, onSetStatus, onSave, onUploadImage, onCopyToTrip, onRemove, onRate, t,
|
|
}: CollectionPlaceDetailProps): React.ReactElement {
|
|
const toast = useToast()
|
|
const [editing, setEditing] = useState(false)
|
|
const coverInputRef = useRef<HTMLInputElement>(null)
|
|
const [imgBusy, setImgBusy] = useState(false)
|
|
const [navOpen, setNavOpen] = useState(false)
|
|
const navBtnRef = useRef<HTMLButtonElement>(null)
|
|
const navigationTargets = getNavigationTargets(place)
|
|
const [name, setName] = useState(place.name)
|
|
const [categoryId, setCategoryId] = useState<number | null>(place.category_id ?? null)
|
|
const [description, setDescription] = useState(place.description ?? '')
|
|
const [links, setLinks] = useState<CollectionLink[]>(place.links ?? [])
|
|
const [labelIds, setLabelIds] = useState<number[]>(place.label_ids ?? [])
|
|
const [address, setAddress] = useState(place.address ?? '')
|
|
const [lat, setLat] = useState(place.lat != null ? String(place.lat) : '')
|
|
const [lng, setLng] = useState(place.lng != null ? String(place.lng) : '')
|
|
const extras = useCollectionPlaceExtras(place)
|
|
const [saving, setSaving] = useState(false)
|
|
// A higher-res photo pulled from the maps provider when the place has none of
|
|
// its own — the list avatar's little thumbnail is too low-res for the cover.
|
|
const [fetchedPhoto, setFetchedPhoto] = useState<string | null>(null)
|
|
const descRef = useRef<HTMLTextAreaElement>(null)
|
|
|
|
// Reset only when a DIFFERENT place is opened (keyed on id, not on every field).
|
|
useEffect(() => {
|
|
setEditing(false)
|
|
setName(place.name)
|
|
setCategoryId(place.category_id ?? null)
|
|
setDescription(place.description ?? '')
|
|
setLinks(place.links ?? [])
|
|
setLabelIds(place.label_ids ?? [])
|
|
setAddress(place.address ?? '')
|
|
setLat(place.lat != null ? String(place.lat) : '')
|
|
setLng(place.lng != null ? String(place.lng) : '')
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [place.id])
|
|
|
|
// Fetch a cover photo when the place doesn't carry its own image.
|
|
useEffect(() => {
|
|
setFetchedPhoto(null)
|
|
if (place.image_url) return
|
|
const photoId = place.google_place_id || place.osm_id || (place.lat != null && place.lng != null ? `${place.lat},${place.lng}` : null)
|
|
if (!photoId) return
|
|
let cancelled = false
|
|
mapsApi.placePhoto(photoId, place.lat ?? undefined, place.lng ?? undefined, place.name)
|
|
.then(res => { if (!cancelled && res?.photoUrl) setFetchedPhoto(res.photoUrl) })
|
|
.catch(() => {})
|
|
return () => { cancelled = true }
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [place.id])
|
|
|
|
const banner = place.image_url || fetchedPhoto
|
|
|
|
const handleCoverPick = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0]
|
|
e.target.value = ''
|
|
if (!file || !onUploadImage) return
|
|
setImgBusy(true)
|
|
try {
|
|
await onUploadImage(await normalizeImageFile(file))
|
|
} catch (err) {
|
|
toast.error(getApiErrorMessage(err, t('places.imageUploadError')))
|
|
} finally {
|
|
setImgBusy(false)
|
|
}
|
|
}
|
|
|
|
const handleImageRemove = async () => {
|
|
setImgBusy(true)
|
|
try {
|
|
await onSave({ image_url: null })
|
|
} catch (err) {
|
|
toast.error(getApiErrorMessage(err, t('places.imageUploadError')))
|
|
} finally {
|
|
setImgBusy(false)
|
|
}
|
|
}
|
|
|
|
const setLink = (i: number, patch: Partial<CollectionLink>) => setLinks(links.map((l, idx) => (idx === i ? { ...l, ...patch } : l)))
|
|
const toggleLabel = (id: number) => setLabelIds(labelIds.includes(id) ? labelIds.filter(x => x !== id) : [...labelIds, id])
|
|
const resetForm = () => { setEditing(false); setName(place.name); setCategoryId(place.category_id ?? null); setDescription(place.description ?? ''); setLinks(place.links ?? []); setLabelIds(place.label_ids ?? []); setAddress(place.address ?? ''); setLat(place.lat != null ? String(place.lat) : ''); setLng(place.lng != null ? String(place.lng) : '') }
|
|
const coordPaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
|
|
const text = e.clipboardData.getData('text').trim()
|
|
const match = text.match(/^(-?\d+(?:\.\d*)?)(?:\s*[,;]\s*|\s+)(-?\d+(?:\.\d*)?)$/)
|
|
if (match) { e.preventDefault(); setLat(match[1]); setLng(match[2]) }
|
|
}
|
|
const assignedLabels = labels.filter(l => (place.label_ids ?? []).includes(l.id))
|
|
const startEdit = () => { extras.reset(place); setEditing(true) }
|
|
|
|
const save = async () => {
|
|
const cleanLinks = links.map(l => ({ label: l.label?.trim() || undefined, url: normalizeLinkUrl(l.url) })).filter(l => l.url)
|
|
const latNum = lat.trim() ? Number(lat) : Number.NaN
|
|
const lngNum = lng.trim() ? Number(lng) : Number.NaN
|
|
setSaving(true)
|
|
try {
|
|
await onSave({ name: name.trim() || place.name, description: description.trim() || null, links: cleanLinks, category_id: categoryId, label_ids: labelIds, address: address.trim() || null, lat: Number.isFinite(latNum) ? latNum : null, lng: Number.isFinite(lngNum) ? lngNum : null, ...extras.patch() })
|
|
setEditing(false)
|
|
} catch (err) {
|
|
toast.error(getApiErrorMessage(err, t('common.error')))
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
const dockStyle = anchorRect ? { left: anchorRect.left, width: anchorRect.width, transform: 'none' as const } : undefined
|
|
const CatIcon = getCategoryIcon(place.category?.icon)
|
|
|
|
return (
|
|
<div className={`col-detail${anchorRect ? ' docked' : ''}`} style={dockStyle} role="presentation" onClick={e => e.stopPropagation()}>
|
|
<div className="col-detail-cover" style={banner ? undefined : { backgroundImage: entityGradient(place.id) }}>
|
|
{banner && <img src={banner} alt="" />}
|
|
<div className="col-detail-cover-scrim" />
|
|
{/* Chip and cover controls share the top bar. The controls keep the end and
|
|
never shrink, so a long category name ellipsizes instead of sliding under
|
|
them, however many buttons there are and whatever size phones give them. */}
|
|
<div className="absolute left-[14px] right-[12px] top-[12px] z-[2] flex items-center gap-[8px]">
|
|
{place.category?.name && (
|
|
<span className="col-detail-cover-cat min-w-0" style={{ ['--cat' as string]: place.category.color || '#6366f1' }}>
|
|
<CatIcon size={12} className="flex-none" />
|
|
<span className="truncate">{place.category.name}</span>
|
|
</span>
|
|
)}
|
|
{/* The cover controls wear the close button's class, so they get its look,
|
|
hover and phone size. */}
|
|
<div className="ms-auto flex flex-none gap-[6px]">
|
|
{canEdit && onUploadImage && (
|
|
<>
|
|
<input ref={coverInputRef} type="file" accept="image/jpeg,image/png,image/gif,image/webp,.heic,.heif" style={{ display: 'none' }} onChange={handleCoverPick} />
|
|
{place.image_url && !imgBusy && (
|
|
<Tooltip label={t('places.removeImage')} placement="bottom">
|
|
<button type="button" className="col-detail-close" onClick={handleImageRemove} aria-label={t('places.removeImage')}>
|
|
<Trash2 size={16} />
|
|
</button>
|
|
</Tooltip>
|
|
)}
|
|
<Tooltip label={place.image_url ? t('places.changeImage') : t('places.uploadImage')} placement="bottom">
|
|
<button
|
|
type="button"
|
|
className="col-detail-close"
|
|
style={imgBusy ? { cursor: 'default' } : undefined}
|
|
onClick={() => { if (!imgBusy) coverInputRef.current?.click() }}
|
|
aria-label={place.image_url ? t('places.changeImage') : t('places.uploadImage')}
|
|
>
|
|
{imgBusy ? <Loader2 size={16} className="animate-spin" /> : <Camera size={16} />}
|
|
</button>
|
|
</Tooltip>
|
|
</>
|
|
)}
|
|
<button type="button" className="col-detail-close" onClick={onClose} aria-label={t('common.close')}><X size={16} /></button>
|
|
</div>
|
|
</div>
|
|
<div className="col-detail-head">
|
|
{editing
|
|
? <input value={name} onChange={e => setName(e.target.value)} className="col-detail-name-input" autoFocus aria-label={t('collections.listName')} />
|
|
: <h2 className="col-detail-name">{place.name}</h2>}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="col-detail-body">
|
|
{/* Meta (view only) */}
|
|
{!editing && (place.address || extras.priceLabel || extras.phone) && (
|
|
<div className="col-detail-meta">
|
|
{place.address && <span className="col-detail-addr"><MapPin size={12} /> {place.address}</span>}
|
|
{extras.priceLabel && <span className="col-detail-price"><Banknote size={12} /> {extras.priceLabel}</span>}
|
|
{extras.phone && extras.phoneHref && <a href={extras.phoneHref} className="col-detail-addr col-detail-tel"><Phone size={12} /> {extras.phone}</a>}
|
|
{/* Same picker as inside a trip (#1455). A saved place is somewhere
|
|
you intend to go, and until now the only way to get directions
|
|
was to add it to a trip first. */}
|
|
</div>
|
|
)}
|
|
|
|
{/* Status — live for editors, read-only for viewers */}
|
|
<StatusSegment status={place.status} onSet={canEdit ? onSetStatus : () => {}} t={t} />
|
|
|
|
{/* Collaborative rating (#1435) — every member votes; the average shows. */}
|
|
{/* Rating and labels on one line, each on its own ground.
|
|
A bare line of stars read as another row of text, and the labels tried the
|
|
cover photo first — where a blue chip on a blue sky is simply not there.
|
|
Side by side they are two badges answering two questions about the place. */}
|
|
{(onRate || assignedLabels.length > 0) && (
|
|
<div className="col-detail-badges">
|
|
{onRate && (
|
|
<div className="col-detail-rating">
|
|
<PlaceRating ratings={place.ratings ?? []} ratingAvg={place.rating_avg} onRate={onRate} />
|
|
</div>
|
|
)}
|
|
{!editing && assignedLabels.map(l => (
|
|
<span key={l.id} className="col-labelchip on static big" style={{ ['--label' as string]: l.color || 'var(--accent)' }}>
|
|
<span className="col-labelchip-dot" /> {l.name}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{editing ? (
|
|
<div className="col-detail-edit">
|
|
{/* Category */}
|
|
<div className="col-detail-field">
|
|
<div className="col-detail-label"><Tag size={12} /> {t('collections.category')}</div>
|
|
<div className="col-detail-cats">
|
|
<button type="button" onClick={() => setCategoryId(null)} className={`col-detail-cat${categoryId == null ? ' on' : ''}`}>{t('collections.noCategory')}</button>
|
|
{categories.map(cat => {
|
|
const Icon = getCategoryIcon(cat.icon ?? undefined)
|
|
const on = categoryId === cat.id
|
|
return (
|
|
<button key={cat.id} type="button" onClick={() => setCategoryId(cat.id)} className={`col-detail-cat${on ? ' on' : ''}`} style={{ ['--cat' as string]: cat.color || '#6366f1' }}>
|
|
<Icon size={12} /> {cat.name}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
{/* Address (#1870): free text, the coordinates stay separate */}
|
|
<div className="col-detail-field">
|
|
<div className="col-detail-label"><MapPin size={12} /> {t('places.formAddress')}</div>
|
|
<input value={address} onChange={e => setAddress(e.target.value)} placeholder={t('places.formAddressPlaceholder')} className="col-detail-input" />
|
|
</div>
|
|
{/* Coordinates */}
|
|
<div className="col-detail-field">
|
|
<div className="col-detail-label"><MapPin size={12} /> {t('collections.coordinates')}</div>
|
|
<div className="col-detail-link-row">
|
|
<NumericInput mode="signed" value={lat} onValueChange={setLat} onPaste={coordPaste} placeholder={t('places.formLat')} className="col-detail-input flex-1" />
|
|
<NumericInput mode="signed" value={lng} onValueChange={setLng} placeholder={t('places.formLng')} className="col-detail-input flex-1" />
|
|
</div>
|
|
</div>
|
|
{/* Price (#2471): a rough cost such as an entry fee, always in its own currency */}
|
|
<div className="col-detail-field">
|
|
<div className="col-detail-label"><Banknote size={12} /> {t('collections.price')}</div>
|
|
<div className="col-detail-link-row">
|
|
<NumericInput {...extras.priceInput} placeholder="0" aria-label={t('collections.price')} className="col-detail-input flex-1" />
|
|
<div className="w-36 flex-none">
|
|
<CustomSelect {...extras.currencySelect} searchable size="sm" style={{ width: '100%' }} />
|
|
</div>
|
|
</div>
|
|
<div className="col-detail-hint">{t('collections.priceHint')}</div>
|
|
</div>
|
|
<div className="col-detail-field">
|
|
<div className="col-detail-label"><Globe size={12} /> {t('places.formWebsite')}</div>
|
|
<input type="url" value={extras.draft.website} onChange={e => extras.set('website', e.target.value)} aria-label={t('places.formWebsite')} className="col-detail-input" />
|
|
</div>
|
|
<div className="col-detail-field">
|
|
<div className="col-detail-label"><Phone size={12} /> {t('collections.phone')}</div>
|
|
<input type="tel" value={extras.draft.phone} onChange={e => extras.set('phone', e.target.value)} maxLength={60} aria-label={t('collections.phone')} className="col-detail-input" />
|
|
</div>
|
|
{/* Labels */}
|
|
{labels.length > 0 && (
|
|
<div className="col-detail-field">
|
|
<div className="col-detail-label"><Tags size={12} /> {t('collections.labels.title')}</div>
|
|
<div className="col-detail-cats">
|
|
{labels.map(l => {
|
|
const on = labelIds.includes(l.id)
|
|
return (
|
|
<button key={l.id} type="button" onClick={() => toggleLabel(l.id)} className={`col-detail-cat${on ? ' on' : ''}`} style={{ ['--cat' as string]: l.color || '#6366f1' }}>
|
|
<span className="col-labelchip-dot" /> {l.name}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{/* Description */}
|
|
<div className="col-detail-field">
|
|
<div className="col-detail-label">{t('collections.description')}</div>
|
|
<MarkdownToolbar textareaRef={descRef} onUpdate={setDescription} />
|
|
<textarea ref={descRef} value={description} onChange={e => setDescription(e.target.value)} rows={4} placeholder={t('collections.descriptionPlaceholder')} className="col-detail-textarea" />
|
|
</div>
|
|
{/* Links */}
|
|
<div className="col-detail-field">
|
|
<div className="col-detail-label">{t('collections.links')}</div>
|
|
<div className="col-detail-links-edit">
|
|
{links.map((l, i) => (
|
|
<div key={i} className="col-detail-link-row">
|
|
<input value={l.label ?? ''} onChange={e => setLink(i, { label: e.target.value })} placeholder={t('collections.linkLabel')} className="col-detail-input w-28" />
|
|
<input value={l.url} onChange={e => setLink(i, { url: e.target.value })} placeholder="https://…" className="col-detail-input flex-1" />
|
|
<button type="button" onClick={() => setLinks(links.filter((_, idx) => idx !== i))} className="col-detail-icon-btn" aria-label={t('common.delete')}><Trash2 size={14} /></button>
|
|
</div>
|
|
))}
|
|
<button type="button" onClick={() => setLinks([...links, { url: '' }])} className="col-detail-add-link"><Plus size={13} /> <Link2 size={12} /> {t('collections.addLink')}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{place.description && (
|
|
<div className="col-detail-md collab-note-md">
|
|
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} components={markdownLinkComponents}>{place.description}</Markdown>
|
|
</div>
|
|
)}
|
|
{(extras.websiteHref || (place.links && place.links.length > 0)) && (
|
|
<div className="col-detail-links">
|
|
{extras.websiteHref && (
|
|
<a href={extras.websiteHref} target="_blank" rel="noopener noreferrer" className="col-detail-link">
|
|
<Globe size={13} /> {linkHost(extras.websiteHref)}
|
|
</a>
|
|
)}
|
|
{(place.links ?? []).map((l, i) => (
|
|
<a key={i} href={l.url} target="_blank" rel="noopener noreferrer" className="col-detail-link">
|
|
<ExternalLink size={13} /> {l.label || linkHost(l.url)}
|
|
</a>
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<div className="col-detail-footer">
|
|
{editing ? (
|
|
<>
|
|
<button type="button" onClick={resetForm} className="col-detail-btn">{t('common.cancel')}</button>
|
|
<button type="button" onClick={save} disabled={saving || !extras.priceValid} className="col-detail-btn primary"><Check size={14} /> {t('common.save')}</button>
|
|
</>
|
|
) : (
|
|
<>
|
|
{canEdit && <button type="button" onClick={startEdit} className="col-detail-btn"><Pencil size={14} /> {t('common.edit')}</button>}
|
|
<button type="button" onClick={onCopyToTrip} className="col-detail-btn"><Copy size={14} /> {t('collections.copyToTrip')}</button>
|
|
{/* Beside the other two rather than up by the address: getting directions is
|
|
something you DO with the place, like editing it or copying it into a
|
|
trip, and the row of things you do belongs in one place. */}
|
|
{navigationTargets.length > 0 && (
|
|
<>
|
|
<button
|
|
ref={navBtnRef}
|
|
type="button"
|
|
className="col-detail-btn"
|
|
onClick={() => {
|
|
if (navigationTargets.length === 1) openNavigationTarget(navigationTargets[0])
|
|
else setNavOpen(o => !o)
|
|
}}
|
|
>
|
|
<Navigation size={14} />
|
|
{navigationTargets.length === 1 ? navigationTargets[0].label : t('inspector.navigation')}
|
|
</button>
|
|
{navOpen && (
|
|
<NavigationMenu
|
|
targets={navigationTargets}
|
|
anchor={navBtnRef.current}
|
|
onClose={() => setNavOpen(false)}
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
<div className="col-detail-footer-spacer" />
|
|
{canDelete && <button type="button" onClick={onRemove} className="col-detail-btn danger"><Trash2 size={14} /> {t('collections.removeFromList')}</button>}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|