From 6cdcc82d9c905c784e02c261a72f89058ed002e2 Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 1 Jul 2026 17:22:50 +0200 Subject: [PATCH] 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. --- client/src/api/collections.ts | 2 + .../Collections/SaveTripPlacesToListModal.tsx | 118 ++++++++++++++++++ .../src/components/Planner/PlacesSidebar.tsx | 11 ++ .../Planner/PlacesSidebarSelectionBar.tsx | 23 +++- .../components/Planner/usePlacesSidebar.ts | 2 + .../collections/collections.controller.ts | 8 ++ .../nest/collections/collections.service.ts | 3 + server/src/services/collectionsService.ts | 43 +++++++ shared/src/collection/collection.schema.ts | 9 ++ shared/src/i18n/ar/collection.ts | 4 + shared/src/i18n/br/collection.ts | 4 + shared/src/i18n/cs/collection.ts | 4 + shared/src/i18n/de/collection.ts | 4 + shared/src/i18n/en/collection.ts | 4 + shared/src/i18n/es/collection.ts | 4 + shared/src/i18n/fr/collection.ts | 4 + shared/src/i18n/gr/collection.ts | 4 + shared/src/i18n/hu/collection.ts | 4 + shared/src/i18n/id/collection.ts | 4 + shared/src/i18n/it/collection.ts | 4 + shared/src/i18n/ja/collection.ts | 4 + shared/src/i18n/ko/collection.ts | 4 + shared/src/i18n/nl/collection.ts | 4 + shared/src/i18n/pl/collection.ts | 4 + shared/src/i18n/ru/collection.ts | 4 + shared/src/i18n/sv/collection.ts | 4 + shared/src/i18n/tr/collection.ts | 4 + shared/src/i18n/uk/collection.ts | 4 + shared/src/i18n/vi/collection.ts | 4 + shared/src/i18n/zh-TW/collection.ts | 4 + shared/src/i18n/zh/collection.ts | 4 + 31 files changed, 305 insertions(+), 2 deletions(-) create mode 100644 client/src/components/Collections/SaveTripPlacesToListModal.tsx diff --git a/client/src/api/collections.ts b/client/src/api/collections.ts index 3825e939a..62713ad6c 100644 --- a/client/src/api/collections.ts +++ b/client/src/api/collections.ts @@ -63,6 +63,8 @@ export const collectionsApi = { ax.post(`${base}/places`, body satisfies CollectionSavePlaceRequest).then((r: AxiosResponse) => r.data), saveFromTrip: (body: CollectionSaveFromTripRequest): Promise => ax.post(`${base}/places/from-trip`, body satisfies CollectionSaveFromTripRequest).then((r: AxiosResponse) => r.data), + saveFromTripMany: (collectionId: number, tripId: number, placeIds: number[], force?: boolean): Promise<{ copied: number; skipped: { id: number; name: string }[] }> => + ax.post(`${base}/places/from-trip-many`, { collection_id: collectionId, source_trip_id: tripId, source_place_ids: placeIds, force }).then((r: AxiosResponse) => r.data), updatePlace: (pid: number, body: CollectionPlaceUpdateRequest): Promise => ax.patch(`${base}/places/${pid}`, body satisfies CollectionPlaceUpdateRequest).then((r: AxiosResponse) => r.data), setStatus: (pid: number, status: CollectionStatus): Promise => diff --git a/client/src/components/Collections/SaveTripPlacesToListModal.tsx b/client/src/components/Collections/SaveTripPlacesToListModal.tsx new file mode 100644 index 000000000..120f18f9c --- /dev/null +++ b/client/src/components/Collections/SaveTripPlacesToListModal.tsx @@ -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([]) + const [loading, setLoading] = useState(false) + const [search, setSearch] = useState('') + const [busyId, setBusyId] = useState(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 ( + +
+ {lists.length > 5 && ( +
+ + 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" + /> +
+ )} + {loading ? ( +
+ ) : filtered.length === 0 ? ( +

{t('collections.noOwnLists')}

+ ) : ( +
+ {filtered.map(list => { + const busy = busyId === list.id + return ( + + ) + })} +
+ )} +

{t('collections.saveToListHint')}

+
+
+ ) +} diff --git a/client/src/components/Planner/PlacesSidebar.tsx b/client/src/components/Planner/PlacesSidebar.tsx index b2c30aec1..06f93bc05 100644 --- a/client/src/components/Planner/PlacesSidebar.tsx +++ b/client/src/components/Planner/PlacesSidebar.tsx @@ -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 (
{ onBulkChangeCategory?.(Array.from(selectedIds), catId); setCategoryPickerOpen(false); exitSelectMode() }} /> )} + {collectionsEnabled && ( + setSaveToListOpen(false)} + onDone={exitSelectMode} + /> + )} {isMobile && ( + {collectionsEnabled && ( + + + + )}