feat(collections): bulk-add selected trip places to a list (#1081)

Add a "Save to collection" action to the trip place list's selection bar (next to
bulk category + delete): it opens a list picker and copies every selected place
into the chosen list in one request, instead of one-by-one from each place.

- Server: saveFromTripPlaces (one access check + one WS notify), POST
  places/from-trip-many; dedups by name/coords, skips missing ids, honours force.
- Client: saveFromTripMany api + SaveTripPlacesToListModal; the selection-bar
  button is gated on the collections addon being enabled.
- Copy count / skipped-duplicates toast; strings in all 22 locales.
- Service + controller tests for the bulk path.
This commit is contained in:
Maurice
2026-07-01 17:22:50 +02:00
committed by Maurice
parent 1e0feb93dd
commit 6cdcc82d9c
31 changed files with 305 additions and 2 deletions
@@ -0,0 +1,118 @@
import React, { useEffect, useMemo, useState } from 'react'
import { Search, Bookmark, ArrowRight, Loader2, Plus } from 'lucide-react'
import Modal from '../shared/Modal'
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 [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 (
<Modal isOpen onClose={onClose} title={t('collections.saveNToList', { count: placeIds.length })} size="sm">
<div className="flex flex-col gap-3">
{lists.length > 5 && (
<div className="relative">
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-content-faint" />
<input
autoFocus
value={search}
onChange={e => setSearch(e.target.value)}
placeholder={t('collections.copyToTripSearch')}
className="w-full pl-8 pr-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[13px] outline-none focus:border-accent"
/>
</div>
)}
{loading ? (
<div className="flex items-center justify-center py-10 text-content-faint"><Loader2 size={20} className="animate-spin" /></div>
) : filtered.length === 0 ? (
<p className="text-center text-[13px] text-content-faint py-8">{t('collections.noOwnLists')}</p>
) : (
<div className="flex flex-col gap-1 max-h-[50vh] overflow-y-auto -mx-1 px-1">
{filtered.map(list => {
const busy = busyId === list.id
return (
<button
key={list.id}
type="button"
onClick={() => pick(list)}
disabled={busyId != null}
className="flex items-center gap-3 px-3 py-2.5 rounded-xl border border-edge bg-surface-card text-left hover:bg-surface-hover transition-colors disabled:opacity-60"
>
<span className="w-9 h-9 min-w-[36px] rounded-lg flex items-center justify-center shrink-0 text-white" style={{ background: list.color || '#6366f1' }}>
<Bookmark size={15} />
</span>
<span className="flex-1 min-w-0">
<span className="block text-[13px] font-semibold text-content truncate">{list.name}</span>
<span className="block text-[11.5px] text-content-faint">{t('collections.placeCount', { count: list.place_count ?? 0 })}</span>
</span>
{busy ? <Loader2 size={15} className="animate-spin text-content-faint shrink-0" /> : <ArrowRight size={15} className="text-content-faint shrink-0" />}
</button>
)
})}
</div>
)}
<p className="text-[11.5px] text-content-faint inline-flex items-center gap-1.5"><Plus size={12} /> {t('collections.saveToListHint')}</p>
</div>
</Modal>
)
}
@@ -9,6 +9,7 @@ import { PlacesList } from './PlacesSidebarList'
import { MobileDayPickerSheet } from './PlacesSidebarMobileDayPicker'
import { ListImportModal } from './PlacesSidebarListImportModal'
import { PlacesBulkCategoryModal } from './PlacesBulkCategoryModal'
import SaveTripPlacesToListModal from '../Collections/SaveTripPlacesToListModal'
const PlacesSidebar = React.memo(function PlacesSidebar(props: PlacesSidebarProps) {
const S = usePlacesSidebar(props)
@@ -18,6 +19,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar(props: PlacesSidebarProp
fileImportOpen, setFileImportOpen, sidebarDropFile, setSidebarDropFile, tripId, pushUndo,
ctxMenu, isMobile, pendingDeleteIds, setPendingDeleteIds, onBulkDeleteConfirm,
categories, selectedIds, exitSelectMode, onBulkChangeCategory, categoryPickerOpen, setCategoryPickerOpen,
collectionsEnabled, saveToListOpen, setSaveToListOpen,
} = S
return (
<div
@@ -61,6 +63,15 @@ const PlacesSidebar = React.memo(function PlacesSidebar(props: PlacesSidebarProp
onPick={(catId) => { onBulkChangeCategory?.(Array.from(selectedIds), catId); setCategoryPickerOpen(false); exitSelectMode() }}
/>
)}
{collectionsEnabled && (
<SaveTripPlacesToListModal
isOpen={saveToListOpen}
tripId={tripId}
placeIds={Array.from(selectedIds)}
onClose={() => setSaveToListOpen(false)}
onDone={exitSelectMode}
/>
)}
{isMobile && (
<ConfirmDialog
isOpen={!!pendingDeleteIds?.length}
@@ -1,9 +1,9 @@
import { Check, Tag, Trash2 } from 'lucide-react'
import { Check, Tag, Trash2, Bookmark } from 'lucide-react'
import Tooltip from '../shared/Tooltip'
import type { SidebarState } from './usePlacesSidebar'
export function PlacesSelectionBar(S: SidebarState) {
const { t, selectedIds, filtered, setSelectedIds, isMobile, setPendingDeleteIds, onBulkDeletePlaces, setCategoryPickerOpen } = S
const { t, selectedIds, filtered, setSelectedIds, isMobile, setPendingDeleteIds, onBulkDeletePlaces, setCategoryPickerOpen, collectionsEnabled, setSaveToListOpen } = S
return (
<div style={{
margin: '6px 16px', padding: '5px 8px 5px 10px', borderRadius: 8,
@@ -49,6 +49,25 @@ export function PlacesSelectionBar(S: SidebarState) {
<Tag size={13} strokeWidth={2} />
</button>
</Tooltip>
{collectionsEnabled && (
<Tooltip label={t('inspector.saveToCollection')} placement="bottom">
<button
onClick={() => { if (selectedIds.size === 0) return; setSaveToListOpen(true) }}
disabled={selectedIds.size === 0}
aria-label={t('inspector.saveToCollection')}
className={selectedIds.size > 0 ? 'bg-transparent text-content-muted' : 'bg-transparent text-content-faint'}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
width: 24, height: 24, borderRadius: 6, border: 'none',
cursor: selectedIds.size > 0 ? 'pointer' : 'default', padding: 0,
}}
onMouseEnter={e => { if (selectedIds.size > 0) e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
>
<Bookmark size={13} strokeWidth={2} />
</button>
</Tooltip>
)}
<Tooltip label={t('places.deleteSelected')} placement="bottom">
<button
onClick={() => {
@@ -153,6 +153,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
const [pendingDeleteIds, setPendingDeleteIds] = useState<number[] | null>(null)
const [categoryPickerOpen, setCategoryPickerOpen] = useState(false)
const [saveToListOpen, setSaveToListOpen] = useState(false)
const exitSelectMode = () => { setSelectMode(false); setSelectedIds(new Set()) }
@@ -266,6 +267,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
search, setSearch, filter, setFilter, categoryFilters, setCategoryFiltersLocal,
selectMode, setSelectMode, selectedIds, setSelectedIds, pendingDeleteIds, setPendingDeleteIds,
categoryPickerOpen, setCategoryPickerOpen,
saveToListOpen, setSaveToListOpen, collectionsEnabled, tripId,
exitSelectMode, toggleSelected, toggleCategoryFilter, dayPickerPlace, setDayPickerPlace,
catDropOpen, setCatDropOpen, mobileShowDays, setMobileShowDays,
hasTracks, plannedIds, filtered, registerPlaceRow, isAssignedToSelectedDay, inDaySet, openContextMenu,