mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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])
|
||||
|
||||
@@ -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) }} />
|
||||
})
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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': 'مكتبتك',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Η βιβλιοθήκη σας',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'ライブラリ',
|
||||
|
||||
@@ -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': '내 라이브러리',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Ваша библиотека',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Ваша бібліотека',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '你的收藏庫',
|
||||
|
||||
@@ -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': '你的收藏库',
|
||||
|
||||
Reference in New Issue
Block a user