mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +00:00
76ec302526
A tour kept in a collection was a card in a box of its own above the list, and its button lost its look to the collections page's button reset. It is now a row of the list like a saved place: its grade or type tile, its name, type and the trip it came from, its measures, and Add to a trip, with removing dimmed beside it. The list's search narrows the tours too. The phone shows the same rows and takes a tour into a trip from a sheet, on the same hooks as the desktop.
482 lines
21 KiB
TypeScript
482 lines
21 KiB
TypeScript
import React from 'react'
|
|
import { List as ListIcon, Map as MapIcon, Search, Bookmark, CheckCheck, X, Trash2, Copy, CopyPlus, FolderInput, Plus, Tags, DownloadCloud } from 'lucide-react'
|
|
import Navbar from '../components/Layout/Navbar'
|
|
import ConfirmDialog from '../components/shared/ConfirmDialog'
|
|
import ListsRail from '../components/Collections/ListsRail'
|
|
import CollectionInviteCard from '../components/Collections/CollectionInviteCard'
|
|
import ListEditorModal from '../components/Collections/ListEditorModal'
|
|
import CollectionHero from '../components/Collections/CollectionHero'
|
|
import CollectionList from '../components/Collections/CollectionList'
|
|
import CollectionFilterBar from '../components/Collections/CollectionFilterBar'
|
|
import CollectionMapPanel from '../components/Collections/CollectionMapPanel'
|
|
import CopyToTripModal from '../components/Collections/CopyToTripModal'
|
|
import MoveToListModal from '../components/Collections/MoveToListModal'
|
|
import ShareCollectionModal from '../components/Collections/ShareCollectionModal'
|
|
import AddPlaceToCollectionModal from '../components/Collections/AddPlaceToCollectionModal'
|
|
import ImportFromTripModal from '../components/Collections/ImportFromTripModal'
|
|
import ImportCollectionModal from '../components/Collections/ImportCollectionModal'
|
|
import CollectionPlaceDetail from '../components/Collections/CollectionPlaceDetail'
|
|
import LabelManager from '../components/Collections/LabelManager'
|
|
import BulkAssignLabelModal from '../components/Collections/BulkAssignLabelModal'
|
|
import { ActiveCollectionTours } from '../components/Tours/collection/CollectionToursSection'
|
|
import { useCollections } from './collections/useCollections'
|
|
import EmptyState from '../components/shared/EmptyState'
|
|
import '../styles/dashboard.css'
|
|
import HelpAnchor from '../components/Help/HelpAnchor'
|
|
import '../styles/collections.css'
|
|
|
|
export default function CollectionsPage(): React.ReactElement {
|
|
// ViewportRoute in App.tsx picks the branch now, so the phone screen is a
|
|
// chunk of its own instead of a dead limb in this one.
|
|
return (
|
|
<>
|
|
<HelpAnchor id="collections" />
|
|
<CollectionsPageDesktop />
|
|
</>
|
|
)
|
|
}
|
|
|
|
function CollectionsPageDesktop(): React.ReactElement {
|
|
const c = useCollections()
|
|
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.activeCollection != null
|
|
// Labels are per-collection, so only on a real (non "All saved") list.
|
|
const isRealList = !c.isAllSaved && typeof c.activeId === 'number'
|
|
const canManageLabels = isRealList && c.canEdit
|
|
// Invites also sit at the top of the body: with no lists of one's own the
|
|
// body is all there is (and the rail is hidden below 1080px), and the invite
|
|
// notification links to a list that is still an invite (#2645).
|
|
const bodyInvites = noLists ? c.orderedInvites : (c.routeInvite ? [c.routeInvite] : [])
|
|
|
|
// Selecting a place from the list toggles it, so clicking the row again clears
|
|
// it, as does the map background. A marker click always shows the place: a second
|
|
// click on the marker of the place that is open read as "the details do not open"
|
|
// rather than as "close them" (#2431). Below the desktop breakpoint the list and
|
|
// map are separate views; above it the list view is a split with a persistent map
|
|
// that pans to the selection (the map stays mounted across the list↔map toggle so
|
|
// it animates).
|
|
const mappable = c.mappable
|
|
const openPlace = (id: number) => c.setSelectedPlaceId(c.selectedPlaceId === id ? null : id)
|
|
const showPlace = (id: number) => c.setSelectedPlaceId(id)
|
|
const deselect = () => c.setSelectedPlaceId(null)
|
|
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) => { showPlace(id); if (c.view === 'map') c.setView('list') }
|
|
|
|
const desktopSplit = c.isWide && c.hasMappable
|
|
const mapShown = c.hasMappable && (c.view === 'map' || c.isWide)
|
|
const mapOverlay = c.isWide && mapShown // the map carries the toggle + search
|
|
const canAddPlace = typeof c.activeId === 'number' && c.canEdit // a real list you can edit
|
|
|
|
const listEl = (
|
|
<CollectionList
|
|
places={c.visiblePlaces}
|
|
labels={c.labels}
|
|
selectedPlaceId={c.selectedPlaceId}
|
|
selectMode={c.selectMode}
|
|
selectedIds={c.selectedIds}
|
|
onOpenPlace={openPlace}
|
|
onStatusChange={c.canEdit ? c.handleStatusChange : undefined}
|
|
onToggleSelect={c.toggleSelect}
|
|
t={t}
|
|
/>
|
|
)
|
|
const mapPanel = (overlay: boolean) => (
|
|
<CollectionMapPanel
|
|
labelOptions={isRealList ? c.labelOptions : []}
|
|
labelFilter={c.labelFilter}
|
|
onLabelFilter={isRealList ? c.setLabelFilter : undefined}
|
|
canManageLabels={canManageLabels}
|
|
onManageLabels={() => c.setShowLabelManager(true)}
|
|
places={mappable}
|
|
selectedPlaceId={c.selectedPlaceId}
|
|
onSelect={onMapSelect}
|
|
onDeselect={deselect}
|
|
dark={c.dark}
|
|
overlay={overlay}
|
|
view={c.view}
|
|
onToggleView={toggleView}
|
|
search={c.search}
|
|
onSearch={c.setSearch}
|
|
t={t}
|
|
/>
|
|
)
|
|
|
|
// Filter row + the list (or a "no match" note when a filter hides everything).
|
|
// Kept together so the filters stay reachable even when nothing matches.
|
|
const filterBar = hasPlaces ? (
|
|
<CollectionFilterBar
|
|
statusFilter={c.statusFilter}
|
|
counts={c.counts}
|
|
categoryFilter={c.categoryFilter}
|
|
categoryOptions={c.categoryOptions}
|
|
ratingFilter={c.ratingFilter}
|
|
sortMode={c.sortMode}
|
|
onStatusFilter={c.setStatusFilter}
|
|
onCategoryFilter={c.setCategoryFilter}
|
|
onRatingFilter={c.setRatingFilter}
|
|
onSortMode={c.setSortMode}
|
|
canAddPlace={canAddPlace}
|
|
onAddPlace={() => c.setShowAddPlace(true)}
|
|
showLabels={isRealList && !mapShown}
|
|
labelOptions={c.labelOptions}
|
|
labelFilter={c.labelFilter}
|
|
onLabelFilter={c.setLabelFilter}
|
|
canManageLabels={canManageLabels}
|
|
onManageLabels={() => c.setShowLabelManager(true)}
|
|
canImport={canAddPlace}
|
|
onImport={() => c.setShowImport(true)}
|
|
showSelect={showSelect}
|
|
selectMode={c.selectMode}
|
|
onToggleSelect={() => c.setSelectMode(!c.selectMode)}
|
|
t={t}
|
|
/>
|
|
) : null
|
|
// The whole tours the list keeps (LIB-03) open its list, as rows like its places.
|
|
const tourRows = isRealList ? <ActiveCollectionTours search={c.search} /> : null
|
|
const listColumn = (
|
|
<>
|
|
{filterBar}
|
|
{tourRows}
|
|
{c.visiblePlaces.length > 0
|
|
? listEl
|
|
: <EmptyState scene="search" title={t('collections.empty.noMatchTitle')} />}
|
|
</>
|
|
)
|
|
|
|
let body: React.ReactElement
|
|
if (c.placesLoading && !hasPlaces) {
|
|
body = <div className="col-loading"><div className="col-spinner" /></div>
|
|
} else if (!hasPlaces) {
|
|
body = (
|
|
<div className="flex flex-col items-center">
|
|
{tourRows && <div className="w-full pb-2">{tourRows}</div>}
|
|
<EmptyState scene="collections" title={t('collections.empty.title')} className={canAddPlace ? 'pb-4' : undefined} />
|
|
{canAddPlace && (
|
|
<div className="flex flex-wrap items-center justify-center gap-2">
|
|
<button type="button" onClick={() => c.setShowAddPlace(true)} className="col-cta">
|
|
<Plus size={16} /> {t('collections.addPlace')}
|
|
</button>
|
|
{/* An empty list is exactly where a trip import saves the most typing,
|
|
so it gets the same billing as adding one place by hand. */}
|
|
<button type="button" onClick={() => c.setShowImport(true)} className="col-cta col-cta-ghost">
|
|
<DownloadCloud size={16} /> {t('collections.importFromTrip')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
} else if (desktopSplit) {
|
|
body = (
|
|
<div className={`col-split${c.view === 'map' ? ' map-full' : ''}`}>
|
|
<div className="col-split-list" ref={c.listColRef}>{listColumn}</div>
|
|
<div className="col-split-map">{mapPanel(true)}</div>
|
|
</div>
|
|
)
|
|
} else if (c.view === 'map' && c.hasMappable) {
|
|
body = <div className="col-mapwrap">{mapPanel(false)}</div>
|
|
} else {
|
|
body = listColumn
|
|
}
|
|
|
|
const rail = (
|
|
<ListsRail
|
|
ownedLists={c.ownedLists}
|
|
sharedLists={c.sharedLists}
|
|
activeId={c.activeId}
|
|
incomingInvites={c.incomingInvites}
|
|
onSelect={c.handleSelectList}
|
|
onNewList={() => { c.setMobileRailOpen(false); c.setEditorTarget('new') }}
|
|
onImportList={() => { c.setMobileRailOpen(false); c.setShowImportFile(true) }}
|
|
onAcceptInvite={c.handleAcceptInvite}
|
|
onDeclineInvite={c.handleDeclineInvite}
|
|
t={t}
|
|
/>
|
|
)
|
|
|
|
return (
|
|
<>
|
|
<Navbar />
|
|
<div className="trek-dash col-root">
|
|
<div className="col-page">
|
|
<aside className="col-rail" style={{ minHeight: c.heroHeight || undefined }}>{rail}</aside>
|
|
|
|
<div className="col-body">
|
|
{bodyInvites.length > 0 && (
|
|
<section className="col-invites-main" aria-label={t('collections.invites.title')}>
|
|
<div className="col-rail-label">
|
|
{t('collections.invites.title')}
|
|
<span className="badge">{bodyInvites.length}</span>
|
|
</div>
|
|
{bodyInvites.map(inv => (
|
|
<CollectionInviteCard key={inv.collection_id} invite={inv} onAccept={c.handleAcceptInvite} onDecline={c.handleDeclineInvite} t={t} />
|
|
))}
|
|
</section>
|
|
)}
|
|
{noLists ? (
|
|
<div className="flex flex-col items-center">
|
|
<EmptyState scene="collections" title={t('collections.empty.firstTitle')} className="pb-4" />
|
|
<button type="button" onClick={() => c.setEditorTarget('new')} className="col-cta">
|
|
<Bookmark size={16} /> {t('collections.newList')}
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div ref={c.heroRef}>
|
|
<CollectionHero
|
|
eyebrow={eyebrow}
|
|
title={title}
|
|
color={heroColor}
|
|
coverImage={heroCover}
|
|
description={c.isAllSaved ? null : (c.activeCollection?.description ?? null)}
|
|
links={c.isAllSaved ? undefined : c.activeCollection?.links}
|
|
members={c.members}
|
|
canShare={c.canShare}
|
|
isOwner={c.isOwner}
|
|
canEdit={!c.isAllSaved && c.isOwner && c.activeCollection != null}
|
|
onEdit={() => { if (c.activeCollection) c.setEditorTarget(c.activeCollection) }}
|
|
shareMemberCount={c.shareMemberCount}
|
|
onShare={() => c.setShowShare(true)}
|
|
onExport={c.isAllSaved || !c.activeCollection ? undefined : c.handleExportList}
|
|
exporting={c.exporting}
|
|
t={t}
|
|
/>
|
|
</div>
|
|
|
|
{!mapOverlay && (
|
|
<div className="col-toolbar">
|
|
<button type="button" className="col-rail-toggle" onClick={() => c.setMobileRailOpen(true)}>
|
|
<Bookmark size={15} /> {t('collections.title')}
|
|
</button>
|
|
{!c.isWide && c.hasMappable && (
|
|
<div className="col-viewseg" role="group" aria-label={t('collections.title')}>
|
|
<button type="button" aria-pressed={c.view === 'list'} onClick={() => c.setView('list')} aria-label={t('collections.view.list')} title={t('collections.view.list')} className={c.view === 'list' ? 'on' : ''}>
|
|
<ListIcon size={16} />
|
|
</button>
|
|
<button type="button" aria-pressed={c.view === 'map'} onClick={() => c.setView('map')} aria-label={t('collections.view.map')} title={t('collections.view.map')} className={c.view === 'map' ? 'on' : ''}>
|
|
<MapIcon size={16} />
|
|
</button>
|
|
</div>
|
|
)}
|
|
<div className="col-toolbar-spacer" />
|
|
{!mapOverlay && (
|
|
<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 && (
|
|
<div className="col-selbar">
|
|
<button type="button" onClick={c.handleSelectAll} className="col-selbar-btn">
|
|
<CheckCheck size={14} /> {c.allVisibleSelected ? t('collections.deselectAll') : t('collections.selectAll')}
|
|
</button>
|
|
<span className="lbl">{t('collections.selectedCount', { count: c.selectedIds.length })}</span>
|
|
<div className="col-toolbar-spacer" />
|
|
{c.canEdit && isRealList && (
|
|
<button type="button" onClick={() => c.setLabelPickerOpen(true)} disabled={c.selectedIds.length === 0} className="col-selbar-btn">
|
|
<Tags size={14} /> {t('collections.labels.assign')}
|
|
</button>
|
|
)}
|
|
{c.canDelete && (
|
|
<button type="button" onClick={() => c.setListPickerMode('move')} disabled={c.selectedIds.length === 0} className="col-selbar-btn">
|
|
<FolderInput size={14} /> {t('collections.moveToList')}
|
|
</button>
|
|
)}
|
|
<button type="button" onClick={() => c.setListPickerMode('copy')} disabled={c.selectedIds.length === 0} className="col-selbar-btn">
|
|
<CopyPlus size={14} /> {t('collections.duplicateToList')}
|
|
</button>
|
|
<button type="button" onClick={c.openCopyForSelection} disabled={c.selectedIds.length === 0} className="col-selbar-btn">
|
|
<Copy size={14} /> {t('collections.copyToTrip')}
|
|
</button>
|
|
{c.canDelete && (
|
|
<button type="button" onClick={c.handleDeleteSelected} disabled={c.selectedIds.length === 0} 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>
|
|
|
|
{/* Mobile rail drawer */}
|
|
{c.mobileRailOpen && (
|
|
<>
|
|
<div role="presentation" className="col-drawer-backdrop" onClick={() => c.setMobileRailOpen(false)} />
|
|
<div className="col-drawer">
|
|
<div className="col-drawer-head">
|
|
<button type="button" onClick={() => c.setMobileRailOpen(false)} aria-label={t('common.close')}><X size={18} /></button>
|
|
</div>
|
|
{rail}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Place detail — a bottom sheet (no backdrop, so the map behind stays
|
|
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.view !== 'map' && (
|
|
<CollectionPlaceDetail
|
|
place={c.selectedPlace}
|
|
canEdit={c.canEdit}
|
|
canDelete={c.canDelete}
|
|
categories={c.categories}
|
|
labels={c.labels}
|
|
anchorRect={desktopSplit ? c.listColRect : null}
|
|
onClose={c.handleCloseDetail}
|
|
onSetStatus={c.handleDetailStatus}
|
|
onSave={patch => c.updatePlace(c.selectedPlace!.id, patch)}
|
|
onUploadImage={file => c.uploadPlaceImage(c.selectedPlace!.id, file)}
|
|
onCopyToTrip={c.openCopyForSelectedPlace}
|
|
onRemove={c.handleDetailRemove}
|
|
onRate={r => c.handleRatePlace(c.selectedPlace!.id, r)}
|
|
t={t}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* Add a place to the current list */}
|
|
{typeof c.activeId === 'number' && c.activeCollection && (
|
|
<AddPlaceToCollectionModal
|
|
isOpen={c.showAddPlace}
|
|
collectionId={c.activeId}
|
|
collectionName={c.activeCollection.name}
|
|
categories={c.categories}
|
|
onClose={() => c.setShowAddPlace(false)}
|
|
onAdded={c.handlePlaceAdded}
|
|
t={t}
|
|
/>
|
|
)}
|
|
|
|
{/* A list file as a new list, or added to one that is already there (#2198, #2301) */}
|
|
{c.showImportFile && (
|
|
<ImportCollectionModal
|
|
onImport={c.handleImportFile}
|
|
onImportInto={c.handleImportFileInto}
|
|
lists={c.writableLists}
|
|
defaultListId={typeof c.activeId === 'number' ? c.activeId : null}
|
|
onReadGpx={c.handleReadGpx}
|
|
onClose={() => c.setShowImportFile(false)}
|
|
t={t}
|
|
/>
|
|
)}
|
|
|
|
{/* Bulk import of a trip's places into the current list */}
|
|
{typeof c.activeId === 'number' && c.activeCollection && (
|
|
<ImportFromTripModal
|
|
isOpen={c.showImport}
|
|
collectionId={c.activeId}
|
|
collectionName={c.activeCollection.name}
|
|
categories={c.categories}
|
|
onClose={() => c.setShowImport(false)}
|
|
onImported={c.handlePlaceAdded}
|
|
t={t}
|
|
/>
|
|
)}
|
|
|
|
{/* Copy to trip */}
|
|
<CopyToTripModal
|
|
isOpen={c.copyIds != null}
|
|
onClose={c.closeCopy}
|
|
placeIds={c.copyIds ?? []}
|
|
onCopy={c.handleCopyToTrip}
|
|
t={t}
|
|
/>
|
|
|
|
{/* Move / duplicate the selection into another list */}
|
|
{c.listPickerMode && (
|
|
<MoveToListModal
|
|
mode={c.listPickerMode}
|
|
lists={c.ownedLists.filter(l => l.id !== c.activeId)}
|
|
count={c.selectedIds.length}
|
|
onPick={c.listPickerMode === 'move' ? c.handleMoveToList : c.handleDuplicateToList}
|
|
onClose={() => c.setListPickerMode(null)}
|
|
t={t}
|
|
/>
|
|
)}
|
|
|
|
{/* Share / fusion */}
|
|
{c.canShare && typeof c.activeId === 'number' && c.activeCollection && (
|
|
<ShareCollectionModal
|
|
isOpen={c.showShare}
|
|
onClose={() => c.setShowShare(false)}
|
|
collectionId={c.activeId}
|
|
collectionName={c.activeCollection.name}
|
|
isOwner={c.isOwner}
|
|
members={c.members}
|
|
onAfterLeave={c.handleAfterLeave}
|
|
t={t}
|
|
/>
|
|
)}
|
|
|
|
{/* Create / edit a list — name, colour, cover, description, links */}
|
|
<ListEditorModal target={c.editorTarget} onClose={() => c.setEditorTarget(null)} onCreated={c.handleEditorCreated} onRequestDelete={c.setConfirmDeleteList} t={t} />
|
|
|
|
{/* Manage the list's custom labels (editor+) */}
|
|
{canManageLabels && (
|
|
<LabelManager
|
|
isOpen={c.showLabelManager}
|
|
labels={c.labels}
|
|
onCreate={c.handleCreateLabel}
|
|
onUpdate={c.handleUpdateLabel}
|
|
onDelete={c.handleDeleteLabel}
|
|
onClose={() => c.setShowLabelManager(false)}
|
|
t={t}
|
|
/>
|
|
)}
|
|
|
|
{/* Bulk-assign labels to the current selection */}
|
|
{canManageLabels && (
|
|
<BulkAssignLabelModal
|
|
isOpen={c.labelPickerOpen}
|
|
labels={c.labels}
|
|
count={c.selectedIds.length}
|
|
onAssign={c.handleBulkAssignLabels}
|
|
onManage={() => { c.setLabelPickerOpen(false); c.setShowLabelManager(true) }}
|
|
onClose={() => c.setLabelPickerOpen(false)}
|
|
t={t}
|
|
/>
|
|
)}
|
|
|
|
{/* Delete-list confirm */}
|
|
<ConfirmDialog
|
|
isOpen={c.confirmDeleteList != null}
|
|
onClose={() => c.setConfirmDeleteList(null)}
|
|
onConfirm={c.handleDeleteList}
|
|
title={t('collections.deleteList')}
|
|
message={t('collections.deleteListConfirm')}
|
|
confirmLabel={t('common.delete')}
|
|
cancelLabel={t('common.cancel')}
|
|
/>
|
|
</>
|
|
)
|
|
}
|