mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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" />
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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%); }
|
||||
|
||||
Reference in New Issue
Block a user