mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
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:
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user