Files
TREK/client/src/components/Collections/SaveTripPlacesToListModal.tsx
T
Maurice acd003ea67 feat(dialogs): Vacay, Collections, Atlas and Journey in the new dialog look
Every popup of the four pages moves to the shared dialog frame the trip
uses: head band with its icon, the scrolling body, the footer with its
actions, and ConfirmDialog for the questions.

- Vacay: settings, removing a year (now says so when it fails), the
  fusion request, inviting a person, a person's colour and sharing the
  calendar.
- Collections: new and edited lists, adding a place, importing a file and
  a trip's places, sharing, labels and assigning them, copying to a trip,
  moving or duplicating, saving a trip's places and deleting a list. The
  stylesheet of the old import view goes.
- Atlas: the country popup with the flag in its head, its choices as rows
  and the wishlist step; Dawarich's Atlas dialog and its accept dialog on
  the desktop.
- Journey: creating a journey, its settings, linking a trip, inviting a
  contributor, the entry editor (discarding asks in a dialog) and the
  photo provider picker; the entry card's menu is the shared one.

The phone keeps its own sheets: where a dialog is shared with it
(Dawarich, the contributor invite, the provider picker) only the desktop
frame changes.
2026-09-28 16:52:32 +02:00

138 lines
5.8 KiB
TypeScript

import React, { useEffect, useId, useMemo, useState } from 'react'
import { Search, Bookmark, ArrowRight, Loader2 } from 'lucide-react'
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell'
import { INPUT } from '../shared/dialogParts'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { collectionsApi } from '../../api/collections'
import { getApiErrorMessage } from '../../utils/apiError'
import type { Collection } from '@trek/shared'
interface SaveTripPlacesToListModalProps {
isOpen: boolean
tripId: number
/** The selected trip place ids to copy into the chosen list. */
placeIds: number[]
onClose: () => void
/** Called after a successful save (e.g. to clear the trip selection). */
onDone: () => void
}
/**
* Bulk "save to collection" for the trip place list: pick one of the user's lists
* and copy every selected trip place into it at once (server dedups by name/coords).
*/
export default function SaveTripPlacesToListModal({ isOpen, tripId, placeIds, onClose, onDone }: SaveTripPlacesToListModalProps): React.ReactElement | null {
const { t } = useTranslation()
const toast = useToast()
const labelId = useId()
const [lists, setLists] = useState<Collection[]>([])
const [loading, setLoading] = useState(false)
const [search, setSearch] = useState('')
const [busyId, setBusyId] = useState<number | null>(null)
useEffect(() => {
if (!isOpen) return
let cancelled = false
setLoading(true)
setSearch('')
collectionsApi.list()
// Only lists the user can add to (their own or an editor/admin share). The
// server still enforces this; here we drop lists that are clearly read-only.
.then(res => { if (!cancelled) setLists((res.collections ?? []).filter(c => c.is_owner !== false)) })
.catch(() => { if (!cancelled) setLists([]) })
.finally(() => { if (!cancelled) setLoading(false) })
return () => { cancelled = true }
}, [isOpen])
const filtered = useMemo(() => {
const q = search.trim().toLowerCase()
return q ? lists.filter(l => l.name.toLowerCase().includes(q)) : lists
}, [lists, search])
if (!isOpen) return null
const pick = async (list: Collection) => {
if (busyId != null || placeIds.length === 0) return
setBusyId(list.id)
try {
const res = await collectionsApi.saveFromTripMany(list.id, tripId, placeIds)
if (res.copied > 0) toast.success(t('collections.addedNToList', { count: res.copied, name: list.name }))
if (res.skipped.length > 0) toast.info(t('collections.skippedDuplicates', { count: res.skipped.length }))
if (res.copied === 0 && res.skipped.length === 0) toast.info(t('collections.copyNothing'))
onDone()
onClose()
} catch (err) {
toast.error(getApiErrorMessage(err, t('common.error')))
} finally {
setBusyId(null)
}
}
return (
<DialogShell
onClose={onClose}
labelledBy={labelId}
width="narrow"
align="top"
header={(
<DialogHeader
tile={<DialogTile><Bookmark size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
tint={NEUTRAL_TINT}
labelId={labelId}
onClose={onClose}
title={t('collections.saveNToList', { count: placeIds.length })}
sub={t('collections.saveToListHint')}
/>
)}
footer={(
<DialogFooter>
<FooterSpacer />
<DialogButton onClick={onClose}>{t('common.cancel')}</DialogButton>
</DialogFooter>
)}
>
{lists.length > 5 && (
<div className="relative">
<Search size={14} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-content-faint" aria-hidden="true" />
<input
autoFocus
value={search}
onChange={e => setSearch(e.target.value)}
placeholder={t('collections.searchLists')}
aria-label={t('collections.searchLists')}
className={`${INPUT} pl-8`}
/>
</div>
)}
{loading ? (
<div className="flex items-center justify-center py-8 text-content-faint"><Loader2 size={20} className="animate-spin" /></div>
) : filtered.length === 0 ? (
<p className="m-0 rounded-[12px] bg-surface-secondary px-4 py-6 text-center text-content-faint" style={fs(12.5, 'body')}>{t('collections.noOwnLists')}</p>
) : (
<div className="flex flex-col gap-0.5 rounded-[14px] border border-edge-faint bg-surface-secondary p-1.5">
{filtered.map(list => {
const busy = busyId === list.id
return (
<button
key={list.id}
type="button"
onClick={() => pick(list)}
disabled={busyId != null}
className="flex min-h-[48px] items-center gap-3 rounded-[10px] px-3 py-2 text-left hover:bg-surface-card disabled:opacity-60"
>
<span className="h-2.5 w-2.5 flex-none rounded-full" style={{ background: list.color || '#6366f1' }} /* theme-lint-disable: the list's own colour, and the default a list without one is drawn in (ListsRail) */ />
<span className="min-w-0 flex-1">
<span className="block truncate font-semibold text-content" style={fs(13, 'body')}>{list.name}</span>
<span className="block text-content-faint" style={fs(11.5)}>{t('collections.placeCount', { count: list.place_count ?? 0 })}</span>
</span>
{busy ? <Loader2 size={15} className="flex-none animate-spin text-content-faint" /> : <ArrowRight size={15} className="flex-none text-content-faint" />}
</button>
)
})}
</div>
)}
</DialogShell>
)
}