mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 14:36:31 +00:00
2c06c9904b
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.
111 lines
4.2 KiB
TypeScript
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>
|
|
))}
|
|
</>
|
|
)}
|
|
</>
|
|
)
|
|
}
|