fix(collections): detail-sheet, edit-refresh, map + rail polish (#1081)

- Editing a place (status, description, title, …) no longer reloads the view or
  closes the detail: the WS echo now refreshes via loadCollection, which keeps
  the current selection + select-mode instead of setActive resetting them.
- Place detail: docks over the list column on the desktop split (measured rect)
  instead of centred over the map, and the card is now opaque (was too see-through).
- Map: click a marker in full-map view to drop back to the split; picking a place
  scrolls its list row into view; the select toggle is disabled in full-map view;
  the floating controls are one non-overlapping top bar and less transparent.
- Hero: drop the New-list button (it's already in the rail).
- Rail: the kebab is always visible (easy to hit); menu is Edit + Delete only
  (colour moved into the editor); "Rename" → "Edit".
- Add-place: pick a result, then set description (markdown) / links / status
  before saving, all in one step.
- Share modal: member roster as cards with clearer role badges + a count.
This commit is contained in:
Maurice
2026-07-01 12:00:37 +02:00
committed by Maurice
parent de4077d461
commit 0910c90e7f
11 changed files with 267 additions and 168 deletions
@@ -1,12 +1,18 @@
import React, { useEffect, useState } from 'react'
import { Search, MapPin, Plus, Loader2 } from 'lucide-react'
import React, { useEffect, useRef, useState } from 'react'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import { Search, MapPin, Plus, Loader2, ArrowLeft, Link2, Trash2, Check } from 'lucide-react'
import Modal from '../shared/Modal'
import MarkdownToolbar from '../Journey/MarkdownToolbar'
import { mapsApi } from '../../api/client'
import { collectionsApi } from '../../api/collections'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import { getApiErrorMessage } from '../../types'
import { normalizeLinkUrl, STATUS_META, STATUS_ORDER } from '../../pages/collections/collectionsModel'
import type { TranslationFn } from '../../types'
import type { CollectionLink, CollectionStatus } from '@trek/shared'
type MapsPlace = Record<string, unknown>
const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined)
@@ -22,9 +28,10 @@ interface AddPlaceToCollectionModalProps {
}
/**
* 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.
* Add a place to the current list. Step 1: search (OSM / Google via the maps
* service). Step 2: an optional detail form (description in markdown, links,
* status) prefilled from the pick, then save. Stays open after each add so
* several places can be added in a row.
*/
export default function AddPlaceToCollectionModal({ isOpen, collectionId, collectionName, onClose, onAdded, t }: AddPlaceToCollectionModalProps): React.ReactElement {
const { language } = useTranslation()
@@ -32,10 +39,16 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec
const [query, setQuery] = useState('')
const [results, setResults] = useState<MapsPlace[]>([])
const [searching, setSearching] = useState(false)
const [addingIdx, setAddingIdx] = useState<number | null>(null)
// Step 2 draft — the picked place plus the editable detail fields.
const [draft, setDraft] = useState<MapsPlace | null>(null)
const [description, setDescription] = useState('')
const [links, setLinks] = useState<CollectionLink[]>([])
const [status, setStatus] = useState<CollectionStatus>('idea')
const [saving, setSaving] = useState(false)
const descRef = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
if (!isOpen) { setQuery(''); setResults([]); setAddingIdx(null) }
if (!isOpen) { setQuery(''); setResults([]); setDraft(null) }
}, [isOpen])
const search = async () => {
@@ -51,71 +64,138 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec
}
}
const add = async (r: MapsPlace, idx: number) => {
const name = str(r.name)
const pick = (r: MapsPlace) => { setDraft(r); setDescription(''); setLinks([]); setStatus('idea') }
const setLink = (i: number, patch: Partial<CollectionLink>) => setLinks(links.map((l, idx) => (idx === i ? { ...l, ...patch } : l)))
const save = async () => {
if (!draft) return
const name = str(draft.name)
if (!name) return
setAddingIdx(idx)
const cleanLinks = links.map(l => ({ label: l.label?.trim() || undefined, url: normalizeLinkUrl(l.url) })).filter(l => l.url)
setSaving(true)
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,
address: str(draft.address) ?? null,
lat: num(draft.lat) ?? null,
lng: num(draft.lng) ?? null,
google_place_id: str(draft.google_place_id) ?? null,
google_ftid: str(draft.google_ftid) ?? null,
osm_id: str(draft.osm_id) ?? null,
website: str(draft.website) ?? null,
phone: str(draft.phone) ?? null,
description: description.trim() || null,
links: cleanLinks,
status,
force: true,
})
if (res.duplicate) toast.info(t('collections.duplicateWarning'))
else { toast.success(t('collections.addedToList', { name: collectionName })); onAdded() }
setDraft(null)
} catch (err) {
toast.error(getApiErrorMessage(err, t('common.error')))
} finally {
setAddingIdx(null)
setSaving(false)
}
}
return (
<Modal isOpen={isOpen} onClose={onClose} title={t('collections.addPlace')} size="md">
<div className="flex flex-col gap-3">
<div className="flex gap-2">
<div className="relative flex-1">
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-content-faint" />
<input
autoFocus
value={query}
onChange={e => 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"
/>
</div>
<button type="button" onClick={search} disabled={!query.trim() || searching} className="px-4 py-2 rounded-lg bg-accent text-accent-text text-[13px] font-semibold disabled:opacity-50 inline-flex items-center gap-2">
{searching ? <Loader2 size={15} className="animate-spin" /> : <Search size={15} />}
{t('common.search')}
</button>
</div>
const title = draft ? (str(draft.name) ?? t('collections.addPlace')) : t('collections.addPlace')
<div className="flex flex-col gap-1.5 max-h-[380px] overflow-y-auto">
{results.length === 0 && !searching && (
<p className="text-[13px] text-content-faint py-6 text-center">{t('collections.addPlaceHint')}</p>
return (
<Modal isOpen={isOpen} onClose={onClose} title={title} size="md">
{!draft ? (
<div className="flex flex-col gap-3">
<div className="flex gap-2">
<div className="relative flex-1">
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-content-faint" />
<input
autoFocus
value={query}
onChange={e => 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"
/>
</div>
<button type="button" onClick={search} disabled={!query.trim() || searching} className="px-4 py-2 rounded-lg bg-accent text-accent-text text-[13px] font-semibold disabled:opacity-50 inline-flex items-center gap-2">
{searching ? <Loader2 size={15} className="animate-spin" /> : <Search size={15} />}
{t('common.search')}
</button>
</div>
<div className="flex flex-col gap-1.5 max-h-[380px] overflow-y-auto">
{results.length === 0 && !searching && (
<p className="text-[13px] text-content-faint py-6 text-center">{t('collections.addPlaceHint')}</p>
)}
{results.map((r, i) => (
<button key={i} type="button" onClick={() => pick(r)} className="flex items-center gap-3 px-3 py-2.5 rounded-lg border border-edge bg-surface-card text-left hover:border-accent transition-colors">
<div className="w-9 h-9 rounded-lg bg-surface-secondary flex items-center justify-center text-content-faint shrink-0"><MapPin size={16} /></div>
<div className="flex flex-col min-w-0 flex-1">
<span className="text-[13.5px] font-semibold text-content truncate">{str(r.name)}</span>
{str(r.address) && <span className="text-[12px] text-content-faint truncate">{str(r.address)}</span>}
</div>
<ArrowLeft size={15} className="text-content-faint rotate-180 shrink-0" />
</button>
))}
</div>
</div>
) : (
<div className="flex flex-col gap-4">
<button type="button" onClick={() => setDraft(null)} className="inline-flex items-center gap-1.5 self-start text-[12.5px] font-medium text-content-secondary hover:text-content">
<ArrowLeft size={14} /> {t('common.search')}
</button>
{str(draft.address) && (
<div className="flex items-center gap-1.5 text-[12.5px] text-content-faint"><MapPin size={13} /> {str(draft.address)}</div>
)}
{results.map((r, i) => (
<div key={i} className="flex items-center gap-3 px-3 py-2.5 rounded-lg border border-edge bg-surface-card">
<div className="w-9 h-9 rounded-lg bg-surface-secondary flex items-center justify-center text-content-faint shrink-0"><MapPin size={16} /></div>
<div className="flex flex-col min-w-0 flex-1">
<span className="text-[13.5px] font-semibold text-content truncate">{str(r.name)}</span>
{str(r.address) && <span className="text-[12px] text-content-faint truncate">{str(r.address)}</span>}
</div>
<button type="button" onClick={() => add(r, i)} disabled={addingIdx === i} className="px-2.5 py-1.5 rounded-lg bg-inverse text-inverse-text text-[12px] font-semibold inline-flex items-center gap-1.5 disabled:opacity-50 shrink-0">
{addingIdx === i ? <Loader2 size={13} className="animate-spin" /> : <Plus size={13} />} {t('common.add')}
{/* Status */}
<div className="flex gap-1.5">
{STATUS_ORDER.map(s => {
const Icon = STATUS_META[s].icon
const on = status === s
return (
<button key={s} type="button" onClick={() => setStatus(s)} className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[12px] font-semibold border transition-colors ${on ? 'bg-inverse text-inverse-text border-transparent' : 'bg-surface-card text-content-secondary border-edge hover:bg-surface-hover'}`}>
<Icon size={13} style={{ color: on ? undefined : STATUS_META[s].color }} /> {t(STATUS_META[s].labelKey)}
</button>
)
})}
</div>
{/* Description */}
<div>
<label className="block text-[12px] font-medium text-content-secondary mb-1.5">{t('collections.description')}</label>
<MarkdownToolbar textareaRef={descRef} onUpdate={setDescription} />
<textarea ref={descRef} value={description} onChange={e => setDescription(e.target.value)} rows={3} placeholder={t('collections.descriptionPlaceholder')} className="w-full px-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[13px] outline-none focus:border-accent resize-y" />
{description.trim() && (
<div className="collab-note-md mt-2 text-[13px] text-content-secondary"><Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{description}</Markdown></div>
)}
</div>
{/* Links */}
<div>
<label className="block text-[12px] font-medium text-content-secondary mb-1.5">{t('collections.links')}</label>
<div className="flex flex-col gap-2">
{links.map((l, i) => (
<div key={i} className="flex items-center gap-2">
<input value={l.label ?? ''} onChange={e => setLink(i, { label: e.target.value })} placeholder={t('collections.linkLabel')} className="w-28 shrink-0 px-2.5 py-1.5 rounded-lg border border-edge bg-surface-input text-content text-[12.5px] outline-none focus:border-accent" />
<input value={l.url} onChange={e => setLink(i, { url: e.target.value })} placeholder="https://…" className="flex-1 min-w-0 px-2.5 py-1.5 rounded-lg border border-edge bg-surface-input text-content text-[12.5px] outline-none focus:border-accent" />
<button type="button" onClick={() => setLinks(links.filter((_, idx) => idx !== i))} className="p-1.5 rounded-md text-content-faint hover:text-danger hover:bg-danger-soft" aria-label={t('common.delete')}><Trash2 size={14} /></button>
</div>
))}
<button type="button" onClick={() => setLinks([...links, { url: '' }])} className="inline-flex items-center gap-1.5 self-start px-2.5 py-1.5 rounded-lg border border-dashed border-edge text-content-secondary text-[12.5px] font-medium hover:bg-surface-hover">
<Plus size={14} /> <Link2 size={13} /> {t('collections.addLink')}
</button>
</div>
))}
</div>
<div className="flex justify-end gap-2 pt-1">
<button type="button" onClick={() => setDraft(null)} className="px-3 py-1.5 rounded-lg border border-edge text-content-secondary text-[13px] hover:bg-surface-hover">{t('common.cancel')}</button>
<button type="button" onClick={save} disabled={saving} className="px-3 py-1.5 rounded-lg bg-accent text-accent-text text-[13px] font-semibold disabled:opacity-50 inline-flex items-center gap-1.5">
{saving ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />} {t('common.add')}
</button>
</div>
</div>
</div>
)}
</Modal>
)
}
@@ -1,5 +1,5 @@
import React from 'react'
import { Share2, Users, Plus, Link2 } from 'lucide-react'
import { Share2, Users, Link2 } from 'lucide-react'
import type { CollectionMember, CollectionLink } from '@trek/shared'
import type { TranslationFn } from '../../types'
import type { StatusFilter } from '../../store/collectionStore'
@@ -28,7 +28,6 @@ interface CollectionHeroProps {
isOwner: boolean
shareMemberCount: number
onShare: () => void
onNewList: () => void
t: TranslationFn
}
@@ -45,7 +44,7 @@ function linkHost(url: string): string {
export default function CollectionHero({
eyebrow, title, color, coverImage, description, links, counts, statusFilter, onStatusFilter,
members, canShare, isOwner, shareMemberCount, onShare, onNewList, t,
members, canShare, isOwner, shareMemberCount, onShare, t,
}: CollectionHeroProps): React.ReactElement {
const accepted = members.filter(m => m.status === 'accepted' || m.is_owner)
const showAvatars = accepted.length > 1
@@ -83,9 +82,6 @@ export default function CollectionHero({
{isOwner && shareMemberCount > 0 && <span className="cnt">{shareMemberCount}</span>}
</button>
)}
<button type="button" onClick={onNewList} aria-label={t('collections.newList')} title={t('collections.newList')} className="col-glass-btn">
<Plus size={15} /> <span className="txt">{t('collections.newList')}</span>
</button>
</div>
<div className="col-hero-content">
@@ -1,4 +1,4 @@
import React from 'react'
import React, { useEffect, useRef } from 'react'
import { Check, MapPin } from 'lucide-react'
import type { CollectionPlace, CollectionStatus } from '@trek/shared'
import type { TranslationFn } from '../../types'
@@ -24,6 +24,12 @@ interface CollectionListProps {
export default function CollectionList({
places, selectedPlaceId, selectMode, selectedIds, onOpenPlace, onStatusChange, onToggleSelect, t,
}: CollectionListProps): React.ReactElement {
// Bring the selected row into view — e.g. when it was picked from the map.
const selectedRef = useRef<HTMLDivElement>(null)
useEffect(() => {
selectedRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}, [selectedPlaceId])
return (
<div className="col-listview">
{places.map(place => {
@@ -32,6 +38,7 @@ export default function CollectionList({
return (
<div
key={place.id}
ref={active ? selectedRef : undefined}
role="button"
tabIndex={0}
onClick={() => (selectMode ? onToggleSelect(place.id) : onOpenPlace(place.id))}
@@ -61,6 +61,7 @@ export default function CollectionMapPanel({
<button
type="button"
onClick={onToggleSelect}
disabled={view === 'map'}
className={`col-map-btn${selectMode ? ' on' : ''}`}
aria-label={t('collections.selectMode')}
title={t('collections.selectMode')}
@@ -19,6 +19,8 @@ function linkHost(url: string): string {
interface CollectionPlaceDetailProps {
place: CollectionPlace
canEdit: boolean
/** 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[] }) => Promise<void>
@@ -35,7 +37,7 @@ interface CollectionPlaceDetailProps {
* 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,
place, canEdit, anchorRect, onClose, onSetStatus, onSave, onCopyToTrip, onRemove, t,
}: CollectionPlaceDetailProps): React.ReactElement {
const toast = useToast()
const [editing, setEditing] = useState(false)
@@ -74,8 +76,10 @@ export default function CollectionPlaceDetail({
}
}
const dockStyle = anchorRect ? { left: anchorRect.left, width: anchorRect.width, transform: 'none' as const } : undefined
return (
<div className="col-detail" onClick={e => e.stopPropagation()}>
<div className={`col-detail${anchorRect ? ' docked' : ''}`} style={dockStyle} 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" />
+25 -56
View File
@@ -1,13 +1,11 @@
import React, { useEffect, useRef, useState } from 'react'
import ReactDOM from 'react-dom'
import { Plus, Layers, MoreHorizontal, Pencil, Trash2, Users, Check, Palette } from 'lucide-react'
import { Plus, Layers, MoreHorizontal, Pencil, Trash2, Users } from 'lucide-react'
import type { Collection } from '@trek/shared'
import type { TranslationFn } from '../../types'
import { ALL_SAVED } from '../../store/collectionStore'
import type { ActiveCollectionId, IncomingCollectionInvite } from '../../store/collectionStore'
const SWATCHES = ['#6366f1', '#ec4899', '#14b8a6', '#f97316', '#8b5cf6', '#ef4444', '#3b82f6', '#22c55e']
interface ListsRailProps {
ownedLists: Collection[]
sharedLists: Collection[]
@@ -16,7 +14,6 @@ interface ListsRailProps {
onSelect: (id: ActiveCollectionId) => void
onNewList: () => void
onEdit: (list: Collection) => void
onSetColor: (id: number, color: string) => void
onRequestDelete: (id: number) => void
onAcceptInvite: (id: number) => void
onDeclineInvite: (id: number) => void
@@ -28,34 +25,30 @@ interface ListRowProps {
active: boolean
onSelect: (id: number) => void
onEdit: (list: Collection) => void
onSetColor: (id: number, color: string) => void
onRequestDelete: (id: number) => void
t: TranslationFn
}
function ListRow({ list, active, onSelect, onEdit, onSetColor, onRequestDelete, t }: ListRowProps): React.ReactElement {
const [menuOpen, setMenuOpen] = useState(false)
const [colorOpen, setColorOpen] = useState(false)
function ListRow({ list, active, onSelect, onEdit, onRequestDelete, t }: ListRowProps): React.ReactElement {
const [open, setOpen] = useState(false)
const [anchor, setAnchor] = useState<{ top: number; right: number } | null>(null)
const rowRef = useRef<HTMLDivElement>(null)
const btnRef = useRef<HTMLButtonElement>(null)
const open = menuOpen || colorOpen
// Owner or accepted member may rename/recolour any accessible list; only the
// owner may delete it.
// Owner or accepted member may edit any accessible list; only the owner may
// delete it.
const canDelete = list.is_owner !== false
const openMenu = (e: React.MouseEvent) => {
const toggle = (e: React.MouseEvent) => {
e.stopPropagation()
if (open) { setMenuOpen(false); setColorOpen(false); return }
if (open) { setOpen(false); return }
const r = btnRef.current?.getBoundingClientRect()
if (r) setAnchor({ top: r.bottom + 6, right: Math.max(8, window.innerWidth - r.right) })
setColorOpen(false)
setMenuOpen(true)
setOpen(true)
}
useEffect(() => {
if (!open) return
const close = () => { setMenuOpen(false); setColorOpen(false) }
const close = () => setOpen(false)
const onDown = (e: MouseEvent) => {
const el = e.target as HTMLElement
if (rowRef.current?.contains(el) || el.closest?.('[data-list-pop]')) return
@@ -77,39 +70,16 @@ function ListRow({ list, active, onSelect, onEdit, onSetColor, onRequestDelete,
const popover = open && anchor && ReactDOM.createPortal(
<div
data-list-pop
className="fixed z-[300] min-w-[176px] p-1.5 rounded-xl bg-surface-card border border-edge shadow-dropdown"
className="fixed z-[300] min-w-[172px] p-1.5 rounded-xl bg-surface-card border border-edge shadow-dropdown"
style={{ top: anchor.top, right: anchor.right }}
>
{menuOpen && !colorOpen && (
<>
<button type="button" onClick={() => { setMenuOpen(false); onEdit(list) }} className="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-[13px] text-content-secondary hover:bg-surface-hover">
<Pencil size={14} /> {t('collections.editList')}
</button>
<button type="button" onClick={() => setColorOpen(true)} className="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-[13px] text-content-secondary hover:bg-surface-hover">
<Palette size={14} /> {t('collections.listColor')}
</button>
{canDelete && (
<button type="button" onClick={() => { setMenuOpen(false); onRequestDelete(list.id) }} className="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-[13px] text-danger hover:bg-danger-soft">
<Trash2 size={14} /> {t('collections.deleteList')}
</button>
)}
</>
)}
{colorOpen && (
<div className="grid grid-cols-4 gap-1.5 p-1">
{SWATCHES.map(col => (
<button
key={col}
type="button"
onClick={() => { onSetColor(list.id, col); setColorOpen(false); setMenuOpen(false) }}
className="w-7 h-7 rounded-full flex items-center justify-center text-white"
style={{ background: col }}
aria-label={col}
>
{list.color === col && <Check size={13} strokeWidth={3} />}
</button>
))}
</div>
<button type="button" onClick={() => { setOpen(false); onEdit(list) }} className="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-[13px] text-content-secondary hover:bg-surface-hover">
<Pencil size={14} /> {t('common.edit')}
</button>
{canDelete && (
<button type="button" onClick={() => { setOpen(false); onRequestDelete(list.id) }} className="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-[13px] text-danger hover:bg-danger-soft">
<Trash2 size={14} /> {t('collections.deleteList')}
</button>
)}
</div>,
document.body,
@@ -125,13 +95,13 @@ function ListRow({ list, active, onSelect, onEdit, onSetColor, onRequestDelete,
<button
ref={btnRef}
type="button"
onClick={openMenu}
onClick={toggle}
className="col-row-menu"
aria-label={t('collections.listMenu')}
aria-haspopup="menu"
aria-expanded={open}
>
<MoreHorizontal size={15} />
<MoreHorizontal size={16} />
</button>
{popover}
</div>
@@ -140,15 +110,14 @@ function ListRow({ list, active, onSelect, onEdit, onSetColor, onRequestDelete,
/**
* Left rail of the user's lists: a "New list" action, the "All saved" union
* pseudo-list, owned lists (colour dot + count + edit/colour/delete menu), a
* shared section, and an incoming-invites block. Styled with the collections
* glass tokens (`.col-*`); rendered both in the desktop rail and the mobile
* drawer. Editing a list opens the shared ListEditorModal via onEdit.
* pseudo-list, owned lists (colour dot + count + an always-visible kebab →
* edit/delete), a shared section, and an incoming-invites block. Editing opens
* the shared ListEditorModal (name/colour/cover/description/links) via onEdit.
*/
export default function ListsRail(props: ListsRailProps): React.ReactElement {
const {
ownedLists, sharedLists, activeId, incomingInvites,
onSelect, onNewList, onEdit, onSetColor, onRequestDelete,
onSelect, onNewList, onEdit, onRequestDelete,
onAcceptInvite, onDeclineInvite, t,
} = props
@@ -167,14 +136,14 @@ export default function ListsRail(props: ListsRailProps): React.ReactElement {
{ownedLists.length > 0 && <div className="col-rail-sep" />}
{ownedLists.map(list => (
<ListRow key={list.id} list={list} active={activeId === list.id} onSelect={onSelect} onEdit={onEdit} onSetColor={onSetColor} onRequestDelete={onRequestDelete} t={t} />
<ListRow key={list.id} list={list} active={activeId === list.id} onSelect={onSelect} onEdit={onEdit} onRequestDelete={onRequestDelete} t={t} />
))}
{sharedLists.length > 0 && (
<>
<div className="col-rail-label"><Users size={12} /> {t('collections.shared')}</div>
{sharedLists.map(list => (
<ListRow key={list.id} list={list} active={activeId === list.id} onSelect={onSelect} onEdit={onEdit} onSetColor={onSetColor} onRequestDelete={onRequestDelete} t={t} />
<ListRow key={list.id} list={list} active={activeId === list.id} onSelect={onSelect} onEdit={onEdit} onRequestDelete={onRequestDelete} t={t} />
))}
</>
)}
@@ -154,39 +154,40 @@ export default function ShareCollectionModal({
<div className="flex flex-col gap-5">
{/* Member roster */}
<div>
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-content-faint mb-2">
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-content-faint mb-2.5 flex items-center gap-2">
{t('collections.share.members')}
<span className="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-surface-secondary text-content-secondary text-[10px] font-bold tabular-nums">{sortedMembers.length}</span>
</h3>
<div className="flex flex-col gap-1">
<div className="flex flex-col gap-1.5">
{sortedMembers.map(member => {
const isSelf = member.user_id === currentUserId
const pending = member.status === 'pending'
return (
<div
key={member.user_id}
className={`flex items-center gap-2.5 px-2 py-1.5 rounded-lg ${pending ? 'opacity-80' : ''}`}
className={`flex items-center gap-3 px-3 py-2.5 rounded-xl border border-edge bg-surface-secondary/50 ${pending ? 'opacity-90' : ''}`}
>
<MemberAvatar member={member} />
<div className="flex-1 min-w-0">
<p className="text-[13px] font-medium text-content truncate flex items-center gap-1.5">
<p className="text-[13.5px] font-semibold text-content truncate flex items-center gap-1.5">
{member.username}
{isSelf && <span className="text-content-faint font-normal">({t('collections.share.you')})</span>}
{isSelf && <span className="text-content-faint font-normal text-[12px]">({t('collections.share.you')})</span>}
</p>
{member.email && !pending && (
<p className="text-[11px] text-content-faint truncate">{member.email}</p>
<p className="text-[11.5px] text-content-faint truncate">{member.email}</p>
)}
</div>
{member.is_owner ? (
<span className="inline-flex items-center gap-1 text-[11px] font-medium text-content-faint shrink-0">
<Crown size={12} /> {t('collections.share.owner')}
<span className="inline-flex items-center gap-1 text-[10.5px] font-semibold uppercase tracking-wide px-2 py-1 rounded-full bg-amber-500/12 text-amber-600 dark:text-amber-400 shrink-0">
<Crown size={11} /> {t('collections.share.owner')}
</span>
) : pending ? (
<span className="inline-flex items-center gap-1 text-[11px] font-medium text-amber-500 shrink-0">
<Clock size={12} /> {t('collections.share.pending')}
<span className="inline-flex items-center gap-1 text-[10.5px] font-semibold uppercase tracking-wide px-2 py-1 rounded-full bg-amber-500/12 text-amber-600 dark:text-amber-400 shrink-0">
<Clock size={11} /> {t('collections.share.pending')}
</span>
) : (
<span className="inline-flex items-center gap-1 text-[11px] font-medium text-emerald-500 shrink-0">
<Check size={12} /> {t('collections.share.accepted')}
<span className="inline-flex items-center gap-1 text-[10.5px] font-semibold uppercase tracking-wide px-2 py-1 rounded-full bg-emerald-500/12 text-emerald-600 dark:text-emerald-400 shrink-0">
<Check size={11} /> {t('collections.share.accepted')}
</span>
)}
{isOwner && pending && (
+40
View File
@@ -0,0 +1,40 @@
import { useCallback, useEffect, useRef, useState } from 'react'
/**
* Observe an element's viewport rect (left + width) via a callback ref. Used to
* dock a fixed-position panel over another column. Re-measures on the element's
* own resize and on window resize (layout shifts); vertical scroll doesn't move
* a viewport-fixed left/width so it isn't tracked.
*/
export function useElementRect<T extends HTMLElement = HTMLElement>(): {
ref: (el: T | null) => void
rect: { left: number; width: number } | null
} {
const [rect, setRect] = useState<{ left: number; width: number } | null>(null)
const elRef = useRef<T | null>(null)
const obs = useRef<ResizeObserver | null>(null)
const measure = useCallback(() => {
const el = elRef.current
if (!el) return
const r = el.getBoundingClientRect()
setRect(prev => (prev && prev.left === r.left && prev.width === r.width ? prev : { left: r.left, width: r.width }))
}, [])
const ref = useCallback((el: T | null) => {
obs.current?.disconnect()
obs.current = null
elRef.current = el
if (!el) { setRect(null); return }
measure()
obs.current = new ResizeObserver(measure)
obs.current.observe(el)
}, [measure])
useEffect(() => {
window.addEventListener('resize', measure)
return () => window.removeEventListener('resize', measure)
}, [measure])
return { ref, rect }
}
+9 -6
View File
@@ -49,6 +49,9 @@ export default function CollectionsPage(): React.ReactElement {
const openPlace = (id: number) => c.setSelectedPlaceId(c.selectedPlaceId === id ? null : id)
const deselect = () => c.setSelectedPlaceId(null)
const toggleView = () => c.setView(c.view === 'map' ? 'list' : 'map')
// Clicking a marker in the full-map view drops back to the split so the list
// + detail come into view alongside the map.
const onMapSelect = (id: number) => { openPlace(id); if (c.view === 'map') c.setView('list') }
const desktopSplit = c.isWide && mappable.length > 0
const mapShown = mappable.length > 0 && (c.view === 'map' || c.isWide)
@@ -71,7 +74,7 @@ export default function CollectionsPage(): React.ReactElement {
<CollectionMapPanel
places={mappable}
selectedPlaceId={c.selectedPlaceId}
onSelect={openPlace}
onSelect={onMapSelect}
onDeselect={deselect}
dark={c.dark}
overlay={overlay}
@@ -98,7 +101,7 @@ export default function CollectionsPage(): React.ReactElement {
} else if (desktopSplit) {
body = (
<div className={`col-split${c.view === 'map' ? ' map-full' : ''}`}>
<div className="col-split-list">{listEl}</div>
<div className="col-split-list" ref={c.listColRef}>{listEl}</div>
<div className="col-split-map">{mapPanel(true)}</div>
</div>
)
@@ -117,7 +120,6 @@ export default function CollectionsPage(): React.ReactElement {
onSelect={c.handleSelectList}
onNewList={() => { c.setMobileRailOpen(false); c.setEditorTarget('new') }}
onEdit={list => { c.setMobileRailOpen(false); c.setEditorTarget(list) }}
onSetColor={c.handleSetColor}
onRequestDelete={c.setConfirmDeleteList}
onAcceptInvite={c.handleAcceptInvite}
onDeclineInvite={c.handleDeclineInvite}
@@ -162,7 +164,6 @@ export default function CollectionsPage(): React.ReactElement {
isOwner={c.isOwner}
shareMemberCount={c.shareMemberCount}
onShare={() => c.setShowShare(true)}
onNewList={() => c.setEditorTarget('new')}
t={t}
/>
</div>
@@ -243,12 +244,14 @@ export default function CollectionsPage(): React.ReactElement {
)}
{/* Place detail — a bottom sheet (no backdrop, so the map behind stays
visible + interactive). Clicking another place / the map background
re-points or clears the selection. */}
visible + interactive). On the desktop split it docks over the list
column so the map stays clear; otherwise it's a full-width sheet.
Clicking another place / the map background re-points the selection. */}
{c.selectedPlace && (
<CollectionPlaceDetail
place={c.selectedPlace}
canEdit
anchorRect={desktopSplit && c.view === 'list' ? c.listColRect : null}
onClose={c.handleCloseDetail}
onSetStatus={c.handleDetailStatus}
onSave={patch => c.updatePlace(c.selectedPlace!.id, patch)}
+12 -13
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useTranslation } from '../../i18n'
import { useElementSize } from '../../hooks/useElementSize'
import { useElementRect } from '../../hooks/useElementRect'
import { useSettingsStore } from '../../store/settingsStore'
import { useToast } from '../../components/shared/Toast'
import { getApiErrorMessage } from '../../types'
@@ -40,14 +41,16 @@ export function useCollections() {
// Measure the hero so the list rail can be kept at least as tall as it.
const hero = useElementSize<HTMLDivElement>()
// Measure the split's list column so the place-detail sheet can dock over it.
const listCol = useElementRect<HTMLDivElement>()
const store = useCollectionStore()
const {
collections, activeId, places, members, incomingInvites,
view, statusFilter, search, selectedPlaceId, selectMode, selectedIds,
loading, placesLoading,
loadAll, setActive, refreshActive,
updateCollection, deleteCollection,
loadAll, setActive, refreshActive, loadCollection,
deleteCollection,
setStatus, updatePlace, deletePlace, deleteMany, copyToTrip, clearSelection,
acceptInvite, declineInvite,
setView, setStatusFilter, setSearch, setSelectedPlaceId, setSelectMode, toggleSelect,
@@ -109,9 +112,12 @@ export function useCollections() {
if (msg.type === 'collections:invite') {
toast.info(t('collections.invites.received'))
}
// invite / accepted / declined / cancelled / left / updated → reload.
// invite / accepted / declined / cancelled / left / updated → refresh the
// rail, and refresh the active list WITHOUT clearing the current selection or
// select-mode (setActive would reset selectedPlaceId → close the open detail).
loadAll()
if (typeof activeId === 'number') setActive(activeId)
if (typeof activeId === 'number') loadCollection(activeId)
else if (activeId === ALL_SAVED) setActive(ALL_SAVED)
}, [activeId])
useEffect(() => {
@@ -158,14 +164,6 @@ export function useCollections() {
const handlePlaceAdded = useCallback(() => { refreshActive() }, [refreshActive])
const handleSetColor = useCallback(async (id: number, color: string) => {
try {
await updateCollection(id, { color })
} catch (err) {
toast.error(getApiErrorMessage(err, t('common.error')))
}
}, [updateCollection, toast, t])
const handleDeleteList = useCallback(async () => {
if (confirmDeleteList == null) return
const id = confirmDeleteList
@@ -279,6 +277,7 @@ export function useCollections() {
return {
t, language, dark, navigate,
isWide, heroRef: hero.ref, heroHeight: hero.height,
listColRef: listCol.ref, listColRect: listCol.rect,
// store data
collections, ownedLists, sharedLists, activeCollection, isAllSaved, isOwner,
canShare, shareMemberCount,
@@ -299,7 +298,7 @@ export function useCollections() {
handleDetailStatus, handleDetailRemove,
copyIds, openCopyForSelectedPlace, openCopyForSelection, closeCopy, handleCopyToTrip,
// handlers
handleSelectList, handleSetColor, handleDeleteList,
handleSelectList, handleDeleteList,
handleStatusChange, handleDeletePlace, handleDeleteSelected,
handleAcceptInvite, handleDeclineInvite,
}
+18 -19
View File
@@ -56,19 +56,17 @@
.trek-dash .col-row-btn .ico { color: var(--ink-3); flex-shrink: 0; display: grid; place-items: center; }
.trek-dash .col-row-btn.on .ico { color: var(--ink-2); }
.trek-dash .col-row-btn .nm { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.trek-dash .col-row-btn .ct { font-size: 11.5px; color: var(--ink-3); font-variant-numeric: tabular-nums; flex-shrink: 0; transition: opacity .12s; }
/* On hover / while its menu is open the count fades out (keeping its layout
space) so the kebab button can take the same slot without overlapping it. */
.trek-dash .col-row:hover .col-row-btn .ct,
.trek-dash .col-row.menu-open .col-row-btn .ct { opacity: 0; }
.trek-dash .col-row-btn .ct { font-size: 11.5px; color: var(--ink-3); font-variant-numeric: tabular-nums; flex-shrink: 0; }
/* The kebab lives to the right of the count and is always visible (just dimmed)
so it is easy to hit — no hover-to-reveal timing. Reserve space for it. */
.trek-dash .col-row:has(.col-row-menu) .col-row-btn { padding-right: 34px; }
.trek-dash .col-row-menu {
position: absolute; right: 6px; top: 50%; transform: translateY(-50%);
width: 26px; height: 26px; border-radius: 7px; display: grid; place-items: center;
color: var(--ink-3); opacity: 0; transition: opacity .12s, background .12s;
position: absolute; right: 4px; top: 50%; transform: translateY(-50%);
width: 28px; height: 28px; border-radius: 8px; display: grid; place-items: center;
color: var(--ink-3); opacity: .5; transition: opacity .12s, background .12s, color .12s;
}
.trek-dash .col-row:hover .col-row-menu,
.trek-dash .col-row.menu-open .col-row-menu { opacity: 1; }
.trek-dash .col-row-menu:hover { background: var(--bg-2); color: var(--ink); }
.trek-dash .col-row:hover .col-row-menu, .trek-dash .col-row-menu:hover { opacity: 1; }
.trek-dash .col-row-menu:hover, .trek-dash .col-row.menu-open .col-row-menu { background: var(--bg-2); color: var(--ink); opacity: 1; }
/* rename inline input */
.trek-dash .col-row-edit {
@@ -226,16 +224,17 @@
.trek-dash .col-map-group.right { margin-left: auto; min-width: 0; }
.trek-dash .col-map-btn {
width: 38px; height: 38px; flex-shrink: 0; border-radius: 11px; display: grid; place-items: center; color: var(--ink);
background: var(--glass-bg); border: 1px solid var(--glass-border); box-shadow: var(--glass-shadow);
backdrop-filter: var(--glass-blur); -webkit-backdrop-filter: var(--glass-blur);
background: color-mix(in oklch, var(--surface) 92%, transparent); border: 1px solid var(--glass-border); box-shadow: var(--sh-md);
backdrop-filter: blur(8px) saturate(1.2); -webkit-backdrop-filter: blur(8px) saturate(1.2);
transition: transform .12s, background .15s, color .15s;
}
.trek-dash .col-map-btn:hover { transform: translateY(-1px); background: var(--surface); }
.trek-dash .col-map-btn.on { background: var(--accent); border-color: var(--accent); color: #fff; }
.trek-dash .col-map-btn:disabled { opacity: .45; cursor: default; transform: none; }
.trek-dash .col-map-search {
display: flex; align-items: center; gap: 8px; height: 38px; padding: 0 12px; border-radius: 11px; color: var(--ink-3);
background: var(--glass-bg); border: 1px solid var(--glass-border); box-shadow: var(--glass-shadow);
backdrop-filter: var(--glass-blur); -webkit-backdrop-filter: var(--glass-blur);
background: color-mix(in oklch, var(--surface) 92%, transparent); border: 1px solid var(--glass-border); box-shadow: var(--sh-md);
backdrop-filter: blur(8px) saturate(1.2); -webkit-backdrop-filter: blur(8px) saturate(1.2);
flex: 0 1 auto; min-width: 0;
}
.trek-dash .col-map-search svg { flex-shrink: 0; }
@@ -303,11 +302,11 @@
/* ----------------- place detail bottom sheet ----------------- */
.trek-dash .col-detail {
position: fixed; left: 50%; transform: translateX(-50%); bottom: 20px; z-index: 150;
width: min(760px, calc(100% - 32px)); max-height: 64vh; display: flex; flex-direction: column; overflow: hidden;
border-radius: var(--r-xl); background: var(--glass-bg); border: 1px solid var(--glass-border);
box-shadow: var(--glass-shadow-hover), var(--glass-highlight);
backdrop-filter: var(--glass-blur); -webkit-backdrop-filter: var(--glass-blur);
width: min(720px, calc(100% - 32px)); max-height: 66vh; display: flex; flex-direction: column; overflow: hidden;
border-radius: var(--r-xl); background: var(--surface); border: 1px solid var(--line);
box-shadow: var(--sh-lg);
}
.trek-dash .col-detail.docked { max-height: min(66vh, calc(100vh - var(--nav-h) - 60px)); }
.trek-dash .col-detail-cover { position: relative; height: 116px; flex-shrink: 0; background-size: cover; background-position: center; }
.trek-dash .col-detail-cover img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.trek-dash .col-detail-cover-scrim { position: absolute; inset: 0; background: linear-gradient(180deg, rgba(0,0,0,.12) 0%, rgba(0,0,0,.55) 100%); }