mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
acd003ea67
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.
138 lines
5.8 KiB
TypeScript
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>
|
|
)
|
|
}
|