diff --git a/client/src/components/Collections/CollectionGrid.tsx b/client/src/components/Collections/CollectionGrid.tsx index 0e6ef8926..f1d7d1fd1 100644 --- a/client/src/components/Collections/CollectionGrid.tsx +++ b/client/src/components/Collections/CollectionGrid.tsx @@ -1,8 +1,8 @@ import React from 'react' -import { Check } from 'lucide-react' +import { Check, MapPin } from 'lucide-react' import type { CollectionPlace, CollectionStatus } from '@trek/shared' import type { TranslationFn } from '../../types' -import PlaceAvatar from '../shared/PlaceAvatar' +import PlaceCover from '../shared/PlaceCover' import StatusBadge from './StatusBadge' interface CollectionGridProps { @@ -17,52 +17,60 @@ interface CollectionGridProps { } /** - * Grid view — PlaceAvatar thumb cards (rounded-xl) with the status badge in the - * top-right corner. In select mode a tap toggles the checkbox; otherwise it - * opens the place. + * 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 ( - +
+ )} +
) })} diff --git a/client/src/components/Collections/CollectionHero.tsx b/client/src/components/Collections/CollectionHero.tsx new file mode 100644 index 000000000..c6a5d1b25 --- /dev/null +++ b/client/src/components/Collections/CollectionHero.tsx @@ -0,0 +1,114 @@ +import React from 'react' +import { Share2, Users, Plus } from 'lucide-react' +import type { CollectionMember } from '@trek/shared' +import type { TranslationFn } from '../../types' +import type { StatusFilter } from '../../store/collectionStore' +import { STATUS_META, STATUS_ORDER } from '../../pages/collections/collectionsModel' + +const AV_COLORS = ['#6366f1', '#ec4899', '#14b8a6', '#f97316', '#8b5cf6', '#3b82f6', '#ef4444', '#22c55e'] + +function initials(name: string): string { + return name.trim().split(/\s+/).slice(0, 2).map(w => w[0]?.toUpperCase() ?? '').join('') || '?' +} + +interface CollectionHeroProps { + eyebrow: string + title: string + /** List colour — drives the gradient wash when there is no cover image. */ + color: string + coverImage?: string | null + counts: Record + statusFilter: StatusFilter + onStatusFilter: (f: StatusFilter) => void + /** Accepted members (owner first) — shown as an avatar stack when shared. */ + members: CollectionMember[] + canShare: boolean + isOwner: boolean + shareMemberCount: number + onShare: () => void + onNewList: () => void + t: TranslationFn +} + +/** + * The page header — a colour-washed (or cover-image) glass hero that gives the + * active list an identity: an eyebrow with the sharing state + member avatars, + * the big list name, and a row of stat chips that double as the status filter + * (All / Idea / Want / Visited with live counts). Share + New-list actions sit + * top-right. Modelled on the dashboard hero-trip. + */ +export default function CollectionHero({ + eyebrow, title, color, coverImage, counts, statusFilter, onStatusFilter, + members, canShare, isOwner, shareMemberCount, onShare, onNewList, t, +}: CollectionHeroProps): React.ReactElement { + const accepted = members.filter(m => m.status === 'accepted' || m.is_owner) + const showAvatars = accepted.length > 1 + const shown = accepted.slice(0, 5) + const extra = accepted.length - shown.length + + const chips: { key: StatusFilter; label: string; color?: string }[] = [ + { key: 'all', label: t('collections.status.filterAll') }, + ...STATUS_ORDER.map(s => ({ key: s as StatusFilter, label: t(STATUS_META[s].labelKey), color: STATUS_META[s].color })), + ] + + return ( +
+ {coverImage + ? + :
} +
+ +
+ {canShare && ( + + )} + +
+ +
+
+ {eyebrow} + {showAvatars && ( + + {shown.map(m => ( + m.avatar + ? {m.username} + : {initials(m.username)} + ))} + {extra > 0 && +{extra}} + + )} +
+ +

{title}

+ +
+ {chips.map(chip => ( + + ))} +
+
+
+ ) +} diff --git a/client/src/components/Collections/CollectionList.tsx b/client/src/components/Collections/CollectionList.tsx index 4aec166ca..9d45c4389 100644 --- a/client/src/components/Collections/CollectionList.tsx +++ b/client/src/components/Collections/CollectionList.tsx @@ -17,14 +17,15 @@ interface CollectionListProps { } /** - * Dense list view — one row per saved place with a one-tap status cycle on the - * badge. Click the row to open the place (or toggle in select mode). + * List view — one glass row per saved place with a photo avatar, name + + * category/address, and a one-tap status cycle on the badge. Click the row to + * open the place (or toggle it in select mode). */ export default function CollectionList({ places, selectedPlaceId, selectMode, selectedIds, onOpenPlace, onStatusChange, onToggleSelect, t, }: CollectionListProps): React.ReactElement { return ( -
+
{places.map(place => { const selected = selectedIds.includes(place.id) const active = selectedPlaceId === place.id @@ -34,25 +35,24 @@ export default function CollectionList({ role="button" tabIndex={0} onClick={() => (selectMode ? onToggleSelect(place.id) : onOpenPlace(place.id))} - onKeyDown={e => { if (e.key === 'Enter') onOpenPlace(place.id) }} - className={`flex items-center gap-3 px-3 py-2.5 cursor-pointer transition-colors hover:bg-surface-hover ${active ? 'bg-surface-selected' : ''}`} + onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (selectMode) onToggleSelect(place.id); else onOpenPlace(place.id) } }} + className={`col-lrow${active || selected ? ' sel' : ''}`} > - {selectMode && ( -
- {selected && } -
+ {selectMode ? ( + {selected && } + ) : ( + )} - -
- {place.name} +
+
{place.name}
{(place.category?.name || place.address) && ( - - {!place.category?.name && } - {place.category?.name || place.address} - +
+ {!place.category?.name && } + {place.category?.name || place.address} +
)}
- onStatusChange(place.id, next)} t={t} /> + onStatusChange(place.id, next)} t={t} />
) })} diff --git a/client/src/components/Collections/CollectionMap.tsx b/client/src/components/Collections/CollectionMap.tsx index c1f031632..7072d3f31 100644 --- a/client/src/components/Collections/CollectionMap.tsx +++ b/client/src/components/Collections/CollectionMap.tsx @@ -13,6 +13,8 @@ interface CollectionMapProps { /** * Map view — reuses the trip map stack (MapViewAuto → Leaflet / GL with marker * clustering). One of the three list views; clicking a marker selects the place. + * 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 { const pts = mappablePlaces(places) @@ -24,7 +26,7 @@ export default function CollectionMap({ places, selectedPlaceId, onOpenPlace, da : 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' return ( -
+
-} - interface ListRowProps { list: Collection active: boolean @@ -66,66 +62,62 @@ function ListRow({ if (editing) { return ( -
+
setEditingName(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') onCommitRename(); if (e.key === 'Escape') onCommitRename() }} + onKeyDown={e => { if (e.key === 'Enter' || e.key === 'Escape') onCommitRename() }} onBlur={onCommitRename} - className="w-full px-2 py-1.5 rounded-lg border border-accent bg-surface-input text-content text-[13px] outline-none" + className="col-row-edit" />
) } return ( -
- {menuOpen && !colorOpen && ( -
- - {canDelete && ( - )}
)} {colorOpen && ( -
-
+
+
{SWATCHES.map(c => ( ))}
@@ -137,8 +129,10 @@ function ListRow({ /** * Left rail of the user's lists: a "New list" action, the "All saved" union - * pseudo-list, owned lists (colour dot + count + rename/colour/delete menu), - * a shared section, and an incoming-invites block. + * pseudo-list, owned lists (colour dot + count + rename/colour/delete menu), a + * shared section, and an incoming-invites block. Styled with the collections + * glass tokens (`.col-*`); rendered both in the desktop rail and the mobile + * drawer. */ export default function ListsRail(props: ListsRailProps): React.ReactElement { const { @@ -149,25 +143,19 @@ export default function ListsRail(props: ListsRailProps): React.ReactElement { } = props return ( -
- - +
+ +
- {ownedLists.length > 0 &&
} + {ownedLists.length > 0 &&
} {ownedLists.map(list => ( 0 && ( <> -
- {t('collections.shared')} -
+
{t('collections.shared')}
{sharedLists.map(list => ( 0 && ( <> -
- {t('collections.invites.title')} ({incomingInvites.length}) +
+ {t('collections.invites.title')} + {incomingInvites.length}
{incomingInvites.map(inv => ( -
-

{inv.name}

-

{t('collections.invites.from')} {inv.from.username}

-
- -
@@ -230,6 +217,6 @@ export default function ListsRail(props: ListsRailProps): React.ReactElement { ))} )} -
+ ) } diff --git a/client/src/components/Collections/StatusBadge.tsx b/client/src/components/Collections/StatusBadge.tsx index e7ff80d85..a118ce511 100644 --- a/client/src/components/Collections/StatusBadge.tsx +++ b/client/src/components/Collections/StatusBadge.tsx @@ -9,22 +9,26 @@ interface StatusBadgeProps { onChange?: (next: CollectionStatus) => void showLabel?: boolean size?: number + /** Dark-glass variant for a pill sitting over a photo cover / the hero. */ + onCover?: boolean t: TranslationFn } /** * Coloured per-place status pill (idea / want-to-go / visited). When `onChange` * is supplied a single tap cycles the status optimistically; otherwise it is a - * static badge. Looks intentional in both light and dark mode via the - * accent/success tokens in STATUS_META. + * static badge. Two skins: the default surface pill (list / inspector) and the + * `onCover` dark-glass pill that stays legible on top of a photo cover. Styled + * with utility classes only, so it works both inside and outside `.trek-dash`. */ -export default function StatusBadge({ status, onChange, showLabel = true, size = 13, t }: StatusBadgeProps): React.ReactElement { +export default function StatusBadge({ status, onChange, showLabel = true, size = 13, onCover = false, t }: StatusBadgeProps): React.ReactElement { const meta = STATUS_META[status] const Icon = meta.icon const label = t(meta.labelKey) + const color = onCover ? meta.coverColor : meta.color const interactive = !!onChange - const handleClick = (e: React.MouseEvent) => { + const cycle = (e: React.SyntheticEvent) => { if (!onChange) return e.preventDefault() e.stopPropagation() @@ -33,26 +37,35 @@ export default function StatusBadge({ status, onChange, showLabel = true, size = const content = ( <> - - {showLabel && {label}} + + {showLabel && {label}} ) - const className = `inline-flex items-center gap-1.5 rounded-full text-[11px] leading-none ${showLabel ? 'px-2.5 py-1' : 'p-1.5'} border border-edge bg-surface-card/80 backdrop-blur-sm` + const skin = onCover + ? 'bg-black/45 border-white/25 text-white' + : 'bg-surface-card/85 border-edge text-content' + const className = `inline-flex items-center gap-1.5 rounded-full text-[11px] leading-none border backdrop-blur-md ${showLabel ? 'px-2.5 py-1' : 'p-1.5'} ${skin}` if (!interactive) { return {content} } + // Rendered as a role=button span (not a native + ) } diff --git a/client/src/components/shared/PlaceCover.tsx b/client/src/components/shared/PlaceCover.tsx new file mode 100644 index 000000000..078c1bc5f --- /dev/null +++ b/client/src/components/shared/PlaceCover.tsx @@ -0,0 +1,89 @@ +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 88c217a4e..ebe248008 100644 --- a/client/src/pages/CollectionsPage.tsx +++ b/client/src/pages/CollectionsPage.tsx @@ -1,8 +1,9 @@ import React from 'react' -import { LayoutGrid, List as ListIcon, Map as MapIcon, Search, Bookmark, Plus, CheckSquare, X, Trash2, Copy, Share2, Users } from 'lucide-react' +import { LayoutGrid, 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' @@ -10,26 +11,29 @@ 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, StatusFilter } from '../store/collectionStore' -import { STATUS_META, STATUS_ORDER, mappablePlaces } from './collections/collectionsModel' +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 ( @@ -39,40 +43,13 @@ function ViewSwitch({ view, onChange, t }: { view: CollectionView; onChange: (v: ) } -function StatusChips({ - statusFilter, counts, onChange, t, -}: { statusFilter: StatusFilter; counts: Record; onChange: (f: StatusFilter) => void; t: TranslationFn }): React.ReactElement { - const chips: { key: StatusFilter; label: string; color?: string }[] = [ - { key: 'all', label: t('collections.status.filterAll') }, - ...STATUS_ORDER.map(s => ({ key: s as StatusFilter, label: t(STATUS_META[s].labelKey), color: STATUS_META[s].color })), - ] +function EmptyState({ icon, title, text, action }: { icon: React.ReactNode; title: string; text: string; action?: React.ReactNode }): React.ReactElement { return ( -
- {chips.map(chip => { - const active = statusFilter === chip.key - return ( - - ) - })} -
- ) -} - -function EmptyState({ icon, title, text }: { icon: React.ReactNode; title: string; text: string }): React.ReactElement { - return ( -
-
{icon}
-

{title}

-

{text}

+
+
{icon}
+

{title}

+

{text}

+ {action &&
{action}
}
) } @@ -82,33 +59,22 @@ export default function CollectionsPage(): React.ReactElement { const { t } = c const title = c.isAllSaved ? t('collections.allSaved') : (c.activeCollection?.name ?? t('collections.title')) + const isShared = c.activeCollection?.is_owner === false + const eyebrow = c.isAllSaved ? t('collections.hero.all') : (isShared ? t('collections.hero.shared') : t('collections.hero.mine')) + const heroColor = c.activeCollection?.color || '#6366f1' + const heroCover = c.activeCollection?.cover_image ?? null + const hasPlaces = c.places.length > 0 const noLists = !c.loading && c.collections.length === 0 - const showSelect = !c.isAllSaved && (c.isOwner || c.activeCollection?.is_owner === false) + const showSelect = !c.isAllSaved && c.activeCollection != null let body: React.ReactElement if (c.placesLoading && !hasPlaces) { - body = ( -
-
-
- ) + body =
} else if (!hasPlaces) { - body = ( - } - title={t('collections.empty.title')} - text={t('collections.empty.text')} - /> - ) + 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')} - /> - ) + body = } title={t('collections.empty.noMatchTitle')} text={t('collections.empty.noMatchText')} /> } else if (c.view === 'grid') { body = ( } - title={t('collections.empty.noMatchTitle')} - text={t('collections.empty.noMatchText')} - /> + } title={t('collections.empty.noMatchTitle')} text={t('collections.empty.noMatchText')} /> ) : ( - +
+ +
) } + const rail = ( + { c.setMobileRailOpen(false); c.setShowNewList(true) }} + onStartRename={c.handleStartRename} + onCommitRename={c.handleCommitRename} + onSetColor={c.handleSetColor} + onRequestDelete={c.setConfirmDeleteList} + onAcceptInvite={c.handleAcceptInvite} + onDeclineInvite={c.handleDeclineInvite} + t={t} + /> + ) + return ( -
+ <> -
- {/* Lists rail — desktop sidebar / mobile slide-over */} - - - {/* Main column */} -
-
- {/* Header */} -
-
- -

{title}

- {c.activeCollection?.is_owner === false && ( - {t('collections.shared')} - )} -
-
- - {c.canShare && ( - - )} - {showSelect && ( - - )} - -
-
- - {/* Toolbar: search + status chips */} -
- -
- - c.setSearch(e.target.value)} - placeholder={t('collections.search')} - className="w-44 sm:w-56 pl-8 pr-3 py-1.5 rounded-lg border border-edge bg-surface-input text-content text-[13px] outline-none focus:border-accent" - /> -
-
- - {/* Select-mode action bar */} - {c.selectMode && c.selectedIds.length > 0 && ( -
- {t('collections.selectedCount', { count: c.selectedIds.length })} -
- - - -
- )} +
+
+ +
{noLists ? ( - } title={t('collections.empty.firstTitle')} text={t('collections.empty.firstText')} /> - ) : body} -
-
-
+ } + title={t('collections.empty.firstTitle')} + text={t('collections.empty.firstText')} + action={ + + } + /> + ) : ( + <> + c.setShowShare(true)} + onNewList={() => c.setShowNewList(true)} + t={t} + /> - {/* Mobile rail slide-over */} - {c.mobileRailOpen && ( -
c.setMobileRailOpen(false)}> -
-
e.stopPropagation()}> - { c.setMobileRailOpen(false); c.setShowNewList(true) }} - onStartRename={c.handleStartRename} - onCommitRename={c.handleCommitRename} - onSetColor={c.handleSetColor} - onRequestDelete={c.setConfirmDeleteList} - onAcceptInvite={c.handleAcceptInvite} - onDeclineInvite={c.handleDeclineInvite} - t={t} - /> +
+ + + {showSelect && ( + + )} +
+
+ + c.setSearch(e.target.value)} + placeholder={t('collections.search')} + /> +
+
+ + {c.selectMode && c.selectedIds.length > 0 && ( +
+ {t('collections.selectedCount', { count: c.selectedIds.length })} +
+ + + +
+ )} + + {body} + + )}
- )} - {/* Detail panel — re-pointed PlaceInspector in collection mode. The outer - layer is click-through (pointer-events-none) so the grid behind stays - interactive; only the floating card re-enables pointer events. */} + {/* Mobile rail drawer */} + {c.mobileRailOpen && ( + <> +
c.setMobileRailOpen(false)} /> +
{rail}
+ + )} +
+ + {/* Detail panel — re-pointed PlaceInspector in collection mode. Kept outside + .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 && (
@@ -410,6 +341,6 @@ export default function CollectionsPage(): React.ReactElement { >

{t('collections.deleteListConfirm')}

-
+ ) } diff --git a/client/src/pages/collections/collectionsModel.ts b/client/src/pages/collections/collectionsModel.ts index cbd163cbe..fda8becb2 100644 --- a/client/src/pages/collections/collectionsModel.ts +++ b/client/src/pages/collections/collectionsModel.ts @@ -14,14 +14,16 @@ export interface StatusMeta { icon: LucideIcon /** i18n key for the human label. */ labelKey: string - /** CSS colour token / hex for the badge. */ + /** CSS colour token / hex for the badge on a light/surface background. */ color: string + /** Brighter variant for a pill sitting over a photo cover / the hero scrim. */ + coverColor: string } export const STATUS_META: Record = { - idea: { icon: Circle, labelKey: 'collections.status.idea', color: 'var(--text-muted)' }, - want: { icon: Bookmark, labelKey: 'collections.status.want', color: 'var(--accent)' }, - visited: { icon: CheckCircle2, labelKey: 'collections.status.visited', color: '#10b981' }, + idea: { icon: Circle, labelKey: 'collections.status.idea', color: 'var(--text-muted)', coverColor: '#e5e7eb' }, + want: { icon: Bookmark, labelKey: 'collections.status.want', color: 'var(--accent)', coverColor: '#c7d2fe' }, + visited: { icon: CheckCircle2, labelKey: 'collections.status.visited', color: '#10b981', coverColor: '#6ee7b7' }, } /** Stable order for the filter chips + the one-tap cycle. */ diff --git a/client/src/styles/collections.css b/client/src/styles/collections.css new file mode 100644 index 000000000..6291d57e0 --- /dev/null +++ b/client/src/styles/collections.css @@ -0,0 +1,326 @@ +/* ============================================================================ + Collections page — visual language built on the dashboard glass tokens. + Imports nothing itself: it relies on the `.trek-dash` design tokens declared + in dashboard.css (which the page imports first), so light/dark, glass + surfaces, radii and shadows all match the dashboard for free. Only the pieces + unique to collections live here — the list rail, the hero, the place cards, + the status pills and the mobile rail drawer. + ============================================================================ */ + +/* The page wrapper: dashboard background + font, normal document flow (no fixed + shell), cleared below the fixed navbar. */ +.trek-dash.col-root { min-height: 100vh; padding-top: var(--nav-h); } + +/* Sticky left list rail + fluid body. */ +.trek-dash .col-page { + max-width: 1720px; margin: 0 auto; padding: 30px 44px 110px; + display: grid; grid-template-columns: 272px minmax(0, 1fr); gap: 30px; align-items: start; +} +.trek-dash .col-body { min-width: 0; } + +/* ----------------- list rail ----------------- */ +.trek-dash .col-rail { + position: sticky; top: calc(var(--nav-h) + 22px); + background: var(--glass-bg); border: 1px solid var(--glass-border); + border-radius: var(--r-xl); box-shadow: var(--glass-shadow), var(--glass-highlight); + backdrop-filter: var(--glass-blur); -webkit-backdrop-filter: var(--glass-blur); + padding: 12px; display: flex; flex-direction: column; gap: 2px; + max-height: calc(100vh - var(--nav-h) - 44px); overflow-y: auto; overscroll-behavior: contain; +} +.trek-dash .col-rail-new { + display: flex; align-items: center; gap: 9px; width: 100%; + padding: 10px 12px; margin-bottom: 6px; border-radius: var(--r-sm); + border: 1px dashed var(--line-2); color: var(--ink-2); + font-size: 13px; font-weight: 500; transition: background .15s, color .15s, border-color .15s; +} +.trek-dash .col-rail-new:hover { background: var(--surface-2); color: var(--ink); border-color: var(--ink-3); } +.trek-dash .col-rail-label { + display: flex; align-items: center; gap: 6px; padding: 14px 11px 5px; + font-size: 10.5px; text-transform: uppercase; letter-spacing: .13em; color: var(--ink-3); font-weight: 600; +} +.trek-dash .col-rail-label .badge { + margin-left: auto; min-width: 18px; height: 18px; padding: 0 5px; border-radius: 9px; + display: grid; place-items: center; background: var(--accent); color: #fff; font-size: 10px; letter-spacing: 0; +} +.trek-dash .col-rail-sep { height: 1px; background: var(--line); margin: 8px 6px; } + +.trek-dash .col-row { position: relative; } +.trek-dash .col-row-btn { + display: flex; align-items: center; gap: 10px; width: 100%; + padding: 9px 11px; border-radius: var(--r-sm); text-align: left; + color: var(--ink-2); font-size: 13.5px; font-weight: 500; transition: background .12s, color .12s; +} +.trek-dash .col-row-btn:hover { background: var(--surface-2); color: var(--ink); } +.trek-dash .col-row-btn.on { background: var(--surface-2); color: var(--ink); box-shadow: var(--sh-xs); } +.trek-dash .col-row-btn .dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; } +.trek-dash .col-row-btn .ico { color: var(--ink-3); flex-shrink: 0; display: grid; place-items: center; } +.trek-dash .col-row-btn.on .ico { color: var(--ink-2); } +.trek-dash .col-row-btn .nm { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.trek-dash .col-row-btn .ct { font-size: 11.5px; color: var(--ink-3); font-variant-numeric: tabular-nums; flex-shrink: 0; } +.trek-dash .col-row-menu { + position: absolute; right: 6px; top: 50%; transform: translateY(-50%); + width: 26px; height: 26px; border-radius: 7px; display: grid; place-items: center; + color: var(--ink-3); opacity: 0; transition: opacity .12s, background .12s; +} +.trek-dash .col-row:hover .col-row-menu { opacity: 1; } +.trek-dash .col-row-menu:hover { background: var(--bg-2); color: var(--ink); } + +/* rename inline input */ +.trek-dash .col-row-edit { + width: 100%; padding: 8px 11px; border-radius: var(--r-sm); + border: 1px solid var(--accent); background: var(--surface); color: var(--ink); + font-size: 13.5px; font-weight: 500; outline: none; +} + +/* row popover (menu / colour) */ +.trek-dash .col-pop { + position: absolute; right: 4px; top: calc(50% + 16px); z-index: 30; min-width: 168px; + padding: 5px; border-radius: var(--r-sm); background: var(--surface); border: 1px solid var(--line); + box-shadow: var(--sh-lg); +} +.trek-dash .col-pop-item { + display: flex; align-items: center; gap: 9px; width: 100%; padding: 8px 10px; + border-radius: 9px; font-size: 12.5px; color: var(--ink-2); font-weight: 500; transition: background .12s; +} +.trek-dash .col-pop-item:hover { background: var(--surface-2); color: var(--ink); } +.trek-dash .col-pop-item.danger { color: oklch(0.6 0.2 25); } +.dark .trek-dash .col-pop-item.danger { color: oklch(0.72 0.17 25); } +.trek-dash .col-pop-swatches { display: grid; grid-template-columns: repeat(4, 1fr); gap: 7px; padding: 6px; } +.trek-dash .col-swatch { width: 26px; height: 26px; border-radius: 50%; display: grid; place-items: center; color: #fff; } + +/* incoming-invite card */ +.trek-dash .col-invite { padding: 11px; border-radius: var(--r-sm); background: var(--surface-2); margin: 2px 0; } +.trek-dash .col-invite .t { font-size: 12.5px; font-weight: 600; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.trek-dash .col-invite .s { font-size: 11px; color: var(--ink-3); margin-top: 1px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.trek-dash .col-invite-actions { display: flex; gap: 7px; margin-top: 9px; } +.trek-dash .col-invite-actions button { flex: 1; padding: 6px 8px; border-radius: 8px; font-size: 11.5px; font-weight: 600; transition: background .12s, opacity .12s; } +.trek-dash .col-invite-accept { background: var(--accent); color: #fff; } +.trek-dash .col-invite-accept:hover { opacity: .9; } +.trek-dash .col-invite-decline { border: 1px solid var(--line-2); color: var(--ink-2); } +.trek-dash .col-invite-decline:hover { background: var(--surface); } + +/* ----------------- hero ----------------- */ +.trek-dash .col-hero { + position: relative; isolation: isolate; overflow: hidden; display: flex; + border-radius: var(--r-2xl); box-shadow: var(--sh-lg); + min-height: 232px; margin-bottom: 26px; +} +.trek-dash .col-hero-bg { + position: absolute; inset: 0; z-index: 0; + background: + radial-gradient(135% 130% at 10% 6%, color-mix(in oklch, var(--hero-color, var(--accent)) 55%, white) 0%, transparent 50%), + linear-gradient(135deg, var(--hero-color, var(--accent)) 0%, color-mix(in oklch, var(--hero-color, var(--accent)) 60%, black 40%) 100%); +} +.trek-dash .col-hero-img { position: absolute; inset: 0; z-index: 0; width: 100%; height: 100%; object-fit: cover; } +.trek-dash .col-hero-scrim { + position: absolute; inset: 0; z-index: 1; + background: + linear-gradient(180deg, rgba(0,0,0,.20) 0%, rgba(0,0,0,0) 42%, rgba(0,0,0,.52) 100%), + linear-gradient(90deg, rgba(0,0,0,.30) 0%, rgba(0,0,0,0) 62%); +} +.trek-dash .col-hero-content { + position: relative; z-index: 2; width: 100%; padding: 26px 30px 24px; + display: flex; flex-direction: column; justify-content: flex-end; gap: 15px; color: #fff; +} +.trek-dash .col-hero-actions { position: absolute; top: 20px; right: 22px; z-index: 3; display: flex; gap: 8px; } +.trek-dash .col-glass-btn { + display: inline-flex; align-items: center; gap: 7px; height: 38px; padding: 0 15px; border-radius: 999px; + background: rgba(255,255,255,.16); border: 1px solid rgba(255,255,255,.24); color: #fff; + font-size: 13px; font-weight: 600; letter-spacing: -.005em; + backdrop-filter: blur(14px) saturate(1.4); -webkit-backdrop-filter: blur(14px) saturate(1.4); + transition: background .15s, transform .12s; +} +.trek-dash .col-glass-btn:hover { background: rgba(255,255,255,.28); transform: translateY(-1px); } +.trek-dash .col-glass-btn.icon { width: 38px; padding: 0; justify-content: center; } +.trek-dash .col-glass-btn .cnt { + min-width: 18px; height: 18px; padding: 0 5px; border-radius: 9px; display: grid; place-items: center; + background: rgba(255,255,255,.28); font-size: 10.5px; font-weight: 700; font-variant-numeric: tabular-nums; +} +.trek-dash .col-hero-eyebrow { + display: inline-flex; align-items: center; gap: 9px; font-size: 11.5px; + letter-spacing: .18em; text-transform: uppercase; font-weight: 600; opacity: .94; +} +.trek-dash .col-hero-eyebrow .members { display: inline-flex; align-items: center; margin-left: 2px; } +.trek-dash .col-hero-title { + font-size: 46px; font-weight: 600; letter-spacing: -.03em; line-height: 1; margin: 0; + text-shadow: 0 1px 12px rgba(0,0,0,.3), 0 1px 3px rgba(0,0,0,.35); overflow-wrap: anywhere; +} +.trek-dash .col-hero-stats { display: flex; flex-wrap: wrap; gap: 8px; } +.trek-dash .col-chip { + display: inline-flex; align-items: center; gap: 7px; padding: 8px 13px; border-radius: 999px; + background: rgba(255,255,255,.15); border: 1px solid rgba(255,255,255,.22); color: #fff; + font-size: 12.5px; font-weight: 500; letter-spacing: -.005em; cursor: pointer; + backdrop-filter: blur(12px) saturate(1.4); -webkit-backdrop-filter: blur(12px) saturate(1.4); + transition: background .14s, border-color .14s, transform .12s; +} +.trek-dash .col-chip:hover { background: rgba(255,255,255,.24); transform: translateY(-1px); } +.trek-dash .col-chip.on { background: #fff; color: oklch(0.28 0.02 260); border-color: #fff; } +.trek-dash .col-chip .sw { width: 8px; height: 8px; border-radius: 50%; } +.trek-dash .col-chip .n { font-weight: 700; font-variant-numeric: tabular-nums; } + +/* member avatar stack in the eyebrow */ +.trek-dash .col-av { + width: 24px; height: 24px; border-radius: 50%; margin-left: -7px; object-fit: cover; + border: 2px solid rgba(255,255,255,.85); display: grid; place-items: center; + font-size: 10px; font-weight: 700; color: #fff; +} +.trek-dash .col-av:first-child { margin-left: 0; } + +/* ----------------- toolbar ----------------- */ +.trek-dash .col-toolbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 20px; } +.trek-dash .col-toolbar-spacer { flex: 1; } +.trek-dash .col-search { position: relative; } +.trek-dash .col-search svg { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); color: var(--ink-3); pointer-events: none; } +.trek-dash .col-search input { + height: 38px; width: 210px; padding: 0 14px 0 35px; border-radius: var(--r-sm); + border: 1px solid var(--line-2); background: var(--surface); color: var(--ink); + font-size: 13.5px; font-family: inherit; outline: none; transition: border-color .15s, box-shadow .15s; +} +.trek-dash .col-search input::placeholder { color: var(--ink-3); } +.trek-dash .col-search input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in oklch, var(--accent) 22%, transparent); } + +/* icon toggle button + segmented view switch reuse dashboard .seg for the group */ +.trek-dash .col-iconbtn { + height: 38px; width: 38px; border-radius: var(--r-sm); display: grid; place-items: center; + border: 1px solid var(--line-2); background: var(--surface); color: var(--ink-2); transition: background .12s, color .12s, border-color .12s; +} +.trek-dash .col-iconbtn:hover { background: var(--surface-2); color: var(--ink); } +.trek-dash .col-iconbtn.on { background: var(--accent); border-color: var(--accent); color: #fff; } +.trek-dash .col-viewseg { display: flex; padding: 3px; border-radius: var(--r-sm); background: oklch(0.94 0.008 70); } +.dark .trek-dash .col-viewseg { background: var(--bg-2); } +.trek-dash .col-viewseg button { width: 34px; height: 32px; border-radius: 8px; display: grid; place-items: center; color: var(--ink-2); transition: background .12s, color .12s; } +.trek-dash .col-viewseg button.on { background: var(--surface); color: var(--ink); box-shadow: var(--sh-xs); } + +/* select-mode action bar */ +.trek-dash .col-selbar { + display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 18px; padding: 10px 14px; + border-radius: var(--r-md); background: var(--glass-bg); border: 1px solid var(--glass-border); box-shadow: var(--sh-sm); +} +.trek-dash .col-selbar .lbl { font-size: 13px; font-weight: 600; color: var(--ink); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.trek-dash .col-selbar-btn { display: inline-flex; align-items: center; gap: 6px; padding: 7px 12px; border-radius: 9px; font-size: 12.5px; font-weight: 600; color: var(--ink-2); transition: background .12s, color .12s; } +.trek-dash .col-selbar-btn:hover { background: var(--surface-2); color: var(--ink); } +.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; +} +.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-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-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); } + +/* ----------------- list view ----------------- */ +.trek-dash .col-listview { display: flex; flex-direction: column; gap: 7px; } +.trek-dash .col-lrow { + display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 14px; align-items: center; + padding: 10px 14px; border-radius: var(--r-md); + background: var(--glass-bg); border: 1px solid var(--glass-border); box-shadow: var(--sh-xs); + cursor: pointer; transition: transform .12s, box-shadow .15s, border-color .15s; +} +.trek-dash .col-lrow:hover { transform: translateX(3px); box-shadow: var(--sh-sm); } +.trek-dash .col-lrow.sel { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); } +.trek-dash .col-lrow .li { min-width: 0; } +.trek-dash .col-lrow .li .t { font-size: 14px; font-weight: 600; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.trek-dash .col-lrow .li .s { font-size: 12px; color: var(--ink-3); margin-top: 2px; display: flex; align-items: center; gap: 5px; overflow: hidden; } +.trek-dash .col-lrow .li .s span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.trek-dash .col-lcheck { width: 22px; height: 22px; border-radius: 7px; display: grid; place-items: center; background: var(--surface-2); border: 1px solid var(--line-2); color: transparent; flex-shrink: 0; } +.trek-dash .col-lcheck.on { background: var(--accent); border-color: var(--accent); color: #fff; } + +/* map view fills the body */ +.trek-dash .col-mapwrap { + border-radius: var(--r-xl); overflow: hidden; border: 1px solid var(--glass-border); box-shadow: var(--glass-shadow); + height: calc(100vh - var(--nav-h) - 320px); min-height: 380px; +} + +/* The status pill itself (idea/want/visited) is the StatusBadge component, + styled with utility classes so it also works outside .trek-dash (the place + inspector). It is a role=button span, not a