mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(collections): detail redesign + categories, close-on-map, highlight fix (#1081)
- Rebuild the place detail as a clean, opaque, sectioned sheet (cover → meta → status segment → description → links) with a proper footer action bar — the loose "white lower half" is gone. - Assign a place to a central (admin-defined) category, both in the detail edit and when adding a place; categories are fetched once for the page. - Add-place now sets category + description (markdown) + links + status in the same step, closer to the trip's place form. - Switching to the full-map view now closes the (list-docked) detail. - Fix the selected-row highlight: it was clipped by the column's overflow — use an inset ring and only clip during the map-collapse animation; a picked row now scrolls into view above the detail sheet. - New category strings across all 22 locales.
This commit is contained in:
@@ -10,8 +10,9 @@ import { collectionsApi } from '../../api/collections'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { normalizeLinkUrl, STATUS_META, STATUS_ORDER } from '../../pages/collections/collectionsModel'
|
||||
import type { TranslationFn } from '../../types'
|
||||
import type { Category, TranslationFn } from '../../types'
|
||||
import type { CollectionLink, CollectionStatus } from '@trek/shared'
|
||||
|
||||
type MapsPlace = Record<string, unknown>
|
||||
@@ -22,6 +23,7 @@ interface AddPlaceToCollectionModalProps {
|
||||
isOpen: boolean
|
||||
collectionId: number
|
||||
collectionName: string
|
||||
categories: Category[]
|
||||
onClose: () => void
|
||||
onAdded: () => void
|
||||
t: TranslationFn
|
||||
@@ -33,7 +35,7 @@ interface AddPlaceToCollectionModalProps {
|
||||
* status) prefilled from the pick, then save. Stays open after each add so
|
||||
* several places can be added in a row.
|
||||
*/
|
||||
export default function AddPlaceToCollectionModal({ isOpen, collectionId, collectionName, onClose, onAdded, t }: AddPlaceToCollectionModalProps): React.ReactElement {
|
||||
export default function AddPlaceToCollectionModal({ isOpen, collectionId, collectionName, categories, onClose, onAdded, t }: AddPlaceToCollectionModalProps): React.ReactElement {
|
||||
const { language } = useTranslation()
|
||||
const toast = useToast()
|
||||
const [query, setQuery] = useState('')
|
||||
@@ -41,6 +43,7 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec
|
||||
const [searching, setSearching] = useState(false)
|
||||
// Step 2 draft — the picked place plus the editable detail fields.
|
||||
const [draft, setDraft] = useState<MapsPlace | null>(null)
|
||||
const [categoryId, setCategoryId] = useState<number | null>(null)
|
||||
const [description, setDescription] = useState('')
|
||||
const [links, setLinks] = useState<CollectionLink[]>([])
|
||||
const [status, setStatus] = useState<CollectionStatus>('idea')
|
||||
@@ -64,7 +67,7 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec
|
||||
}
|
||||
}
|
||||
|
||||
const pick = (r: MapsPlace) => { setDraft(r); setDescription(''); setLinks([]); setStatus('idea') }
|
||||
const pick = (r: MapsPlace) => { setDraft(r); setDescription(''); setLinks([]); setStatus('idea'); setCategoryId(null) }
|
||||
const setLink = (i: number, patch: Partial<CollectionLink>) => setLinks(links.map((l, idx) => (idx === i ? { ...l, ...patch } : l)))
|
||||
|
||||
const save = async () => {
|
||||
@@ -85,6 +88,7 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec
|
||||
osm_id: str(draft.osm_id) ?? null,
|
||||
website: str(draft.website) ?? null,
|
||||
phone: str(draft.phone) ?? null,
|
||||
category_id: categoryId,
|
||||
description: description.trim() || null,
|
||||
links: cleanLinks,
|
||||
status,
|
||||
@@ -161,6 +165,34 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
{categories.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-[12px] font-medium text-content-secondary mb-1.5">{t('collections.category')}</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<button type="button" onClick={() => setCategoryId(null)} className={`px-3 py-1.5 rounded-full text-[12px] font-medium border transition-colors ${categoryId == null ? 'bg-inverse text-inverse-text border-transparent' : 'bg-surface-card text-content-secondary border-edge hover:bg-surface-hover'}`}>
|
||||
{t('collections.noCategory')}
|
||||
</button>
|
||||
{categories.map(cat => {
|
||||
const Icon = getCategoryIcon(cat.icon ?? undefined)
|
||||
const on = categoryId === cat.id
|
||||
const col = cat.color || '#6366f1'
|
||||
return (
|
||||
<button
|
||||
key={cat.id}
|
||||
type="button"
|
||||
onClick={() => setCategoryId(cat.id)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[12px] font-medium border transition-colors bg-surface-card border-edge hover:bg-surface-hover"
|
||||
style={on ? { color: col, background: `color-mix(in oklch, ${col} 15%, transparent)`, borderColor: `color-mix(in oklch, ${col} 40%, transparent)` } : undefined}
|
||||
>
|
||||
<Icon size={13} /> {cat.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-[12px] font-medium text-content-secondary mb-1.5">{t('collections.description')}</label>
|
||||
|
||||
@@ -2,13 +2,13 @@ import React, { useEffect, useRef, useState } from 'react'
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import { X, Pencil, Copy, Trash2, MapPin, Link2, Plus, ExternalLink, Check } from 'lucide-react'
|
||||
import { X, Pencil, Copy, Trash2, MapPin, Link2, Plus, ExternalLink, Check, Tag } from 'lucide-react'
|
||||
import type { CollectionPlace, CollectionStatus, CollectionLink } from '@trek/shared'
|
||||
import type { TranslationFn } from '../../types'
|
||||
import type { Category, TranslationFn } from '../../types'
|
||||
import MarkdownToolbar from '../Journey/MarkdownToolbar'
|
||||
import StatusBadge from './StatusBadge'
|
||||
import { entityGradient } from '../../utils/gradients'
|
||||
import { normalizeLinkUrl } from '../../pages/collections/collectionsModel'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { STATUS_META, STATUS_ORDER, normalizeLinkUrl } from '../../pages/collections/collectionsModel'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
|
||||
@@ -19,55 +19,71 @@ function linkHost(url: string): string {
|
||||
interface CollectionPlaceDetailProps {
|
||||
place: CollectionPlace
|
||||
canEdit: boolean
|
||||
categories: Category[]
|
||||
/** When set, dock the sheet over that column (desktop split) instead of centred. */
|
||||
anchorRect?: { left: number; width: number } | null
|
||||
onClose: () => void
|
||||
onSetStatus: (status: CollectionStatus) => void
|
||||
onSave: (patch: { name?: string; description?: string | null; links?: CollectionLink[] }) => Promise<void>
|
||||
onSave: (patch: { name?: string; description?: string | null; links?: CollectionLink[]; category_id?: number | null }) => Promise<void>
|
||||
onCopyToTrip: () => void
|
||||
onRemove: () => void
|
||||
t: TranslationFn
|
||||
}
|
||||
|
||||
function StatusSegment({ status, onSet, t }: { status: CollectionStatus; onSet: (s: CollectionStatus) => void; t: TranslationFn }): React.ReactElement {
|
||||
return (
|
||||
<div className="col-detail-seg" role="group">
|
||||
{STATUS_ORDER.map(s => {
|
||||
const Icon = STATUS_META[s].icon
|
||||
const on = status === s
|
||||
return (
|
||||
<button key={s} type="button" aria-pressed={on} onClick={() => onSet(s)} className={on ? 'on' : ''}>
|
||||
<Icon size={14} style={{ color: on ? STATUS_META[s].color : undefined }} /> {t(STATUS_META[s].labelKey)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Bottom detail sheet for a saved place — a glassy card docked to the bottom
|
||||
* (full-width on mobile). Read mode renders the description as markdown + link
|
||||
* chips; edit mode swaps in a name field, a markdown editor (toolbar + textarea)
|
||||
* and a links editor, saving via collectionsApi.updatePlace. Also cycles the
|
||||
* status, copies the place to a trip and removes it from the list.
|
||||
* Bottom detail sheet for a saved place — an opaque, clearly-sectioned card
|
||||
* (cover → meta → status → description → links) docked over the list column.
|
||||
* Read mode renders the description as markdown + link chips; edit mode swaps in
|
||||
* name / category / markdown description / links, saving via updatePlace. Status
|
||||
* is an always-live segmented control (auto-saves).
|
||||
*/
|
||||
export default function CollectionPlaceDetail({
|
||||
place, canEdit, anchorRect, onClose, onSetStatus, onSave, onCopyToTrip, onRemove, t,
|
||||
place, canEdit, categories, anchorRect, onClose, onSetStatus, onSave, onCopyToTrip, onRemove, t,
|
||||
}: CollectionPlaceDetailProps): React.ReactElement {
|
||||
const toast = useToast()
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [name, setName] = useState(place.name)
|
||||
const [categoryId, setCategoryId] = useState<number | null>(place.category_id ?? null)
|
||||
const [description, setDescription] = useState(place.description ?? '')
|
||||
const [links, setLinks] = useState<CollectionLink[]>(place.links ?? [])
|
||||
const [saving, setSaving] = useState(false)
|
||||
const descRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
// Reset the form only when a DIFFERENT place is opened (keyed on id, not on
|
||||
// every field change — otherwise a save would clobber in-flight edits).
|
||||
// Reset only when a DIFFERENT place is opened (keyed on id, not on every field).
|
||||
useEffect(() => {
|
||||
setEditing(false)
|
||||
setName(place.name)
|
||||
setCategoryId(place.category_id ?? null)
|
||||
setDescription(place.description ?? '')
|
||||
setLinks(place.links ?? [])
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [place.id])
|
||||
|
||||
const sub = place.category?.name || place.address
|
||||
const banner = place.image_url
|
||||
|
||||
const setLink = (i: number, patch: Partial<CollectionLink>) =>
|
||||
setLinks(links.map((l, idx) => (idx === i ? { ...l, ...patch } : l)))
|
||||
const setLink = (i: number, patch: Partial<CollectionLink>) => setLinks(links.map((l, idx) => (idx === i ? { ...l, ...patch } : l)))
|
||||
const resetForm = () => { setEditing(false); setName(place.name); setCategoryId(place.category_id ?? null); setDescription(place.description ?? ''); setLinks(place.links ?? []) }
|
||||
|
||||
const save = async () => {
|
||||
const cleanLinks = links.map(l => ({ label: l.label?.trim() || undefined, url: normalizeLinkUrl(l.url) })).filter(l => l.url)
|
||||
setSaving(true)
|
||||
try {
|
||||
await onSave({ name: name.trim() || place.name, description: description.trim() || null, links: cleanLinks })
|
||||
await onSave({ name: name.trim() || place.name, description: description.trim() || null, links: cleanLinks, category_id: categoryId })
|
||||
setEditing(false)
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
@@ -77,6 +93,7 @@ export default function CollectionPlaceDetail({
|
||||
}
|
||||
|
||||
const dockStyle = anchorRect ? { left: anchorRect.left, width: anchorRect.width, transform: 'none' as const } : undefined
|
||||
const CatIcon = getCategoryIcon(place.category?.icon)
|
||||
|
||||
return (
|
||||
<div className={`col-detail${anchorRect ? ' docked' : ''}`} style={dockStyle} onClick={e => e.stopPropagation()}>
|
||||
@@ -85,56 +102,65 @@ export default function CollectionPlaceDetail({
|
||||
<div className="col-detail-cover-scrim" />
|
||||
<button type="button" className="col-detail-close" onClick={onClose} aria-label={t('common.close')}><X size={16} /></button>
|
||||
<div className="col-detail-head">
|
||||
{editing ? (
|
||||
<input value={name} onChange={e => setName(e.target.value)} className="col-detail-name-input" autoFocus />
|
||||
) : (
|
||||
<h2 className="col-detail-name">{place.name}</h2>
|
||||
)}
|
||||
{sub && <div className="col-detail-sub">{!place.category?.name && <MapPin size={12} />}{sub}</div>}
|
||||
{editing
|
||||
? <input value={name} onChange={e => setName(e.target.value)} className="col-detail-name-input" autoFocus aria-label={t('collections.listName')} />
|
||||
: <h2 className="col-detail-name">{place.name}</h2>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-detail-body">
|
||||
<div className="col-detail-actions">
|
||||
<StatusBadge status={place.status} onChange={onSetStatus} t={t} />
|
||||
<div className="col-detail-actions-spacer" />
|
||||
{canEdit && !editing && (
|
||||
<button type="button" className="col-detail-btn" onClick={() => setEditing(true)}><Pencil size={14} /> {t('common.edit')}</button>
|
||||
)}
|
||||
<button type="button" className="col-detail-btn" onClick={onCopyToTrip}><Copy size={14} /> {t('collections.copyToTrip')}</button>
|
||||
{canEdit && (
|
||||
<button type="button" className="col-detail-btn danger" onClick={onRemove}><Trash2 size={14} /> {t('collections.removeFromList')}</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Meta (view only) */}
|
||||
{!editing && (place.category?.name || place.address) && (
|
||||
<div className="col-detail-meta">
|
||||
{place.category?.name && (
|
||||
<span className="col-detail-catchip" style={{ ['--cat' as string]: place.category.color || '#6366f1' }}>
|
||||
<CatIcon size={12} /> {place.category.name}
|
||||
</span>
|
||||
)}
|
||||
{place.address && <span className="col-detail-addr"><MapPin size={12} /> {place.address}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status — always live */}
|
||||
<StatusSegment status={place.status} onSet={onSetStatus} t={t} />
|
||||
|
||||
{editing ? (
|
||||
<div className="col-detail-edit">
|
||||
<label className="col-detail-label">{t('collections.description')}</label>
|
||||
<MarkdownToolbar textareaRef={descRef} onUpdate={setDescription} />
|
||||
<textarea
|
||||
ref={descRef}
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
rows={5}
|
||||
placeholder={t('collections.descriptionPlaceholder')}
|
||||
className="col-detail-textarea"
|
||||
/>
|
||||
|
||||
<label className="col-detail-label">{t('collections.links')}</label>
|
||||
<div className="col-detail-links-edit">
|
||||
{links.map((l, i) => (
|
||||
<div key={i} className="col-detail-link-row">
|
||||
<input value={l.label ?? ''} onChange={e => setLink(i, { label: e.target.value })} placeholder={t('collections.linkLabel')} className="col-detail-input w-28" />
|
||||
<input value={l.url} onChange={e => setLink(i, { url: e.target.value })} placeholder="https://…" className="col-detail-input flex-1" />
|
||||
<button type="button" onClick={() => setLinks(links.filter((_, idx) => idx !== i))} className="col-detail-icon-btn" aria-label={t('common.delete')}><Trash2 size={14} /></button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" onClick={() => setLinks([...links, { url: '' }])} className="col-detail-add-link"><Plus size={13} /> <Link2 size={12} /> {t('collections.addLink')}</button>
|
||||
{/* Category */}
|
||||
<div className="col-detail-field">
|
||||
<div className="col-detail-label"><Tag size={12} /> {t('collections.category')}</div>
|
||||
<div className="col-detail-cats">
|
||||
<button type="button" onClick={() => setCategoryId(null)} className={`col-detail-cat${categoryId == null ? ' on' : ''}`}>{t('collections.noCategory')}</button>
|
||||
{categories.map(cat => {
|
||||
const Icon = getCategoryIcon(cat.icon ?? undefined)
|
||||
const on = categoryId === cat.id
|
||||
return (
|
||||
<button key={cat.id} type="button" onClick={() => setCategoryId(cat.id)} className={`col-detail-cat${on ? ' on' : ''}`} style={{ ['--cat' as string]: cat.color || '#6366f1' }}>
|
||||
<Icon size={12} /> {cat.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-detail-edit-actions">
|
||||
<button type="button" onClick={() => { setEditing(false); setName(place.name); setDescription(place.description ?? ''); setLinks(place.links ?? []) }} className="col-detail-btn">{t('common.cancel')}</button>
|
||||
<button type="button" onClick={save} disabled={saving} className="col-detail-btn primary"><Check size={14} /> {t('common.save')}</button>
|
||||
{/* Description */}
|
||||
<div className="col-detail-field">
|
||||
<div className="col-detail-label">{t('collections.description')}</div>
|
||||
<MarkdownToolbar textareaRef={descRef} onUpdate={setDescription} />
|
||||
<textarea ref={descRef} value={description} onChange={e => setDescription(e.target.value)} rows={4} placeholder={t('collections.descriptionPlaceholder')} className="col-detail-textarea" />
|
||||
</div>
|
||||
{/* Links */}
|
||||
<div className="col-detail-field">
|
||||
<div className="col-detail-label">{t('collections.links')}</div>
|
||||
<div className="col-detail-links-edit">
|
||||
{links.map((l, i) => (
|
||||
<div key={i} className="col-detail-link-row">
|
||||
<input value={l.label ?? ''} onChange={e => setLink(i, { label: e.target.value })} placeholder={t('collections.linkLabel')} className="col-detail-input w-28" />
|
||||
<input value={l.url} onChange={e => setLink(i, { url: e.target.value })} placeholder="https://…" className="col-detail-input flex-1" />
|
||||
<button type="button" onClick={() => setLinks(links.filter((_, idx) => idx !== i))} className="col-detail-icon-btn" aria-label={t('common.delete')}><Trash2 size={14} /></button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" onClick={() => setLinks([...links, { url: '' }])} className="col-detail-add-link"><Plus size={13} /> <Link2 size={12} /> {t('collections.addLink')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -153,11 +179,22 @@ export default function CollectionPlaceDetail({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!place.description && (!place.links || place.links.length === 0) && canEdit && (
|
||||
<button type="button" className="col-detail-empty" onClick={() => setEditing(true)}>
|
||||
<Plus size={14} /> {t('collections.addDetails')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="col-detail-footer">
|
||||
{editing ? (
|
||||
<>
|
||||
<button type="button" onClick={resetForm} className="col-detail-btn">{t('common.cancel')}</button>
|
||||
<button type="button" onClick={save} disabled={saving} className="col-detail-btn primary"><Check size={14} /> {t('common.save')}</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{canEdit && <button type="button" onClick={() => setEditing(true)} className="col-detail-btn"><Pencil size={14} /> {t('common.edit')}</button>}
|
||||
<button type="button" onClick={onCopyToTrip} className="col-detail-btn"><Copy size={14} /> {t('collections.copyToTrip')}</button>
|
||||
<div className="col-detail-footer-spacer" />
|
||||
{canEdit && <button type="button" onClick={onRemove} className="col-detail-btn danger"><Trash2 size={14} /> {t('collections.removeFromList')}</button>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -48,7 +48,11 @@ export default function CollectionsPage(): React.ReactElement {
|
||||
const mappable = mappablePlaces(c.visiblePlaces)
|
||||
const openPlace = (id: number) => c.setSelectedPlaceId(c.selectedPlaceId === id ? null : id)
|
||||
const deselect = () => c.setSelectedPlaceId(null)
|
||||
const toggleView = () => c.setView(c.view === 'map' ? 'list' : 'map')
|
||||
const toggleView = () => {
|
||||
// Going to the full-map view closes the (list-docked) detail sheet.
|
||||
if (c.view !== 'map') c.setSelectedPlaceId(null)
|
||||
c.setView(c.view === 'map' ? 'list' : 'map')
|
||||
}
|
||||
// Clicking a marker in the full-map view drops back to the split so the list
|
||||
// + detail come into view alongside the map.
|
||||
const onMapSelect = (id: number) => { openPlace(id); if (c.view === 'map') c.setView('list') }
|
||||
@@ -247,11 +251,12 @@ export default function CollectionsPage(): React.ReactElement {
|
||||
visible + interactive). On the desktop split it docks over the list
|
||||
column so the map stays clear; otherwise it's a full-width sheet.
|
||||
Clicking another place / the map background re-points the selection. */}
|
||||
{c.selectedPlace && (
|
||||
{c.selectedPlace && c.view !== 'map' && (
|
||||
<CollectionPlaceDetail
|
||||
place={c.selectedPlace}
|
||||
canEdit
|
||||
anchorRect={desktopSplit && c.view === 'list' ? c.listColRect : null}
|
||||
categories={c.categories}
|
||||
anchorRect={desktopSplit ? c.listColRect : null}
|
||||
onClose={c.handleCloseDetail}
|
||||
onSetStatus={c.handleDetailStatus}
|
||||
onSave={patch => c.updatePlace(c.selectedPlace!.id, patch)}
|
||||
@@ -268,6 +273,7 @@ export default function CollectionsPage(): React.ReactElement {
|
||||
isOpen={c.showAddPlace}
|
||||
collectionId={c.activeId}
|
||||
collectionName={c.activeCollection.name}
|
||||
categories={c.categories}
|
||||
onClose={() => c.setShowAddPlace(false)}
|
||||
onAdded={c.handlePlaceAdded}
|
||||
t={t}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { getApiErrorMessage } from '../../types'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
import { useCollectionStore, ALL_SAVED } from '../../store/collectionStore'
|
||||
import type { ActiveCollectionId } from '../../store/collectionStore'
|
||||
import { categoriesApi } from '../../api/client'
|
||||
import type { Collection, CollectionStatus } from '@trek/shared'
|
||||
import type { Category, Place } from '../../types'
|
||||
import { filterPlaces, sortPlaces, statusCounts } from './collectionsModel'
|
||||
@@ -70,6 +71,10 @@ export function useCollections() {
|
||||
// Initial load.
|
||||
useEffect(() => { loadAll() }, [])
|
||||
|
||||
// Central admin-defined categories, for assigning a category to a saved place.
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
useEffect(() => { categoriesApi.list().then((d: { categories: Category[] }) => setCategories(d.categories ?? [])).catch(() => {}) }, [])
|
||||
|
||||
// When the list↔map split toggles, its grid columns animate; keep nudging the
|
||||
// mounted map to re-layout during the transition (Leaflet's trackResize hooks
|
||||
// window resize; MapLibre observes its own box). Skip the initial mount — the
|
||||
@@ -277,7 +282,7 @@ export function useCollections() {
|
||||
return {
|
||||
t, language, dark, navigate,
|
||||
isWide, heroRef: hero.ref, heroHeight: hero.height,
|
||||
listColRef: listCol.ref, listColRect: listCol.rect,
|
||||
listColRef: listCol.ref, listColRect: listCol.rect, categories,
|
||||
// store data
|
||||
collections, ownedLists, sharedLists, activeCollection, isAllSaved, isOwner,
|
||||
canShare, shareMemberCount,
|
||||
|
||||
@@ -251,8 +251,11 @@
|
||||
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;
|
||||
}
|
||||
/* Keep a picked row above the docked detail sheet (which covers the lower ~62vh). */
|
||||
.trek-dash .col-lrow { scroll-margin: calc(var(--nav-h) + 90px) 0 62vh; }
|
||||
.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); }
|
||||
/* Inset ring so the highlight is never clipped by the column's overflow. */
|
||||
.trek-dash .col-lrow.sel { border-color: var(--accent); box-shadow: inset 0 0 0 1.5px 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; }
|
||||
@@ -276,8 +279,9 @@
|
||||
transition: grid-template-columns .4s cubic-bezier(.2,.7,.2,1), gap .4s cubic-bezier(.2,.7,.2,1);
|
||||
}
|
||||
.trek-dash .col-split.map-full { grid-template-columns: 0fr 1fr; gap: 0; }
|
||||
.trek-dash .col-split-list { min-width: 0; overflow: hidden; transition: opacity .25s; }
|
||||
.trek-dash .col-split.map-full .col-split-list { opacity: 0; pointer-events: none; }
|
||||
.trek-dash .col-split-list { min-width: 0; overflow: visible; transition: opacity .25s; }
|
||||
/* Clip only while the column collapses under the full-map view. */
|
||||
.trek-dash .col-split.map-full .col-split-list { opacity: 0; pointer-events: none; overflow: hidden; }
|
||||
.trek-dash .col-split-map {
|
||||
position: sticky; top: calc(var(--nav-h) + 22px);
|
||||
height: calc(100vh - var(--nav-h) - 210px); min-height: 440px;
|
||||
@@ -316,37 +320,57 @@
|
||||
backdrop-filter: blur(8px); transition: background .12s;
|
||||
}
|
||||
.trek-dash .col-detail-close:hover { background: rgba(0,0,0,.6); }
|
||||
.trek-dash .col-detail-head { position: absolute; left: 18px; right: 18px; bottom: 12px; z-index: 1; color: #fff; }
|
||||
.trek-dash .col-detail-name { font-size: 21px; font-weight: 700; letter-spacing: -.02em; margin: 0; text-shadow: 0 1px 6px rgba(0,0,0,.5); }
|
||||
.trek-dash .col-detail-head { position: absolute; left: 20px; right: 20px; bottom: 12px; z-index: 1; color: #fff; }
|
||||
.trek-dash .col-detail-name { font-size: 22px; font-weight: 700; letter-spacing: -.02em; line-height: 1.1; margin: 0; text-shadow: 0 1px 6px rgba(0,0,0,.55); overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
||||
.trek-dash .col-detail-name-input { width: 100%; font-size: 20px; font-weight: 700; background: rgba(255,255,255,.18); border: 1px solid rgba(255,255,255,.32); border-radius: 8px; padding: 3px 8px; color: #fff; outline: none; }
|
||||
.trek-dash .col-detail-sub { display: flex; align-items: center; gap: 5px; font-size: 12.5px; margin-top: 3px; opacity: .92; text-shadow: 0 1px 4px rgba(0,0,0,.5); }
|
||||
.trek-dash .col-detail-body { padding: 16px 18px 18px; overflow-y: auto; display: flex; flex-direction: column; gap: 14px; }
|
||||
.trek-dash .col-detail-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.trek-dash .col-detail-actions-spacer { flex: 1 1 auto; }
|
||||
.trek-dash .col-detail-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); background: var(--surface-2); border: 1px solid var(--line-2); transition: background .12s, color .12s; }
|
||||
.trek-dash .col-detail-btn:hover { background: var(--surface); color: var(--ink); }
|
||||
.trek-dash .col-detail-btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
.trek-dash .col-detail-btn.danger { color: oklch(0.6 0.2 25); }
|
||||
.trek-dash .col-detail-btn.danger:hover { background: oklch(0.6 0.2 25 / .12); }
|
||||
.trek-dash .col-detail-md { font-size: 13.5px; color: var(--ink-2); line-height: 1.5; }
|
||||
.trek-dash .col-detail-body { padding: 16px 20px; overflow-y: auto; display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
/* meta: category chip + address */
|
||||
.trek-dash .col-detail-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; }
|
||||
.trek-dash .col-detail-catchip { display: inline-flex; align-items: center; gap: 6px; padding: 4px 11px 4px 9px; border-radius: 999px; font-size: 12px; font-weight: 600; color: var(--cat, var(--accent)); background: color-mix(in oklch, var(--cat, var(--accent)) 15%, transparent); }
|
||||
.trek-dash .col-detail-addr { display: inline-flex; align-items: center; gap: 5px; font-size: 12.5px; color: var(--ink-3); min-width: 0; }
|
||||
.trek-dash .col-detail-addr svg { flex-shrink: 0; }
|
||||
|
||||
/* status segmented control */
|
||||
.trek-dash .col-detail-seg { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; padding: 4px; border-radius: 12px; background: var(--surface-2); }
|
||||
.trek-dash .col-detail-seg button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 8px 6px; border-radius: 9px; font-size: 12.5px; font-weight: 600; color: var(--ink-2); transition: background .12s, color .12s, box-shadow .12s; }
|
||||
.trek-dash .col-detail-seg button:hover { color: var(--ink); }
|
||||
.trek-dash .col-detail-seg button.on { background: var(--surface); color: var(--ink); box-shadow: var(--sh-xs); }
|
||||
|
||||
/* rendered markdown + link chips (view) */
|
||||
.trek-dash .col-detail-md { font-size: 13.5px; color: var(--ink-2); line-height: 1.55; }
|
||||
.trek-dash .col-detail-links { display: flex; flex-wrap: wrap; gap: 7px; }
|
||||
.trek-dash .col-detail-link { display: inline-flex; align-items: center; gap: 5px; padding: 5px 11px; border-radius: 999px; font-size: 12px; font-weight: 500; color: var(--accent-ink); background: var(--accent-soft); transition: filter .12s; max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.trek-dash .col-detail-link { display: inline-flex; align-items: center; gap: 5px; padding: 6px 12px; border-radius: 999px; font-size: 12px; font-weight: 500; color: var(--accent-ink); background: var(--accent-soft); transition: filter .12s; max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.trek-dash .col-detail-link:hover { filter: brightness(1.05); }
|
||||
.trek-dash .col-detail-empty { display: inline-flex; align-items: center; gap: 7px; align-self: flex-start; padding: 8px 12px; border-radius: 9px; border: 1px dashed var(--line-2); color: var(--ink-3); font-size: 12.5px; font-weight: 500; }
|
||||
.trek-dash .col-detail-empty:hover { color: var(--ink); border-color: var(--ink-3); }
|
||||
.trek-dash .col-detail-edit { display: flex; flex-direction: column; gap: 8px; }
|
||||
.trek-dash .col-detail-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .1em; color: var(--ink-3); margin-top: 4px; }
|
||||
.trek-dash .col-detail-textarea { width: 100%; padding: 10px 12px; border-radius: 10px; border: 1px solid var(--line-2); background: var(--surface); color: var(--ink); font-size: 13.5px; font-family: inherit; outline: none; resize: vertical; }
|
||||
.trek-dash .col-detail-textarea:focus { border-color: var(--accent); }
|
||||
|
||||
/* edit form */
|
||||
.trek-dash .col-detail-edit { display: flex; flex-direction: column; gap: 16px; }
|
||||
.trek-dash .col-detail-field { display: flex; flex-direction: column; gap: 8px; }
|
||||
.trek-dash .col-detail-label { display: inline-flex; align-items: center; gap: 6px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .1em; color: var(--ink-3); }
|
||||
.trek-dash .col-detail-cats { display: flex; flex-wrap: wrap; gap: 7px; }
|
||||
.trek-dash .col-detail-cat { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; border-radius: 999px; font-size: 12.5px; font-weight: 500; color: var(--ink-2); background: var(--surface-2); border: 1px solid transparent; transition: background .12s, border-color .12s, color .12s; }
|
||||
.trek-dash .col-detail-cat:hover { background: var(--surface); border-color: var(--line-2); }
|
||||
.trek-dash .col-detail-cat.on { color: var(--cat, var(--accent)); background: color-mix(in oklch, var(--cat, var(--accent)) 16%, transparent); border-color: color-mix(in oklch, var(--cat, var(--accent)) 40%, transparent); }
|
||||
.trek-dash .col-detail-textarea { width: 100%; padding: 10px 12px; border-radius: 10px; border: 1px solid var(--line-2); background: var(--surface-2); color: var(--ink); font-size: 13.5px; font-family: inherit; outline: none; resize: vertical; }
|
||||
.trek-dash .col-detail-textarea:focus { border-color: var(--accent); background: var(--surface); }
|
||||
.trek-dash .col-detail-links-edit { display: flex; flex-direction: column; gap: 8px; }
|
||||
.trek-dash .col-detail-link-row { display: flex; align-items: center; gap: 8px; }
|
||||
.trek-dash .col-detail-input { padding: 7px 10px; border-radius: 9px; border: 1px solid var(--line-2); background: var(--surface); color: var(--ink); font-size: 12.5px; outline: none; min-width: 0; }
|
||||
.trek-dash .col-detail-input:focus { border-color: var(--accent); }
|
||||
.trek-dash .col-detail-input { padding: 7px 10px; border-radius: 9px; border: 1px solid var(--line-2); background: var(--surface-2); color: var(--ink); font-size: 12.5px; outline: none; min-width: 0; }
|
||||
.trek-dash .col-detail-input:focus { border-color: var(--accent); background: var(--surface); }
|
||||
.trek-dash .col-detail-icon-btn { padding: 7px; border-radius: 8px; color: var(--ink-3); }
|
||||
.trek-dash .col-detail-icon-btn:hover { color: oklch(0.6 0.2 25); background: var(--surface-2); }
|
||||
.trek-dash .col-detail-add-link { display: inline-flex; align-items: center; gap: 6px; align-self: flex-start; padding: 7px 11px; border-radius: 9px; border: 1px dashed var(--line-2); color: var(--ink-2); font-size: 12.5px; font-weight: 500; }
|
||||
.trek-dash .col-detail-add-link:hover { background: var(--surface-2); }
|
||||
.trek-dash .col-detail-edit-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; }
|
||||
|
||||
/* footer action bar */
|
||||
.trek-dash .col-detail-footer { display: flex; align-items: center; gap: 8px; padding: 12px 16px; border-top: 1px solid var(--line); flex-shrink: 0; }
|
||||
.trek-dash .col-detail-footer-spacer { flex: 1 1 auto; }
|
||||
.trek-dash .col-detail-btn { display: inline-flex; align-items: center; gap: 6px; padding: 8px 14px; border-radius: 10px; font-size: 12.5px; font-weight: 600; color: var(--ink-2); background: var(--surface-2); border: 1px solid var(--line-2); transition: background .12s, color .12s; }
|
||||
.trek-dash .col-detail-btn:hover { background: var(--surface); color: var(--ink); }
|
||||
.trek-dash .col-detail-btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
.trek-dash .col-detail-btn.danger { color: oklch(0.6 0.2 25); border-color: transparent; background: transparent; }
|
||||
.trek-dash .col-detail-btn.danger:hover { background: oklch(0.6 0.2 25 / .12); }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.trek-dash .col-detail { left: 0; right: 0; bottom: 0; transform: none; width: 100%; max-height: 84vh; border-radius: var(--r-xl) var(--r-xl) 0 0; }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const collection: TranslationStrings = {
|
||||
'collections.category': 'الفئة',
|
||||
'collections.noCategory': 'بدون فئة',
|
||||
'collections.editListTitle': 'تعديل القائمة',
|
||||
'collections.coverImage': 'صورة الغلاف',
|
||||
'collections.addCover': 'إضافة غلاف',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const collection: TranslationStrings = {
|
||||
'collections.category': 'Categoria',
|
||||
'collections.noCategory': 'Sem categoria',
|
||||
'collections.editListTitle': 'Editar lista',
|
||||
'collections.coverImage': 'Imagem de capa',
|
||||
'collections.addCover': 'Adicionar capa',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const collection: TranslationStrings = {
|
||||
'collections.category': 'Kategorie',
|
||||
'collections.noCategory': 'Bez kategorie',
|
||||
'collections.editListTitle': 'Upravit seznam',
|
||||
'collections.coverImage': 'Obrázek na obálku',
|
||||
'collections.addCover': 'Přidat obálku',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const collection: TranslationStrings = {
|
||||
'collections.category': 'Kategorie',
|
||||
'collections.noCategory': 'Keine Kategorie',
|
||||
'collections.editListTitle': 'Liste bearbeiten',
|
||||
'collections.coverImage': 'Titelbild',
|
||||
'collections.addCover': 'Titelbild hinzufügen',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const collection: TranslationStrings = {
|
||||
'collections.category': 'Category',
|
||||
'collections.noCategory': 'No category',
|
||||
'collections.editListTitle': 'Edit list',
|
||||
'collections.coverImage': 'Cover image',
|
||||
'collections.addCover': 'Add cover',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const collection: TranslationStrings = {
|
||||
'collections.category': 'Categoría',
|
||||
'collections.noCategory': 'Sin categoría',
|
||||
'collections.editListTitle': 'Editar lista',
|
||||
'collections.coverImage': 'Imagen de portada',
|
||||
'collections.addCover': 'Añadir portada',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const collection: TranslationStrings = {
|
||||
'collections.category': 'Catégorie',
|
||||
'collections.noCategory': 'Aucune catégorie',
|
||||
'collections.editListTitle': 'Modifier la liste',
|
||||
'collections.coverImage': 'Image de couverture',
|
||||
'collections.addCover': 'Ajouter une couverture',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const collection: TranslationStrings = {
|
||||
'collections.category': 'Κατηγορία',
|
||||
'collections.noCategory': 'Χωρίς κατηγορία',
|
||||
'collections.editListTitle': 'Επεξεργασία λίστας',
|
||||
'collections.coverImage': 'Εικόνα εξωφύλλου',
|
||||
'collections.addCover': 'Προσθήκη εξωφύλλου',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const collection: TranslationStrings = {
|
||||
'collections.category': 'Kategória',
|
||||
'collections.noCategory': 'Nincs kategória',
|
||||
'collections.editListTitle': 'Lista szerkesztése',
|
||||
'collections.coverImage': 'Borítókép',
|
||||
'collections.addCover': 'Borító hozzáadása',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const collection: TranslationStrings = {
|
||||
'collections.category': 'Kategori',
|
||||
'collections.noCategory': 'Tanpa kategori',
|
||||
'collections.editListTitle': 'Edit daftar',
|
||||
'collections.coverImage': 'Gambar sampul',
|
||||
'collections.addCover': 'Tambah sampul',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const collection: TranslationStrings = {
|
||||
'collections.category': 'Categoria',
|
||||
'collections.noCategory': 'Nessuna categoria',
|
||||
'collections.editListTitle': 'Modifica elenco',
|
||||
'collections.coverImage': 'Immagine di copertina',
|
||||
'collections.addCover': 'Aggiungi copertina',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const collection: TranslationStrings = {
|
||||
'collections.category': 'カテゴリ',
|
||||
'collections.noCategory': 'カテゴリなし',
|
||||
'collections.editListTitle': 'リストを編集',
|
||||
'collections.coverImage': 'カバー画像',
|
||||
'collections.addCover': 'カバーを追加',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const collection: TranslationStrings = {
|
||||
'collections.category': '카테고리',
|
||||
'collections.noCategory': '카테고리 없음',
|
||||
'collections.editListTitle': '목록 편집',
|
||||
'collections.coverImage': '커버 이미지',
|
||||
'collections.addCover': '커버 추가',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const collection: TranslationStrings = {
|
||||
'collections.category': 'Categorie',
|
||||
'collections.noCategory': 'Geen categorie',
|
||||
'collections.editListTitle': 'Lijst bewerken',
|
||||
'collections.coverImage': 'Omslagafbeelding',
|
||||
'collections.addCover': 'Omslag toevoegen',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const collection: TranslationStrings = {
|
||||
'collections.category': 'Kategoria',
|
||||
'collections.noCategory': 'Bez kategorii',
|
||||
'collections.editListTitle': 'Edytuj listę',
|
||||
'collections.coverImage': 'Obraz okładki',
|
||||
'collections.addCover': 'Dodaj okładkę',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const collection: TranslationStrings = {
|
||||
'collections.category': 'Категория',
|
||||
'collections.noCategory': 'Без категории',
|
||||
'collections.editListTitle': 'Изменить список',
|
||||
'collections.coverImage': 'Обложка',
|
||||
'collections.addCover': 'Добавить обложку',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const collection: TranslationStrings = {
|
||||
'collections.category': 'Kategori',
|
||||
'collections.noCategory': 'Ingen kategori',
|
||||
'collections.editListTitle': 'Redigera lista',
|
||||
'collections.coverImage': 'Omslagsbild',
|
||||
'collections.addCover': 'Lägg till omslag',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const collection: TranslationStrings = {
|
||||
'collections.category': 'Kategori',
|
||||
'collections.noCategory': 'Kategori yok',
|
||||
'collections.editListTitle': 'Listeyi düzenle',
|
||||
'collections.coverImage': 'Kapak resmi',
|
||||
'collections.addCover': 'Kapak ekle',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const collection: TranslationStrings = {
|
||||
'collections.category': 'Категорія',
|
||||
'collections.noCategory': 'Без категорії',
|
||||
'collections.editListTitle': 'Редагувати список',
|
||||
'collections.coverImage': 'Обкладинка',
|
||||
'collections.addCover': 'Додати обкладинку',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const collection: TranslationStrings = {
|
||||
'collections.category': 'Danh mục',
|
||||
'collections.noCategory': 'Không có danh mục',
|
||||
'collections.editListTitle': 'Chỉnh sửa danh sách',
|
||||
'collections.coverImage': 'Ảnh bìa',
|
||||
'collections.addCover': 'Thêm ảnh bìa',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const collection: TranslationStrings = {
|
||||
'collections.category': '類別',
|
||||
'collections.noCategory': '無類別',
|
||||
'collections.editListTitle': '編輯清單',
|
||||
'collections.coverImage': '封面圖片',
|
||||
'collections.addCover': '新增封面',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const collection: TranslationStrings = {
|
||||
'collections.category': '类别',
|
||||
'collections.noCategory': '无类别',
|
||||
'collections.editListTitle': '编辑清单',
|
||||
'collections.coverImage': '封面图片',
|
||||
'collections.addCover': '添加封面',
|
||||
|
||||
Reference in New Issue
Block a user