feat(collections): list+map default, map-only toggle, deselect + tooltip fixes (#1081)

- Drop the grid/tile view. The list view is now the default and, on wide
  screens, a list + persistent map split; a top-left control on the map
  collapses the list to a full-width map (and back), animating smoothly (the
  map stays mounted and is nudged to re-layout during the transition). The
  place search moves onto the map (top-right); mobile keeps a list/map toggle.
- Let a place be deselected again: clicking it once more, clicking the map
  background, or picking another all toggle the selection (collections map now
  wires onMapClick).
- Fix the stuck hover tooltip: selecting a place swaps its marker's DOM node so
  the browser never fires mouseout/mouseleave, orphaning the fixed-position
  tooltip (it hung on screen and drifted with scroll). Both map stacks now clear
  the hover on selection change and on scroll.
- Remove CollectionGrid + PlaceCover; add hero eyebrow + map control strings.
This commit is contained in:
Maurice
2026-07-01 10:25:21 +02:00
committed by Maurice
parent 74cace7c8f
commit 25b8d28574
32 changed files with 267 additions and 302 deletions
@@ -1,78 +0,0 @@
import React from 'react'
import { Check, MapPin } from 'lucide-react'
import type { CollectionPlace, CollectionStatus } from '@trek/shared'
import type { TranslationFn } from '../../types'
import PlaceCover from '../shared/PlaceCover'
import StatusBadge from './StatusBadge'
interface CollectionGridProps {
places: CollectionPlace[]
selectedPlaceId: number | null
selectMode: boolean
selectedIds: number[]
onOpenPlace: (id: number) => void
onStatusChange: (placeId: number, status: CollectionStatus) => void
onToggleSelect: (id: number) => void
t: TranslationFn
}
/**
* Grid view — gradient/photo cover cards modelled on the dashboard trip cards.
* The place name sits over the cover with a scrim; the status pill (top-right)
* cycles idea→want→visited on tap; a compact body carries the category. In
* select mode a tap toggles the checkbox instead of opening the place.
*/
export default function CollectionGrid({
places, selectedPlaceId, selectMode, selectedIds, onOpenPlace, onStatusChange, onToggleSelect, t,
}: CollectionGridProps): React.ReactElement {
return (
<div className="col-grid">
{places.map(place => {
const selected = selectedIds.includes(place.id)
const active = selectedPlaceId === place.id
const sub = place.category?.name || place.address
const activate = () => (selectMode ? onToggleSelect(place.id) : onOpenPlace(place.id))
return (
<div
key={place.id}
role="button"
tabIndex={0}
aria-label={place.name}
onClick={activate}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); activate() } }}
className={`col-card${active || selected ? ' sel' : ''}`}
>
<div className="col-cover">
<PlaceCover place={place} />
{selectMode && (
<span className={`col-card-check${selected ? ' on' : ''}`}>
{selected && <Check size={14} strokeWidth={3} />}
</span>
)}
<span className="col-card-status">
<StatusBadge
status={place.status}
showLabel={false}
onCover
onChange={selectMode ? undefined : next => onStatusChange(place.id, next)}
t={t}
/>
</span>
<span className="col-card-name">{place.name}</span>
</div>
{sub && (
<div className="col-card-body">
<span className="col-card-cat">
{place.category?.name
? <span className="cdot" style={{ background: place.category.color || '#6366f1' }} />
: <MapPin size={13} />}
<span>{sub}</span>
</span>
</div>
)}
</div>
)
})}
</div>
)
}
@@ -7,6 +7,8 @@ interface CollectionMapProps {
places: CollectionPlace[]
selectedPlaceId: number | null
onOpenPlace: (id: number) => void
/** Clicking the map background clears the selection. */
onDeselect?: () => void
dark: boolean
}
@@ -16,7 +18,7 @@ interface CollectionMapProps {
* The parent `.col-mapwrap` supplies the rounded, bordered box + height, so this
* just fills it.
*/
export default function CollectionMap({ places, selectedPlaceId, onOpenPlace, dark }: CollectionMapProps): React.ReactElement {
export default function CollectionMap({ places, selectedPlaceId, onOpenPlace, onDeselect, dark }: CollectionMapProps): React.ReactElement {
const pts = mappablePlaces(places)
const center: [number, number] = pts.length > 0
? [pts[0].lat as number, pts[0].lng as number]
@@ -31,6 +33,7 @@ export default function CollectionMap({ places, selectedPlaceId, onOpenPlace, da
places={pts}
selectedPlaceId={selectedPlaceId}
onMarkerClick={onOpenPlace}
onMapClick={onDeselect ? () => onDeselect() : undefined}
center={center}
zoom={pts.length > 0 ? 6 : 3}
tileUrl={tileUrl}
@@ -0,0 +1,59 @@
import React from 'react'
import { PanelLeftClose, PanelLeftOpen, Search } from 'lucide-react'
import type { CollectionPlace } from '@trek/shared'
import type { TranslationFn } from '../../types'
import CollectionMap from './CollectionMap'
interface CollectionMapPanelProps {
places: CollectionPlace[]
selectedPlaceId: number | null
onSelect: (id: number) => void
onDeselect: () => void
dark: boolean
/** Render the floating map controls (desktop). Mobile drives view from the toolbar. */
overlay: boolean
/** 'list' = split (map can be expanded); 'map' = full (list collapsed). */
view: 'list' | 'map'
onToggleView: () => void
search: string
onSearch: (v: string) => void
t: TranslationFn
}
/**
* 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.
*/
export default function CollectionMapPanel({
places, selectedPlaceId, onSelect, onDeselect, dark, overlay, view, onToggleView, search, onSearch, t,
}: CollectionMapPanelProps): React.ReactElement {
return (
<div className="col-map-shell">
<CollectionMap
places={places}
selectedPlaceId={selectedPlaceId}
onOpenPlace={onSelect}
onDeselect={onDeselect}
dark={dark}
/>
{overlay && (
<>
<button
type="button"
onClick={onToggleView}
className="col-map-toggle"
aria-label={view === 'map' ? t('collections.showList') : t('collections.expandMap')}
title={view === 'map' ? t('collections.showList') : t('collections.expandMap')}
>
{view === 'map' ? <PanelLeftOpen size={17} /> : <PanelLeftClose size={17} />}
</button>
<div className="col-map-search">
<Search size={15} />
<input value={search} onChange={e => onSearch(e.target.value)} placeholder={t('collections.search')} />
</div>
</>
)}
</div>
)
}
+14
View File
@@ -470,8 +470,22 @@ export const MapView = memo(function MapView({
const handleMarkerHoverOut = useCallback(() => {
setHoveredPlace(null)
setTooltipPos(null)
}, [])
// A marker's DOM node is replaced when it becomes selected (its icon grows
// 36→44px, and the cluster group re-adds it), so the browser never fires
// mouseout on the old node and the fixed-position hover tooltip gets orphaned
// — it hangs on screen and drifts with page scroll. Drop it on any selection
// change and on any scroll so it can never get stuck.
useEffect(() => { setHoveredPlace(null); setTooltipPos(null) }, [selectedPlaceId])
useEffect(() => {
if (!hoveredPlace) return
const clear = () => { setHoveredPlace(null); setTooltipPos(null) }
window.addEventListener('scroll', clear, true)
return () => window.removeEventListener('scroll', clear, true)
}, [hoveredPlace])
const handleMarkerClick = useCallback((id: number) => {
onMarkerClick?.(id)
}, [onMarkerClick])
+12
View File
@@ -224,6 +224,18 @@ export function MapViewGL({
const [hoverPlace, setHoverPlace] = useState<(Place & { category_color?: string | null; category_icon?: string | null; category_name?: string | null }) | null>(null)
const [hoverPos, setHoverPos] = useState<{ x: number; y: number } | null>(null)
const hoverIdRef = useRef<number | null>(null)
// Selecting a place rebuilds its marker element, so the browser never fires
// mouseleave on the removed node and the fixed-position hover card gets
// orphaned (it stays put and drifts with page scroll). Clear it on selection
// change and on any scroll so it can't get stuck.
useEffect(() => { hoverIdRef.current = null; setHoverPlace(null); setHoverPos(null) }, [selectedPlaceId])
useEffect(() => {
if (!hoverPlace) return
const clear = () => { hoverIdRef.current = null; setHoverPlace(null); setHoverPos(null) }
window.addEventListener('scroll', clear, true)
return () => window.removeEventListener('scroll', clear, true)
}, [hoverPlace])
const containerRef = useRef<HTMLDivElement>(null)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mapRef = useRef<any | null>(null)
@@ -1,89 +0,0 @@
import React, { useEffect, useRef, useState } from 'react'
import { getCached, isLoading, fetchPhoto, onThumbReady } from '../../services/photoService'
import { useAuthStore } from '../../store/authStore'
import { entityGradient } from '../../utils/gradients'
import type { Place } from '../../types'
interface PlaceCoverProps {
place: Pick<Place, 'id' | 'name' | 'image_url' | 'google_place_id' | 'osm_id' | 'lat' | 'lng'>
}
/**
* Full-bleed rectangular cover for a place card — the same photoService-backed
* lazy fetch as PlaceAvatar, but rendered edge-to-edge (object-fit: cover)
* instead of a circle. When there is no photo it falls back to a deterministic
* vibrant gradient (entityGradient), matching the dashboard trip cards. The
* parent supplies the aspect box + rounding via `.col-cover`.
*/
export default React.memo(function PlaceCover({ place }: PlaceCoverProps) {
const [photoSrc, setPhotoSrc] = useState<string | null>(place.image_url || null)
const [visible, setVisible] = useState(false)
const imageUrlFailed = useRef(false)
const ref = useRef<HTMLDivElement>(null)
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
// Fetch the photo only once the card scrolls near the viewport (or if it is
// already cached / carries an image_url), so a long grid stays cheap.
useEffect(() => {
if (place.image_url) { setVisible(true); return }
if (!placesPhotosEnabled) return
const el = ref.current
if (!el) return
const photoId = place.google_place_id || place.osm_id
const cacheKey = photoId || `${place.lat},${place.lng}`
if (cacheKey && getCached(cacheKey)) { setVisible(true); return }
const io = new IntersectionObserver(([e]) => { if (e.isIntersecting) { setVisible(true); io.disconnect() } }, { rootMargin: '250px' })
io.observe(el)
return () => io.disconnect()
}, [place.id])
useEffect(() => {
if (!visible) return
if (place.image_url) { setPhotoSrc(place.image_url); return }
if (!placesPhotosEnabled) return
const photoId = place.google_place_id || place.osm_id
if (!photoId && !(place.lat && place.lng)) { setPhotoSrc(null); return }
const cacheKey = photoId || `${place.lat},${place.lng}`
const cached = getCached(cacheKey)
if (cached) {
setPhotoSrc(cached.thumbDataUrl || cached.photoUrl)
if (!cached.thumbDataUrl && cached.photoUrl) {
return onThumbReady(cacheKey, thumb => setPhotoSrc(thumb))
}
return
}
if (isLoading(cacheKey)) {
return onThumbReady(cacheKey, thumb => setPhotoSrc(thumb))
}
fetchPhoto(cacheKey, photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name,
entry => { setPhotoSrc(entry.thumbDataUrl || entry.photoUrl) })
return onThumbReady(cacheKey, thumb => setPhotoSrc(thumb))
}, [visible, place.id, place.image_url, place.google_place_id, place.osm_id])
if (photoSrc) {
return (
<div ref={ref} className="col-cover-media">
<img
src={photoSrc}
alt={place.name}
decoding="async"
loading="lazy"
onError={() => {
if (!imageUrlFailed.current && photoSrc === place.image_url && (place.google_place_id || place.osm_id)) {
imageUrlFailed.current = true
const photoId = place.google_place_id || place.osm_id!
fetchPhoto(`refetch:${photoId}`, photoId, place.lat ?? undefined, place.lng ?? undefined, place.name,
entry => { setPhotoSrc(entry.thumbDataUrl || entry.photoUrl) })
} else {
setPhotoSrc(null)
}
}}
/>
</div>
)
}
return <div ref={ref} className="col-cover-media col-cover-fallback" style={{ backgroundImage: entityGradient(place.id) }} />
})
+86 -95
View File
@@ -1,47 +1,20 @@
import React from 'react'
import { LayoutGrid, List as ListIcon, Map as MapIcon, Search, Bookmark, CheckSquare, X, Trash2, Copy } from 'lucide-react'
import { List as ListIcon, Map as MapIcon, Search, Bookmark, CheckSquare, X, Trash2, Copy } from 'lucide-react'
import Navbar from '../components/Layout/Navbar'
import Modal from '../components/shared/Modal'
import ListsRail from '../components/Collections/ListsRail'
import CollectionHero from '../components/Collections/CollectionHero'
import CollectionGrid from '../components/Collections/CollectionGrid'
import CollectionList from '../components/Collections/CollectionList'
import CollectionMap from '../components/Collections/CollectionMap'
import CollectionMapPanel from '../components/Collections/CollectionMapPanel'
import CopyToTripModal from '../components/Collections/CopyToTripModal'
import ShareCollectionModal from '../components/Collections/ShareCollectionModal'
import PlaceInspector from '../components/Planner/PlaceInspector'
import type { TranslationFn } from '../types'
import type { CollectionView } from '../store/collectionStore'
import { mappablePlaces } from './collections/collectionsModel'
import { useCollections } from './collections/useCollections'
import '../styles/dashboard.css'
import '../styles/collections.css'
const SWATCHES = ['#6366f1', '#ec4899', '#14b8a6', '#f97316', '#8b5cf6', '#ef4444', '#3b82f6', '#22c55e']
const VIEW_ICONS: Record<CollectionView, typeof LayoutGrid> = { grid: LayoutGrid, list: ListIcon, map: MapIcon }
function ViewSwitch({ view, onChange, t }: { view: CollectionView; onChange: (v: CollectionView) => void; t: TranslationFn }): React.ReactElement {
return (
<div className="col-viewseg" role="group" aria-label={t('collections.title')}>
{(['grid', 'list', 'map'] as CollectionView[]).map(v => {
const Icon = VIEW_ICONS[v]
return (
<button
key={v}
type="button"
aria-pressed={view === v}
onClick={() => onChange(v)}
aria-label={t(`collections.view.${v}`)}
title={t(`collections.view.${v}`)}
className={view === v ? 'on' : ''}
>
<Icon size={16} />
</button>
)
})}
</div>
)
}
function EmptyState({ icon, title, text, action }: { icon: React.ReactNode; title: string; text: string; action?: React.ReactNode }): React.ReactElement {
return (
@@ -68,10 +41,46 @@ export default function CollectionsPage(): React.ReactElement {
const noLists = !c.loading && c.collections.length === 0
const showSelect = !c.isAllSaved && c.activeCollection != null
// On a wide screen the list view splits into list + persistent map; clicking a
// place pans the map instead of opening the (map-covering) inspector.
// Selecting a place toggles it, so clicking it again — or the map background —
// clears it. Below the desktop breakpoint the list and map are separate views;
// above it the list view is a split with a persistent map that pans to the
// selection (the map stays mounted across the list↔map toggle so it animates).
const mappable = mappablePlaces(c.visiblePlaces)
const inSplit = c.isWide && c.view === 'list' && mappable.length > 0
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')
const desktopSplit = c.isWide && mappable.length > 0
const mapShown = mappable.length > 0 && (c.view === 'map' || c.isWide)
const mapOverlay = c.isWide && mapShown // the map carries the toggle + search
const listEl = (
<CollectionList
places={c.visiblePlaces}
selectedPlaceId={c.selectedPlaceId}
selectMode={c.selectMode}
selectedIds={c.selectedIds}
onOpenPlace={openPlace}
onStatusChange={c.handleStatusChange}
onToggleSelect={c.toggleSelect}
t={t}
/>
)
const mapPanel = (overlay: boolean) => (
<CollectionMapPanel
places={mappable}
selectedPlaceId={c.selectedPlaceId}
onSelect={openPlace}
onDeselect={deselect}
dark={c.dark}
overlay={overlay}
view={c.view}
onToggleView={toggleView}
search={c.search}
onSearch={c.setSearch}
t={t}
/>
)
let body: React.ReactElement
if (c.placesLoading && !hasPlaces) {
@@ -80,48 +89,17 @@ export default function CollectionsPage(): React.ReactElement {
body = <EmptyState icon={<Bookmark size={26} />} title={t('collections.empty.title')} text={t('collections.empty.text')} />
} else if (c.visiblePlaces.length === 0) {
body = <EmptyState icon={<Search size={26} />} title={t('collections.empty.noMatchTitle')} text={t('collections.empty.noMatchText')} />
} else if (c.view === 'grid') {
} else if (desktopSplit) {
body = (
<CollectionGrid
places={c.visiblePlaces}
selectedPlaceId={c.selectedPlaceId}
selectMode={c.selectMode}
selectedIds={c.selectedIds}
onOpenPlace={c.setSelectedPlaceId}
onStatusChange={c.handleStatusChange}
onToggleSelect={c.toggleSelect}
t={t}
/>
)
} else if (c.view === 'list') {
const listEl = (
<CollectionList
places={c.visiblePlaces}
selectedPlaceId={c.selectedPlaceId}
selectMode={c.selectMode}
selectedIds={c.selectedIds}
onOpenPlace={c.setSelectedPlaceId}
onStatusChange={c.handleStatusChange}
onToggleSelect={c.toggleSelect}
t={t}
/>
)
body = inSplit ? (
<div className="col-split">
<div className={`col-split${c.view === 'map' ? ' map-full' : ''}`}>
<div className="col-split-list">{listEl}</div>
<div className="col-split-map">
<CollectionMap places={mappable} selectedPlaceId={c.selectedPlaceId} onOpenPlace={c.setSelectedPlaceId} dark={c.dark} />
</div>
</div>
) : listEl
} else {
body = mappable.length === 0 ? (
<EmptyState icon={<Search size={26} />} title={t('collections.empty.noMatchTitle')} text={t('collections.empty.noMatchText')} />
) : (
<div className="col-mapwrap">
<CollectionMap places={mappable} selectedPlaceId={c.selectedPlaceId} onOpenPlace={c.setSelectedPlaceId} dark={c.dark} />
<div className="col-split-map">{mapPanel(true)}</div>
</div>
)
} else if (c.view === 'map' && mappable.length > 0) {
body = <div className="col-mapwrap">{mapPanel(false)}</div>
} else {
body = listEl
}
const rail = (
@@ -185,32 +163,45 @@ export default function CollectionsPage(): React.ReactElement {
/>
</div>
<div className="col-toolbar">
<button type="button" className="col-rail-toggle" onClick={() => c.setMobileRailOpen(true)}>
<Bookmark size={15} /> {t('collections.title')}
</button>
<ViewSwitch view={c.view} onChange={c.setView} t={t} />
{showSelect && (
<button
type="button"
onClick={() => c.setSelectMode(!c.selectMode)}
className={`col-iconbtn${c.selectMode ? ' on' : ''}`}
aria-label={t('collections.selectMode')}
title={t('collections.selectMode')}
>
<CheckSquare size={16} />
{(!c.isWide || showSelect || !mapOverlay) && (
<div className="col-toolbar">
<button type="button" className="col-rail-toggle" onClick={() => c.setMobileRailOpen(true)}>
<Bookmark size={15} /> {t('collections.title')}
</button>
)}
<div className="col-toolbar-spacer" />
<div className="col-search">
<Search size={15} />
<input
value={c.search}
onChange={e => c.setSearch(e.target.value)}
placeholder={t('collections.search')}
/>
{!c.isWide && mappable.length > 0 && (
<div className="col-viewseg" role="group" aria-label={t('collections.title')}>
<button type="button" aria-pressed={c.view === 'list'} onClick={() => c.setView('list')} aria-label={t('collections.view.list')} title={t('collections.view.list')} className={c.view === 'list' ? 'on' : ''}>
<ListIcon size={16} />
</button>
<button type="button" aria-pressed={c.view === 'map'} onClick={() => c.setView('map')} aria-label={t('collections.view.map')} title={t('collections.view.map')} className={c.view === 'map' ? 'on' : ''}>
<MapIcon size={16} />
</button>
</div>
)}
{showSelect && (
<button
type="button"
onClick={() => c.setSelectMode(!c.selectMode)}
className={`col-iconbtn${c.selectMode ? ' on' : ''}`}
aria-label={t('collections.selectMode')}
title={t('collections.selectMode')}
>
<CheckSquare size={16} />
</button>
)}
<div className="col-toolbar-spacer" />
{!mapOverlay && (
<div className="col-search">
<Search size={15} />
<input
value={c.search}
onChange={e => c.setSearch(e.target.value)}
placeholder={t('collections.search')}
/>
</div>
)}
</div>
</div>
)}
{c.selectMode && c.selectedIds.length > 0 && (
<div className="col-selbar">
@@ -247,7 +238,7 @@ export default function CollectionsPage(): React.ReactElement {
.trek-dash (it's a shared component on the app's own tokens). The outer
layer is click-through so the grid behind stays interactive; only the
floating card re-enables pointer events. */}
{c.detailPlace && !inSplit && (
{c.detailPlace && !mapShown && (
<div className="fixed inset-0 z-[150]" style={{ pointerEvents: 'none', paddingTop: 'var(--nav-h)' }}>
<div className="relative w-full h-full">
<div style={{ pointerEvents: 'auto' }}>
+19 -1
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useTranslation } from '../../i18n'
import { useElementSize } from '../../hooks/useElementSize'
@@ -70,6 +70,24 @@ export function useCollections() {
// Initial load.
useEffect(() => { loadAll() }, [])
// When the list↔map split toggles, its grid columns animate; keep nudging the
// mounted map to re-layout during the transition (Leaflet's trackResize hooks
// window resize; MapLibre observes its own box). Skip the initial mount — the
// map already sizes itself there, and nudging would fight its first fit.
const firstViewRun = useRef(true)
useEffect(() => {
if (firstViewRun.current) { firstViewRun.current = false; return }
if (!isWide) return
const start = performance.now()
let raf = 0
const tick = () => {
window.dispatchEvent(new Event('resize'))
if (performance.now() - start < 440) raf = requestAnimationFrame(tick)
}
raf = requestAnimationFrame(tick)
return () => cancelAnimationFrame(raf)
}, [view, isWide])
// Keep the active list in sync with the URL (/collections/:id, or the
// "All saved" union at /collections).
useEffect(() => {
+2 -2
View File
@@ -15,7 +15,7 @@ export type IncomingCollectionInvite = CollectionListResponse['incomingInvites']
export const ALL_SAVED = 'all' as const
export type ActiveCollectionId = number | typeof ALL_SAVED | null
export type CollectionView = 'grid' | 'list' | 'map'
export type CollectionView = 'list' | 'map'
export type StatusFilter = CollectionStatus | 'all'
interface CollectionState {
@@ -69,7 +69,7 @@ export const useCollectionStore = create<CollectionState>((set, get) => ({
places: [],
members: [],
incomingInvites: [],
view: 'grid',
view: 'list',
statusFilter: 'all',
search: '',
selectedPlaceId: null,
+27 -36
View File
@@ -195,40 +195,26 @@
.trek-dash .col-selbar-btn.danger { color: oklch(0.6 0.2 25); }
.trek-dash .col-selbar-btn.danger:hover { background: oklch(0.6 0.2 25 / .12); }
/* ----------------- place grid + cards ----------------- */
.trek-dash .col-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(214px, 1fr)); gap: 20px; }
.trek-dash .col-card {
position: relative; isolation: isolate; overflow: hidden; cursor: pointer; text-align: left; width: 100%;
border-radius: var(--r-xl); background: var(--glass-bg); border: 1px solid var(--glass-border);
box-shadow: var(--glass-shadow), var(--glass-highlight);
transition: transform .25s cubic-bezier(.2,.7,.2,1), box-shadow .25s, border-color .25s;
/* ----------------- map panel + floating controls ----------------- */
.trek-dash .col-map-shell { position: relative; width: 100%; height: 100%; }
.trek-dash .col-map-toggle {
position: absolute; top: 12px; left: 12px; z-index: 20;
width: 38px; height: 38px; 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);
transition: transform .12s, background .15s;
}
.trek-dash .col-card:hover { transform: translateY(-4px); box-shadow: var(--glass-shadow-hover), var(--glass-highlight); border-color: oklch(0.8 0.01 70 / .8); }
.dark .trek-dash .col-card:hover { border-color: oklch(1 0 0 / .2); }
.trek-dash .col-card.sel { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent), var(--glass-shadow); }
.trek-dash .col-cover { position: relative; aspect-ratio: 4 / 3; overflow: hidden; }
.trek-dash .col-cover-media { position: absolute; inset: 0; width: 100%; height: 100%; }
.trek-dash .col-cover-media img { width: 100%; height: 100%; object-fit: cover; transition: transform .6s cubic-bezier(.2,.7,.2,1); }
.trek-dash .col-card:hover .col-cover-media img { transform: scale(1.05); }
.trek-dash .col-cover-fallback { background-size: cover; background-position: center; }
.trek-dash .col-cover::after { content: ""; position: absolute; inset: 0; background: linear-gradient(180deg, rgba(0,0,0,0) 44%, rgba(0,0,0,.58) 100%); }
.trek-dash .col-card-name {
position: absolute; left: 14px; right: 14px; bottom: 11px; z-index: 1; color: #fff;
font-size: 15px; font-weight: 600; letter-spacing: -.01em; line-height: 1.18;
text-shadow: 0 1px 6px rgba(0,0,0,.42), 0 1px 2px rgba(0,0,0,.5);
overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
.trek-dash .col-map-toggle:hover { transform: translateY(-1px); background: var(--surface); }
.trek-dash .col-map-search {
position: absolute; top: 12px; right: 12px; z-index: 20;
display: flex; align-items: center; gap: 8px; height: 38px; padding: 0 14px 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);
}
.trek-dash .col-card-status { position: absolute; top: 10px; right: 10px; z-index: 2; }
.trek-dash .col-card-check {
position: absolute; top: 10px; left: 10px; z-index: 2; width: 22px; height: 22px; border-radius: 7px;
display: grid; place-items: center; background: rgba(255,255,255,.86); border: 1px solid rgba(0,0,0,.1); color: transparent;
.trek-dash .col-map-search input {
width: 180px; background: none; border: 0; outline: none; color: var(--ink); font-size: 13.5px; font-family: inherit;
}
.trek-dash .col-card-check.on { background: var(--accent); border-color: var(--accent); color: #fff; }
.trek-dash .col-card-body { display: flex; align-items: center; gap: 8px; padding: 11px 13px; min-width: 0; }
.trek-dash .col-card-cat { display: inline-flex; align-items: center; gap: 7px; min-width: 0; font-size: 12px; color: var(--ink-2); font-weight: 500; }
.trek-dash .col-card-cat .cdot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.trek-dash .col-card-cat span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.trek-dash .col-card-cat svg { flex-shrink: 0; color: var(--ink-3); }
.trek-dash .col-map-search input::placeholder { color: var(--ink-3); }
/* ----------------- list view ----------------- */
.trek-dash .col-listview { display: flex; flex-direction: column; gap: 7px; }
@@ -255,9 +241,16 @@
/* list + map split (list view on wide screens): scrollable list left, sticky
map right. Clicking a place pans the map (selectedPlaceId drives it). Only
rendered above 1024px (the page gates it in JS), so no mobile override needed. */
.trek-dash .col-split { display: grid; grid-template-columns: minmax(0, 1fr) minmax(360px, 1.06fr); gap: 20px; align-items: start; }
.trek-dash .col-split-list { min-width: 0; }
rendered above 1024px (the page gates it in JS). The columns animate between
the split and the collapsed-list "map full" state; the map stays mounted so
the grow/shrink is smooth (the page nudges it to re-layout during the anim). */
.trek-dash .col-split {
display: grid; grid-template-columns: 1fr 1.05fr; gap: 20px; align-items: start;
transition: grid-template-columns .4s cubic-bezier(.2,.7,.2,1), gap .4s cubic-bezier(.2,.7,.2,1);
}
.trek-dash .col-split.map-full { grid-template-columns: 0fr 1fr; gap: 0; }
.trek-dash .col-split-list { min-width: 0; overflow: hidden; transition: opacity .25s; }
.trek-dash .col-split.map-full .col-split-list { opacity: 0; pointer-events: none; }
.trek-dash .col-split-map {
position: sticky; top: calc(var(--nav-h) + 22px);
height: calc(100vh - var(--nav-h) - 210px); min-height: 440px;
@@ -314,8 +307,6 @@
.trek-dash .col-hero-stats { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; margin: 0 -18px; padding: 0 18px; }
.trek-dash .col-hero-stats::-webkit-scrollbar { display: none; }
.trek-dash .col-chip { flex-shrink: 0; }
.trek-dash .col-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.trek-dash .col-card-name { font-size: 13.5px; }
.trek-dash .col-search input { width: 100%; }
.trek-dash .col-search { flex: 1; }
.trek-dash .col-mapwrap { height: calc(100vh - 260px); min-height: 320px; }
+2
View File
@@ -1,6 +1,8 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.showList': 'إظهار القائمة',
'collections.expandMap': 'توسيع الخريطة',
'collections.hero.mine': 'مجموعتي',
'collections.hero.shared': 'تمت مشاركتها معك',
'collections.hero.all': 'مكتبتك',
+2
View File
@@ -1,6 +1,8 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.showList': 'Mostrar lista',
'collections.expandMap': 'Ampliar mapa',
'collections.hero.mine': 'Minha coleção',
'collections.hero.shared': 'Compartilhada com você',
'collections.hero.all': 'Sua biblioteca',
+2
View File
@@ -1,6 +1,8 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.showList': 'Zobrazit seznam',
'collections.expandMap': 'Zvětšit mapu',
'collections.hero.mine': 'Moje sbírka',
'collections.hero.shared': 'Sdíleno s vámi',
'collections.hero.all': 'Vaše knihovna',
+2
View File
@@ -1,6 +1,8 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.showList': 'Liste zeigen',
'collections.expandMap': 'Karte vergrößern',
'collections.hero.mine': 'Meine Sammlung',
'collections.hero.shared': 'Mit dir geteilt',
'collections.hero.all': 'Deine Bibliothek',
+2
View File
@@ -1,6 +1,8 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.showList': 'Show list',
'collections.expandMap': 'Expand map',
'collections.hero.mine': 'My collection',
'collections.hero.shared': 'Shared with you',
'collections.hero.all': 'Your library',
+2
View File
@@ -1,6 +1,8 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.showList': 'Mostrar lista',
'collections.expandMap': 'Ampliar mapa',
'collections.hero.mine': 'Mi colección',
'collections.hero.shared': 'Compartida contigo',
'collections.hero.all': 'Tu biblioteca',
+2
View File
@@ -1,6 +1,8 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.showList': 'Afficher la liste',
'collections.expandMap': 'Agrandir la carte',
'collections.hero.mine': 'Ma collection',
'collections.hero.shared': 'Partagée avec vous',
'collections.hero.all': 'Votre bibliothèque',
+2
View File
@@ -1,6 +1,8 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.showList': 'Εμφάνιση λίστας',
'collections.expandMap': 'Μεγέθυνση χάρτη',
'collections.hero.mine': 'Η συλλογή μου',
'collections.hero.shared': 'Κοινή χρήση με εσάς',
'collections.hero.all': 'Η βιβλιοθήκη σας',
+2
View File
@@ -1,6 +1,8 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.showList': 'Lista megjelenítése',
'collections.expandMap': 'Térkép nagyítása',
'collections.hero.mine': 'Saját gyűjtemény',
'collections.hero.shared': 'Megosztva veled',
'collections.hero.all': 'A könyvtárad',
+2
View File
@@ -1,6 +1,8 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.showList': 'Tampilkan daftar',
'collections.expandMap': 'Perbesar peta',
'collections.hero.mine': 'Koleksi saya',
'collections.hero.shared': 'Dibagikan dengan Anda',
'collections.hero.all': 'Pustaka Anda',
+2
View File
@@ -1,6 +1,8 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.showList': 'Mostra elenco',
'collections.expandMap': 'Espandi mappa',
'collections.hero.mine': 'La mia raccolta',
'collections.hero.shared': 'Condivisa con te',
'collections.hero.all': 'La tua libreria',
+2
View File
@@ -1,6 +1,8 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.showList': 'リストを表示',
'collections.expandMap': '地図を拡大',
'collections.hero.mine': 'マイコレクション',
'collections.hero.shared': '共有されています',
'collections.hero.all': 'ライブラリ',
+2
View File
@@ -1,6 +1,8 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.showList': '목록 표시',
'collections.expandMap': '지도 확대',
'collections.hero.mine': '내 컬렉션',
'collections.hero.shared': '나와 공유됨',
'collections.hero.all': '내 라이브러리',
+2
View File
@@ -1,6 +1,8 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.showList': 'Lijst tonen',
'collections.expandMap': 'Kaart vergroten',
'collections.hero.mine': 'Mijn collectie',
'collections.hero.shared': 'Gedeeld met jou',
'collections.hero.all': 'Jouw bibliotheek',
+2
View File
@@ -1,6 +1,8 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.showList': 'Pokaż listę',
'collections.expandMap': 'Powiększ mapę',
'collections.hero.mine': 'Moja kolekcja',
'collections.hero.shared': 'Udostępniono Tobie',
'collections.hero.all': 'Twoja biblioteka',
+2
View File
@@ -1,6 +1,8 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.showList': 'Показать список',
'collections.expandMap': 'Развернуть карту',
'collections.hero.mine': 'Моя коллекция',
'collections.hero.shared': 'Доступно вам',
'collections.hero.all': 'Ваша библиотека',
+2
View File
@@ -1,6 +1,8 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.showList': 'Visa lista',
'collections.expandMap': 'Förstora karta',
'collections.hero.mine': 'Min samling',
'collections.hero.shared': 'Delad med dig',
'collections.hero.all': 'Ditt bibliotek',
+2
View File
@@ -1,6 +1,8 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.showList': 'Listeyi göster',
'collections.expandMap': 'Haritayı genişlet',
'collections.hero.mine': 'Koleksiyonum',
'collections.hero.shared': 'Sizinle paylaşıldı',
'collections.hero.all': 'Kitaplığınız',
+2
View File
@@ -1,6 +1,8 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.showList': 'Показати список',
'collections.expandMap': 'Розгорнути карту',
'collections.hero.mine': 'Моя колекція',
'collections.hero.shared': 'Поділено з вами',
'collections.hero.all': 'Ваша бібліотека',
+2
View File
@@ -1,6 +1,8 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.showList': 'Hiện danh sách',
'collections.expandMap': 'Mở rộng bản đồ',
'collections.hero.mine': 'Bộ sưu tập của tôi',
'collections.hero.shared': 'Được chia sẻ với bạn',
'collections.hero.all': 'Thư viện của bạn',
+2
View File
@@ -1,6 +1,8 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.showList': '顯示清單',
'collections.expandMap': '展開地圖',
'collections.hero.mine': '我的收藏',
'collections.hero.shared': '與你共享',
'collections.hero.all': '你的收藏庫',
+2
View File
@@ -1,6 +1,8 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.showList': '显示列表',
'collections.expandMap': '展开地图',
'collections.hero.mine': '我的收藏',
'collections.hero.shared': '与你共享',
'collections.hero.all': '你的收藏库',