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 (
-
(selectMode ? onToggleSelect(place.id) : onOpenPlace(place.id))}
- className={`group relative flex flex-col rounded-xl border bg-surface-card text-left overflow-hidden transition-all hover:shadow-card hover:-translate-y-0.5 ${active || selected ? 'border-accent ring-1 ring-accent' : 'border-edge'}`}
+ 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' : ''}`}
>
- {/* Photo band */}
-
-
-
+
+
+ {selectMode && (
+
+ {selected && }
+
+ )}
+
onStatusChange(place.id, next)}
t={t}
/>
-
- {selectMode && (
-
- {selected && }
-
- )}
+
+
{place.name}
- {/* Body */}
-
-
{place.name}
- {(place.category?.name || place.address) && (
-
- {place.category?.name || place.address}
+ {sub && (
+
+
+ {place.category?.name
+ ?
+ : }
+ {sub}
- )}
-
-
+
+ )}
+
)
})}
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 (
+
+ )
+}
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 (
-
-
onSelect(list.id)}
- className={`w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-left transition-colors ${active ? 'bg-surface-selected text-content' : 'text-content-secondary hover:bg-surface-hover'}`}
- >
-
- {list.name}
- {list.place_count ?? 0}
+
+
onSelect(list.id)} className={`col-row-btn${active ? ' on' : ''}`}>
+
+ {list.name}
+ {list.place_count ?? 0}
{ e.stopPropagation(); setMenuOpen(o => !o); setColorOpen(false) }}
- className="absolute right-1.5 top-1/2 -translate-y-1/2 p-1 rounded-md text-content-faint opacity-0 group-hover:opacity-100 hover:bg-surface-hover transition-opacity"
+ className="col-row-menu"
aria-label={t('collections.listMenu')}
>
{menuOpen && !colorOpen && (
-
-
{ setMenuOpen(false); onStartRename(list.id, list.name) }} className="w-full flex items-center gap-2 px-3 py-1.5 text-[12px] text-content-secondary hover:bg-surface-hover">
- {t('collections.editList')}
+
+
{ setMenuOpen(false); onStartRename(list.id, list.name) }} className="col-pop-item">
+ {t('collections.editList')}
-
setColorOpen(true)} className="w-full flex items-center gap-2 px-3 py-1.5 text-[12px] text-content-secondary hover:bg-surface-hover">
- {t('collections.listColor')}
+ setColorOpen(true)} className="col-pop-item">
+ {t('collections.listColor')}
{canDelete && (
- { setMenuOpen(false); onRequestDelete(list.id) }} className="w-full flex items-center gap-2 px-3 py-1.5 text-[12px] text-danger hover:bg-surface-hover">
- {t('collections.deleteList')}
+ { setMenuOpen(false); onRequestDelete(list.id) }} className="col-pop-item danger">
+ {t('collections.deleteList')}
)}
)}
{colorOpen && (
-
-
+
+
{SWATCHES.map(c => (
{ onSetColor(list.id, c); setColorOpen(false); setMenuOpen(false) }}
- className="w-6 h-6 rounded-full flex items-center justify-center"
+ className="col-swatch"
style={{ background: c }}
aria-label={c}
>
- {list.color === c && }
+ {list.color === 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 (
-
-
- {t('collections.newList')}
+ <>
+
+ {t('collections.newList')}
- onSelect(ALL_SAVED)}
- className={`w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-left transition-colors ${activeId === ALL_SAVED ? 'bg-surface-selected text-content' : 'text-content-secondary hover:bg-surface-hover'}`}
- >
-
- {t('collections.allSaved')}
-
+
+ onSelect(ALL_SAVED)} className={`col-row-btn${activeId === ALL_SAVED ? ' on' : ''}`}>
+
+ {t('collections.allSaved')}
+
+
- {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}
-
-
onAcceptInvite(inv.collection_id)} className="flex-1 px-2 py-1 rounded-md bg-accent text-accent-text text-[11px] font-semibold hover:bg-accent-hover">
+
+
{inv.name}
+
{t('collections.invites.from')} {inv.from.username}
+
+ onAcceptInvite(inv.collection_id)} className="col-invite-accept">
{t('collections.invites.accept')}
- onDeclineInvite(inv.collection_id)} className="flex-1 px-2 py-1 rounded-md border border-edge text-content-secondary text-[11px] font-semibold hover:bg-surface-hover">
+ onDeclineInvite(inv.collection_id)} className="col-invite-decline">
{t('collections.invites.decline')}
@@ -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 ) on purpose: inside
+ // the .trek-dash scope the global `.trek-dash button` reset would strip the
+ // pill's background/border/padding, and the pill also has to sit inside the
+ // grid card's own clickable element without nesting one button in another.
return (
- { if (e.key === 'Enter' || e.key === ' ') cycle(e) }}
title={`${label} — ${t('collections.status.cycleHint')}`}
aria-label={label}
- className={`${className} transition-transform hover:scale-105 active:scale-95 cursor-pointer`}
+ className={`${className} transition-transform hover:scale-110 active:scale-95 cursor-pointer select-none`}
>
{content}
-
+
)
}
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 (
+
+
{
+ 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 (
onChange(v)}
aria-label={t(`collections.view.${v}`)}
title={t(`collections.view.${v}`)}
- className={`p-1.5 rounded-md transition-colors ${view === v ? 'bg-surface-card text-content shadow-sm' : 'text-content-faint hover:text-content-secondary'}`}
+ className={view === v ? 'on' : ''}
>
@@ -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 (
- onChange(chip.key)}
- className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[12px] font-medium border transition-colors ${active ? 'bg-inverse text-inverse-text border-transparent' : 'bg-surface-card text-content-secondary border-edge hover:bg-surface-hover'}`}
- >
- {chip.color && }
- {chip.label}
- {counts[chip.key]}
-
- )
- })}
-
- )
-}
-
-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 */}
-
- c.setShowNewList(true)}
- onStartRename={c.handleStartRename}
- onCommitRename={c.handleCommitRename}
- onSetColor={c.handleSetColor}
- onRequestDelete={c.setConfirmDeleteList}
- onAcceptInvite={c.handleAcceptInvite}
- onDeclineInvite={c.handleDeclineInvite}
- t={t}
- />
-
-
- {/* Main column */}
-
-
- {/* Header */}
-
-
- c.setMobileRailOpen(true)}
- className="md:hidden p-2 -ml-1 rounded-lg text-content-secondary hover:bg-surface-hover"
- aria-label={t('collections.title')}
- >
-
-
-
{title}
- {c.activeCollection?.is_owner === false && (
- {t('collections.shared')}
- )}
-
-
-
- {c.canShare && (
-
c.setShowShare(true)}
- className="relative inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-[12px] font-medium border bg-surface-card text-content-secondary border-edge hover:bg-surface-hover transition-colors"
- >
- {c.isOwner ? : }
- {c.isOwner ? t('collections.share.button') : t('collections.shared')}
- {c.shareMemberCount > 0 && (
-
- {c.shareMemberCount}
-
- )}
-
- )}
- {showSelect && (
-
c.setSelectMode(!c.selectMode)}
- className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-[12px] font-medium border transition-colors ${c.selectMode ? 'bg-accent text-accent-text border-accent' : 'bg-surface-card text-content-secondary border-edge hover:bg-surface-hover'}`}
- >
- {t('collections.selectMode')}
-
- )}
-
c.setShowNewList(true)}
- className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-[12px] font-semibold bg-inverse text-inverse-text hover:opacity-90"
- >
- {t('collections.newList')}
-
-
-
-
- {/* 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 })}
-
-
- {t('collections.copyN', { count: c.selectedIds.length })}
-
-
- {t('common.delete')}
-
-
c.setSelectMode(false)} className="p-1 rounded-md text-content-faint hover:bg-surface-hover" aria-label={t('common.cancel')}>
-
-
-
- )}
+
+
+
+
{noLists ? (
- } title={t('collections.empty.firstTitle')} text={t('collections.empty.firstText')} />
- ) : body}
-
-
-
+
}
+ title={t('collections.empty.firstTitle')}
+ text={t('collections.empty.firstText')}
+ action={
+
c.setShowNewList(true)} className="col-cta">
+ {t('collections.newList')}
+
+ }
+ />
+ ) : (
+ <>
+
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}
- />
+
+
c.setMobileRailOpen(true)}>
+ {t('collections.title')}
+
+
+ {showSelect && (
+
c.setSelectMode(!c.selectMode)}
+ className={`col-iconbtn${c.selectMode ? ' on' : ''}`}
+ aria-label={t('collections.selectMode')}
+ title={t('collections.selectMode')}
+ >
+
+
+ )}
+
+
+
+ c.setSearch(e.target.value)}
+ placeholder={t('collections.search')}
+ />
+
+
+
+ {c.selectMode && c.selectedIds.length > 0 && (
+
+
{t('collections.selectedCount', { count: c.selectedIds.length })}
+
+
+ {t('collections.copyN', { count: c.selectedIds.length })}
+
+
+ {t('common.delete')}
+
+
c.setSelectMode(false)} className="col-selbar-btn" aria-label={t('common.cancel')}>
+
+
+
+ )}
+
+ {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 , so the global
+ `.trek-dash button` reset can't strip its background/border/padding. */
+
+/* ----------------- primary CTA (empty state) ----------------- */
+.trek-dash .col-cta {
+ display: inline-flex; align-items: center; gap: 8px; height: 42px; padding: 0 22px; border-radius: 999px;
+ background: var(--ink); color: var(--bg); font-size: 14px; font-weight: 600; letter-spacing: -.005em;
+ box-shadow: var(--sh-md), inset 0 1px 0 oklch(1 0 0 / .12); transition: transform .12s, box-shadow .15s;
+}
+.trek-dash .col-cta:hover { transform: translateY(-1px); box-shadow: var(--sh-lg); }
+.trek-dash .col-cta:active { transform: translateY(0); }
+
+/* ----------------- empty / loading ----------------- */
+.trek-dash .col-emptystate { display: flex; flex-direction: column; align-items: center; text-align: center; padding: 84px 24px; }
+.trek-dash .col-emptystate .ic { width: 60px; height: 60px; border-radius: var(--r-md); display: grid; place-items: center; margin-bottom: 18px; background: var(--surface-2); color: var(--ink-3); }
+.trek-dash .col-emptystate h3 { font-size: 17px; font-weight: 600; color: var(--ink); margin: 0; }
+.trek-dash .col-emptystate p { font-size: 13.5px; color: var(--ink-3); margin: 7px 0 0; max-width: 320px; }
+.trek-dash .col-loading { display: grid; place-items: center; padding: 96px 0; }
+.trek-dash .col-spinner { width: 30px; height: 30px; border-radius: 50%; border: 3px solid var(--line-2); border-top-color: var(--ink-2); animation: col-spin .7s linear infinite; }
+@keyframes col-spin { to { transform: rotate(360deg); } }
+
+/* ----------------- mobile rail toggle (hidden on desktop) ----------------- */
+.trek-dash .col-rail-toggle { display: none; }
+
+/* ----------------- responsive ----------------- */
+@media (max-width: 1080px) {
+ .trek-dash .col-page { grid-template-columns: minmax(0, 1fr); padding: 24px 28px 120px; }
+ .trek-dash .col-rail { display: none; }
+ .trek-dash .col-rail-toggle {
+ display: inline-flex; align-items: center; gap: 8px; height: 38px; padding: 0 14px; border-radius: var(--r-sm);
+ border: 1px solid var(--line-2); background: var(--surface); color: var(--ink-2); font-size: 13px; font-weight: 600;
+ }
+ .trek-dash .col-rail-toggle:hover { background: var(--surface-2); color: var(--ink); }
+}
+@media (max-width: 720px) {
+ .trek-dash.col-root { padding-top: 0; }
+ .trek-dash .col-page { padding: 14px 14px 130px; }
+ .trek-dash .col-hero { min-height: 178px; border-radius: var(--r-xl); margin-bottom: 18px; }
+ .trek-dash .col-hero-content { padding: 18px 18px 16px; gap: 12px; }
+ .trek-dash .col-hero-title { font-size: 30px; }
+ .trek-dash .col-hero-actions { top: 14px; right: 14px; }
+ .trek-dash .col-glass-btn span.txt { display: none; }
+ .trek-dash .col-glass-btn { padding: 0; width: 38px; justify-content: center; }
+ .trek-dash .col-glass-btn.has-count { width: auto; padding: 0 12px; }
+ .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; }
+}
+
+/* ----------------- mobile rail drawer ----------------- */
+.trek-dash .col-drawer-backdrop { position: fixed; inset: 0; z-index: 150; background: rgba(15,23,42,.5); }
+.trek-dash .col-drawer {
+ position: fixed; left: 0; top: 0; bottom: 0; z-index: 151; width: 288px; max-width: 82vw;
+ background: var(--surface); border-right: 1px solid var(--line); padding: 12px; overflow-y: auto;
+ padding-top: calc(var(--nav-h) + 12px);
+}
+@media (max-width: 720px) { .trek-dash .col-drawer { padding-top: 16px; } }
diff --git a/client/src/utils/gradients.ts b/client/src/utils/gradients.ts
new file mode 100644
index 000000000..61f0c21ce
--- /dev/null
+++ b/client/src/utils/gradients.ts
@@ -0,0 +1,22 @@
+/**
+ * Vibrant two-stop cover gradients — the fallback backdrop for any entity that
+ * has no photo yet (a trip, a saved place). Picked by a stable numeric id so a
+ * given entity always keeps the same colour. Mirrors the dashboard trip-card
+ * palette so collection cards feel of a piece with the dashboard.
+ */
+export const GRADIENTS = [
+ 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
+ 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)',
+ 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)',
+ 'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)',
+ 'linear-gradient(135deg, #fa709a 0%, #fee140 100%)',
+ 'linear-gradient(135deg, #a18cd1 0%, #fbc2eb 100%)',
+ 'linear-gradient(135deg, #ff9a9e 0%, #fad0c4 100%)',
+ 'linear-gradient(135deg, #30cfd0 0%, #330867 100%)',
+] as const
+
+/** Deterministic gradient for a numeric id (handles negatives defensively). */
+export function entityGradient(id: number): string {
+ const i = ((id % GRADIENTS.length) + GRADIENTS.length) % GRADIENTS.length
+ return GRADIENTS[i]
+}
diff --git a/shared/src/i18n/ar/collection.ts b/shared/src/i18n/ar/collection.ts
index 4dc7a73d4..90cecc59d 100644
--- a/shared/src/i18n/ar/collection.ts
+++ b/shared/src/i18n/ar/collection.ts
@@ -1,6 +1,9 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
+ 'collections.hero.mine': 'مجموعتي',
+ 'collections.hero.shared': 'تمت مشاركتها معك',
+ 'collections.hero.all': 'مكتبتك',
'collections.title': 'المجموعات',
'collections.subtitle': 'أماكنك المحفوظة، منظمة في قوائم',
diff --git a/shared/src/i18n/br/collection.ts b/shared/src/i18n/br/collection.ts
index f865fcfc2..552c3ad6f 100644
--- a/shared/src/i18n/br/collection.ts
+++ b/shared/src/i18n/br/collection.ts
@@ -1,6 +1,9 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
+ 'collections.hero.mine': 'Minha coleção',
+ 'collections.hero.shared': 'Compartilhada com você',
+ 'collections.hero.all': 'Sua biblioteca',
'collections.title': 'Coleções',
'collections.subtitle': 'Seus lugares salvos, organizados em listas',
diff --git a/shared/src/i18n/cs/collection.ts b/shared/src/i18n/cs/collection.ts
index 7ac6d32e6..169105967 100644
--- a/shared/src/i18n/cs/collection.ts
+++ b/shared/src/i18n/cs/collection.ts
@@ -1,6 +1,9 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
+ 'collections.hero.mine': 'Moje sbírka',
+ 'collections.hero.shared': 'Sdíleno s vámi',
+ 'collections.hero.all': 'Vaše knihovna',
'collections.title': 'Sbírky',
'collections.subtitle': 'Vaše uložená místa uspořádaná do seznamů',
diff --git a/shared/src/i18n/de/collection.ts b/shared/src/i18n/de/collection.ts
index 09c9b63d6..d4bed63d2 100644
--- a/shared/src/i18n/de/collection.ts
+++ b/shared/src/i18n/de/collection.ts
@@ -1,6 +1,9 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
+ 'collections.hero.mine': 'Meine Sammlung',
+ 'collections.hero.shared': 'Mit dir geteilt',
+ 'collections.hero.all': 'Deine Bibliothek',
'collections.title': 'Sammlungen',
'collections.subtitle': 'Deine gespeicherten Orte, in Listen organisiert',
diff --git a/shared/src/i18n/en/collection.ts b/shared/src/i18n/en/collection.ts
index 3414841f8..d5d45a6f6 100644
--- a/shared/src/i18n/en/collection.ts
+++ b/shared/src/i18n/en/collection.ts
@@ -1,6 +1,9 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
+ 'collections.hero.mine': 'My collection',
+ 'collections.hero.shared': 'Shared with you',
+ 'collections.hero.all': 'Your library',
'collections.title': 'Collections',
'collections.subtitle': 'Your saved places, organised into lists',
diff --git a/shared/src/i18n/es/collection.ts b/shared/src/i18n/es/collection.ts
index 508bbf64a..4f1b87531 100644
--- a/shared/src/i18n/es/collection.ts
+++ b/shared/src/i18n/es/collection.ts
@@ -1,6 +1,9 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
+ 'collections.hero.mine': 'Mi colección',
+ 'collections.hero.shared': 'Compartida contigo',
+ 'collections.hero.all': 'Tu biblioteca',
'collections.title': 'Colecciones',
'collections.subtitle': 'Tus lugares guardados, organizados en listas',
diff --git a/shared/src/i18n/fr/collection.ts b/shared/src/i18n/fr/collection.ts
index 9df38139c..a3532605f 100644
--- a/shared/src/i18n/fr/collection.ts
+++ b/shared/src/i18n/fr/collection.ts
@@ -1,6 +1,9 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
+ 'collections.hero.mine': 'Ma collection',
+ 'collections.hero.shared': 'Partagée avec vous',
+ 'collections.hero.all': 'Votre bibliothèque',
'collections.title': 'Collections',
'collections.subtitle': 'Vos lieux enregistrés, organisés en listes',
diff --git a/shared/src/i18n/gr/collection.ts b/shared/src/i18n/gr/collection.ts
index 4013f0416..9c981873c 100644
--- a/shared/src/i18n/gr/collection.ts
+++ b/shared/src/i18n/gr/collection.ts
@@ -1,6 +1,9 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
+ 'collections.hero.mine': 'Η συλλογή μου',
+ 'collections.hero.shared': 'Κοινή χρήση με εσάς',
+ 'collections.hero.all': 'Η βιβλιοθήκη σας',
'collections.title': 'Συλλογές',
'collections.subtitle': 'Τα αποθηκευμένα σου μέρη, οργανωμένα σε λίστες',
diff --git a/shared/src/i18n/hu/collection.ts b/shared/src/i18n/hu/collection.ts
index 4fe1bcb2f..056dca8c6 100644
--- a/shared/src/i18n/hu/collection.ts
+++ b/shared/src/i18n/hu/collection.ts
@@ -1,6 +1,9 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
+ 'collections.hero.mine': 'Saját gyűjtemény',
+ 'collections.hero.shared': 'Megosztva veled',
+ 'collections.hero.all': 'A könyvtárad',
'collections.title': 'Gyűjtemények',
'collections.subtitle': 'Mentett helyeid listákba rendezve',
diff --git a/shared/src/i18n/id/collection.ts b/shared/src/i18n/id/collection.ts
index 85ac9a9e7..396f5975c 100644
--- a/shared/src/i18n/id/collection.ts
+++ b/shared/src/i18n/id/collection.ts
@@ -1,6 +1,9 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
+ 'collections.hero.mine': 'Koleksi saya',
+ 'collections.hero.shared': 'Dibagikan dengan Anda',
+ 'collections.hero.all': 'Pustaka Anda',
'collections.title': 'Koleksi',
'collections.subtitle': 'Tempat yang kamu simpan, tertata dalam daftar',
diff --git a/shared/src/i18n/it/collection.ts b/shared/src/i18n/it/collection.ts
index ddf853c45..85118b079 100644
--- a/shared/src/i18n/it/collection.ts
+++ b/shared/src/i18n/it/collection.ts
@@ -1,6 +1,9 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
+ 'collections.hero.mine': 'La mia raccolta',
+ 'collections.hero.shared': 'Condivisa con te',
+ 'collections.hero.all': 'La tua libreria',
'collections.title': 'Raccolte',
'collections.subtitle': 'I tuoi luoghi salvati, organizzati in liste',
diff --git a/shared/src/i18n/ja/collection.ts b/shared/src/i18n/ja/collection.ts
index c24a6b1f4..89903e9c6 100644
--- a/shared/src/i18n/ja/collection.ts
+++ b/shared/src/i18n/ja/collection.ts
@@ -1,6 +1,9 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
+ 'collections.hero.mine': 'マイコレクション',
+ 'collections.hero.shared': '共有されています',
+ 'collections.hero.all': 'ライブラリ',
'collections.title': 'コレクション',
'collections.subtitle': '保存した場所をリストごとに整理',
diff --git a/shared/src/i18n/ko/collection.ts b/shared/src/i18n/ko/collection.ts
index 0913add6b..e54fe8ece 100644
--- a/shared/src/i18n/ko/collection.ts
+++ b/shared/src/i18n/ko/collection.ts
@@ -1,6 +1,9 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
+ 'collections.hero.mine': '내 컬렉션',
+ 'collections.hero.shared': '나와 공유됨',
+ 'collections.hero.all': '내 라이브러리',
'collections.title': '컬렉션',
'collections.subtitle': '저장한 장소를 목록으로 정리하세요',
diff --git a/shared/src/i18n/nl/collection.ts b/shared/src/i18n/nl/collection.ts
index 9d1cadf74..101b8ac07 100644
--- a/shared/src/i18n/nl/collection.ts
+++ b/shared/src/i18n/nl/collection.ts
@@ -1,6 +1,9 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
+ 'collections.hero.mine': 'Mijn collectie',
+ 'collections.hero.shared': 'Gedeeld met jou',
+ 'collections.hero.all': 'Jouw bibliotheek',
'collections.title': 'Collecties',
'collections.subtitle': 'Je opgeslagen plekken, geordend in lijsten',
diff --git a/shared/src/i18n/pl/collection.ts b/shared/src/i18n/pl/collection.ts
index 2eb158397..b2ecb8e33 100644
--- a/shared/src/i18n/pl/collection.ts
+++ b/shared/src/i18n/pl/collection.ts
@@ -1,6 +1,9 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
+ 'collections.hero.mine': 'Moja kolekcja',
+ 'collections.hero.shared': 'Udostępniono Tobie',
+ 'collections.hero.all': 'Twoja biblioteka',
'collections.title': 'Kolekcje',
'collections.subtitle': 'Twoje zapisane miejsca, uporządkowane na listach',
diff --git a/shared/src/i18n/ru/collection.ts b/shared/src/i18n/ru/collection.ts
index cefb845fe..c70120a91 100644
--- a/shared/src/i18n/ru/collection.ts
+++ b/shared/src/i18n/ru/collection.ts
@@ -1,6 +1,9 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
+ 'collections.hero.mine': 'Моя коллекция',
+ 'collections.hero.shared': 'Доступно вам',
+ 'collections.hero.all': 'Ваша библиотека',
'collections.title': 'Коллекции',
'collections.subtitle': 'Ваши сохранённые места, организованные в списки',
diff --git a/shared/src/i18n/sv/collection.ts b/shared/src/i18n/sv/collection.ts
index d14a3cf73..c87373be1 100644
--- a/shared/src/i18n/sv/collection.ts
+++ b/shared/src/i18n/sv/collection.ts
@@ -1,6 +1,9 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
+ 'collections.hero.mine': 'Min samling',
+ 'collections.hero.shared': 'Delad med dig',
+ 'collections.hero.all': 'Ditt bibliotek',
'collections.title': 'Samlingar',
'collections.subtitle': 'Dina sparade platser, organiserade i listor',
diff --git a/shared/src/i18n/tr/collection.ts b/shared/src/i18n/tr/collection.ts
index 6f0c4af55..f6c93a284 100644
--- a/shared/src/i18n/tr/collection.ts
+++ b/shared/src/i18n/tr/collection.ts
@@ -1,6 +1,9 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
+ 'collections.hero.mine': 'Koleksiyonum',
+ 'collections.hero.shared': 'Sizinle paylaşıldı',
+ 'collections.hero.all': 'Kitaplığınız',
'collections.title': 'Koleksiyonlar',
'collections.subtitle': 'Kaydettiğin yerler, listeler halinde düzenlenmiş',
diff --git a/shared/src/i18n/uk/collection.ts b/shared/src/i18n/uk/collection.ts
index a0abc770c..2c9714e78 100644
--- a/shared/src/i18n/uk/collection.ts
+++ b/shared/src/i18n/uk/collection.ts
@@ -1,6 +1,9 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
+ 'collections.hero.mine': 'Моя колекція',
+ 'collections.hero.shared': 'Поділено з вами',
+ 'collections.hero.all': 'Ваша бібліотека',
'collections.title': 'Колекції',
'collections.subtitle': 'Ваші збережені місця, упорядковані у списки',
diff --git a/shared/src/i18n/vi/collection.ts b/shared/src/i18n/vi/collection.ts
index 8786d3c6f..3da9fd233 100644
--- a/shared/src/i18n/vi/collection.ts
+++ b/shared/src/i18n/vi/collection.ts
@@ -1,6 +1,9 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
+ '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',
'collections.title': 'Bộ sưu tập',
'collections.subtitle': 'Những địa điểm bạn đã lưu, sắp xếp thành danh sách',
diff --git a/shared/src/i18n/zh-TW/collection.ts b/shared/src/i18n/zh-TW/collection.ts
index b6eea17a4..570e4f385 100644
--- a/shared/src/i18n/zh-TW/collection.ts
+++ b/shared/src/i18n/zh-TW/collection.ts
@@ -1,6 +1,9 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
+ 'collections.hero.mine': '我的收藏',
+ 'collections.hero.shared': '與你共享',
+ 'collections.hero.all': '你的收藏庫',
'collections.title': '收藏',
'collections.subtitle': '你儲存的地點,依清單整理',
diff --git a/shared/src/i18n/zh/collection.ts b/shared/src/i18n/zh/collection.ts
index 55a53f94a..40d8bb61c 100644
--- a/shared/src/i18n/zh/collection.ts
+++ b/shared/src/i18n/zh/collection.ts
@@ -1,6 +1,9 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
+ 'collections.hero.mine': '我的收藏',
+ 'collections.hero.shared': '与你共享',
+ 'collections.hero.all': '你的收藏库',
'collections.title': '收藏',
'collections.subtitle': '你保存的地点,按列表整理',