Files
TREK/client/src/components/Collections/CopyToTripModal.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

167 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useId, useMemo, useState } from 'react'
import { Search, MapPin, Loader2, Copy, CalendarDays } 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 { tripsApi } from '../../api/client'
import { getApiErrorMessage } from '../../utils/apiError'
import { formatDate } from '../../utils/formatters'
import { useTranslation } from '../../i18n'
import type { TranslationFn } from '../../types'
interface TripOption {
id: number
title: string
start_date?: string | null
end_date?: string | null
cover_image?: string | null
}
interface CopyToTripModalProps {
isOpen: boolean
onClose: () => void
/** The collection place ids to copy. */
placeIds: number[]
/** Delegates to collectionStore.copyToTrip; returns the server reconcile result. */
onCopy: (tripId: number) => Promise<{ copied: number; skipped: { id: number; name: string }[] }>
t: TranslationFn
}
/**
* Trip picker for "Copy to trip" — lists the user's trips (searchable), copies
* the selected collection places into the chosen trip and reconciles the server
* dedup result into a copied / skipped-duplicates toast. Works for a single
* place (detail panel) and bulk select-mode ("Copy N to trip").
*/
export default function CopyToTripModal({ isOpen, onClose, placeIds, onCopy, t }: CopyToTripModalProps): React.ReactElement | null {
const toast = useToast()
const { language } = useTranslation()
const labelId = useId()
const [trips, setTrips] = useState<TripOption[]>([])
const [loading, setLoading] = useState(false)
const [search, setSearch] = useState('')
const [busyTripId, setBusyTripId] = useState<number | null>(null)
useEffect(() => {
if (!isOpen) return
let cancelled = false
setLoading(true)
setSearch('')
tripsApi.list()
.then((res: { trips?: TripOption[] }) => { if (!cancelled) setTrips(res.trips ?? []) })
.catch(() => { if (!cancelled) setTrips([]) })
.finally(() => { if (!cancelled) setLoading(false) })
return () => { cancelled = true }
}, [isOpen])
const filtered = useMemo(() => {
const q = search.trim().toLowerCase()
if (!q) return trips
return trips.filter(tr => (tr.title ?? '').toLowerCase().includes(q))
}, [trips, search])
const dateRange = (tr: TripOption): string => {
const s = formatDate(tr.start_date, language)
const e = formatDate(tr.end_date, language)
if (s && e) return `${s} – ${e}`
return s || e || ''
}
if (!isOpen) return null
const handleCopy = async (tripId: number) => {
if (busyTripId != null || placeIds.length === 0) return
setBusyTripId(tripId)
try {
const res = await onCopy(tripId)
if (res.copied > 0) {
toast.success(t('collections.copiedCount', { count: res.copied }))
}
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'))
}
onClose()
} catch (err) {
toast.error(getApiErrorMessage(err, t('common.error')))
} finally {
setBusyTripId(null)
}
}
return (
<DialogShell
onClose={onClose}
labelledBy={labelId}
width="narrow"
// The list shrinks while the search narrows it; a pinned top edge keeps the field still.
align="top"
header={(
<DialogHeader
tile={<DialogTile><Copy size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
tint={NEUTRAL_TINT}
labelId={labelId}
onClose={onClose}
title={placeIds.length > 1 ? t('collections.copyN', { count: placeIds.length }) : t('collections.copyToTripTitle')}
/>
)}
footer={(
<DialogFooter>
<FooterSpacer />
<DialogButton onClick={onClose}>{t('common.cancel')}</DialogButton>
</DialogFooter>
)}
>
<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.copyToTripSearch')}
aria-label={t('collections.copyToTripSearch')}
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.noTrips')}</p>
) : (
<div className="flex flex-col gap-0.5 rounded-[14px] border border-edge-faint bg-surface-secondary p-1.5">
{filtered.map(trip => {
const busy = busyTripId === trip.id
const range = dateRange(trip)
return (
<button
key={trip.id}
type="button"
onClick={() => handleCopy(trip.id)}
disabled={busy}
className="flex min-h-[52px] items-center gap-3 rounded-[10px] px-2.5 py-2 text-left hover:bg-surface-card disabled:opacity-60"
>
<span className="grid h-9 w-9 flex-none place-items-center overflow-hidden rounded-[9px] bg-surface-tertiary text-content-faint">
{trip.cover_image ? <img src={trip.cover_image} alt="" className="h-full w-full object-cover" /> : <MapPin size={15} />}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-semibold text-content" style={fs(13, 'body')}>{trip.title}</span>
{range && (
<span className="mt-0.5 flex items-center gap-1 truncate text-content-faint" style={fs(11.5)}>
<CalendarDays size={11} className="flex-none" /> {range}
</span>
)}
</span>
{busy ? <Loader2 size={15} className="flex-none animate-spin text-content-faint" /> : <Copy size={15} className="flex-none text-content-faint" />}
</button>
)
})}
</div>
)}
</DialogShell>
)
}