diff --git a/client/src/api/collections.ts b/client/src/api/collections.ts index 368ac9464..67e531194 100644 --- a/client/src/api/collections.ts +++ b/client/src/api/collections.ts @@ -51,6 +51,8 @@ export const collectionsApi = { ax.post(base, body satisfies CollectionCreateRequest).then((r: AxiosResponse) => r.data), update: (id: number, body: CollectionUpdateRequest): Promise<{ collection: Collection }> => ax.patch(`${base}/${id}`, body satisfies CollectionUpdateRequest).then((r: AxiosResponse) => r.data), + uploadCover: (id: number, formData: FormData): Promise => + ax.post(`${base}/${id}/cover`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then((r: AxiosResponse) => r.data), remove: (id: number): Promise => ax.delete(`${base}/${id}`).then((r: AxiosResponse) => r.data), reorder: (orderedIds: number[]): Promise => @@ -84,6 +86,8 @@ export const collectionsApi = { ax.post(`${base}/invite/cancel`, { collection_id: collectionId, user_id: userId } satisfies CollectionInviteCancelRequest).then((r: AxiosResponse) => r.data), leave: (collectionId: number): Promise => ax.post(`${base}/leave`, { collection_id: collectionId }).then((r: AxiosResponse) => r.data), + removeMember: (collectionId: number, userId: number): Promise => + ax.post(`${base}/members/remove`, { collection_id: collectionId, user_id: userId }).then((r: AxiosResponse) => r.data), availableUsers: (id: number): Promise<{ users: { id: number; username: string }[] }> => ax.get(`${base}/${id}/available-users`).then((r: AxiosResponse) => r.data), } diff --git a/client/src/components/Collections/AddPlaceToCollectionModal.tsx b/client/src/components/Collections/AddPlaceToCollectionModal.tsx new file mode 100644 index 000000000..beb039588 --- /dev/null +++ b/client/src/components/Collections/AddPlaceToCollectionModal.tsx @@ -0,0 +1,121 @@ +import React, { useEffect, useState } from 'react' +import { Search, MapPin, Plus, Loader2 } from 'lucide-react' +import Modal from '../shared/Modal' +import { mapsApi } from '../../api/client' +import { collectionsApi } from '../../api/collections' +import { useTranslation } from '../../i18n' +import { useToast } from '../shared/Toast' +import { getApiErrorMessage } from '../../types' +import type { TranslationFn } from '../../types' + +type MapsPlace = Record +const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined) +const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : typeof v === 'string' && v !== '' ? Number(v) : undefined) + +interface AddPlaceToCollectionModalProps { + isOpen: boolean + collectionId: number + collectionName: string + onClose: () => void + onAdded: () => void + t: TranslationFn +} + +/** + * Search for a place (OSM / Google via the maps service) and save it straight + * into the current list. Stays open after each add so several places can be + * added in one go. + */ +export default function AddPlaceToCollectionModal({ isOpen, collectionId, collectionName, onClose, onAdded, t }: AddPlaceToCollectionModalProps): React.ReactElement { + const { language } = useTranslation() + const toast = useToast() + const [query, setQuery] = useState('') + const [results, setResults] = useState([]) + const [searching, setSearching] = useState(false) + const [addingIdx, setAddingIdx] = useState(null) + + useEffect(() => { + if (!isOpen) { setQuery(''); setResults([]); setAddingIdx(null) } + }, [isOpen]) + + const search = async () => { + if (!query.trim()) return + setSearching(true) + try { + const res = await mapsApi.search(query, language) + setResults((res.places as MapsPlace[]) || []) + } catch (err) { + toast.error(getApiErrorMessage(err, t('places.mapsSearchError'))) + } finally { + setSearching(false) + } + } + + const add = async (r: MapsPlace, idx: number) => { + const name = str(r.name) + if (!name) return + setAddingIdx(idx) + try { + const res = await collectionsApi.savePlace({ + collection_id: collectionId, + name, + address: str(r.address) ?? null, + lat: num(r.lat) ?? null, + lng: num(r.lng) ?? null, + google_place_id: str(r.google_place_id) ?? null, + google_ftid: str(r.google_ftid) ?? null, + osm_id: str(r.osm_id) ?? null, + website: str(r.website) ?? null, + phone: str(r.phone) ?? null, + }) + if (res.duplicate) toast.info(t('collections.duplicateWarning')) + else { toast.success(t('collections.addedToList', { name: collectionName })); onAdded() } + } catch (err) { + toast.error(getApiErrorMessage(err, t('common.error'))) + } finally { + setAddingIdx(null) + } + } + + return ( + +
+
+
+ + setQuery(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') search() }} + placeholder={t('collections.addPlaceSearch')} + className="w-full pl-9 pr-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[14px] outline-none focus:border-accent" + /> +
+ +
+ +
+ {results.length === 0 && !searching && ( +

{t('collections.addPlaceHint')}

+ )} + {results.map((r, i) => ( +
+
+
+ {str(r.name)} + {str(r.address) && {str(r.address)}} +
+ +
+ ))} +
+
+
+ ) +} diff --git a/client/src/components/Collections/CollectionHero.tsx b/client/src/components/Collections/CollectionHero.tsx index c6a5d1b25..1b2fc8805 100644 --- a/client/src/components/Collections/CollectionHero.tsx +++ b/client/src/components/Collections/CollectionHero.tsx @@ -1,6 +1,6 @@ import React from 'react' -import { Share2, Users, Plus } from 'lucide-react' -import type { CollectionMember } from '@trek/shared' +import { Share2, Users, Plus, Link2 } from 'lucide-react' +import type { CollectionMember, CollectionLink } from '@trek/shared' import type { TranslationFn } from '../../types' import type { StatusFilter } from '../../store/collectionStore' import { STATUS_META, STATUS_ORDER } from '../../pages/collections/collectionsModel' @@ -14,9 +14,11 @@ function initials(name: string): string { interface CollectionHeroProps { eyebrow: string title: string - /** List colour — drives the gradient wash when there is no cover image. */ + /** List colour — drives the gradient wash (or tints the cover image). */ color: string coverImage?: string | null + description?: string | null + links?: CollectionLink[] counts: Record statusFilter: StatusFilter onStatusFilter: (f: StatusFilter) => void @@ -37,8 +39,12 @@ interface CollectionHeroProps { * (All / Idea / Want / Visited with live counts). Share + New-list actions sit * top-right. Modelled on the dashboard hero-trip. */ +function linkHost(url: string): string { + try { return new URL(url).hostname.replace(/^www\./, '') } catch { return url } +} + export default function CollectionHero({ - eyebrow, title, color, coverImage, counts, statusFilter, onStatusFilter, + eyebrow, title, color, coverImage, description, links, counts, statusFilter, onStatusFilter, members, canShare, isOwner, shareMemberCount, onShare, onNewList, t, }: CollectionHeroProps): React.ReactElement { const accepted = members.filter(m => m.status === 'accepted' || m.is_owner) @@ -53,9 +59,14 @@ export default function CollectionHero({ return (
- {coverImage - ? - :
} + {coverImage ? ( + <> + +
+ + ) : ( +
+ )}
@@ -94,6 +105,8 @@ export default function CollectionHero({

{title}

+ {description &&

{description}

} +
{chips.map(chip => (
+ + {links && links.length > 0 && ( + + )}
) diff --git a/client/src/components/Collections/CollectionMapPanel.tsx b/client/src/components/Collections/CollectionMapPanel.tsx index 7e80e6a4a..5d95218da 100644 --- a/client/src/components/Collections/CollectionMapPanel.tsx +++ b/client/src/components/Collections/CollectionMapPanel.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { PanelLeftClose, PanelLeftOpen, Search } from 'lucide-react' +import { PanelLeftClose, PanelLeftOpen, Search, CheckSquare, Plus } from 'lucide-react' import type { CollectionPlace } from '@trek/shared' import type { TranslationFn } from '../../types' import CollectionMap from './CollectionMap' @@ -15,6 +15,13 @@ interface CollectionMapPanelProps { /** 'list' = split (map can be expanded); 'map' = full (list collapsed). */ view: 'list' | 'map' onToggleView: () => void + /** Bulk-select toggle — hidden for the "All saved" view. */ + showSelect: boolean + selectMode: boolean + onToggleSelect: () => void + /** Show a "+" to add a place to the current list (real lists only). */ + canAddPlace: boolean + onAddPlace: () => void search: string onSearch: (v: string) => void t: TranslationFn @@ -22,11 +29,12 @@ interface CollectionMapPanelProps { /** * The map surface for the collections page — the map plus its floating controls: - * a top-left toggle that collapses/expands the list (animating the split) and a - * top-right search box. Used both in the desktop split and full-map view. + * a top-left cluster (collapse/expand the list, toggle bulk-select) and a + * top-right search box. Used both in the desktop split and the full-map view. */ export default function CollectionMapPanel({ - places, selectedPlaceId, onSelect, onDeselect, dark, overlay, view, onToggleView, search, onSearch, t, + places, selectedPlaceId, onSelect, onDeselect, dark, overlay, view, onToggleView, + showSelect, selectMode, onToggleSelect, canAddPlace, onAddPlace, search, onSearch, t, }: CollectionMapPanelProps): React.ReactElement { return (
@@ -39,18 +47,38 @@ export default function CollectionMapPanel({ /> {overlay && ( <> - -
- - onSearch(e.target.value)} placeholder={t('collections.search')} /> +
+ + {showSelect && ( + + )} +
+
+ {canAddPlace && ( + + )} +
+ + onSearch(e.target.value)} placeholder={t('collections.search')} /> +
)} diff --git a/client/src/components/Collections/CollectionPlaceDetail.tsx b/client/src/components/Collections/CollectionPlaceDetail.tsx new file mode 100644 index 000000000..450826457 --- /dev/null +++ b/client/src/components/Collections/CollectionPlaceDetail.tsx @@ -0,0 +1,156 @@ +import React, { useEffect, useRef, useState } from 'react' +import Markdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import remarkBreaks from 'remark-breaks' +import { X, Pencil, Copy, Trash2, MapPin, Link2, Plus, ExternalLink, Check } from 'lucide-react' +import type { CollectionPlace, CollectionStatus, CollectionLink } from '@trek/shared' +import type { TranslationFn } from '../../types' +import MarkdownToolbar from '../Journey/MarkdownToolbar' +import StatusBadge from './StatusBadge' +import { entityGradient } from '../../utils/gradients' + +function linkHost(url: string): string { + try { return new URL(url).hostname.replace(/^www\./, '') } catch { return url } +} + +interface CollectionPlaceDetailProps { + place: CollectionPlace + canEdit: boolean + onClose: () => void + onSetStatus: (status: CollectionStatus) => void + onSave: (patch: { name?: string; description?: string | null; links?: CollectionLink[] }) => Promise + onCopyToTrip: () => void + onRemove: () => void + t: TranslationFn +} + +/** + * Bottom detail sheet for a saved place — a glassy card docked to the bottom + * (full-width on mobile). Read mode renders the description as markdown + link + * chips; edit mode swaps in a name field, a markdown editor (toolbar + textarea) + * and a links editor, saving via collectionsApi.updatePlace. Also cycles the + * status, copies the place to a trip and removes it from the list. + */ +export default function CollectionPlaceDetail({ + place, canEdit, onClose, onSetStatus, onSave, onCopyToTrip, onRemove, t, +}: CollectionPlaceDetailProps): React.ReactElement { + const [editing, setEditing] = useState(false) + const [name, setName] = useState(place.name) + const [description, setDescription] = useState(place.description ?? '') + const [links, setLinks] = useState(place.links ?? []) + const [saving, setSaving] = useState(false) + const descRef = useRef(null) + + // Reset the form only when a DIFFERENT place is opened (keyed on id, not on + // every field change — otherwise a save would clobber in-flight edits). + useEffect(() => { + setEditing(false) + setName(place.name) + setDescription(place.description ?? '') + setLinks(place.links ?? []) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [place.id]) + + const sub = place.category?.name || place.address + const banner = place.image_url + + const setLink = (i: number, patch: Partial) => + setLinks(links.map((l, idx) => (idx === i ? { ...l, ...patch } : l))) + + const save = async () => { + const cleanLinks = links.map(l => ({ label: l.label?.trim() || undefined, url: l.url.trim() })).filter(l => l.url) + setSaving(true) + try { + await onSave({ name: name.trim() || place.name, description: description.trim() || null, links: cleanLinks }) + setEditing(false) + } finally { + setSaving(false) + } + } + + return ( +
e.stopPropagation()}> +
+ {banner && } +
+ +
+ {editing ? ( + setName(e.target.value)} className="col-detail-name-input" autoFocus /> + ) : ( +

{place.name}

+ )} + {sub &&
{!place.category?.name && }{sub}
} +
+
+ +
+
+ +
+ {canEdit && !editing && ( + + )} + + {canEdit && ( + + )} +
+ + {editing ? ( +
+ + +