fix(collections): copy-to-trip labels + Unsplash cover search (#1081)

- Copy-to-trip modal showed blank rows: trips are keyed by `title`, not `name`,
  so nothing rendered. Read `title` and add the trip's date range under it.
- List editor gains an Unsplash cover search (same source as trip creation) next
  to the upload button; picking a photo sets it as the list cover.
- Add-place result rows: pin keeps a hard min width so a long address can't
  squeeze it.
This commit is contained in:
Maurice
2026-07-01 14:26:41 +02:00
committed by Maurice
parent b8ceb081fa
commit 6a3e6ae083
3 changed files with 105 additions and 8 deletions
@@ -135,7 +135,7 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec
</div>
{results.map((r, i) => (
<button key={i} type="button" onClick={() => pick(r)} className="flex items-center gap-3 px-2.5 py-2 rounded-lg text-left hover:bg-surface-hover transition-colors">
<div className="w-8 h-8 rounded-lg bg-surface-secondary flex items-center justify-center text-content-faint shrink-0"><MapPin size={15} /></div>
<div className="w-8 h-8 min-w-[32px] rounded-lg bg-surface-secondary flex items-center justify-center text-content-faint shrink-0"><MapPin size={15} /></div>
<div className="flex flex-col min-w-0 flex-1">
<span className="text-[13px] font-semibold text-content truncate">{str(r.name)}</span>
{str(r.address) && <span className="text-[11.5px] text-content-faint truncate">{str(r.address)}</span>}
@@ -1,14 +1,16 @@
import React, { useEffect, useMemo, useState } from 'react'
import { Search, MapPin, Loader2, Copy } from 'lucide-react'
import { Search, MapPin, Loader2, Copy, CalendarDays } from 'lucide-react'
import Modal from '../shared/Modal'
import { useToast } from '../shared/Toast'
import { tripsApi } from '../../api/client'
import { getApiErrorMessage } from '../../utils/apiError'
import { formatDate } from '../../utils/formatters'
import { useTranslation } from '../../i18n'
import type { TranslationFn } from '../../types'
interface TripOption {
id: number
name: string
title: string
start_date?: string | null
end_date?: string | null
cover_image?: string | null
@@ -32,6 +34,7 @@ interface CopyToTripModalProps {
*/
export default function CopyToTripModal({ isOpen, onClose, placeIds, onCopy, t }: CopyToTripModalProps): React.ReactElement | null {
const toast = useToast()
const { language } = useTranslation()
const [trips, setTrips] = useState<TripOption[]>([])
const [loading, setLoading] = useState(false)
const [search, setSearch] = useState('')
@@ -52,9 +55,16 @@ export default function CopyToTripModal({ isOpen, onClose, placeIds, onCopy, t }
const filtered = useMemo(() => {
const q = search.trim().toLowerCase()
if (!q) return trips
return trips.filter(tr => tr.name.toLowerCase().includes(q))
return trips.filter(tr => (tr.title ?? '').toLowerCase().includes(q))
}, [trips, search])
const dateRange = (tr: TripOption): string => {
const s = formatDate(tr.start_date, language)
const e = formatDate(tr.end_date, language)
if (s && e) return `${s} – ${e}`
return s || e || ''
}
if (!isOpen) return null
const handleCopy = async (tripId: number) => {
@@ -115,10 +125,17 @@ export default function CopyToTripModal({ isOpen, onClose, placeIds, onCopy, t }
disabled={busy}
className="flex items-center gap-3 px-3 py-2.5 rounded-xl border border-edge bg-surface-card text-left hover:bg-surface-hover transition-colors disabled:opacity-60"
>
<span className="w-8 h-8 rounded-lg bg-surface-secondary flex items-center justify-center shrink-0 overflow-hidden text-content-faint">
<span className="w-9 h-9 rounded-lg bg-surface-secondary flex items-center justify-center shrink-0 overflow-hidden text-content-faint">
{trip.cover_image ? <img src={trip.cover_image} alt="" className="w-full h-full object-cover" /> : <MapPin size={15} />}
</span>
<span className="flex-1 min-w-0 text-[13px] font-medium text-content truncate">{trip.name}</span>
<span className="flex-1 min-w-0">
<span className="block text-[13px] font-medium text-content truncate">{trip.title}</span>
{dateRange(trip) && (
<span className="flex items-center gap-1 text-[11.5px] text-content-faint truncate">
<CalendarDays size={11} className="shrink-0" /> {dateRange(trip)}
</span>
)}
</span>
{busy ? <Loader2 size={15} className="animate-spin text-content-faint shrink-0" /> : <Copy size={15} className="text-content-faint shrink-0" />}
</button>
)
@@ -1,13 +1,22 @@
import React, { useEffect, useRef, useState } from 'react'
import { ImagePlus, Link2, Plus, Trash2 } from 'lucide-react'
import { ImagePlus, Link2, Plus, Trash2, Search, Loader2 } from 'lucide-react'
import Modal from '../shared/Modal'
import { useCollectionStore } from '../../store/collectionStore'
import { useToast } from '../shared/Toast'
import { tripsApi } from '../../api/client'
import { getApiErrorMessage } from '../../types'
import { normalizeLinkUrl } from '../../pages/collections/collectionsModel'
import type { TranslationFn } from '../../types'
import type { Collection, CollectionLink } from '@trek/shared'
interface CoverSearchPhoto {
id: string
url: string
thumb: string
description?: string | null
photographer?: string | null
}
const SWATCHES = ['#6366f1', '#ec4899', '#14b8a6', '#f97316', '#8b5cf6', '#ef4444', '#3b82f6', '#22c55e']
interface ListEditorModalProps {
@@ -39,6 +48,11 @@ export default function ListEditorModal({ target, onClose, onCreated, onRequestD
const [links, setLinks] = useState<CollectionLink[]>([])
const [coverFile, setCoverFile] = useState<File | null>(null)
const [coverPreview, setCoverPreview] = useState<string | null>(null)
const [pendingUnsplashUrl, setPendingUnsplashUrl] = useState<string | null>(null)
const [coverQuery, setCoverQuery] = useState('')
const [coverResults, setCoverResults] = useState<CoverSearchPhoto[]>([])
const [searchingCover, setSearchingCover] = useState(false)
const coverSeq = useRef(0)
const [saving, setSaving] = useState(false)
// Remembers a freshly created list id so a retry after a cover-upload failure
// updates it instead of creating a duplicate list.
@@ -57,6 +71,9 @@ export default function ListEditorModal({ target, onClose, onCreated, onRequestD
setCoverFile(null)
dropObjectUrl()
setCoverPreview(editing?.cover_image ?? null)
setPendingUnsplashUrl(null)
setCoverQuery('')
setCoverResults([])
setCreatedId(null)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [target])
@@ -73,6 +90,31 @@ export default function ListEditorModal({ target, onClose, onCreated, onRequestD
objectUrl.current = url
setCoverFile(file)
setCoverPreview(url)
setPendingUnsplashUrl(null) // an uploaded file wins over a picked Unsplash photo
}
const searchCover = async () => {
const query = coverQuery.trim() || name.trim()
if (!query) return
const seq = ++coverSeq.current
setSearchingCover(true)
try {
const data = await tripsApi.searchCoverImages(query)
if (seq !== coverSeq.current) return
setCoverResults(data.photos || [])
} catch {
if (seq === coverSeq.current) setCoverResults([])
} finally {
if (seq === coverSeq.current) setSearchingCover(false)
}
}
const pickUnsplash = (photo: CoverSearchPhoto) => {
if (!photo.url) return
dropObjectUrl()
setCoverFile(null)
setPendingUnsplashUrl(photo.url)
setCoverPreview(photo.url)
}
const setLink = (i: number, patch: Partial<CollectionLink>) =>
@@ -85,7 +127,7 @@ export default function ListEditorModal({ target, onClose, onCreated, onRequestD
const cleanLinks = links
.map(l => ({ label: l.label?.trim() || undefined, url: normalizeLinkUrl(l.url) }))
.filter(l => l.url)
const payload = { name: trimmed, color, description: description.trim() || null, links: cleanLinks }
const payload = { name: trimmed, color, description: description.trim() || null, links: cleanLinks, ...(pendingUnsplashUrl ? { cover_image: pendingUnsplashUrl } : {}) }
setSaving(true)
try {
// A prior create that failed at the cover step left `createdId` set — reuse
@@ -152,6 +194,44 @@ export default function ListEditorModal({ target, onClose, onCreated, onRequestD
</span>
</button>
<input ref={fileRef} type="file" accept="image/jpeg,image/png,image/gif,image/webp" className="hidden" onChange={e => pickCover(e.target.files?.[0])} />
{/* Unsplash cover search — same source as trip creation */}
<div className="mt-2 flex gap-2">
<input
value={coverQuery}
onChange={e => setCoverQuery(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); searchCover() } }}
placeholder={t('dashboard.unsplashSearchPlaceholder')}
className="flex-1 min-w-0 px-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[13px] outline-none focus:border-accent"
/>
<button
type="button"
onClick={searchCover}
disabled={searchingCover || (!coverQuery.trim() && !name.trim())}
className="px-3 py-2 rounded-lg border border-edge text-content-secondary text-[13px] hover:bg-surface-hover disabled:opacity-40 inline-flex items-center gap-1.5 whitespace-nowrap"
>
{searchingCover ? <Loader2 size={14} className="animate-spin" /> : <Search size={14} />}
{t('dashboard.searchUnsplash')}
</button>
</div>
{coverResults.length > 0 && (
<div className="grid grid-cols-3 gap-2 mt-2">
{coverResults.map(photo => (
<button
type="button"
key={photo.id}
onClick={() => pickUnsplash(photo)}
aria-label={photo.photographer || 'Unsplash'}
className={`relative h-20 overflow-hidden rounded-lg border transition-colors ${coverPreview === photo.url ? 'border-accent ring-2 ring-accent/25' : 'border-edge hover:border-accent'}`}
>
<img src={photo.thumb} alt={photo.description || ''} loading="lazy" className="w-full h-full object-cover" />
{photo.photographer && (
<span className="absolute inset-x-0 bottom-0 truncate bg-black/55 px-1.5 py-1 text-[10px] text-white">{photo.photographer}</span>
)}
</button>
))}
</div>
)}
</div>
{/* Name */}