From 25b8d2857415f39c5fd12f54a457bf9ed1d7f43a Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 1 Jul 2026 10:25:21 +0200 Subject: [PATCH] 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. --- .../components/Collections/CollectionGrid.tsx | 78 -------- .../components/Collections/CollectionMap.tsx | 5 +- .../Collections/CollectionMapPanel.tsx | 59 ++++++ client/src/components/Map/MapView.tsx | 14 ++ client/src/components/Map/MapViewGL.tsx | 12 ++ client/src/components/shared/PlaceCover.tsx | 89 --------- client/src/pages/CollectionsPage.tsx | 181 +++++++++--------- .../src/pages/collections/useCollections.ts | 20 +- client/src/store/collectionStore.ts | 4 +- client/src/styles/collections.css | 63 +++--- shared/src/i18n/ar/collection.ts | 2 + shared/src/i18n/br/collection.ts | 2 + shared/src/i18n/cs/collection.ts | 2 + shared/src/i18n/de/collection.ts | 2 + shared/src/i18n/en/collection.ts | 2 + shared/src/i18n/es/collection.ts | 2 + shared/src/i18n/fr/collection.ts | 2 + shared/src/i18n/gr/collection.ts | 2 + shared/src/i18n/hu/collection.ts | 2 + shared/src/i18n/id/collection.ts | 2 + shared/src/i18n/it/collection.ts | 2 + shared/src/i18n/ja/collection.ts | 2 + shared/src/i18n/ko/collection.ts | 2 + shared/src/i18n/nl/collection.ts | 2 + shared/src/i18n/pl/collection.ts | 2 + shared/src/i18n/ru/collection.ts | 2 + shared/src/i18n/sv/collection.ts | 2 + shared/src/i18n/tr/collection.ts | 2 + shared/src/i18n/uk/collection.ts | 2 + shared/src/i18n/vi/collection.ts | 2 + shared/src/i18n/zh-TW/collection.ts | 2 + shared/src/i18n/zh/collection.ts | 2 + 32 files changed, 267 insertions(+), 302 deletions(-) delete mode 100644 client/src/components/Collections/CollectionGrid.tsx create mode 100644 client/src/components/Collections/CollectionMapPanel.tsx delete mode 100644 client/src/components/shared/PlaceCover.tsx diff --git a/client/src/components/Collections/CollectionGrid.tsx b/client/src/components/Collections/CollectionGrid.tsx deleted file mode 100644 index f1d7d1fd1..000000000 --- a/client/src/components/Collections/CollectionGrid.tsx +++ /dev/null @@ -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 ( -
- {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 ( -
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); activate() } }} - className={`col-card${active || selected ? ' sel' : ''}`} - > -
- - {selectMode && ( - - {selected && } - - )} - - onStatusChange(place.id, next)} - t={t} - /> - - {place.name} -
- {sub && ( -
- - {place.category?.name - ? - : } - {sub} - -
- )} -
- ) - })} -
- ) -} diff --git a/client/src/components/Collections/CollectionMap.tsx b/client/src/components/Collections/CollectionMap.tsx index 7072d3f31..bfeb5f478 100644 --- a/client/src/components/Collections/CollectionMap.tsx +++ b/client/src/components/Collections/CollectionMap.tsx @@ -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} diff --git a/client/src/components/Collections/CollectionMapPanel.tsx b/client/src/components/Collections/CollectionMapPanel.tsx new file mode 100644 index 000000000..7e80e6a4a --- /dev/null +++ b/client/src/components/Collections/CollectionMapPanel.tsx @@ -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 ( +
+ + {overlay && ( + <> + +
+ + onSearch(e.target.value)} placeholder={t('collections.search')} /> +
+ + )} +
+ ) +} diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index e473fc4f8..a44cba33f 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -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]) diff --git a/client/src/components/Map/MapViewGL.tsx b/client/src/components/Map/MapViewGL.tsx index 9eaf41a92..da8916e3b 100644 --- a/client/src/components/Map/MapViewGL.tsx +++ b/client/src/components/Map/MapViewGL.tsx @@ -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(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(null) // eslint-disable-next-line @typescript-eslint/no-explicit-any const mapRef = useRef(null) diff --git a/client/src/components/shared/PlaceCover.tsx b/client/src/components/shared/PlaceCover.tsx deleted file mode 100644 index 078c1bc5f..000000000 --- a/client/src/components/shared/PlaceCover.tsx +++ /dev/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 -} - -/** - * 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(place.image_url || null) - const [visible, setVisible] = useState(false) - const imageUrlFailed = useRef(false) - const ref = useRef(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 ( -
- {place.name} { - 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) - } - }} - /> -
- ) - } - - return
-}) diff --git a/client/src/pages/CollectionsPage.tsx b/client/src/pages/CollectionsPage.tsx index 6c746eba1..f7d983e7c 100644 --- a/client/src/pages/CollectionsPage.tsx +++ b/client/src/pages/CollectionsPage.tsx @@ -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 = { grid: LayoutGrid, list: ListIcon, map: MapIcon } - -function ViewSwitch({ view, onChange, t }: { view: CollectionView; onChange: (v: CollectionView) => void; t: TranslationFn }): React.ReactElement { - return ( -
- {(['grid', 'list', 'map'] as CollectionView[]).map(v => { - const Icon = VIEW_ICONS[v] - return ( - - ) - })} -
- ) -} 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 = ( + + ) + const mapPanel = (overlay: boolean) => ( + + ) let body: React.ReactElement if (c.placesLoading && !hasPlaces) { @@ -80,48 +89,17 @@ export default function CollectionsPage(): React.ReactElement { body = } title={t('collections.empty.title')} text={t('collections.empty.text')} /> } else if (c.visiblePlaces.length === 0) { body = } title={t('collections.empty.noMatchTitle')} text={t('collections.empty.noMatchText')} /> - } else if (c.view === 'grid') { + } else if (desktopSplit) { body = ( - - ) - } else if (c.view === 'list') { - const listEl = ( - - ) - body = inSplit ? ( -
+
{listEl}
-
- -
-
- ) : listEl - } else { - body = mappable.length === 0 ? ( - } title={t('collections.empty.noMatchTitle')} text={t('collections.empty.noMatchText')} /> - ) : ( -
- +
{mapPanel(true)}
) + } else if (c.view === 'map' && mappable.length > 0) { + body =
{mapPanel(false)}
+ } else { + body = listEl } const rail = ( @@ -185,32 +163,45 @@ export default function CollectionsPage(): React.ReactElement { />
-
- - - {showSelect && ( - - )} -
-
- - c.setSearch(e.target.value)} - placeholder={t('collections.search')} - /> + {!c.isWide && mappable.length > 0 && ( +
+ + +
+ )} + {showSelect && ( + + )} +
+ {!mapOverlay && ( +
+ + c.setSearch(e.target.value)} + placeholder={t('collections.search')} + /> +
+ )}
-
+ )} {c.selectMode && c.selectedIds.length > 0 && (
@@ -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 && (
diff --git a/client/src/pages/collections/useCollections.ts b/client/src/pages/collections/useCollections.ts index 254f2c7df..4834d2fc4 100644 --- a/client/src/pages/collections/useCollections.ts +++ b/client/src/pages/collections/useCollections.ts @@ -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(() => { diff --git a/client/src/store/collectionStore.ts b/client/src/store/collectionStore.ts index ddb5dc307..b1076d14b 100644 --- a/client/src/store/collectionStore.ts +++ b/client/src/store/collectionStore.ts @@ -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((set, get) => ({ places: [], members: [], incomingInvites: [], - view: 'grid', + view: 'list', statusFilter: 'all', search: '', selectedPlaceId: null, diff --git a/client/src/styles/collections.css b/client/src/styles/collections.css index 2b33a86de..2d1f50af0 100644 --- a/client/src/styles/collections.css +++ b/client/src/styles/collections.css @@ -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; } diff --git a/shared/src/i18n/ar/collection.ts b/shared/src/i18n/ar/collection.ts index 90cecc59d..32315d6df 100644 --- a/shared/src/i18n/ar/collection.ts +++ b/shared/src/i18n/ar/collection.ts @@ -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': 'مكتبتك', diff --git a/shared/src/i18n/br/collection.ts b/shared/src/i18n/br/collection.ts index 552c3ad6f..b67a9487d 100644 --- a/shared/src/i18n/br/collection.ts +++ b/shared/src/i18n/br/collection.ts @@ -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', diff --git a/shared/src/i18n/cs/collection.ts b/shared/src/i18n/cs/collection.ts index 169105967..e09b264d2 100644 --- a/shared/src/i18n/cs/collection.ts +++ b/shared/src/i18n/cs/collection.ts @@ -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', diff --git a/shared/src/i18n/de/collection.ts b/shared/src/i18n/de/collection.ts index d4bed63d2..10d307714 100644 --- a/shared/src/i18n/de/collection.ts +++ b/shared/src/i18n/de/collection.ts @@ -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', diff --git a/shared/src/i18n/en/collection.ts b/shared/src/i18n/en/collection.ts index d5d45a6f6..0bcd92b10 100644 --- a/shared/src/i18n/en/collection.ts +++ b/shared/src/i18n/en/collection.ts @@ -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', diff --git a/shared/src/i18n/es/collection.ts b/shared/src/i18n/es/collection.ts index 4f1b87531..9e26e9e33 100644 --- a/shared/src/i18n/es/collection.ts +++ b/shared/src/i18n/es/collection.ts @@ -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', diff --git a/shared/src/i18n/fr/collection.ts b/shared/src/i18n/fr/collection.ts index a3532605f..c6d4bdcbb 100644 --- a/shared/src/i18n/fr/collection.ts +++ b/shared/src/i18n/fr/collection.ts @@ -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', diff --git a/shared/src/i18n/gr/collection.ts b/shared/src/i18n/gr/collection.ts index 9c981873c..e7bfb07db 100644 --- a/shared/src/i18n/gr/collection.ts +++ b/shared/src/i18n/gr/collection.ts @@ -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': 'Η βιβλιοθήκη σας', diff --git a/shared/src/i18n/hu/collection.ts b/shared/src/i18n/hu/collection.ts index 056dca8c6..ef1d385ed 100644 --- a/shared/src/i18n/hu/collection.ts +++ b/shared/src/i18n/hu/collection.ts @@ -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', diff --git a/shared/src/i18n/id/collection.ts b/shared/src/i18n/id/collection.ts index 396f5975c..d78e5363b 100644 --- a/shared/src/i18n/id/collection.ts +++ b/shared/src/i18n/id/collection.ts @@ -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', diff --git a/shared/src/i18n/it/collection.ts b/shared/src/i18n/it/collection.ts index 85118b079..b30021ab1 100644 --- a/shared/src/i18n/it/collection.ts +++ b/shared/src/i18n/it/collection.ts @@ -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', diff --git a/shared/src/i18n/ja/collection.ts b/shared/src/i18n/ja/collection.ts index 89903e9c6..2f41cc2ed 100644 --- a/shared/src/i18n/ja/collection.ts +++ b/shared/src/i18n/ja/collection.ts @@ -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': 'ライブラリ', diff --git a/shared/src/i18n/ko/collection.ts b/shared/src/i18n/ko/collection.ts index e54fe8ece..6457443db 100644 --- a/shared/src/i18n/ko/collection.ts +++ b/shared/src/i18n/ko/collection.ts @@ -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': '내 라이브러리', diff --git a/shared/src/i18n/nl/collection.ts b/shared/src/i18n/nl/collection.ts index 101b8ac07..af2450b57 100644 --- a/shared/src/i18n/nl/collection.ts +++ b/shared/src/i18n/nl/collection.ts @@ -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', diff --git a/shared/src/i18n/pl/collection.ts b/shared/src/i18n/pl/collection.ts index b2ecb8e33..9a495fa2e 100644 --- a/shared/src/i18n/pl/collection.ts +++ b/shared/src/i18n/pl/collection.ts @@ -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', diff --git a/shared/src/i18n/ru/collection.ts b/shared/src/i18n/ru/collection.ts index c70120a91..e28e55c30 100644 --- a/shared/src/i18n/ru/collection.ts +++ b/shared/src/i18n/ru/collection.ts @@ -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': 'Ваша библиотека', diff --git a/shared/src/i18n/sv/collection.ts b/shared/src/i18n/sv/collection.ts index c87373be1..ef6a34096 100644 --- a/shared/src/i18n/sv/collection.ts +++ b/shared/src/i18n/sv/collection.ts @@ -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', diff --git a/shared/src/i18n/tr/collection.ts b/shared/src/i18n/tr/collection.ts index f6c93a284..99ed10b44 100644 --- a/shared/src/i18n/tr/collection.ts +++ b/shared/src/i18n/tr/collection.ts @@ -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', diff --git a/shared/src/i18n/uk/collection.ts b/shared/src/i18n/uk/collection.ts index 2c9714e78..3f542af65 100644 --- a/shared/src/i18n/uk/collection.ts +++ b/shared/src/i18n/uk/collection.ts @@ -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': 'Ваша бібліотека', diff --git a/shared/src/i18n/vi/collection.ts b/shared/src/i18n/vi/collection.ts index 3da9fd233..cf482e29a 100644 --- a/shared/src/i18n/vi/collection.ts +++ b/shared/src/i18n/vi/collection.ts @@ -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', diff --git a/shared/src/i18n/zh-TW/collection.ts b/shared/src/i18n/zh-TW/collection.ts index 570e4f385..eb52ca0cb 100644 --- a/shared/src/i18n/zh-TW/collection.ts +++ b/shared/src/i18n/zh-TW/collection.ts @@ -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': '你的收藏庫', diff --git a/shared/src/i18n/zh/collection.ts b/shared/src/i18n/zh/collection.ts index 40d8bb61c..27a734aff 100644 --- a/shared/src/i18n/zh/collection.ts +++ b/shared/src/i18n/zh/collection.ts @@ -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': '你的收藏库',