feat(collections): select toolbar — select-all, move/duplicate to another list (#1081)

- The select toggle now sits at the right of the filter row (same height as the
  status/category dropdowns) instead of the top toolbar.
- Select mode gains a "select all / deselect all" toggle and shows even with
  nothing selected yet.
- Selected places can be moved or duplicated into another of your lists via a
  target-list picker (move re-points collection_id; duplicate re-saves the place
  data, carrying description / category / notes / etc.).
- New strings across all 22 locales.
This commit is contained in:
Maurice
2026-07-01 15:07:39 +02:00
committed by Maurice
parent 3198fd35eb
commit 6be8a67c82
28 changed files with 455 additions and 18 deletions
@@ -1,5 +1,5 @@
import React, { useEffect, useRef, useState } from 'react'
import { ChevronDown, Check, Layers, Tag } from 'lucide-react'
import { ChevronDown, Check, Layers, Tag, CheckSquare } from 'lucide-react'
import type { StatusFilter } from '../../store/collectionStore'
import type { TranslationFn } from '../../types'
import { getCategoryIcon } from '../shared/categoryIcons'
@@ -69,6 +69,9 @@ interface CollectionFilterBarProps {
categoryOptions: CategoryOption[]
onStatusFilter: (f: StatusFilter) => void
onCategoryFilter: (f: number | 'all') => void
showSelect: boolean
selectMode: boolean
onToggleSelect: () => void
t: TranslationFn
}
@@ -78,7 +81,8 @@ interface CollectionFilterBarProps {
* Custom compact dropdowns so they barely take any space.
*/
export default function CollectionFilterBar({
statusFilter, counts, categoryFilter, categoryOptions, onStatusFilter, onCategoryFilter, t,
statusFilter, counts, categoryFilter, categoryOptions, onStatusFilter, onCategoryFilter,
showSelect, selectMode, onToggleSelect, t,
}: CollectionFilterBarProps): React.ReactElement {
const statusOpts: Opt[] = [
{ key: 'all', label: t('common.all'), count: counts.all },
@@ -103,6 +107,11 @@ export default function CollectionFilterBar({
{categoryOptions.length > 0 && (
<Dropdown current={categoryFilter} options={catOpts} onSelect={k => onCategoryFilter(k as number | 'all')} lead={<Tag size={13} />} />
)}
{showSelect && (
<button type="button" onClick={onToggleSelect} className={`col-filter-btn col-filter-select${selectMode ? ' open' : ''}`} aria-pressed={selectMode}>
<CheckSquare size={14} /> <span className="col-filter-lbl">{t('collections.select')}</span>
</button>
)}
</div>
)
}
@@ -0,0 +1,90 @@
import React, { useMemo, useState } from 'react'
import { Search, ArrowRight, Copy, Loader2, Bookmark } from 'lucide-react'
import Modal from '../shared/Modal'
import type { Collection } from '@trek/shared'
import type { TranslationFn } from '../../types'
interface MoveToListModalProps {
mode: 'move' | 'copy'
/** Candidate target lists (owned, excluding the current one). */
lists: Collection[]
/** Number of selected places. */
count: number
onPick: (targetId: number) => Promise<void> | void
onClose: () => void
t: TranslationFn
}
/**
* Target-list picker for moving or duplicating the selected places into another
* of the user's lists. `mode` only changes the wording + the trailing icon; the
* action itself is the parent's onPick.
*/
export default function MoveToListModal({ mode, lists, count, onPick, onClose, t }: MoveToListModalProps): React.ReactElement {
const [search, setSearch] = useState('')
const [busy, setBusy] = useState<number | null>(null)
const filtered = useMemo(() => {
const q = search.trim().toLowerCase()
return q ? lists.filter(l => l.name.toLowerCase().includes(q)) : lists
}, [lists, search])
const pick = async (id: number) => {
if (busy != null) return
setBusy(id)
try { await onPick(id) } finally { setBusy(null) }
}
const Trailing = mode === 'move' ? ArrowRight : Copy
return (
<Modal
isOpen
onClose={onClose}
title={mode === 'move' ? t('collections.moveToListTitle', { count }) : t('collections.duplicateToListTitle', { count })}
size="sm"
>
<div className="flex flex-col gap-3">
{lists.length > 3 && (
<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>
)}
{filtered.length === 0 ? (
<p className="text-center text-[13px] text-content-faint py-8">{t('collections.noOtherLists')}</p>
) : (
<div className="flex flex-col gap-1 max-h-[50vh] overflow-y-auto -mx-1 px-1">
{filtered.map(list => {
const isBusy = busy === list.id
return (
<button
key={list.id}
type="button"
onClick={() => pick(list.id)}
disabled={busy != 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>
{isBusy ? <Loader2 size={15} className="animate-spin text-content-faint shrink-0" /> : <Trailing size={15} className="text-content-faint shrink-0" />}
</button>
)
})}
</div>
)}
</div>
</Modal>
)
}
+30 -16
View File
@@ -1,5 +1,5 @@
import React from 'react'
import { List as ListIcon, Map as MapIcon, Search, Bookmark, CheckSquare, X, Trash2, Copy, Plus } from 'lucide-react'
import { List as ListIcon, Map as MapIcon, Search, Bookmark, CheckCheck, X, Trash2, Copy, CopyPlus, FolderInput, Plus } from 'lucide-react'
import Navbar from '../components/Layout/Navbar'
import Modal from '../components/shared/Modal'
import ListsRail from '../components/Collections/ListsRail'
@@ -9,6 +9,7 @@ import CollectionList from '../components/Collections/CollectionList'
import CollectionFilterBar from '../components/Collections/CollectionFilterBar'
import CollectionMapPanel from '../components/Collections/CollectionMapPanel'
import CopyToTripModal from '../components/Collections/CopyToTripModal'
import MoveToListModal from '../components/Collections/MoveToListModal'
import ShareCollectionModal from '../components/Collections/ShareCollectionModal'
import AddPlaceToCollectionModal from '../components/Collections/AddPlaceToCollectionModal'
import CollectionPlaceDetail from '../components/Collections/CollectionPlaceDetail'
@@ -105,6 +106,9 @@ export default function CollectionsPage(): React.ReactElement {
categoryOptions={c.categoryOptions}
onStatusFilter={c.setStatusFilter}
onCategoryFilter={c.setCategoryFilter}
showSelect={showSelect}
selectMode={c.selectMode}
onToggleSelect={() => c.setSelectMode(!c.selectMode)}
t={t}
/>
) : null
@@ -204,17 +208,6 @@ export default function CollectionsPage(): React.ReactElement {
</button>
</div>
)}
{showSelect && (
<button
type="button"
onClick={() => c.setSelectMode(!c.selectMode)}
className={`col-iconbtn${c.selectMode ? ' on' : ''}`}
aria-label={t('collections.selectMode')}
title={t('collections.selectMode')}
>
<CheckSquare size={16} />
</button>
)}
{canAddPlace && (
<button type="button" onClick={() => c.setShowAddPlace(true)} className="col-iconbtn" aria-label={t('collections.addPlace')} title={t('collections.addPlace')}>
<Plus size={16} />
@@ -234,14 +227,23 @@ export default function CollectionsPage(): React.ReactElement {
</div>
)}
{c.selectMode && c.selectedIds.length > 0 && (
{c.selectMode && (
<div className="col-selbar">
<button type="button" onClick={c.handleSelectAll} className="col-selbar-btn">
<CheckCheck size={14} /> {c.allVisibleSelected ? t('collections.deselectAll') : t('collections.selectAll')}
</button>
<span className="lbl">{t('collections.selectedCount', { count: c.selectedIds.length })}</span>
<div className="col-toolbar-spacer" />
<button type="button" onClick={c.openCopyForSelection} className="col-selbar-btn">
<Copy size={14} /> {t('collections.copyN', { count: c.selectedIds.length })}
<button type="button" onClick={() => c.setListPickerMode('move')} disabled={c.selectedIds.length === 0} className="col-selbar-btn">
<FolderInput size={14} /> {t('collections.moveToList')}
</button>
<button type="button" onClick={c.handleDeleteSelected} className="col-selbar-btn danger">
<button type="button" onClick={() => c.setListPickerMode('copy')} disabled={c.selectedIds.length === 0} className="col-selbar-btn">
<CopyPlus size={14} /> {t('collections.duplicateToList')}
</button>
<button type="button" onClick={c.openCopyForSelection} disabled={c.selectedIds.length === 0} className="col-selbar-btn">
<Copy size={14} /> {t('collections.copyToTrip')}
</button>
<button type="button" onClick={c.handleDeleteSelected} disabled={c.selectedIds.length === 0} className="col-selbar-btn danger">
<Trash2 size={14} /> {t('common.delete')}
</button>
<button type="button" onClick={() => c.setSelectMode(false)} className="col-selbar-btn" aria-label={t('common.cancel')}>
@@ -306,6 +308,18 @@ export default function CollectionsPage(): React.ReactElement {
t={t}
/>
{/* Move / duplicate the selection into another list */}
{c.listPickerMode && (
<MoveToListModal
mode={c.listPickerMode}
lists={c.ownedLists.filter(l => l.id !== c.activeId)}
count={c.selectedIds.length}
onPick={c.listPickerMode === 'move' ? c.handleMoveToList : c.handleDuplicateToList}
onClose={() => c.setListPickerMode(null)}
t={t}
/>
)}
{/* Share / fusion */}
{c.canShare && typeof c.activeId === 'number' && c.activeCollection && (
<ShareCollectionModal
@@ -53,6 +53,7 @@ export function useCollections() {
loadAll, setActive, refreshActive, loadCollection,
deleteCollection,
setStatus, updatePlace, deletePlace, deleteMany, copyToTrip, clearSelection,
moveToList, duplicateToList, setSelectedIds,
acceptInvite, declineInvite,
setView, setStatusFilter, setCategoryFilter, setSearch, setSelectedPlaceId, setSelectMode, toggleSelect,
} = store
@@ -211,6 +212,35 @@ export function useCollections() {
}
}, [selectedIds, deleteMany, toast, t])
// Select-all toggles between every visible place and none.
const allVisibleSelected = visiblePlaces.length > 0 && visiblePlaces.every(p => selectedIds.includes(p.id))
const handleSelectAll = useCallback(() => {
setSelectedIds(allVisibleSelected ? [] : visiblePlaces.map(p => p.id))
}, [allVisibleSelected, visiblePlaces, setSelectedIds])
// Move / duplicate the selection into another list (a list-picker modal).
const [listPickerMode, setListPickerMode] = useState<'move' | 'copy' | null>(null)
const handleMoveToList = useCallback(async (targetId: number) => {
if (selectedIds.length === 0) return
try {
await moveToList(selectedIds, targetId)
toast.success(t('collections.movedCount', { count: selectedIds.length }))
setListPickerMode(null)
} catch (err) {
toast.error(getApiErrorMessage(err, t('common.error')))
}
}, [selectedIds, moveToList, toast, t])
const handleDuplicateToList = useCallback(async (targetId: number) => {
if (selectedIds.length === 0) return
try {
await duplicateToList(selectedIds, targetId)
toast.success(t('collections.duplicatedCount', { count: selectedIds.length }))
setListPickerMode(null)
} catch (err) {
toast.error(getApiErrorMessage(err, t('common.error')))
}
}, [selectedIds, duplicateToList, toast, t])
const handleAcceptInvite = useCallback(async (collectionId: number) => {
try {
await acceptInvite(collectionId)
@@ -311,5 +341,7 @@ export function useCollections() {
handleSelectList, handleDeleteList,
handleStatusChange, handleDeletePlace, handleDeleteSelected,
handleAcceptInvite, handleDeclineInvite,
allVisibleSelected, handleSelectAll,
listPickerMode, setListPickerMode, handleMoveToList, handleDuplicateToList,
}
}
+47
View File
@@ -53,6 +53,8 @@ interface CollectionState {
deletePlace: (placeId: number) => Promise<void>
deleteMany: (ids: number[]) => Promise<void>
copyToTrip: (tripId: number, placeIds: number[], force?: boolean) => Promise<{ copied: number; skipped: { id: number; name: string }[] }>
moveToList: (placeIds: number[], targetId: number) => Promise<void>
duplicateToList: (placeIds: number[], targetId: number) => Promise<void>
invite: (collectionId: number, userId: number) => Promise<void>
acceptInvite: (collectionId: number) => Promise<void>
@@ -68,6 +70,7 @@ interface CollectionState {
setSelectedPlaceId: (id: number | null) => void
setSelectMode: (on: boolean) => void
toggleSelect: (id: number) => void
setSelectedIds: (ids: number[]) => void
clearSelection: () => void
}
@@ -226,6 +229,49 @@ export const useCollectionStore = create<CollectionState>((set, get) => ({
return res
},
// Move the selected places into another list (re-point collection_id). They
// leave the current list, so drop them locally + refresh.
moveToList: async (placeIds: number[], targetId: number) => {
for (const id of placeIds) await collectionsApi.updatePlace(id, { collection_id: targetId })
const idSet = new Set(placeIds)
set({ places: get().places.filter(p => !idSet.has(p.id)), selectedIds: [], selectMode: false })
await get().loadAll()
const active = get().activeId
if (typeof active === 'number') await get().loadCollection(active)
},
// Duplicate the selected places into another list (re-save each place's data).
duplicateToList: async (placeIds: number[], targetId: number) => {
const byId = new Map(get().places.map(p => [p.id, p]))
for (const id of placeIds) {
const p = byId.get(id)
if (!p) continue
await collectionsApi.savePlace({
collection_id: targetId,
name: p.name,
description: p.description ?? null,
lat: p.lat ?? null,
lng: p.lng ?? null,
address: p.address ?? null,
category_id: p.category_id ?? null,
price: p.price ?? null,
currency: p.currency ?? null,
notes: p.notes ?? null,
image_url: p.image_url ?? null,
google_place_id: p.google_place_id ?? null,
google_ftid: p.google_ftid ?? null,
osm_id: p.osm_id ?? null,
website: p.website ?? null,
phone: p.phone ?? null,
status: p.status,
links: p.links ?? [],
force: true,
})
}
set({ selectedIds: [], selectMode: false })
await get().loadAll()
},
invite: async (collectionId: number, userId: number) => {
await collectionsApi.invite(collectionId, userId)
if (get().activeId === collectionId) await get().loadCollection(collectionId)
@@ -267,5 +313,6 @@ export const useCollectionStore = create<CollectionState>((set, get) => ({
const selected = get().selectedIds
set({ selectedIds: selected.includes(id) ? selected.filter(x => x !== id) : [...selected, id] })
},
setSelectedIds: (ids: number[]) => set({ selectedIds: ids }),
clearSelection: () => set({ selectedIds: [], selectMode: false }),
}))
+3
View File
@@ -273,6 +273,9 @@
/* ----------------- filter bar (status + category dropdowns) ----------------- */
.trek-dash .col-filterbar { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; }
.trek-dash .col-filter { position: relative; }
.trek-dash .col-filter-select { margin-left: auto; }
.trek-dash .col-filter-select.open { background: var(--accent); border-color: var(--accent); color: #fff; }
.trek-dash .col-filter-select.open:hover { background: var(--accent); color: #fff; }
.trek-dash .col-filter-btn { display: inline-flex; align-items: center; gap: 7px; padding: 7px 11px; border-radius: 10px; font-size: 12.5px; font-weight: 600; color: var(--ink-2); background: var(--surface); border: 1px solid var(--line-2); transition: background .12s, border-color .12s, color .12s; }
.trek-dash .col-filter-btn:hover { background: var(--surface-2); color: var(--ink); }
.trek-dash .col-filter-btn.open { border-color: var(--accent); color: var(--ink); }