Files
TREK/client/src/store/collectionStore.ts
T
Maurice ac723744ab feat(collections): add places by coordinates + sort A–Z (#1647)
* feat(collections): add places by coordinates and sort the list

Raised in #1640: collections behaved differently from trips — you
couldn't add a place by typing coordinates, and the list had no order
control.

The add-place modal and the place detail sheet now take an editable
address plus latitude/longitude, with a "lat, lng" paste shortcut, so a
place can be saved by GPS without searching. The place update path
learned to persist coordinates, so an existing one can be corrected
later.

A sort control in the filter row toggles between the saved order and
alphabetical by name. The add-place button moved into that row to match
the other controls, and the add-label button dropped its dashed one-off
styling. New strings land across all 23 locales.

* feat(collections): make the add-place button icon-only

Keep the filter row compact — the button carries just the plus icon (with
an aria-label/title), squared off to match the dropdown height.
2026-07-21 22:10:41 +02:00

413 lines
15 KiB
TypeScript

import { create } from 'zustand'
import { collectionsApi } from '../api/collections'
import type {
Collection,
CollectionPlace,
CollectionMember,
CollectionStatus,
CollectionRole,
CollectionListResponse,
CollectionCreateRequest,
CollectionUpdateRequest,
CollectionPlaceUpdateRequest,
CollectionLabel,
CollectionLabelUpdateRequest,
} from '@trek/shared'
/** A pending invitation the current user has received (derived server-side). */
export type IncomingCollectionInvite = CollectionListResponse['incomingInvites'][number]
/** Sentinel id for the client-side "All saved" union pseudo-list. */
export const ALL_SAVED = 'all' as const
export type ActiveCollectionId = number | typeof ALL_SAVED | null
export type CollectionView = 'list' | 'map'
export type StatusFilter = CollectionStatus | 'all'
/** Display order for a list's places: the saved order, or alphabetical by name. */
export type CollectionSortMode = 'default' | 'name_asc'
interface CollectionState {
collections: Collection[]
activeId: ActiveCollectionId
places: CollectionPlace[]
members: CollectionMember[]
labels: CollectionLabel[]
incomingInvites: IncomingCollectionInvite[]
view: CollectionView
statusFilter: StatusFilter
categoryFilter: number | 'all'
ratingFilter: number | 'all'
labelFilter: number[]
sortMode: CollectionSortMode
search: string
selectedPlaceId: number | null
selectMode: boolean
selectedIds: number[]
loading: boolean
placesLoading: boolean
loadAll: () => Promise<void>
loadCollection: (id: number) => Promise<void>
setActive: (id: ActiveCollectionId) => Promise<void>
refreshActive: () => Promise<void>
createCollection: (payload: CollectionCreateRequest) => Promise<Collection | null>
updateCollection: (id: number, updates: CollectionUpdateRequest) => Promise<void>
uploadCover: (id: number, file: File) => Promise<void>
deleteCollection: (id: number) => Promise<void>
reorderCollections: (orderedIds: number[]) => Promise<void>
setStatus: (placeId: number, status: CollectionStatus) => Promise<void>
updatePlace: (placeId: number, body: CollectionPlaceUpdateRequest) => Promise<void>
uploadPlaceImage: (placeId: number, file: File) => Promise<void>
ratePlace: (placeId: number, rating: number | null) => Promise<void>
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>
createLabel: (name: string, color?: string) => Promise<void>
updateLabel: (labelId: number, body: CollectionLabelUpdateRequest) => Promise<void>
deleteLabel: (labelId: number) => Promise<void>
assignLabels: (labelIds: number[], placeIds: number[], remove?: boolean) => Promise<void>
invite: (collectionId: number, userId: number, role?: CollectionRole) => Promise<void>
setMemberRole: (collectionId: number, userId: number, role: CollectionRole) => Promise<void>
acceptInvite: (collectionId: number) => Promise<void>
declineInvite: (collectionId: number) => Promise<void>
cancelInvite: (collectionId: number, userId: number) => Promise<void>
removeMember: (collectionId: number, userId: number) => Promise<void>
leave: (collectionId: number) => Promise<void>
setView: (view: CollectionView) => void
setStatusFilter: (filter: StatusFilter) => void
setCategoryFilter: (filter: number | 'all') => void
setRatingFilter: (filter: number | 'all') => void
setLabelFilter: (labelIds: number[]) => void
setSortMode: (mode: CollectionSortMode) => void
setSearch: (search: string) => void
setSelectedPlaceId: (id: number | null) => void
setSelectMode: (on: boolean) => void
toggleSelect: (id: number) => void
setSelectedIds: (ids: number[]) => void
clearSelection: () => void
}
export const useCollectionStore = create<CollectionState>((set, get) => ({
collections: [],
activeId: null,
places: [],
members: [],
labels: [],
incomingInvites: [],
view: 'list',
statusFilter: 'all',
categoryFilter: 'all',
ratingFilter: 'all',
labelFilter: [],
sortMode: 'default',
search: '',
selectedPlaceId: null,
selectMode: false,
selectedIds: [],
loading: false,
placesLoading: false,
loadAll: async () => {
set({ loading: true })
try {
const data = await collectionsApi.list()
set({ collections: data.collections, incomingInvites: data.incomingInvites })
} finally {
set({ loading: false })
}
},
loadCollection: async (id: number) => {
set({ placesLoading: true })
try {
const data = await collectionsApi.get(id)
set({
activeId: id,
places: data.places,
members: data.collection.members ?? [],
labels: data.collection.labels ?? [],
})
} catch {
// The list may have been left / removed / deleted out from under us (a WS
// event or the URL sync can re-request an id we just lost access to). Clear
// it instead of leaving an uncaught 403/404 rejection; the route sync then
// bounces to /collections.
if (get().activeId === id) set({ activeId: null, places: [], members: [], labels: [] })
} finally {
set({ placesLoading: false })
}
},
setActive: async (id: ActiveCollectionId) => {
// Labels are per-collection, so their filter can't carry across lists.
set({ selectMode: false, selectedIds: [], selectedPlaceId: null, labelFilter: [] })
if (id === null) {
set({ activeId: null, places: [], members: [], labels: [] })
return
}
if (id === ALL_SAVED) {
set({ activeId: ALL_SAVED, members: [], labels: [], placesLoading: true })
try {
// Client-side union of every list the user owns or co-owns (no server change).
// On first load the lists may not be fetched yet (loadAll still in flight),
// which would union nothing — make sure they're loaded first.
let lists = get().collections
if (lists.length === 0) { await get().loadAll(); lists = get().collections }
const results = await Promise.all(lists.map(l => collectionsApi.get(l.id).catch(() => null)))
const seen = new Set<number>()
const merged: CollectionPlace[] = []
for (const res of results) {
if (!res) continue
for (const p of res.places) {
if (seen.has(p.id)) continue
seen.add(p.id)
merged.push(p)
}
}
set({ places: merged })
} finally {
set({ placesLoading: false })
}
return
}
await get().loadCollection(id)
},
refreshActive: async () => {
const { activeId } = get()
if (activeId === null) return
await get().setActive(activeId)
},
createCollection: async (payload) => {
const data = await collectionsApi.create(payload)
await get().loadAll()
return data.collection ?? null
},
updateCollection: async (id, updates) => {
await collectionsApi.update(id, updates)
await get().loadAll()
if (get().activeId === id) await get().loadCollection(id)
},
uploadCover: async (id: number, file: File) => {
const fd = new FormData()
fd.append('cover', file)
await collectionsApi.uploadCover(id, fd)
await get().loadAll()
if (get().activeId === id) await get().loadCollection(id)
},
deleteCollection: async (id: number) => {
await collectionsApi.remove(id)
if (get().activeId === id) set({ activeId: null, places: [], members: [] })
await get().loadAll()
},
reorderCollections: async (orderedIds: number[]) => {
// optimistic
const byId = new Map(get().collections.map(c => [c.id, c]))
const reordered = orderedIds.map(id => byId.get(id)).filter(Boolean) as Collection[]
set({ collections: reordered })
try {
await collectionsApi.reorder(orderedIds)
} finally {
await get().loadAll()
}
},
setStatus: async (placeId: number, status: CollectionStatus) => {
// optimistic
set({ places: get().places.map(p => (p.id === placeId ? { ...p, status } : p)) })
try {
await collectionsApi.setStatus(placeId, status)
} catch {
await get().refreshActive()
}
},
updatePlace: async (placeId, body) => {
// The endpoint returns the updated place directly (not wrapped in { place }).
const updated = await collectionsApi.updatePlace(placeId, body)
if (updated) set({ places: get().places.map(p => (p.id === placeId ? updated : p)) })
},
uploadPlaceImage: async (placeId: number, file: File) => {
const fd = new FormData()
fd.append('image', file)
const updated = await collectionsApi.uploadPlaceImage(placeId, fd)
if (updated) set({ places: get().places.map(p => (p.id === placeId ? updated : p)) })
},
ratePlace: async (placeId: number, rating: number | null) => {
const updated = await collectionsApi.ratePlace(placeId, rating)
if (updated) set({ places: get().places.map(p => (p.id === placeId ? updated : p)) })
},
deletePlace: async (placeId: number) => {
set({ places: get().places.filter(p => p.id !== placeId) })
await collectionsApi.deletePlace(placeId)
await get().loadAll()
},
deleteMany: async (ids: number[]) => {
const idSet = new Set(ids)
set({ places: get().places.filter(p => !idSet.has(p.id)), selectedIds: [], selectMode: false })
await collectionsApi.deleteMany(ids)
await get().loadAll()
},
copyToTrip: async (tripId: number, placeIds: number[], force?: boolean) => {
const res = await collectionsApi.copyToTrip({ trip_id: tripId, place_ids: placeIds, force })
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()
},
createLabel: async (name: string, color?: string) => {
const active = get().activeId
if (typeof active !== 'number') return
await collectionsApi.createLabel(active, name, color)
await get().loadCollection(active)
},
updateLabel: async (labelId: number, body: CollectionLabelUpdateRequest) => {
// optimistic recolor/rename
set({ labels: get().labels.map(l => (l.id === labelId ? { ...l, ...body } : l)) })
await collectionsApi.updateLabel(labelId, body)
const active = get().activeId
if (typeof active === 'number') await get().loadCollection(active)
},
deleteLabel: async (labelId: number) => {
// optimistic: drop the label + its assignments + any active filter on it
set({
labels: get().labels.filter(l => l.id !== labelId),
labelFilter: get().labelFilter.filter(id => id !== labelId),
places: get().places.map(p => ({ ...p, label_ids: (p.label_ids ?? []).filter(id => id !== labelId) })),
})
await collectionsApi.deleteLabel(labelId)
const active = get().activeId
if (typeof active === 'number') await get().loadCollection(active)
},
assignLabels: async (labelIds: number[], placeIds: number[], remove = false) => {
const idSet = new Set(placeIds)
// optimistic per-place label_ids update
set({
places: get().places.map(p => {
if (!idSet.has(p.id)) return p
const current = new Set(p.label_ids ?? [])
if (remove) labelIds.forEach(id => current.delete(id))
else labelIds.forEach(id => current.add(id))
return { ...p, label_ids: [...current] }
}),
})
if (remove) await collectionsApi.unassignLabels(labelIds, placeIds)
else await collectionsApi.assignLabels(labelIds, placeIds)
const active = get().activeId
if (typeof active === 'number') await get().loadCollection(active)
},
invite: async (collectionId: number, userId: number, role?: CollectionRole) => {
await collectionsApi.invite(collectionId, userId, role)
if (get().activeId === collectionId) await get().loadCollection(collectionId)
},
setMemberRole: async (collectionId: number, userId: number, role: CollectionRole) => {
await collectionsApi.setMemberRole(collectionId, userId, role)
if (get().activeId === collectionId) await get().loadCollection(collectionId)
},
acceptInvite: async (collectionId: number) => {
await collectionsApi.acceptInvite(collectionId)
await get().loadAll()
},
declineInvite: async (collectionId: number) => {
await collectionsApi.declineInvite(collectionId)
await get().loadAll()
},
cancelInvite: async (collectionId: number, userId: number) => {
await collectionsApi.cancelInvite(collectionId, userId)
if (get().activeId === collectionId) await get().loadCollection(collectionId)
},
removeMember: async (collectionId: number, userId: number) => {
await collectionsApi.removeMember(collectionId, userId)
if (get().activeId === collectionId) await get().loadCollection(collectionId)
},
leave: async (collectionId: number) => {
await collectionsApi.leave(collectionId)
if (get().activeId === collectionId) set({ activeId: null, places: [], members: [] })
await get().loadAll()
},
setView: (view: CollectionView) => set({ view }),
setStatusFilter: (filter: StatusFilter) => set({ statusFilter: filter }),
setCategoryFilter: (filter: number | 'all') => set({ categoryFilter: filter }),
setRatingFilter: (filter: number | 'all') => set({ ratingFilter: filter }),
setLabelFilter: (labelIds: number[]) => set({ labelFilter: labelIds }),
setSortMode: (mode: CollectionSortMode) => set({ sortMode: mode }),
setSearch: (search: string) => set({ search }),
setSelectedPlaceId: (id: number | null) => set({ selectedPlaceId: id }),
setSelectMode: (on: boolean) => set({ selectMode: on, selectedIds: on ? get().selectedIds : [] }),
toggleSelect: (id: number) => {
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 }),
}))