mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 23:16:46 +00:00
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user