Files
TREK/client/src/components/Collections/ListsRail.tsx
T
Maurice 2c06c9904b feat(collections): a list as a file, so it can leave the instance (#2198)
Sharing a list works between people on the same TREK. This is the other
case: giving one to somebody who runs their own, or keeping a copy. Export
sits beside Edit and Share in the list header and downloads the open list;
Import sits beside New list in the rail, reads a file, shows what is in it
and makes a new list of it. Desktop only, which the route already decides.

What travels is what somebody wrote down: the list's name, colour and
description, its labels, and each place with its address, coordinates,
notes, status, contact details, category name and labels. A file is dull
JSON on purpose, so it can be read and edited by a person.

What does not travel is everything that only means something on the instance
it came from. Ids of every kind, since the receiver issues its own and a
category crosses over by name. Star ratings and members, because a rating is
an opinion somebody gave the people on that list. Provenance into trips,
which points at rows the receiver cannot see. And an image path under
/uploads, which resolves on the sender's server: only an absolute https URL
survives.

A file is a stranger's JSON and is read as one. It is parsed in the browser
that chose it and never uploaded; the parsed list goes through the same
contract on both sides; and a link that is not http(s) arrives as nothing
rather than as an anchor. Strict where it must refuse, forgiving where it
can drop: a file that does not say what it is never reaches the importer,
while one unreadable place inside it is skipped and counted, and one bad
field on a good place costs that field alone.

An import is always a new list. Importing into an existing one would mean
deciding what happens to a place that is already there, and every answer to
that is a merge nobody asked for.
2026-09-11 14:58:34 +02:00

111 lines
4.2 KiB
TypeScript

import React from 'react'
import { Plus, Layers, Users, Upload } from 'lucide-react'
import type { Collection } from '@trek/shared'
import type { TranslationFn } from '../../types'
import { ALL_SAVED } from '../../store/collectionStore'
import type { ActiveCollectionId, IncomingCollectionInvite } from '../../store/collectionStore'
interface ListsRailProps {
ownedLists: Collection[]
sharedLists: Collection[]
activeId: ActiveCollectionId
incomingInvites: IncomingCollectionInvite[]
onSelect: (id: ActiveCollectionId) => void
onNewList: () => void
/** Read a list file as a new list (#2198). */
onImportList: () => void
onAcceptInvite: (id: number) => void
onDeclineInvite: (id: number) => void
t: TranslationFn
}
function ListRow({ list, active, onSelect }: { list: Collection; active: boolean; onSelect: (id: number) => void }): React.ReactElement {
return (
<div 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>
</div>
)
}
/**
* Left rail of the user's lists: "New list" and "Import list" actions, the "All saved" union
* pseudo-list, owned lists (colour dot + count), a shared section, and an
* incoming-invites block. Selecting a list makes it active; editing / deleting
* happens from the Edit button in the hero of the active list.
*/
export default function ListsRail(props: ListsRailProps): React.ReactElement {
const {
ownedLists, sharedLists, activeId, incomingInvites,
onSelect, onNewList, onImportList, onAcceptInvite, onDeclineInvite, t,
} = props
return (
<>
<div className="col-rail-newrow">
<button type="button" onClick={onNewList} className="col-rail-new">
<Plus size={16} /> {t('collections.newList')}
</button>
{/* Import sits beside New rather than in the hero: a file becomes a NEW
list, so it belongs where lists are made, not on the one open. */}
<button
type="button"
onClick={onImportList}
className="col-rail-new col-rail-import"
aria-label={t('collections.file.importButton')}
title={t('collections.file.importTitle')}
>
<Upload size={15} />
</button>
</div>
<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="col-rail-sep" />}
{ownedLists.map(list => (
<ListRow key={list.id} list={list} active={activeId === list.id} onSelect={onSelect} />
))}
{sharedLists.length > 0 && (
<>
<div className="col-rail-label"><Users size={12} /> {t('collections.shared')}</div>
{sharedLists.map(list => (
<ListRow key={list.id} list={list} active={activeId === list.id} onSelect={onSelect} />
))}
</>
)}
{incomingInvites.length > 0 && (
<>
<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="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="col-invite-decline">
{t('collections.invites.decline')}
</button>
</div>
</div>
))}
</>
)}
</>
)
}