feat(collections): redesign the page on the dashboard glass language (#1081)

Rebuilds the /collections page from the functional placeholder into the
dashboard's glass visual language (light + dark):

- A colour-washed hero per list: eyebrow + member avatars, big title, and
  stat chips (All / Idea / Want / Visited) that double as the status filter.
- A sticky glass list rail (owned + shared + invites) with a mobile drawer.
- Gradient/photo cover place cards modelled on the trip cards, via a new
  rectangular PlaceCover (photoService-backed, gradient fallback) + a shared
  gradients util. List and map views restyled to match.
- Status pill rendered as a role=button span so it survives the .trek-dash
  button reset and can nest inside the card; the share member-count badge is
  now owner-only.
- New hero eyebrow strings across all locales.
This commit is contained in:
Maurice
2026-07-01 09:23:12 +02:00
committed by Maurice
parent 5f9da5b72f
commit 4e478722c3
33 changed files with 893 additions and 333 deletions
@@ -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 (
<div className="grid gap-3 [grid-template-columns:repeat(auto-fill,minmax(160px,1fr))]">
<div className="col-grid">
{places.map(place => {
const selected = selectedIds.includes(place.id)
const active = selectedPlaceId === place.id
const sub = place.category?.name || place.address
const activate = () => (selectMode ? onToggleSelect(place.id) : onOpenPlace(place.id))
return (
<button
<div
key={place.id}
type="button"
onClick={() => (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 */}
<div className="relative flex items-center justify-center h-28 bg-surface-secondary">
<PlaceAvatar place={place} size={64} category={place.category ? { color: place.category.color ?? undefined, icon: place.category.icon ?? undefined } : null} />
<div className="absolute top-2 right-2">
<div className="col-cover">
<PlaceCover place={place} />
{selectMode && (
<span className={`col-card-check${selected ? ' on' : ''}`}>
{selected && <Check size={14} strokeWidth={3} />}
</span>
)}
<span className="col-card-status">
<StatusBadge
status={place.status}
showLabel={false}
onCover
onChange={selectMode ? undefined : next => onStatusChange(place.id, next)}
t={t}
/>
</div>
{selectMode && (
<div className={`absolute top-2 left-2 w-5 h-5 rounded-md flex items-center justify-center border ${selected ? 'bg-accent border-accent' : 'bg-surface-card/90 border-edge'}`}>
{selected && <Check size={13} className="text-accent-text" strokeWidth={3} />}
</div>
)}
</span>
<span className="col-card-name">{place.name}</span>
</div>
{/* Body */}
<div className="flex flex-col gap-0.5 px-3 py-2.5 min-w-0">
<span className="text-[13px] font-semibold text-content truncate">{place.name}</span>
{(place.category?.name || place.address) && (
<span className="text-[11px] text-content-faint truncate">
{place.category?.name || place.address}
{sub && (
<div className="col-card-body">
<span className="col-card-cat">
{place.category?.name
? <span className="cdot" style={{ background: place.category.color || '#6366f1' }} />
: <MapPin size={13} />}
<span>{sub}</span>
</span>
)}
</div>
</button>
</div>
)}
</div>
)
})}
</div>
@@ -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, number>
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 (
<header className="col-hero" style={{ ['--hero-color' as string]: color }}>
{coverImage
? <img className="col-hero-img" src={coverImage} alt="" />
: <div className="col-hero-bg" />}
<div className="col-hero-scrim" />
<div className="col-hero-actions">
{canShare && (
<button
type="button"
onClick={onShare}
aria-label={isOwner ? t('collections.share.button') : t('collections.shared')}
title={isOwner ? t('collections.share.button') : t('collections.shared')}
className={`col-glass-btn${isOwner && shareMemberCount > 0 ? ' has-count' : ''}`}
>
{isOwner ? <Share2 size={15} /> : <Users size={15} />}
<span className="txt">{isOwner ? t('collections.share.button') : t('collections.shared')}</span>
{isOwner && shareMemberCount > 0 && <span className="cnt">{shareMemberCount}</span>}
</button>
)}
<button type="button" onClick={onNewList} aria-label={t('collections.newList')} title={t('collections.newList')} className="col-glass-btn">
<Plus size={15} /> <span className="txt">{t('collections.newList')}</span>
</button>
</div>
<div className="col-hero-content">
<div className="col-hero-eyebrow">
<span>{eyebrow}</span>
{showAvatars && (
<span className="members">
{shown.map(m => (
m.avatar
? <img key={m.user_id} className="col-av" src={m.avatar} alt={m.username} />
: <span key={m.user_id} className="col-av" style={{ background: AV_COLORS[m.user_id % AV_COLORS.length] }}>{initials(m.username)}</span>
))}
{extra > 0 && <span className="col-av" style={{ background: 'rgba(255,255,255,.28)' }}>+{extra}</span>}
</span>
)}
</div>
<h1 className="col-hero-title">{title}</h1>
<div className="col-hero-stats">
{chips.map(chip => (
<button
key={chip.key}
type="button"
onClick={() => onStatusFilter(chip.key)}
className={`col-chip${statusFilter === chip.key ? ' on' : ''}`}
>
{chip.color && <span className="sw" style={{ background: chip.color }} />}
{chip.label}
<span className="n">{counts[chip.key]}</span>
</button>
))}
</div>
</div>
</header>
)
}
@@ -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 (
<div className="flex flex-col rounded-xl border border-edge bg-surface-card overflow-hidden divide-y divide-edge-faint">
<div className="col-listview">
{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 && (
<div className={`w-5 h-5 rounded-md flex items-center justify-center border shrink-0 ${selected ? 'bg-accent border-accent' : 'bg-surface-card border-edge'}`}>
{selected && <Check size={13} className="text-accent-text" strokeWidth={3} />}
</div>
{selectMode ? (
<span className={`col-lcheck${selected ? ' on' : ''}`}>{selected && <Check size={14} strokeWidth={3} />}</span>
) : (
<PlaceAvatar place={place} size={40} category={place.category ? { color: place.category.color ?? undefined, icon: place.category.icon ?? undefined } : null} />
)}
<PlaceAvatar place={place} size={36} category={place.category ? { color: place.category.color ?? undefined, icon: place.category.icon ?? undefined } : null} />
<div className="flex flex-col min-w-0 flex-1">
<span className="text-[13px] font-semibold text-content truncate">{place.name}</span>
<div className="li">
<div className="t">{place.name}</div>
{(place.category?.name || place.address) && (
<span className="text-[11px] text-content-faint truncate flex items-center gap-1">
{!place.category?.name && <MapPin size={10} />}
{place.category?.name || place.address}
</span>
<div className="s">
{!place.category?.name && <MapPin size={11} />}
<span>{place.category?.name || place.address}</span>
</div>
)}
</div>
<StatusBadge status={place.status} onChange={next => onStatusChange(place.id, next)} t={t} />
<StatusBadge status={place.status} onChange={selectMode ? undefined : next => onStatusChange(place.id, next)} t={t} />
</div>
)
})}
@@ -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 (
<div className="rounded-xl overflow-hidden border border-edge" style={{ height: 'calc(100vh - var(--nav-h) - 180px)', minHeight: 360 }}>
<div style={{ width: '100%', height: '100%' }}>
<MapViewAuto
places={pts}
selectedPlaceId={selectedPlaceId}
+46 -59
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useRef, useState } from 'react'
import { Plus, Bookmark, Layers, MoreHorizontal, Pencil, Trash2, Users, Check } from 'lucide-react'
import { Plus, Layers, MoreHorizontal, Pencil, Trash2, Users, Check, Palette } from 'lucide-react'
import type { Collection } from '@trek/shared'
import type { TranslationFn } from '../../types'
import { ALL_SAVED } from '../../store/collectionStore'
@@ -26,10 +26,6 @@ interface ListsRailProps {
t: TranslationFn
}
function ColorDot({ color }: { color?: string | null }): React.ReactElement {
return <span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ background: color || '#6366f1' }} />
}
interface ListRowProps {
list: Collection
active: boolean
@@ -66,66 +62,62 @@ function ListRow({
if (editing) {
return (
<div className="px-2 py-1.5">
<div className="col-row">
<input
autoFocus
value={editingName}
onChange={e => 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"
/>
</div>
)
}
return (
<div ref={rowRef} className="relative group">
<button
type="button"
onClick={() => 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'}`}
>
<ColorDot color={list.color} />
<span className="flex-1 text-[13px] font-medium truncate">{list.name}</span>
<span className="text-[11px] tabular-nums text-content-faint shrink-0">{list.place_count ?? 0}</span>
<div ref={rowRef} className="col-row">
<button type="button" onClick={() => onSelect(list.id)} className={`col-row-btn${active ? ' on' : ''}`}>
<span className="dot" style={{ background: list.color || '#6366f1' }} />
<span className="nm">{list.name}</span>
<span className="ct">{list.place_count ?? 0}</span>
</button>
<button
type="button"
onClick={e => { 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')}
>
<MoreHorizontal size={15} />
</button>
{menuOpen && !colorOpen && (
<div className="absolute right-1 top-9 z-20 w-40 py-1 rounded-lg border border-edge bg-surface-card shadow-dropdown">
<button type="button" onClick={() => { 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">
<Pencil size={13} /> {t('collections.editList')}
<div className="col-pop">
<button type="button" onClick={() => { setMenuOpen(false); onStartRename(list.id, list.name) }} className="col-pop-item">
<Pencil size={14} /> {t('collections.editList')}
</button>
<button type="button" onClick={() => setColorOpen(true)} className="w-full flex items-center gap-2 px-3 py-1.5 text-[12px] text-content-secondary hover:bg-surface-hover">
<ColorDot color={list.color} /> {t('collections.listColor')}
<button type="button" onClick={() => setColorOpen(true)} className="col-pop-item">
<Palette size={14} /> {t('collections.listColor')}
</button>
{canDelete && (
<button type="button" onClick={() => { 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">
<Trash2 size={13} /> {t('collections.deleteList')}
<button type="button" onClick={() => { setMenuOpen(false); onRequestDelete(list.id) }} className="col-pop-item danger">
<Trash2 size={14} /> {t('collections.deleteList')}
</button>
)}
</div>
)}
{colorOpen && (
<div className="absolute right-1 top-9 z-20 p-2 rounded-lg border border-edge bg-surface-card shadow-dropdown">
<div className="grid grid-cols-4 gap-1.5">
<div className="col-pop">
<div className="col-pop-swatches">
{SWATCHES.map(c => (
<button
key={c}
type="button"
onClick={() => { 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 && <Check size={13} className="text-white" strokeWidth={3} />}
{list.color === c && <Check size={13} strokeWidth={3} />}
</button>
))}
</div>
@@ -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 (
<div className="flex flex-col gap-1">
<button
type="button"
onClick={onNewList}
className="flex items-center gap-2 px-3 py-2 mb-1 rounded-lg border border-dashed border-edge text-content-secondary hover:bg-surface-hover hover:text-content transition-colors text-[13px] font-medium"
>
<Plus size={15} /> {t('collections.newList')}
<>
<button type="button" onClick={onNewList} className="col-rail-new">
<Plus size={16} /> {t('collections.newList')}
</button>
<button
type="button"
onClick={() => 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'}`}
>
<Layers size={15} className="shrink-0 text-content-faint" />
<span className="flex-1 text-[13px] font-medium">{t('collections.allSaved')}</span>
</button>
<div className="col-row">
<button type="button" onClick={() => onSelect(ALL_SAVED)} className={`col-row-btn${activeId === ALL_SAVED ? ' on' : ''}`}>
<span className="ico"><Layers size={16} /></span>
<span className="nm">{t('collections.allSaved')}</span>
</button>
</div>
{ownedLists.length > 0 && <div className="h-px bg-edge-faint my-1.5" />}
{ownedLists.length > 0 && <div className="col-rail-sep" />}
{ownedLists.map(list => (
<ListRow
key={list.id}
@@ -187,9 +175,7 @@ export default function ListsRail(props: ListsRailProps): React.ReactElement {
{sharedLists.length > 0 && (
<>
<div className="flex items-center gap-1.5 px-3 mt-3 mb-1 text-[10px] font-semibold uppercase tracking-wider text-content-faint">
<Users size={11} /> {t('collections.shared')}
</div>
<div className="col-rail-label"><Users size={12} /> {t('collections.shared')}</div>
{sharedLists.map(list => (
<ListRow
key={list.id}
@@ -211,18 +197,19 @@ export default function ListsRail(props: ListsRailProps): React.ReactElement {
{incomingInvites.length > 0 && (
<>
<div className="flex items-center gap-1.5 px-3 mt-3 mb-1 text-[10px] font-semibold uppercase tracking-wider text-content-faint">
<Bookmark size={11} /> {t('collections.invites.title')} ({incomingInvites.length})
<div className="col-rail-label">
{t('collections.invites.title')}
<span className="badge">{incomingInvites.length}</span>
</div>
{incomingInvites.map(inv => (
<div key={inv.collection_id} className="px-3 py-2 rounded-lg bg-surface-secondary">
<p className="text-[12px] font-semibold text-content truncate">{inv.name}</p>
<p className="text-[11px] text-content-faint truncate">{t('collections.invites.from')} {inv.from.username}</p>
<div className="flex gap-1.5 mt-1.5">
<button type="button" onClick={() => 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">
<div key={inv.collection_id} className="col-invite">
<div className="t">{inv.name}</div>
<div className="s">{t('collections.invites.from')} {inv.from.username}</div>
<div className="col-invite-actions">
<button type="button" onClick={() => onAcceptInvite(inv.collection_id)} className="col-invite-accept">
{t('collections.invites.accept')}
</button>
<button type="button" onClick={() => 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">
<button type="button" onClick={() => onDeclineInvite(inv.collection_id)} className="col-invite-decline">
{t('collections.invites.decline')}
</button>
</div>
@@ -230,6 +217,6 @@ export default function ListsRail(props: ListsRailProps): React.ReactElement {
))}
</>
)}
</div>
</>
)
}
@@ -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 = (
<>
<Icon size={size} style={{ color: meta.color }} strokeWidth={2.2} />
{showLabel && <span className="font-semibold" style={{ color: meta.color }}>{label}</span>}
<Icon size={size} style={{ color }} strokeWidth={2.4} />
{showLabel && <span className="font-semibold" style={{ color }}>{label}</span>}
</>
)
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 <span className={className} title={label}>{content}</span>
}
// Rendered as a role=button span (not a native <button>) 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 (
<button
type="button"
onClick={handleClick}
<span
role="button"
tabIndex={0}
onClick={cycle}
onKeyDown={e => { 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}
</button>
</span>
)
}
@@ -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<Place, 'id' | 'name' | 'image_url' | 'google_place_id' | 'osm_id' | 'lat' | 'lng'>
}
/**
* Full-bleed rectangular cover for a place card — the same photoService-backed
* lazy fetch as PlaceAvatar, but rendered edge-to-edge (object-fit: cover)
* instead of a circle. When there is no photo it falls back to a deterministic
* vibrant gradient (entityGradient), matching the dashboard trip cards. The
* parent supplies the aspect box + rounding via `.col-cover`.
*/
export default React.memo(function PlaceCover({ place }: PlaceCoverProps) {
const [photoSrc, setPhotoSrc] = useState<string | null>(place.image_url || null)
const [visible, setVisible] = useState(false)
const imageUrlFailed = useRef(false)
const ref = useRef<HTMLDivElement>(null)
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
// Fetch the photo only once the card scrolls near the viewport (or if it is
// already cached / carries an image_url), so a long grid stays cheap.
useEffect(() => {
if (place.image_url) { setVisible(true); return }
if (!placesPhotosEnabled) return
const el = ref.current
if (!el) return
const photoId = place.google_place_id || place.osm_id
const cacheKey = photoId || `${place.lat},${place.lng}`
if (cacheKey && getCached(cacheKey)) { setVisible(true); return }
const io = new IntersectionObserver(([e]) => { if (e.isIntersecting) { setVisible(true); io.disconnect() } }, { rootMargin: '250px' })
io.observe(el)
return () => io.disconnect()
}, [place.id])
useEffect(() => {
if (!visible) return
if (place.image_url) { setPhotoSrc(place.image_url); return }
if (!placesPhotosEnabled) return
const photoId = place.google_place_id || place.osm_id
if (!photoId && !(place.lat && place.lng)) { setPhotoSrc(null); return }
const cacheKey = photoId || `${place.lat},${place.lng}`
const cached = getCached(cacheKey)
if (cached) {
setPhotoSrc(cached.thumbDataUrl || cached.photoUrl)
if (!cached.thumbDataUrl && cached.photoUrl) {
return onThumbReady(cacheKey, thumb => setPhotoSrc(thumb))
}
return
}
if (isLoading(cacheKey)) {
return onThumbReady(cacheKey, thumb => setPhotoSrc(thumb))
}
fetchPhoto(cacheKey, photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name,
entry => { setPhotoSrc(entry.thumbDataUrl || entry.photoUrl) })
return onThumbReady(cacheKey, thumb => setPhotoSrc(thumb))
}, [visible, place.id, place.image_url, place.google_place_id, place.osm_id])
if (photoSrc) {
return (
<div ref={ref} className="col-cover-media">
<img
src={photoSrc}
alt={place.name}
decoding="async"
loading="lazy"
onError={() => {
if (!imageUrlFailed.current && photoSrc === place.image_url && (place.google_place_id || place.osm_id)) {
imageUrlFailed.current = true
const photoId = place.google_place_id || place.osm_id!
fetchPhoto(`refetch:${photoId}`, photoId, place.lat ?? undefined, place.lng ?? undefined, place.name,
entry => { setPhotoSrc(entry.thumbDataUrl || entry.photoUrl) })
} else {
setPhotoSrc(null)
}
}}
/>
</div>
)
}
return <div ref={ref} className="col-cover-media col-cover-fallback" style={{ backgroundImage: entityGradient(place.id) }} />
})
+142 -211
View File
@@ -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<CollectionView, typeof LayoutGrid> = { grid: LayoutGrid, list: ListIcon, map: MapIcon }
function ViewSwitch({ view, onChange, t }: { view: CollectionView; onChange: (v: CollectionView) => void; t: TranslationFn }): React.ReactElement {
return (
<div className="flex items-center gap-0.5 p-0.5 rounded-lg bg-surface-secondary border border-edge">
<div className="col-viewseg" role="group" aria-label={t('collections.title')}>
{(['grid', 'list', 'map'] as CollectionView[]).map(v => {
const Icon = VIEW_ICONS[v]
return (
<button
key={v}
type="button"
aria-pressed={view === v}
onClick={() => onChange(v)}
aria-label={t(`collections.view.${v}`)}
title={t(`collections.view.${v}`)}
className={`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' : ''}
>
<Icon size={16} />
</button>
@@ -39,40 +43,13 @@ function ViewSwitch({ view, onChange, t }: { view: CollectionView; onChange: (v:
)
}
function StatusChips({
statusFilter, counts, onChange, t,
}: { statusFilter: StatusFilter; counts: Record<StatusFilter, number>; 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 (
<div className="flex items-center gap-1.5 flex-wrap">
{chips.map(chip => {
const active = statusFilter === chip.key
return (
<button
key={chip.key}
type="button"
onClick={() => 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 && <span className="w-2 h-2 rounded-full" style={{ background: chip.color }} />}
{chip.label}
<span className="tabular-nums opacity-60">{counts[chip.key]}</span>
</button>
)
})}
</div>
)
}
function EmptyState({ icon, title, text }: { icon: React.ReactNode; title: string; text: string }): React.ReactElement {
return (
<div className="flex flex-col items-center justify-center text-center py-20 px-6">
<div className="w-14 h-14 rounded-2xl bg-surface-secondary flex items-center justify-center mb-4 text-content-faint">{icon}</div>
<h3 className="text-[15px] font-semibold text-content">{title}</h3>
<p className="text-[13px] text-content-faint mt-1 max-w-xs">{text}</p>
<div className="col-emptystate">
<div className="ic">{icon}</div>
<h3>{title}</h3>
<p>{text}</p>
{action && <div style={{ marginTop: 18 }}>{action}</div>}
</div>
)
}
@@ -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 = (
<div className="flex items-center justify-center py-24">
<div className="w-7 h-7 border-2 rounded-full animate-spin border-edge border-t-content" />
</div>
)
body = <div className="col-loading"><div className="col-spinner" /></div>
} else if (!hasPlaces) {
body = (
<EmptyState
icon={<Bookmark size={26} />}
title={t('collections.empty.title')}
text={t('collections.empty.text')}
/>
)
body = <EmptyState icon={<Bookmark size={26} />} title={t('collections.empty.title')} text={t('collections.empty.text')} />
} else if (c.visiblePlaces.length === 0) {
body = (
<EmptyState
icon={<Search size={26} />}
title={t('collections.empty.noMatchTitle')}
text={t('collections.empty.noMatchText')}
/>
)
body = <EmptyState icon={<Search size={26} />} title={t('collections.empty.noMatchTitle')} text={t('collections.empty.noMatchText')} />
} else if (c.view === 'grid') {
body = (
<CollectionGrid
@@ -138,170 +104,135 @@ export default function CollectionsPage(): React.ReactElement {
} else {
const mappable = mappablePlaces(c.visiblePlaces)
body = mappable.length === 0 ? (
<EmptyState
icon={<Search size={26} />}
title={t('collections.empty.noMatchTitle')}
text={t('collections.empty.noMatchText')}
/>
<EmptyState icon={<Search size={26} />} title={t('collections.empty.noMatchTitle')} text={t('collections.empty.noMatchText')} />
) : (
<CollectionMap
places={mappable}
selectedPlaceId={c.selectedPlaceId}
onOpenPlace={c.setSelectedPlaceId}
dark={c.dark}
/>
<div className="col-mapwrap">
<CollectionMap places={mappable} selectedPlaceId={c.selectedPlaceId} onOpenPlace={c.setSelectedPlaceId} dark={c.dark} />
</div>
)
}
const rail = (
<ListsRail
ownedLists={c.ownedLists}
sharedLists={c.sharedLists}
activeId={c.activeId}
incomingInvites={c.incomingInvites}
editingListId={c.editingListId}
editingName={c.editingName}
setEditingName={c.setEditingName}
onSelect={c.handleSelectList}
onNewList={() => { 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 (
<div className="min-h-screen bg-surface">
<>
<Navbar />
<div className="flex" style={{ paddingTop: 'var(--nav-h)' }}>
{/* Lists rail — desktop sidebar / mobile slide-over */}
<aside className="hidden md:block w-64 shrink-0 border-r border-edge p-3 overflow-y-auto" style={{ height: 'calc(100vh - var(--nav-h))', position: 'sticky', top: 'var(--nav-h)' }}>
<ListsRail
ownedLists={c.ownedLists}
sharedLists={c.sharedLists}
activeId={c.activeId}
incomingInvites={c.incomingInvites}
editingListId={c.editingListId}
editingName={c.editingName}
setEditingName={c.setEditingName}
onSelect={c.handleSelectList}
onNewList={() => c.setShowNewList(true)}
onStartRename={c.handleStartRename}
onCommitRename={c.handleCommitRename}
onSetColor={c.handleSetColor}
onRequestDelete={c.setConfirmDeleteList}
onAcceptInvite={c.handleAcceptInvite}
onDeclineInvite={c.handleDeclineInvite}
t={t}
/>
</aside>
{/* Main column */}
<main className="flex-1 min-w-0 px-4 sm:px-6 py-5 pb-24">
<div className="max-w-6xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between gap-3 mb-4 flex-wrap">
<div className="flex items-center gap-2 min-w-0">
<button
type="button"
onClick={() => c.setMobileRailOpen(true)}
className="md:hidden p-2 -ml-1 rounded-lg text-content-secondary hover:bg-surface-hover"
aria-label={t('collections.title')}
>
<Bookmark size={18} />
</button>
<h1 className="text-xl sm:text-2xl font-bold text-content truncate">{title}</h1>
{c.activeCollection?.is_owner === false && (
<span className="px-2 py-0.5 rounded-full bg-surface-secondary text-content-faint text-[11px] font-medium">{t('collections.shared')}</span>
)}
</div>
<div className="flex items-center gap-2">
<ViewSwitch view={c.view} onChange={c.setView} t={t} />
{c.canShare && (
<button
type="button"
onClick={() => 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 ? <Share2 size={14} /> : <Users size={14} />}
<span className="hidden sm:inline">{c.isOwner ? t('collections.share.button') : t('collections.shared')}</span>
{c.shareMemberCount > 0 && (
<span className="inline-flex items-center justify-center min-w-[16px] h-4 px-1 rounded-full bg-accent text-accent-text text-[10px] font-semibold tabular-nums">
{c.shareMemberCount}
</span>
)}
</button>
)}
{showSelect && (
<button
type="button"
onClick={() => 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'}`}
>
<CheckSquare size={14} /> <span className="hidden sm:inline">{t('collections.selectMode')}</span>
</button>
)}
<button
type="button"
onClick={() => 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"
>
<Plus size={14} /> <span className="hidden sm:inline">{t('collections.newList')}</span>
</button>
</div>
</div>
{/* Toolbar: search + status chips */}
<div className="flex items-center justify-between gap-3 mb-5 flex-wrap">
<StatusChips statusFilter={c.statusFilter} counts={c.counts} onChange={c.setStatusFilter} t={t} />
<div className="relative">
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-content-faint" />
<input
value={c.search}
onChange={e => 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"
/>
</div>
</div>
{/* Select-mode action bar */}
{c.selectMode && c.selectedIds.length > 0 && (
<div className="flex items-center gap-3 mb-4 px-3 py-2 rounded-lg bg-surface-secondary">
<span className="text-[12px] font-medium text-content-secondary">{t('collections.selectedCount', { count: c.selectedIds.length })}</span>
<div className="flex-1" />
<button type="button" onClick={c.openCopyForSelection} className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-[12px] font-medium text-content-secondary hover:bg-surface-hover">
<Copy size={13} /> {t('collections.copyN', { count: c.selectedIds.length })}
</button>
<button type="button" onClick={c.handleDeleteSelected} className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-[12px] font-medium text-danger hover:bg-danger-soft">
<Trash2 size={13} /> {t('common.delete')}
</button>
<button type="button" onClick={() => c.setSelectMode(false)} className="p-1 rounded-md text-content-faint hover:bg-surface-hover" aria-label={t('common.cancel')}>
<X size={15} />
</button>
</div>
)}
<div className="trek-dash col-root">
<div className="col-page">
<aside className="col-rail">{rail}</aside>
<div className="col-body">
{noLists ? (
<EmptyState icon={<Bookmark size={26} />} title={t('collections.empty.firstTitle')} text={t('collections.empty.firstText')} />
) : body}
</div>
</main>
</div>
<EmptyState
icon={<Bookmark size={26} />}
title={t('collections.empty.firstTitle')}
text={t('collections.empty.firstText')}
action={
<button type="button" onClick={() => c.setShowNewList(true)} className="col-cta">
<Bookmark size={16} /> {t('collections.newList')}
</button>
}
/>
) : (
<>
<CollectionHero
eyebrow={eyebrow}
title={title}
color={heroColor}
coverImage={heroCover}
counts={c.counts}
statusFilter={c.statusFilter}
onStatusFilter={c.setStatusFilter}
members={c.members}
canShare={c.canShare}
isOwner={c.isOwner}
shareMemberCount={c.shareMemberCount}
onShare={() => c.setShowShare(true)}
onNewList={() => c.setShowNewList(true)}
t={t}
/>
{/* Mobile rail slide-over */}
{c.mobileRailOpen && (
<div className="md:hidden fixed inset-0 z-[150] flex" onClick={() => c.setMobileRailOpen(false)}>
<div className="absolute inset-0 trek-modal-backdrop trek-backdrop-enter bg-[rgba(15,23,42,0.5)]" />
<div className="relative w-72 max-w-[80vw] h-full bg-surface border-r border-edge p-3 overflow-y-auto" style={{ paddingTop: 'calc(var(--nav-h) + 12px)' }} onClick={e => e.stopPropagation()}>
<ListsRail
ownedLists={c.ownedLists}
sharedLists={c.sharedLists}
activeId={c.activeId}
incomingInvites={c.incomingInvites}
editingListId={c.editingListId}
editingName={c.editingName}
setEditingName={c.setEditingName}
onSelect={c.handleSelectList}
onNewList={() => { 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}
/>
<div className="col-toolbar">
<button type="button" className="col-rail-toggle" onClick={() => c.setMobileRailOpen(true)}>
<Bookmark size={15} /> {t('collections.title')}
</button>
<ViewSwitch view={c.view} onChange={c.setView} t={t} />
{showSelect && (
<button
type="button"
onClick={() => c.setSelectMode(!c.selectMode)}
className={`col-iconbtn${c.selectMode ? ' on' : ''}`}
aria-label={t('collections.selectMode')}
title={t('collections.selectMode')}
>
<CheckSquare size={16} />
</button>
)}
<div className="col-toolbar-spacer" />
<div className="col-search">
<Search size={15} />
<input
value={c.search}
onChange={e => c.setSearch(e.target.value)}
placeholder={t('collections.search')}
/>
</div>
</div>
{c.selectMode && c.selectedIds.length > 0 && (
<div className="col-selbar">
<span className="lbl">{t('collections.selectedCount', { count: c.selectedIds.length })}</span>
<div className="col-toolbar-spacer" />
<button type="button" onClick={c.openCopyForSelection} className="col-selbar-btn">
<Copy size={14} /> {t('collections.copyN', { count: c.selectedIds.length })}
</button>
<button type="button" onClick={c.handleDeleteSelected} className="col-selbar-btn danger">
<Trash2 size={14} /> {t('common.delete')}
</button>
<button type="button" onClick={() => c.setSelectMode(false)} className="col-selbar-btn" aria-label={t('common.cancel')}>
<X size={15} />
</button>
</div>
)}
{body}
</>
)}
</div>
</div>
)}
{/* 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 && (
<>
<div className="col-drawer-backdrop" onClick={() => c.setMobileRailOpen(false)} />
<div className="col-drawer">{rail}</div>
</>
)}
</div>
{/* 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 && (
<div className="fixed inset-0 z-[150]" style={{ pointerEvents: 'none', paddingTop: 'var(--nav-h)' }}>
<div className="relative w-full h-full">
@@ -410,6 +341,6 @@ export default function CollectionsPage(): React.ReactElement {
>
<p className="text-[13px] text-content-secondary">{t('collections.deleteListConfirm')}</p>
</Modal>
</div>
</>
)
}
@@ -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<CollectionStatus, StatusMeta> = {
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. */
+326
View File
@@ -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 <button>, 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; } }
+22
View File
@@ -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]
}
+3
View File
@@ -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': 'أماكنك المحفوظة، منظمة في قوائم',
+3
View File
@@ -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',
+3
View File
@@ -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ů',
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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': 'Τα αποθηκευμένα σου μέρη, οργανωμένα σε λίστες',
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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': '保存した場所をリストごとに整理',
+3
View File
@@ -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': '저장한 장소를 목록으로 정리하세요',
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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': 'Ваши сохранённые места, организованные в списки',
+3
View File
@@ -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',
+3
View File
@@ -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ş',
+3
View File
@@ -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': 'Ваші збережені місця, упорядковані у списки',
+3
View File
@@ -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',
+3
View File
@@ -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': '你儲存的地點,依清單整理',
+3
View File
@@ -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': '你保存的地点,按列表整理',