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
@@ -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,