feat(collections): per-collection custom labels

Each list can now define its own labels (e.g. Berlin, Hamburg, Ostsee in a
"Germany 2026" list) and organise its places by them:

- manage labels (create / rename / recolor / delete) from a label manager
- assign labels to a place from its detail sheet, or to many places at once
  from the selection toolbar
- filter the place list AND the map by label (multi-select, any-match)

Labels are scoped to a collection and shared by all its members. Managing and
assigning labels needs edit rights; filtering is available to everyone. Moving
a place to another list drops its labels, since they belong to the source list.
This commit is contained in:
Maurice
2026-07-05 00:19:51 +02:00
committed by Maurice
parent 6f20c11dff
commit a5522e99d8
45 changed files with 1224 additions and 26 deletions
+14
View File
@@ -18,6 +18,9 @@ import type {
CollectionStatus,
Collection,
CollectionPlace,
CollectionLabel,
CollectionLabelCreateRequest,
CollectionLabelUpdateRequest,
} from '@trek/shared'
const ax = apiClient
@@ -95,4 +98,15 @@ export const collectionsApi = {
ax.post(`${base}/members/remove`, { collection_id: collectionId, user_id: userId }).then((r: AxiosResponse) => r.data),
availableUsers: (id: number): Promise<{ users: { id: number; username: string }[] }> =>
ax.get(`${base}/${id}/available-users`).then((r: AxiosResponse) => r.data),
createLabel: (collectionId: number, name: string, color?: string): Promise<CollectionLabel> =>
ax.post(`${base}/labels`, { collection_id: collectionId, name, color } satisfies CollectionLabelCreateRequest).then((r: AxiosResponse) => r.data),
updateLabel: (labelId: number, body: CollectionLabelUpdateRequest): Promise<CollectionLabel> =>
ax.patch(`${base}/labels/${labelId}`, body satisfies CollectionLabelUpdateRequest).then((r: AxiosResponse) => r.data),
deleteLabel: (labelId: number): Promise<unknown> =>
ax.delete(`${base}/labels/${labelId}`).then((r: AxiosResponse) => r.data),
assignLabels: (labelIds: number[], placeIds: number[]): Promise<{ changed: number }> =>
ax.post(`${base}/labels/assign`, { label_ids: labelIds, place_ids: placeIds }).then((r: AxiosResponse) => r.data),
unassignLabels: (labelIds: number[], placeIds: number[]): Promise<{ changed: number }> =>
ax.post(`${base}/labels/unassign`, { label_ids: labelIds, place_ids: placeIds }).then((r: AxiosResponse) => r.data),
}
@@ -0,0 +1,90 @@
import React, { useState } from 'react'
import { Check, Loader2, Settings2, Tags } from 'lucide-react'
import Modal from '../shared/Modal'
import type { CollectionLabel } from '@trek/shared'
import type { TranslationFn } from '../../types'
interface BulkAssignLabelModalProps {
isOpen: boolean
labels: CollectionLabel[]
/** Number of selected places the labels will be added to. */
count: number
onAssign: (labelIds: number[]) => Promise<void> | void
/** Open the label manager to create labels first. */
onManage: () => void
onClose: () => void
t: TranslationFn
}
/**
* Pick one or more of the list's labels to add to every selected place. Additive
* — it never removes labels a place already has. When the list has no labels yet,
* it points the user at the label manager instead.
*/
export default function BulkAssignLabelModal({ isOpen, labels, count, onAssign, onManage, onClose, t }: BulkAssignLabelModalProps): React.ReactElement {
const [picked, setPicked] = useState<number[]>([])
const [busy, setBusy] = useState(false)
const toggle = (id: number) => setPicked(picked.includes(id) ? picked.filter(x => x !== id) : [...picked, id])
const assign = async () => {
if (picked.length === 0 || busy) return
setBusy(true)
try {
await onAssign(picked)
setPicked([])
} finally {
setBusy(false)
}
}
return (
<Modal isOpen={isOpen} onClose={onClose} title={t('collections.labels.assignN', { count })} size="sm">
{labels.length === 0 ? (
<div className="flex flex-col items-center gap-3 py-6 text-center">
<Tags size={26} className="text-content-faint" />
<p className="text-[13px] text-content-faint">{t('collections.labels.emptyHint')}</p>
<button type="button" onClick={onManage} className="flex items-center gap-1.5 px-3 py-2 rounded-lg border border-edge text-[13px] text-content hover:bg-surface-hover">
<Settings2 size={14} /> {t('collections.labels.manage')}
</button>
</div>
) : (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1 max-h-[46vh] overflow-y-auto -mx-1 px-1">
{labels.map(l => {
const on = picked.includes(l.id)
return (
<button
key={l.id}
type="button"
onClick={() => toggle(l.id)}
className={`flex items-center gap-2.5 px-3 py-2.5 rounded-xl border text-left transition-colors ${on ? 'border-accent bg-accent/10' : 'border-edge bg-surface-card hover:bg-surface-hover'}`}
>
<span className="w-3.5 h-3.5 rounded-full shrink-0" style={{ background: l.color || '#6366f1' }} />
<span className="flex-1 min-w-0 text-[13px] font-medium text-content truncate">{l.name}</span>
{on && <Check size={15} className="text-accent shrink-0" />}
</button>
)
})}
</div>
<div className="flex justify-end gap-2 pt-2 border-t border-edge">
<button type="button" onClick={onManage} className="mr-auto flex items-center gap-1.5 px-3 py-2 rounded-lg text-[13px] text-content-secondary hover:bg-surface-hover">
<Settings2 size={14} /> {t('collections.labels.manage')}
</button>
<button type="button" onClick={onClose} className="px-3 py-2 rounded-lg border border-edge text-content-secondary text-[13px] hover:bg-surface-hover">
{t('common.cancel')}
</button>
<button
type="button"
onClick={assign}
disabled={picked.length === 0 || busy}
className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-accent text-white text-[13px] font-semibold hover:opacity-90 disabled:opacity-50"
>
{busy ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />} {t('collections.labels.assign')}
</button>
</div>
</div>
)}
</Modal>
)
}
@@ -29,6 +29,12 @@ function makeProps(overrides: Partial<HarnessProps> = {}): HarnessProps {
categoryOptions: CATEGORY_OPTIONS,
onStatusFilter: vi.fn(),
onCategoryFilter: vi.fn(),
showLabels: false,
labelOptions: [],
labelFilter: [],
onLabelFilter: vi.fn(),
canManageLabels: false,
onManageLabels: vi.fn(),
showSelect: true,
selectMode: false,
onToggleSelect: vi.fn(),
@@ -1,10 +1,10 @@
import React, { useEffect, useRef, useState } from 'react'
import { ChevronDown, Check, Layers, Tag, CheckSquare } from 'lucide-react'
import { ChevronDown, Check, Layers, Tag, Tags, CheckSquare } from 'lucide-react'
import type { StatusFilter } from '../../store/collectionStore'
import type { TranslationFn } from '../../types'
import { getCategoryIcon } from '../shared/categoryIcons'
import { STATUS_META, STATUS_ORDER } from '../../pages/collections/collectionsModel'
import type { CategoryOption } from '../../pages/collections/collectionsModel'
import type { CategoryOption, LabelOption } from '../../pages/collections/collectionsModel'
interface Opt {
key: string | number
@@ -69,6 +69,13 @@ interface CollectionFilterBarProps {
categoryOptions: CategoryOption[]
onStatusFilter: (f: StatusFilter) => void
onCategoryFilter: (f: number | 'all') => void
// Per-collection labels (hidden on the "All saved" union).
showLabels: boolean
labelOptions: LabelOption[]
labelFilter: number[]
onLabelFilter: (ids: number[]) => void
canManageLabels: boolean
onManageLabels: () => void
showSelect: boolean
selectMode: boolean
onToggleSelect: () => void
@@ -82,6 +89,7 @@ interface CollectionFilterBarProps {
*/
export default function CollectionFilterBar({
statusFilter, counts, categoryFilter, categoryOptions, onStatusFilter, onCategoryFilter,
showLabels, labelOptions, labelFilter, onLabelFilter, canManageLabels, onManageLabels,
showSelect, selectMode, onToggleSelect, t,
}: CollectionFilterBarProps): React.ReactElement {
const statusOpts: Opt[] = [
@@ -112,6 +120,33 @@ export default function CollectionFilterBar({
<CheckSquare size={14} /> <span className="col-filter-lbl">{t('collections.select')}</span>
</button>
)}
{showLabels && (labelOptions.length > 0 || canManageLabels) && (
<div className="col-labelfilter">
{labelOptions.map(l => {
const on = labelFilter.includes(l.id)
return (
<button
key={l.id}
type="button"
className={`col-labelchip${on ? ' on' : ''}`}
style={{ ['--label' as string]: l.color ?? 'var(--accent)' }}
onClick={() => onLabelFilter(on ? labelFilter.filter(id => id !== l.id) : [...labelFilter, l.id])}
aria-pressed={on}
>
<span className="col-labelchip-dot" />
<span className="col-filter-lbl">{l.name}</span>
{l.count > 0 && <span className="col-filter-count">{l.count}</span>}
</button>
)
})}
{canManageLabels && (
<button type="button" className="col-labelchip col-labelchip-manage" onClick={onManageLabels} title={t('collections.labels.manage')}>
<Tags size={13} />
<span className="col-filter-lbl">{labelOptions.length ? t('collections.labels.manage') : t('collections.labels.add')}</span>
</button>
)}
</div>
)}
</div>
)
}
@@ -65,6 +65,7 @@ function renderList(over: Partial<{
render(
<CollectionList
places={places}
labels={[]}
selectedPlaceId={over.selectedPlaceId ?? null}
selectMode={over.selectMode ?? false}
selectedIds={over.selectedIds ?? []}
@@ -1,6 +1,6 @@
import React, { useEffect, useRef } from 'react'
import React, { useEffect, useMemo, useRef } from 'react'
import { Check, MapPin } from 'lucide-react'
import type { CollectionPlace, CollectionStatus } from '@trek/shared'
import type { CollectionPlace, CollectionStatus, CollectionLabel } from '@trek/shared'
import type { TranslationFn } from '../../types'
import PlaceAvatar from '../shared/PlaceAvatar'
import { getCategoryIcon } from '../shared/categoryIcons'
@@ -8,6 +8,7 @@ import StatusBadge from './StatusBadge'
interface CollectionListProps {
places: CollectionPlace[]
labels: CollectionLabel[]
selectedPlaceId: number | null
selectMode: boolean
selectedIds: number[]
@@ -23,8 +24,9 @@ interface CollectionListProps {
* open the place (or toggle it in select mode).
*/
export default function CollectionList({
places, selectedPlaceId, selectMode, selectedIds, onOpenPlace, onStatusChange, onToggleSelect, t,
places, labels, selectedPlaceId, selectMode, selectedIds, onOpenPlace, onStatusChange, onToggleSelect, t,
}: CollectionListProps): React.ReactElement {
const labelsById = useMemo(() => new Map(labels.map(l => [l.id, l])), [labels])
// Bring the selected row into view — e.g. when it was picked from the map.
const selectedRef = useRef<HTMLDivElement>(null)
useEffect(() => {
@@ -36,6 +38,7 @@ export default function CollectionList({
{places.map(place => {
const selected = selectedIds.includes(place.id)
const active = selectedPlaceId === place.id
const placeLabels = (place.label_ids ?? []).map(id => labelsById.get(id)).filter(Boolean) as CollectionLabel[]
return (
<div
key={place.id}
@@ -61,6 +64,12 @@ export default function CollectionList({
)}
</div>
<div className="col-lrow-end">
{placeLabels.slice(0, 2).map(l => (
<span key={l.id} className="col-lrow-label" style={{ ['--label' as string]: l.color || 'var(--accent)' }} title={l.name}>
<span className="col-labelchip-dot" /> {l.name}
</span>
))}
{placeLabels.length > 2 && <span className="col-lrow-label more" title={placeLabels.map(l => l.name).join(', ')}>+{placeLabels.length - 2}</span>}
{place.category?.name && (() => {
const CatIcon = getCategoryIcon(place.category.icon ?? undefined)
return (
@@ -2,8 +2,8 @@ import React, { useEffect, useRef, useState } from 'react'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import { X, Pencil, Copy, Trash2, MapPin, Link2, Plus, ExternalLink, Check, Tag } from 'lucide-react'
import type { CollectionPlace, CollectionStatus, CollectionLink } from '@trek/shared'
import { X, Pencil, Copy, Trash2, MapPin, Link2, Plus, ExternalLink, Check, Tag, Tags } from 'lucide-react'
import type { CollectionPlace, CollectionStatus, CollectionLink, CollectionLabel } from '@trek/shared'
import type { Category, TranslationFn } from '../../types'
import MarkdownToolbar from '../Journey/MarkdownToolbar'
import { mapsApi } from '../../api/client'
@@ -22,11 +22,13 @@ interface CollectionPlaceDetailProps {
canEdit: boolean
canDelete: boolean
categories: Category[]
/** The active list's custom labels, for the assign chips. */
labels: CollectionLabel[]
/** When set, dock the sheet over that column (desktop split) instead of centred. */
anchorRect?: { left: number; width: number } | null
onClose: () => void
onSetStatus: (status: CollectionStatus) => void
onSave: (patch: { name?: string; description?: string | null; links?: CollectionLink[]; category_id?: number | null }) => Promise<void>
onSave: (patch: { name?: string; description?: string | null; links?: CollectionLink[]; category_id?: number | null; label_ids?: number[] }) => Promise<void>
onCopyToTrip: () => void
onRemove: () => void
t: TranslationFn
@@ -56,7 +58,7 @@ function StatusSegment({ status, onSet, t }: { status: CollectionStatus; onSet:
* is an always-live segmented control (auto-saves).
*/
export default function CollectionPlaceDetail({
place, canEdit, canDelete, categories, anchorRect, onClose, onSetStatus, onSave, onCopyToTrip, onRemove, t,
place, canEdit, canDelete, categories, labels, anchorRect, onClose, onSetStatus, onSave, onCopyToTrip, onRemove, t,
}: CollectionPlaceDetailProps): React.ReactElement {
const toast = useToast()
const [editing, setEditing] = useState(false)
@@ -64,6 +66,7 @@ export default function CollectionPlaceDetail({
const [categoryId, setCategoryId] = useState<number | null>(place.category_id ?? null)
const [description, setDescription] = useState(place.description ?? '')
const [links, setLinks] = useState<CollectionLink[]>(place.links ?? [])
const [labelIds, setLabelIds] = useState<number[]>(place.label_ids ?? [])
const [saving, setSaving] = useState(false)
// A higher-res photo pulled from the maps provider when the place has none of
// its own — the list avatar's little thumbnail is too low-res for the cover.
@@ -77,6 +80,7 @@ export default function CollectionPlaceDetail({
setCategoryId(place.category_id ?? null)
setDescription(place.description ?? '')
setLinks(place.links ?? [])
setLabelIds(place.label_ids ?? [])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [place.id])
@@ -96,13 +100,15 @@ export default function CollectionPlaceDetail({
const banner = place.image_url || fetchedPhoto
const setLink = (i: number, patch: Partial<CollectionLink>) => setLinks(links.map((l, idx) => (idx === i ? { ...l, ...patch } : l)))
const resetForm = () => { setEditing(false); setName(place.name); setCategoryId(place.category_id ?? null); setDescription(place.description ?? ''); setLinks(place.links ?? []) }
const toggleLabel = (id: number) => setLabelIds(labelIds.includes(id) ? labelIds.filter(x => x !== id) : [...labelIds, id])
const resetForm = () => { setEditing(false); setName(place.name); setCategoryId(place.category_id ?? null); setDescription(place.description ?? ''); setLinks(place.links ?? []); setLabelIds(place.label_ids ?? []) }
const assignedLabels = labels.filter(l => (place.label_ids ?? []).includes(l.id))
const save = async () => {
const cleanLinks = links.map(l => ({ label: l.label?.trim() || undefined, url: normalizeLinkUrl(l.url) })).filter(l => l.url)
setSaving(true)
try {
await onSave({ name: name.trim() || place.name, description: description.trim() || null, links: cleanLinks, category_id: categoryId })
await onSave({ name: name.trim() || place.name, description: description.trim() || null, links: cleanLinks, category_id: categoryId, label_ids: labelIds })
setEditing(false)
} catch (err) {
toast.error(getApiErrorMessage(err, t('common.error')))
@@ -161,6 +167,22 @@ export default function CollectionPlaceDetail({
})}
</div>
</div>
{/* Labels */}
{labels.length > 0 && (
<div className="col-detail-field">
<div className="col-detail-label"><Tags size={12} /> {t('collections.labels.title')}</div>
<div className="col-detail-cats">
{labels.map(l => {
const on = labelIds.includes(l.id)
return (
<button key={l.id} type="button" onClick={() => toggleLabel(l.id)} className={`col-detail-cat${on ? ' on' : ''}`} style={{ ['--cat' as string]: l.color || '#6366f1' }}>
<span className="col-labelchip-dot" /> {l.name}
</button>
)
})}
</div>
</div>
)}
{/* Description */}
<div className="col-detail-field">
<div className="col-detail-label">{t('collections.description')}</div>
@@ -184,6 +206,15 @@ export default function CollectionPlaceDetail({
</div>
) : (
<>
{assignedLabels.length > 0 && (
<div className="col-detail-labels">
{assignedLabels.map(l => (
<span key={l.id} className="col-labelchip on static" style={{ ['--label' as string]: l.color || 'var(--accent)' }}>
<span className="col-labelchip-dot" /> {l.name}
</span>
))}
</div>
)}
{place.description && (
<div className="col-detail-md collab-note-md">
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{place.description}</Markdown>
@@ -0,0 +1,139 @@
import React, { useState } from 'react'
import { Plus, Trash2, Loader2 } from 'lucide-react'
import Modal from '../shared/Modal'
import type { CollectionLabel, CollectionLabelUpdateRequest } from '@trek/shared'
import type { TranslationFn } from '../../types'
const SWATCHES = ['#6366f1', '#0ea5e9', '#10b981', '#f59e0b', '#ef4444', '#ec4899', '#8b5cf6', '#64748b']
interface LabelManagerProps {
isOpen: boolean
labels: CollectionLabel[]
onCreate: (name: string, color?: string) => Promise<void> | void
onUpdate: (labelId: number, body: CollectionLabelUpdateRequest) => Promise<void> | void
onDelete: (labelId: number) => Promise<void> | void
onClose: () => void
t: TranslationFn
}
/** Swatch row shared by the create form and each row's recolor control. */
function Swatches({ value, onPick }: { value: string; onPick: (c: string) => void }): React.ReactElement {
return (
<div className="flex items-center gap-1.5 flex-wrap">
{SWATCHES.map(c => (
<button
key={c}
type="button"
onClick={() => onPick(c)}
className={`w-5 h-5 rounded-full border transition-transform ${value.toLowerCase() === c ? 'border-content scale-110' : 'border-transparent'}`}
style={{ background: c }}
aria-label={c}
/>
))}
</div>
)
}
/** One existing label: inline rename (save on blur/Enter), recolor, delete. */
function LabelRow({ label, onUpdate, onDelete, t }: {
label: CollectionLabel
onUpdate: LabelManagerProps['onUpdate']
onDelete: LabelManagerProps['onDelete']
t: TranslationFn
}): React.ReactElement {
const [name, setName] = useState(label.name)
const [color, setColor] = useState(label.color || '#6366f1')
const [busy, setBusy] = useState(false)
const commitName = async () => {
const trimmed = name.trim()
if (!trimmed || trimmed === label.name) { setName(label.name); return }
setBusy(true)
try { await onUpdate(label.id, { name: trimmed }) } finally { setBusy(false) }
}
const pickColor = async (c: string) => {
setColor(c)
setBusy(true)
try { await onUpdate(label.id, { color: c }) } finally { setBusy(false) }
}
return (
<div className="flex items-center gap-2 px-2.5 py-2 rounded-xl border border-edge bg-surface-card">
<span className="w-3.5 h-3.5 rounded-full shrink-0" style={{ background: color }} />
<input
value={name}
onChange={e => setName(e.target.value)}
onBlur={commitName}
onKeyDown={e => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }}
maxLength={60}
className="flex-1 min-w-0 bg-transparent text-[13px] text-content outline-none"
aria-label={t('collections.labels.name')}
/>
<Swatches value={color} onPick={pickColor} />
{busy && <Loader2 size={14} className="animate-spin text-content-faint shrink-0" />}
<button type="button" onClick={() => onDelete(label.id)} className="p-1 text-content-faint hover:text-danger shrink-0" aria-label={t('common.delete')}>
<Trash2 size={14} />
</button>
</div>
)
}
/**
* Manage a list's custom labels — create, rename, recolor and delete. Available
* to any member who can edit the list; the labels are shared by the whole list.
*/
export default function LabelManager({ isOpen, labels, onCreate, onUpdate, onDelete, onClose, t }: LabelManagerProps): React.ReactElement {
const [newName, setNewName] = useState('')
const [newColor, setNewColor] = useState(SWATCHES[0])
const [adding, setAdding] = useState(false)
const add = async () => {
const trimmed = newName.trim()
if (!trimmed || adding) return
setAdding(true)
try {
await onCreate(trimmed, newColor)
setNewName('')
setNewColor(SWATCHES[0])
} finally {
setAdding(false)
}
}
return (
<Modal isOpen={isOpen} onClose={onClose} title={t('collections.labels.manage')} size="sm">
<div className="flex flex-col gap-3">
{labels.length === 0 ? (
<p className="text-center text-[13px] text-content-faint py-4">{t('collections.labels.empty')}</p>
) : (
<div className="flex flex-col gap-1.5 max-h-[46vh] overflow-y-auto -mx-1 px-1">
{labels.map(l => <LabelRow key={l.id} label={l} onUpdate={onUpdate} onDelete={onDelete} t={t} />)}
</div>
)}
<div className="flex flex-col gap-2 pt-3 border-t border-edge">
<div className="flex items-center gap-2">
<span className="w-3.5 h-3.5 rounded-full shrink-0" style={{ background: newColor }} />
<input
value={newName}
onChange={e => setNewName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') add() }}
maxLength={60}
placeholder={t('collections.labels.namePlaceholder')}
className="flex-1 min-w-0 px-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[13px] outline-none focus:border-accent"
/>
<button
type="button"
onClick={add}
disabled={!newName.trim() || adding}
className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-accent text-white text-[13px] font-semibold hover:opacity-90 disabled:opacity-50 shrink-0"
>
{adding ? <Loader2 size={14} className="animate-spin" /> : <Plus size={14} />} {t('collections.labels.add')}
</button>
</div>
<Swatches value={newColor} onPick={setNewColor} />
</div>
</div>
</Modal>
)
}
+45 -1
View File
@@ -1,5 +1,5 @@
import React from 'react'
import { List as ListIcon, Map as MapIcon, Search, Bookmark, CheckCheck, X, Trash2, Copy, CopyPlus, FolderInput, Plus } from 'lucide-react'
import { List as ListIcon, Map as MapIcon, Search, Bookmark, CheckCheck, X, Trash2, Copy, CopyPlus, FolderInput, Plus, Tags } from 'lucide-react'
import Navbar from '../components/Layout/Navbar'
import Modal from '../components/shared/Modal'
import ListsRail from '../components/Collections/ListsRail'
@@ -13,6 +13,8 @@ import MoveToListModal from '../components/Collections/MoveToListModal'
import ShareCollectionModal from '../components/Collections/ShareCollectionModal'
import AddPlaceToCollectionModal from '../components/Collections/AddPlaceToCollectionModal'
import CollectionPlaceDetail from '../components/Collections/CollectionPlaceDetail'
import LabelManager from '../components/Collections/LabelManager'
import BulkAssignLabelModal from '../components/Collections/BulkAssignLabelModal'
import { useCollections } from './collections/useCollections'
import '../styles/dashboard.css'
import '../styles/collections.css'
@@ -41,6 +43,9 @@ export default function CollectionsPage(): React.ReactElement {
const hasPlaces = c.places.length > 0
const noLists = !c.loading && c.collections.length === 0
const showSelect = c.isAllSaved || c.activeCollection != null
// Labels are per-collection, so only on a real (non "All saved") list.
const isRealList = !c.isAllSaved && typeof c.activeId === 'number'
const canManageLabels = isRealList && c.canEdit
// Selecting a place toggles it, so clicking it again — or the map background —
// clears it. Below the desktop breakpoint the list and map are separate views;
@@ -66,6 +71,7 @@ export default function CollectionsPage(): React.ReactElement {
const listEl = (
<CollectionList
places={c.visiblePlaces}
labels={c.labels}
selectedPlaceId={c.selectedPlaceId}
selectMode={c.selectMode}
selectedIds={c.selectedIds}
@@ -103,6 +109,12 @@ export default function CollectionsPage(): React.ReactElement {
categoryOptions={c.categoryOptions}
onStatusFilter={c.setStatusFilter}
onCategoryFilter={c.setCategoryFilter}
showLabels={isRealList}
labelOptions={c.labelOptions}
labelFilter={c.labelFilter}
onLabelFilter={c.setLabelFilter}
canManageLabels={canManageLabels}
onManageLabels={() => c.setShowLabelManager(true)}
showSelect={showSelect}
selectMode={c.selectMode}
onToggleSelect={() => c.setSelectMode(!c.selectMode)}
@@ -231,6 +243,11 @@ export default function CollectionsPage(): React.ReactElement {
</button>
<span className="lbl">{t('collections.selectedCount', { count: c.selectedIds.length })}</span>
<div className="col-toolbar-spacer" />
{c.canEdit && isRealList && (
<button type="button" onClick={() => c.setLabelPickerOpen(true)} disabled={c.selectedIds.length === 0} className="col-selbar-btn">
<Tags size={14} /> {t('collections.labels.assign')}
</button>
)}
{c.canEdit && (
<button type="button" onClick={() => c.setListPickerMode('move')} disabled={c.selectedIds.length === 0} className="col-selbar-btn">
<FolderInput size={14} /> {t('collections.moveToList')}
@@ -282,6 +299,7 @@ export default function CollectionsPage(): React.ReactElement {
canEdit={c.canEdit}
canDelete={c.canDelete}
categories={c.categories}
labels={c.labels}
anchorRect={desktopSplit ? c.listColRect : null}
onClose={c.handleCloseDetail}
onSetStatus={c.handleDetailStatus}
@@ -344,6 +362,32 @@ export default function CollectionsPage(): React.ReactElement {
{/* Create / edit a list — name, colour, cover, description, links */}
<ListEditorModal target={c.editorTarget} onClose={() => c.setEditorTarget(null)} onCreated={c.handleEditorCreated} onRequestDelete={c.setConfirmDeleteList} t={t} />
{/* Manage the list's custom labels (editor+) */}
{canManageLabels && (
<LabelManager
isOpen={c.showLabelManager}
labels={c.labels}
onCreate={c.handleCreateLabel}
onUpdate={c.handleUpdateLabel}
onDelete={c.handleDeleteLabel}
onClose={() => c.setShowLabelManager(false)}
t={t}
/>
)}
{/* Bulk-assign labels to the current selection */}
{canManageLabels && (
<BulkAssignLabelModal
isOpen={c.labelPickerOpen}
labels={c.labels}
count={c.selectedIds.length}
onAssign={c.handleBulkAssignLabels}
onManage={() => { c.setLabelPickerOpen(false); c.setShowLabelManager(true) }}
onClose={() => c.setLabelPickerOpen(false)}
t={t}
/>
)}
{/* Delete-list confirm */}
<Modal
isOpen={c.confirmDeleteList != null}
@@ -7,9 +7,11 @@ import {
sortPlaces,
statusCounts,
presentCategories,
presentLabels,
mappablePlaces,
normalizeLinkUrl,
} from './collectionsModel';
import type { CollectionLabel } from '@trek/shared';
// ── Inline CollectionPlace-ish builder ────────────────────────────────────────
// Only the fields the helpers actually read are meaningful; the rest satisfy the
@@ -32,6 +34,7 @@ interface PlaceLike {
notes?: string | null;
sort_order?: number;
created_at?: string;
label_ids?: number[];
}
function cp(overrides: PlaceLike): CollectionPlace {
return {
@@ -319,4 +322,46 @@ describe('collectionsModel', () => {
expect(normalizeLinkUrl('//booking.com')).toBe('https://booking.com');
});
});
describe('labels', () => {
const places = [
cp({ id: 1, name: 'Berlin gate', status: 'idea', label_ids: [10] }),
cp({ id: 2, name: 'Hamburg port', status: 'idea', label_ids: [11] }),
cp({ id: 3, name: 'Both', status: 'idea', label_ids: [10, 11] }),
cp({ id: 4, name: 'None', status: 'idea', label_ids: [] }),
];
const labels: CollectionLabel[] = [
{ id: 10, collection_id: 1, name: 'Berlin', color: '#f00' },
{ id: 11, collection_id: 1, name: 'Hamburg', color: '#00f' },
{ id: 12, collection_id: 1, name: 'Unused', color: null },
];
it('FE-COLLECTIONS-MODEL-031: an empty label filter keeps every place', () => {
expect(filterPlaces(places, 'all', '', 'all', [])).toHaveLength(4);
});
it('FE-COLLECTIONS-MODEL-032: a single label keeps places carrying it (incl. multi-label)', () => {
const out = filterPlaces(places, 'all', '', 'all', [10]).map(p => p.id);
expect(out).toEqual([1, 3]);
});
it('FE-COLLECTIONS-MODEL-033: multiple labels are OR — any match passes', () => {
const out = filterPlaces(places, 'all', '', 'all', [10, 11]).map(p => p.id);
expect(out).toEqual([1, 2, 3]);
});
it('FE-COLLECTIONS-MODEL-034: the label filter composes with status + search', () => {
const mixed = [
cp({ id: 5, name: 'Museum', status: 'visited', label_ids: [10] }),
cp({ id: 6, name: 'Museum', status: 'idea', label_ids: [10] }),
];
const out = filterPlaces(mixed, 'visited', 'mus', 'all', [10]).map(p => p.id);
expect(out).toEqual([5]);
});
it('FE-COLLECTIONS-MODEL-035: presentLabels keeps definition order with per-label counts, incl. zero', () => {
const opts = presentLabels(labels, places);
expect(opts.map(o => [o.id, o.count])).toEqual([[10, 2], [11, 2], [12, 0]]);
});
});
});
@@ -1,6 +1,6 @@
import { Circle, Bookmark, CheckCircle2 } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import type { CollectionPlace, CollectionStatus } from '@trek/shared'
import type { CollectionPlace, CollectionStatus, CollectionLabel } from '@trek/shared'
import { COLLECTION_STATUSES } from '@trek/shared'
import type { StatusFilter } from '../../store/collectionStore'
@@ -44,18 +44,23 @@ export function sortPlaces(places: CollectionPlace[]): CollectionPlace[] {
})
}
/** Apply the active status filter + free-text search (name/address/notes). */
/** Apply the active status filter + free-text search (name/address/notes) +
* category + per-collection label filter. The label filter is OR semantics: a
* place matches if it carries ANY of the selected labels. This one function
* drives BOTH the list and the map, so every filter stays in lockstep. */
export function filterPlaces(
places: CollectionPlace[],
statusFilter: StatusFilter,
search: string,
categoryFilter: number | 'all' = 'all',
labelFilter: number[] = [],
): CollectionPlace[] {
const q = search.trim().toLowerCase()
return places.filter(p => {
if (!p) return false
if (statusFilter !== 'all' && p.status !== statusFilter) return false
if (categoryFilter !== 'all' && (p.category_id ?? null) !== categoryFilter) return false
if (labelFilter.length && !labelFilter.some(id => (p.label_ids ?? []).includes(id))) return false
if (!q) return true
return (
p.name.toLowerCase().includes(q) ||
@@ -86,6 +91,20 @@ export function presentCategories(places: CollectionPlace[]): CategoryOption[] {
return [...byId.values()].sort((a, b) => a.name.localeCompare(b.name))
}
export interface LabelOption { id: number; name: string; color: string | null; count: number }
/** The collection's labels in definition order, each with how many of the given
* places carry it. Zero-count labels are kept so the manager/filter still lists
* a freshly-created label. */
export function presentLabels(labels: CollectionLabel[], places: CollectionPlace[]): LabelOption[] {
const counts = new Map<number, number>()
for (const p of places) {
if (!p) continue
for (const id of p.label_ids ?? []) counts.set(id, (counts.get(id) ?? 0) + 1)
}
return labels.map(l => ({ id: l.id, name: l.name, color: l.color ?? null, count: counts.get(l.id) ?? 0 }))
}
/** Only the places that can render on a map. */
export function mappablePlaces(places: CollectionPlace[]): CollectionPlace[] {
return places.filter(p => p && typeof p.lat === 'number' && typeof p.lng === 'number')
+53 -7
View File
@@ -13,7 +13,8 @@ import type { ActiveCollectionId } from '../../store/collectionStore'
import { categoriesApi } from '../../api/client'
import type { Collection, CollectionStatus } from '@trek/shared'
import type { Category, Place } from '../../types'
import { filterPlaces, sortPlaces, statusCounts, mappablePlaces, presentCategories } from './collectionsModel'
import { filterPlaces, sortPlaces, statusCounts, mappablePlaces, presentCategories, presentLabels } from './collectionsModel'
import type { CollectionLabelUpdateRequest } from '@trek/shared'
/**
* Collections page logic — owns the page-local UI state (new/edit-list forms,
@@ -48,15 +49,16 @@ export function useCollections() {
const store = useCollectionStore()
const {
collections, activeId, places, members, incomingInvites,
view, statusFilter, categoryFilter, search, selectedPlaceId, selectMode, selectedIds,
collections, activeId, places, members, labels, incomingInvites,
view, statusFilter, categoryFilter, labelFilter, search, selectedPlaceId, selectMode, selectedIds,
loading, placesLoading,
loadAll, setActive, refreshActive, loadCollection,
deleteCollection,
setStatus, updatePlace, deletePlace, deleteMany, copyToTrip, clearSelection,
moveToList, duplicateToList, setSelectedIds,
createLabel, updateLabel, deleteLabel, assignLabels,
acceptInvite, declineInvite,
setView, setStatusFilter, setCategoryFilter, setSearch, setSelectedPlaceId, setSelectMode, toggleSelect,
setView, setStatusFilter, setCategoryFilter, setLabelFilter, setSearch, setSelectedPlaceId, setSelectMode, toggleSelect,
} = store
// ── Page-local UI state ─────────────────────────────────────────────
@@ -163,12 +165,15 @@ export function useCollections() {
const ownedLists = useMemo(() => collections.filter(c => c.is_owner !== false), [collections])
const sharedLists = useMemo(() => collections.filter(c => c.is_owner === false), [collections])
// Labels are per-collection, so never apply them on the "All saved" union.
const visiblePlaces = useMemo(
() => sortPlaces(filterPlaces(places, statusFilter, search, categoryFilter)),
[places, statusFilter, search, categoryFilter],
() => sortPlaces(filterPlaces(places, statusFilter, search, categoryFilter, isAllSaved ? [] : labelFilter)),
[places, statusFilter, search, categoryFilter, isAllSaved, labelFilter],
)
// Categories actually present in this list, for the category filter dropdown.
const categoryOptions = useMemo(() => presentCategories(places), [places])
// The active list's labels (with per-label counts) for the filter + manager.
const labelOptions = useMemo(() => presentLabels(labels, places), [labels, places])
// Stable reference so the map doesn't tear down + rebuild every marker on each
// unrelated re-render (which would swallow marker clicks mid-rebuild).
const mappable = useMemo(() => mappablePlaces(visiblePlaces), [visiblePlaces])
@@ -254,6 +259,43 @@ export function useCollections() {
}
}, [selectedIds, duplicateToList, toast, t])
// ── Labels ──────────────────────────────────────────────────────────
const [showLabelManager, setShowLabelManager] = useState(false)
const [labelPickerOpen, setLabelPickerOpen] = useState(false)
const handleCreateLabel = useCallback(async (name: string, color?: string) => {
try { await createLabel(name, color) }
catch (err) { toast.error(getApiErrorMessage(err, t('common.error'))) }
}, [createLabel, toast, t])
const handleUpdateLabel = useCallback(async (labelId: number, body: CollectionLabelUpdateRequest) => {
try { await updateLabel(labelId, body) }
catch (err) { toast.error(getApiErrorMessage(err, t('common.error'))) }
}, [updateLabel, toast, t])
const handleDeleteLabel = useCallback(async (labelId: number) => {
try { await deleteLabel(labelId) }
catch (err) { toast.error(getApiErrorMessage(err, t('common.error'))) }
}, [deleteLabel, toast, t])
// Bulk-assign one or more labels to the current selection.
const handleBulkAssignLabels = useCallback(async (labelIds: number[]) => {
if (selectedIds.length === 0 || labelIds.length === 0) return
try {
await assignLabels(labelIds, selectedIds)
toast.success(t('collections.labels.assignedCount', { count: selectedIds.length }))
setLabelPickerOpen(false)
} catch (err) {
toast.error(getApiErrorMessage(err, t('common.error')))
}
}, [assignLabels, selectedIds, toast, t])
// Replace a single place's labels (from the detail sheet chips).
const handleAssignPlaceLabels = useCallback(async (placeId: number, labelIds: number[]) => {
try { await updatePlace(placeId, { label_ids: labelIds }) }
catch (err) { toast.error(getApiErrorMessage(err, t('common.error'))) }
}, [updatePlace, toast, t])
const handleAcceptInvite = useCallback(async (collectionId: number) => {
try {
await acceptInvite(collectionId)
@@ -337,10 +379,14 @@ export function useCollections() {
canShare, shareMemberCount,
activeId, places, visiblePlaces, mappable, members, incomingInvites, counts,
view, statusFilter, categoryFilter, categoryOptions, search, selectedPlaceId, selectMode, selectedIds,
labels, labelFilter, labelOptions,
loading, placesLoading,
// store setters
setView, setStatusFilter, setCategoryFilter, setSearch, setSelectedPlaceId, setSelectMode, toggleSelect,
setView, setStatusFilter, setCategoryFilter, setLabelFilter, setSearch, setSelectedPlaceId, setSelectMode, toggleSelect,
updatePlace,
// labels
showLabelManager, setShowLabelManager, labelPickerOpen, setLabelPickerOpen,
handleCreateLabel, handleUpdateLabel, handleDeleteLabel, handleBulkAssignLabels, handleAssignPlaceLabels,
// local UI state
editorTarget, setEditorTarget, handleEditorCreated,
showAddPlace, setShowAddPlace, handlePlaceAdded,
+64 -4
View File
@@ -10,6 +10,8 @@ import type {
CollectionCreateRequest,
CollectionUpdateRequest,
CollectionPlaceUpdateRequest,
CollectionLabel,
CollectionLabelUpdateRequest,
} from '@trek/shared'
/** A pending invitation the current user has received (derived server-side). */
@@ -27,10 +29,12 @@ interface CollectionState {
activeId: ActiveCollectionId
places: CollectionPlace[]
members: CollectionMember[]
labels: CollectionLabel[]
incomingInvites: IncomingCollectionInvite[]
view: CollectionView
statusFilter: StatusFilter
categoryFilter: number | 'all'
labelFilter: number[]
search: string
selectedPlaceId: number | null
selectMode: boolean
@@ -57,6 +61,11 @@ interface CollectionState {
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>
@@ -68,6 +77,7 @@ interface CollectionState {
setView: (view: CollectionView) => void
setStatusFilter: (filter: StatusFilter) => void
setCategoryFilter: (filter: number | 'all') => void
setLabelFilter: (labelIds: number[]) => void
setSearch: (search: string) => void
setSelectedPlaceId: (id: number | null) => void
setSelectMode: (on: boolean) => void
@@ -81,10 +91,12 @@ export const useCollectionStore = create<CollectionState>((set, get) => ({
activeId: null,
places: [],
members: [],
labels: [],
incomingInvites: [],
view: 'list',
statusFilter: 'all',
categoryFilter: 'all',
labelFilter: [],
search: '',
selectedPlaceId: null,
selectMode: false,
@@ -110,26 +122,28 @@ export const useCollectionStore = create<CollectionState>((set, get) => ({
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: [] })
if (get().activeId === id) set({ activeId: null, places: [], members: [], labels: [] })
} finally {
set({ placesLoading: false })
}
},
setActive: async (id: ActiveCollectionId) => {
set({ selectMode: false, selectedIds: [], selectedPlaceId: null })
// 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: [] })
set({ activeId: null, places: [], members: [], labels: [] })
return
}
if (id === ALL_SAVED) {
set({ activeId: ALL_SAVED, members: [], placesLoading: true })
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),
@@ -277,6 +291,51 @@ export const useCollectionStore = create<CollectionState>((set, get) => ({
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)
@@ -316,6 +375,7 @@ export const useCollectionStore = create<CollectionState>((set, get) => ({
setView: (view: CollectionView) => set({ view }),
setStatusFilter: (filter: StatusFilter) => set({ statusFilter: filter }),
setCategoryFilter: (filter: number | 'all') => set({ categoryFilter: filter }),
setLabelFilter: (labelIds: number[]) => set({ labelFilter: labelIds }),
setSearch: (search: string) => set({ search }),
setSelectedPlaceId: (id: number | null) => set({ selectedPlaceId: id }),
setSelectMode: (on: boolean) => set({ selectMode: on, selectedIds: on ? get().selectedIds : [] }),
+14
View File
@@ -270,6 +270,20 @@
.trek-dash .col-lrow-cat svg { flex-shrink: 0; }
.trek-dash .col-lrow-div { width: 1px; height: 18px; flex-shrink: 0; background: var(--line-2); }
/* Per-collection label chips — list rows, filter bar and detail read view */
.trek-dash .col-lrow-label { display: inline-flex; align-items: center; gap: 5px; max-width: 120px; padding: 3px 8px; border-radius: 999px; font-size: 11px; font-weight: 600; white-space: nowrap; overflow: hidden; color: var(--label, var(--accent)); background: color-mix(in oklch, var(--label, var(--accent)) 14%, transparent); }
.trek-dash .col-lrow-label.more { color: var(--ink-3); background: var(--surface-2); }
.trek-dash .col-labelchip-dot { width: 8px; height: 8px; border-radius: 999px; flex-shrink: 0; background: var(--label, var(--accent)); }
.trek-dash .col-labelfilter { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.trek-dash .col-labelchip { display: inline-flex; align-items: center; gap: 6px; max-width: 180px; padding: 6px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; white-space: nowrap; color: var(--ink-2); background: var(--surface); border: 1px solid var(--line-2); transition: background .12s, border-color .12s, color .12s; }
.trek-dash .col-labelchip:hover { background: var(--surface-2); color: var(--ink); }
.trek-dash .col-labelchip .col-filter-lbl { overflow: hidden; text-overflow: ellipsis; }
.trek-dash .col-labelchip.on { color: var(--label, var(--accent)); background: color-mix(in oklch, var(--label, var(--accent)) 16%, transparent); border-color: color-mix(in oklch, var(--label, var(--accent)) 42%, transparent); }
.trek-dash .col-labelchip.on .col-labelchip-dot { background: var(--label, var(--accent)); }
.trek-dash .col-labelchip.static { cursor: default; color: var(--label, var(--accent)); background: color-mix(in oklch, var(--label, var(--accent)) 14%, transparent); border-color: transparent; }
.trek-dash .col-labelchip-manage { color: var(--ink-3); border-style: dashed; }
.trek-dash .col-detail-labels { display: flex; flex-wrap: wrap; gap: 7px; margin: 2px 0 10px; }
/* ----------------- 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; }
+21
View File
@@ -3398,6 +3398,27 @@ function runMigrations(db: Database.Database): void {
db.exec('CREATE INDEX IF NOT EXISTS idx_plugin_audit_plugin ON plugin_capability_audit (plugin_id, id);');
} catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); }
},
// Migration 160: per-collection custom labels (#collections). Each list owns
// its own label set (unlike the instance-wide `tags` table), and a place can
// carry several labels. Used for grouping + filtering places within a list.
() => {
db.exec(`CREATE TABLE IF NOT EXISTS collection_labels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
name TEXT NOT NULL,
color TEXT DEFAULT '#6366f1',
sort_order INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);`);
db.exec(`CREATE TABLE IF NOT EXISTS collection_place_labels (
collection_place_id INTEGER NOT NULL REFERENCES collection_places(id) ON DELETE CASCADE,
label_id INTEGER NOT NULL REFERENCES collection_labels(id) ON DELETE CASCADE,
PRIMARY KEY (collection_place_id, label_id)
);`);
db.exec('CREATE INDEX IF NOT EXISTS idx_collection_labels_collection ON collection_labels(collection_id);');
db.exec('CREATE INDEX IF NOT EXISTS idx_collection_place_labels_place ON collection_place_labels(collection_place_id);');
db.exec('CREATE INDEX IF NOT EXISTS idx_collection_place_labels_label ON collection_place_labels(label_id);');
},
];
if (currentVersion < migrations.length) {
+21
View File
@@ -436,6 +436,27 @@ function createTables(db: Database.Database): void {
CREATE INDEX IF NOT EXISTS idx_collection_place_tags_place ON collection_place_tags(collection_place_id);
CREATE INDEX IF NOT EXISTS idx_collection_place_tags_tag ON collection_place_tags(tag_id);
-- Per-collection custom labels (distinct from the instance-wide tags table):
-- each list defines its own labels and every place can carry several.
CREATE TABLE IF NOT EXISTS collection_labels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
name TEXT NOT NULL,
color TEXT DEFAULT '#6366f1',
sort_order INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS collection_place_labels (
collection_place_id INTEGER NOT NULL REFERENCES collection_places(id) ON DELETE CASCADE,
label_id INTEGER NOT NULL REFERENCES collection_labels(id) ON DELETE CASCADE,
PRIMARY KEY (collection_place_id, label_id)
);
CREATE INDEX IF NOT EXISTS idx_collection_labels_collection ON collection_labels(collection_id);
CREATE INDEX IF NOT EXISTS idx_collection_place_labels_place ON collection_place_labels(collection_place_id);
CREATE INDEX IF NOT EXISTS idx_collection_place_labels_label ON collection_place_labels(label_id);
CREATE TABLE IF NOT EXISTS day_accommodations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
@@ -41,6 +41,12 @@ import {
collectionInviteCancelRequestSchema,
collectionRemoveMemberRequestSchema,
collectionSetMemberRoleRequestSchema,
collectionLabelCreateRequestSchema,
collectionLabelUpdateRequestSchema,
collectionLabelAssignRequestSchema,
type CollectionLabelCreateRequest,
type CollectionLabelUpdateRequest,
type CollectionLabelAssignRequest,
type CollectionCreateRequest,
type CollectionUpdateRequest,
type CollectionSavePlaceRequest,
@@ -173,6 +179,53 @@ export class CollectionsController {
return this.collections.copyToTrip(user.id, body);
}
// ── Labels (per-collection custom labels; static prefixes before /:id) ───────
@Post('labels')
@HttpCode(200)
createLabel(
@CurrentUser() user: User,
@Body(new ZodValidationPipe(collectionLabelCreateRequestSchema)) body: CollectionLabelCreateRequest,
@Headers('x-socket-id') socketId?: string,
) {
return this.collections.createLabel(user.id, body.collection_id, body.name, body.color, socketId);
}
@Patch('labels/:lid')
updateLabel(
@CurrentUser() user: User,
@Param('lid') lid: string,
@Body(new ZodValidationPipe(collectionLabelUpdateRequestSchema)) body: CollectionLabelUpdateRequest,
@Headers('x-socket-id') socketId?: string,
) {
return this.collections.updateLabel(user.id, Number(lid), body, socketId);
}
@Delete('labels/:lid')
deleteLabel(@CurrentUser() user: User, @Param('lid') lid: string, @Headers('x-socket-id') socketId?: string) {
this.collections.deleteLabel(user.id, Number(lid), socketId);
return { success: true };
}
@Post('labels/assign')
@HttpCode(200)
assignLabels(
@CurrentUser() user: User,
@Body(new ZodValidationPipe(collectionLabelAssignRequestSchema)) body: CollectionLabelAssignRequest,
@Headers('x-socket-id') socketId?: string,
) {
return this.collections.assignLabels(user.id, body.label_ids, body.place_ids, false, socketId);
}
@Post('labels/unassign')
@HttpCode(200)
unassignLabels(
@CurrentUser() user: User,
@Body(new ZodValidationPipe(collectionLabelAssignRequestSchema)) body: CollectionLabelAssignRequest,
@Headers('x-socket-id') socketId?: string,
) {
return this.collections.assignLabels(user.id, body.label_ids, body.place_ids, true, socketId);
}
// ── Library-wide membership lookup ──────────────────────────────────────────
@Get('membership')
membership(
@@ -41,6 +41,11 @@ export class CollectionsService {
return svc.findMembership(userId, query);
}
createLabel(userId: number, collectionId: number, name: string, color: string | undefined, socketId?: string) { return svc.createLabel(userId, collectionId, name, color, socketId); }
updateLabel(userId: number, labelId: number, body: { name?: string; color?: string; sort_order?: number }, socketId?: string) { return svc.updateLabel(userId, labelId, body, socketId); }
deleteLabel(userId: number, labelId: number, socketId?: string) { return svc.deleteLabel(userId, labelId, socketId); }
assignLabels(userId: number, labelIds: number[], placeIds: number[], remove: boolean, socketId?: string) { return svc.assignLabels(userId, labelIds, placeIds, remove, socketId); }
// Access helpers used by the controller's owner guards.
assertAccess(userId: number, collectionId: number) { return svc.assertAccess(userId, collectionId); }
isOwner(userId: number, collectionId: number) { return svc.isOwner(userId, collectionId); }
+134 -1
View File
@@ -17,6 +17,7 @@ import type {
CollectionSaveResult,
CollectionCopyToTripRequest,
CollectionStatus,
CollectionLabel,
} from '@trek/shared';
/** Links are stored as a JSON TEXT column; parse on read, stringify on write. */
@@ -147,9 +148,32 @@ function loadTagsByCollectionPlaceIds(placeIds: number[]): Record<number, { id:
return out;
}
/** A list's own label definitions, in display order. */
function loadLabelsByCollection(collectionId: number): CollectionLabel[] {
return db.prepare(
'SELECT id, collection_id, name, color, sort_order FROM collection_labels WHERE collection_id = ? ORDER BY sort_order, id',
).all(collectionId) as CollectionLabel[];
}
/** Assigned label ids per place, batched (mirrors loadTagsByCollectionPlaceIds). */
function loadLabelIdsByPlaceIds(placeIds: number[]): Record<number, number[]> {
const out: Record<number, number[]> = {};
if (placeIds.length === 0) return out;
const placeholders = placeIds.map(() => '?').join(',');
const rows = db.prepare(
`SELECT collection_place_id AS pid, label_id FROM collection_place_labels WHERE collection_place_id IN (${placeholders})`,
).all(...placeIds) as { pid: number; label_id: number }[];
for (const r of rows) {
if (!out[r.pid]) out[r.pid] = [];
out[r.pid].push(r.label_id);
}
return out;
}
function hydratePlaces(rows: PlaceRow[]): CollectionPlace[] {
const ids = rows.map(r => r.id);
const tagsByPlace = loadTagsByCollectionPlaceIds(ids);
const labelsByPlace = loadLabelIdsByPlaceIds(ids);
return rows.map(r => {
const { category_name, category_color, category_icon, ...rest } = r;
return {
@@ -159,6 +183,7 @@ function hydratePlaces(rows: PlaceRow[]): CollectionPlace[] {
? { id: r.category_id, name: category_name ?? '', color: category_color ?? null, icon: category_icon ?? null }
: undefined,
tags: tagsByPlace[r.id] || [],
label_ids: labelsByPlace[r.id] || [],
} as CollectionPlace;
});
}
@@ -240,7 +265,10 @@ export function getCollection(userId: number, id: number): CollectionDetailRespo
WHERE cp.collection_id = ?
ORDER BY cp.sort_order, cp.created_at
`).all(id) as PlaceRow[];
return { collection: { ...collection, is_owner: collection.owner_id === userId }, places: hydratePlaces(rows) };
return {
collection: { ...collection, is_owner: collection.owner_id === userId, labels: loadLabelsByCollection(id) },
places: hydratePlaces(rows),
};
}
export function createCollection(userId: number, body: CollectionCreateRequest): Collection {
@@ -528,6 +556,11 @@ export function updatePlace(userId: number, placeId: number, body: import('@trek
attachTags(placeId, body.tag_ids);
}
// Labels are collection-scoped: a move invalidates the source list's labels;
// a provided label_ids set replaces them against the (target) collection.
if (movedTo) db.prepare('DELETE FROM collection_place_labels WHERE collection_place_id = ?').run(placeId);
if (body.label_ids !== undefined) setPlaceLabels(placeId, movedTo ?? currentCollection, body.label_ids);
notifyCollectionUsers(currentCollection, socketId, 'collections:updated');
if (movedTo) notifyCollectionUsers(movedTo, socketId, 'collections:updated');
return getPlaceById(placeId);
@@ -677,6 +710,106 @@ export function notifyCollectionUsers(collectionId: number, excludeSid: string |
userIds.forEach(id => broadcastToUser(id, { type: event, collectionId }, excludeSid));
}
// ---------------------------------------------------------------------------
// Labels — per-collection custom labels. Managing + assigning both require edit
// rights (owner/admin/editor); filtering is a read available to every member.
// ---------------------------------------------------------------------------
const MAX_LABELS_PER_COLLECTION = 50;
function collectionIdOfLabel(labelId: number): number {
const row = db.prepare('SELECT collection_id FROM collection_labels WHERE id = ?').get(labelId) as { collection_id: number } | undefined;
if (!row) httpError(404, 'Label not found');
return row.collection_id;
}
function getLabelById(labelId: number): CollectionLabel {
return db.prepare('SELECT id, collection_id, name, color, sort_order FROM collection_labels WHERE id = ?').get(labelId) as CollectionLabel;
}
/** Replace a place's label assignments, keeping only labels of `collectionId`. */
function setPlaceLabels(placeId: number, collectionId: number, labelIds: number[]): void {
db.prepare('DELETE FROM collection_place_labels WHERE collection_place_id = ?').run(placeId);
if (labelIds.length === 0) return;
const valid = new Set(loadLabelsByCollection(collectionId).map(l => l.id));
const stmt = db.prepare('INSERT OR IGNORE INTO collection_place_labels (collection_place_id, label_id) VALUES (?, ?)');
for (const id of labelIds) if (valid.has(id)) stmt.run(placeId, id);
}
export function createLabel(userId: number, collectionId: number, name: string, color?: string, socketId?: string): CollectionLabel {
assertCanEdit(userId, collectionId);
const trimmed = name.trim();
if (!trimmed) httpError(400, 'Label name is required');
const count = (db.prepare('SELECT COUNT(*) AS n FROM collection_labels WHERE collection_id = ?').get(collectionId) as { n: number }).n;
if (count >= MAX_LABELS_PER_COLLECTION) httpError(400, `A list can have at most ${MAX_LABELS_PER_COLLECTION} labels`);
if (db.prepare('SELECT 1 FROM collection_labels WHERE collection_id = ? AND lower(name) = lower(?)').get(collectionId, trimmed)) {
httpError(409, 'A label with this name already exists');
}
const nextSort = (db.prepare('SELECT COALESCE(MAX(sort_order), -1) AS m FROM collection_labels WHERE collection_id = ?').get(collectionId) as { m: number }).m + 1;
const res = db.prepare('INSERT INTO collection_labels (collection_id, name, color, sort_order) VALUES (?, ?, ?, ?)')
.run(collectionId, trimmed, color ?? '#6366f1', nextSort);
notifyCollectionUsers(collectionId, socketId, 'collections:updated');
return getLabelById(Number(res.lastInsertRowid));
}
export function updateLabel(userId: number, labelId: number, body: { name?: string; color?: string; sort_order?: number }, socketId?: string): CollectionLabel {
const collectionId = collectionIdOfLabel(labelId);
assertCanEdit(userId, collectionId);
const updates: string[] = [];
const params: (string | number)[] = [];
if (body.name !== undefined) {
const trimmed = body.name.trim();
if (!trimmed) httpError(400, 'Label name is required');
if (db.prepare('SELECT 1 FROM collection_labels WHERE collection_id = ? AND lower(name) = lower(?) AND id != ?').get(collectionId, trimmed, labelId)) {
httpError(409, 'A label with this name already exists');
}
updates.push('name = ?'); params.push(trimmed);
}
if (body.color !== undefined) { updates.push('color = ?'); params.push(body.color); }
if (body.sort_order !== undefined) { updates.push('sort_order = ?'); params.push(body.sort_order); }
if (updates.length > 0) {
params.push(labelId);
db.prepare(`UPDATE collection_labels SET ${updates.join(', ')} WHERE id = ?`).run(...params);
}
notifyCollectionUsers(collectionId, socketId, 'collections:updated');
return getLabelById(labelId);
}
export function deleteLabel(userId: number, labelId: number, socketId?: string): void {
const collectionId = collectionIdOfLabel(labelId);
assertCanEdit(userId, collectionId);
db.prepare('DELETE FROM collection_labels WHERE id = ?').run(labelId); // CASCADE clears place assignments
notifyCollectionUsers(collectionId, socketId, 'collections:updated');
}
/** Bulk add (or remove) one or more labels across a selection of places.
* Places are grouped by list so each list is permission-checked once, and only
* labels that belong to that list are applied. */
export function assignLabels(userId: number, labelIds: number[], placeIds: number[], remove: boolean, socketId?: string): { changed: number } {
const byCollection = new Map<number, number[]>();
for (const pid of placeIds) {
const cid = collectionIdOfPlace(pid);
if (!byCollection.has(cid)) byCollection.set(cid, []);
byCollection.get(cid)!.push(pid);
}
let changed = 0;
for (const [cid, pids] of byCollection) {
assertCanEdit(userId, cid);
const valid = new Set(loadLabelsByCollection(cid).map(l => l.id));
const applicable = labelIds.filter(id => valid.has(id));
if (applicable.length === 0) continue;
if (remove) {
const del = db.prepare('DELETE FROM collection_place_labels WHERE collection_place_id = ? AND label_id = ?');
for (const pid of pids) for (const lid of applicable) changed += del.run(pid, lid).changes;
} else {
const ins = db.prepare('INSERT OR IGNORE INTO collection_place_labels (collection_place_id, label_id) VALUES (?, ?)');
for (const pid of pids) for (const lid of applicable) changed += ins.run(pid, lid).changes;
}
notifyCollectionUsers(cid, socketId, 'collections:updated');
}
return { changed };
}
// ---------------------------------------------------------------------------
// Fusion invitations (mirror vacayService, dropping the one-fusion guards)
// ---------------------------------------------------------------------------
+33
View File
@@ -190,6 +190,39 @@ describe('Collections e2e (real auth guard + real service + temp SQLite)', () =>
expect(res.status).toBe(403); // visible (member) but not owner
});
// ── Labels ─────────────────────────────────────────────────────────────────
it('COLLECTIONS-E2E-060: a label route is addon-gated (404 when disabled)', async () => {
isAddonEnabled.mockReturnValue(false);
expect((await request(server).post('/api/addons/collections/labels').send({ collection_id: 1, name: 'X' })).status).toBe(404);
});
it('COLLECTIONS-E2E-061: create a label, assign it to a place, read it back on the detail', async () => {
const col = (await request(server).post('/api/addons/collections').set('Cookie', sessionCookie(ownerId)).send({ name: 'Germany' })).body;
const place = (await request(server).post('/api/addons/collections/places')
.set('Cookie', sessionCookie(ownerId)).send({ collection_id: col.id, name: 'Gate' })).body.place;
const label = await request(server).post('/api/addons/collections/labels')
.set('Cookie', sessionCookie(ownerId)).set('X-Socket-Id', 'sock-2').send({ collection_id: col.id, name: 'Berlin', color: '#ff0000' });
expect(label.status).toBe(200);
expect(label.body.name).toBe('Berlin');
const assign = await request(server).post('/api/addons/collections/labels/assign')
.set('Cookie', sessionCookie(ownerId)).send({ label_ids: [label.body.id], place_ids: [place.id] });
expect(assign.status).toBe(200);
expect(assign.body.changed).toBe(1);
const detail = await request(server).get(`/api/addons/collections/${col.id}`).set('Cookie', sessionCookie(ownerId));
expect(detail.body.collection.labels.map((l: { name: string }) => l.name)).toContain('Berlin');
expect(detail.body.places.find((p: { id: number }) => p.id === place.id).label_ids).toContain(label.body.id);
});
it('COLLECTIONS-E2E-062: a stranger cannot create a label on someone else’s list (404)', async () => {
const col = (await request(server).post('/api/addons/collections').set('Cookie', sessionCookie(ownerId)).send({ name: 'Secret' })).body;
const res = await request(server).post('/api/addons/collections/labels')
.set('Cookie', sessionCookie(otherId)).send({ collection_id: col.id, name: 'Nope' });
expect(res.status).toBe(404);
});
// ── delete ───────────────────────────────────────────────────────────────
it('COLLECTIONS-E2E-050: owner deletes; non-owner member cannot (403)', async () => {
const col = (await request(server).post('/api/addons/collections').set('Cookie', sessionCookie(ownerId)).send({ name: 'Doomed' })).body;
@@ -33,6 +33,10 @@ function makeService(overrides: Partial<Record<keyof CollectionsService, unknown
leaveCollection: vi.fn(),
removeMember: vi.fn(),
availableUsers: vi.fn().mockReturnValue([{ id: 2 }]),
createLabel: vi.fn().mockReturnValue({ id: 5, collection_id: 3, name: 'Berlin' }),
updateLabel: vi.fn().mockReturnValue({ id: 5, collection_id: 3, name: 'Hamburg' }),
deleteLabel: vi.fn(),
assignLabels: vi.fn().mockReturnValue({ changed: 2 }),
...overrides,
} as unknown as CollectionsService;
}
@@ -191,4 +195,26 @@ describe('CollectionsController', () => {
expect(svc.setCollectionCover).toHaveBeenCalledWith(1, 3, '/uploads/covers/x.jpg', 'sid');
});
});
describe('labels', () => {
it('create / update / delete / assign / unassign forward user + socket id', () => {
const svc = makeService();
const c = new CollectionsController(svc);
c.createLabel(user, { collection_id: 3, name: 'Berlin', color: '#ff0000' } as never, 'sock');
expect(svc.createLabel).toHaveBeenCalledWith(1, 3, 'Berlin', '#ff0000', 'sock');
c.updateLabel(user, '5', { name: 'Hamburg' } as never, 'sock');
expect(svc.updateLabel).toHaveBeenCalledWith(1, 5, { name: 'Hamburg' }, 'sock');
expect(c.deleteLabel(user, '5', 'sock')).toEqual({ success: true });
expect(svc.deleteLabel).toHaveBeenCalledWith(1, 5, 'sock');
c.assignLabels(user, { label_ids: [5], place_ids: [9, 10] } as never, 'sock');
expect(svc.assignLabels).toHaveBeenCalledWith(1, [5], [9, 10], false, 'sock');
c.unassignLabels(user, { label_ids: [5], place_ids: [9] } as never, 'sock');
expect(svc.assignLabels).toHaveBeenCalledWith(1, [5], [9], true, 'sock');
});
});
});
@@ -47,6 +47,8 @@ import { removeIfUnreferenced } from '../../../src/services/placePhotoCache';
function clearCollections() {
testDb.exec(`
DELETE FROM collection_place_labels;
DELETE FROM collection_labels;
DELETE FROM collection_place_tags;
DELETE FROM collection_places;
DELETE FROM collection_members;
@@ -482,3 +484,87 @@ describe('photo-cache widening', () => {
expect(meta).toBeUndefined();
});
});
// ── Labels ───────────────────────────────────────────────────────────────────
function addMember(colId: number, userId: number, role: 'viewer' | 'editor' | 'admin') {
testDb.prepare("INSERT INTO collection_members (collection_id, user_id, status, role) VALUES (?, ?, 'accepted', ?)").run(colId, userId, role);
}
describe('collection labels', () => {
it('COLLECTIONS-SVC-050: createLabel is returned by getCollection; duplicate name is 409', () => {
const u = createUser(testDb).user;
const col = svc.createCollection(u.id, { name: 'Germany' });
const label = svc.createLabel(u.id, col.id, 'Berlin', '#ff0000');
expect(label.name).toBe('Berlin');
expect(label.collection_id).toBe(col.id);
expect(svc.getCollection(u.id, col.id).collection.labels).toHaveLength(1);
expect(() => svc.createLabel(u.id, col.id, 'berlin')).toThrow(); // case-insensitive dup
try { svc.createLabel(u.id, col.id, 'berlin'); } catch (e) { expect((e as { status: number }).status).toBe(409); }
});
it('COLLECTIONS-SVC-051: a viewer cannot manage labels (403); an editor can', () => {
const owner = createUser(testDb).user;
const viewer = createUser(testDb).user;
const col = svc.createCollection(owner.id, { name: 'Trip' });
addMember(col.id, viewer.id, 'viewer');
try { svc.createLabel(viewer.id, col.id, 'X'); } catch (e) { expect((e as { status: number }).status).toBe(403); }
const editor = createUser(testDb).user;
addMember(col.id, editor.id, 'editor');
expect(svc.createLabel(editor.id, col.id, 'Museums').id).toBeGreaterThan(0);
});
it('COLLECTIONS-SVC-052: updatePlace label_ids sets labels; a label from another list is ignored', () => {
const u = createUser(testDb).user;
const col = svc.createCollection(u.id, { name: 'DE' });
const other = svc.createCollection(u.id, { name: 'Other' });
const l1 = svc.createLabel(u.id, col.id, 'Berlin');
const foreign = svc.createLabel(u.id, other.id, 'Paris');
const place = svc.savePlace(u.id, { collection_id: col.id, name: 'Gate' }).place!;
svc.updatePlace(u.id, place.id, { label_ids: [l1.id, foreign.id] });
const stored = svc.getCollection(u.id, col.id).places.find(p => p.id === place.id)!;
expect(stored.label_ids).toEqual([l1.id]);
});
it('COLLECTIONS-SVC-053: assignLabels bulk-adds then unassigns across places', () => {
const u = createUser(testDb).user;
const col = svc.createCollection(u.id, { name: 'DE' });
const l = svc.createLabel(u.id, col.id, 'Coast');
const p1 = svc.savePlace(u.id, { collection_id: col.id, name: 'A' }).place!;
const p2 = svc.savePlace(u.id, { collection_id: col.id, name: 'B' }).place!;
expect(svc.assignLabels(u.id, [l.id], [p1.id, p2.id], false).changed).toBe(2);
expect(svc.getCollection(u.id, col.id).places.every(p => p.label_ids?.includes(l.id))).toBe(true);
svc.assignLabels(u.id, [l.id], [p1.id], true);
const after = svc.getCollection(u.id, col.id).places;
expect(after.find(p => p.id === p1.id)!.label_ids).toEqual([]);
expect(after.find(p => p.id === p2.id)!.label_ids).toEqual([l.id]);
});
it('COLLECTIONS-SVC-054: deleteLabel removes it and cascades its place assignments', () => {
const u = createUser(testDb).user;
const col = svc.createCollection(u.id, { name: 'DE' });
const l = svc.createLabel(u.id, col.id, 'Berlin');
const p = svc.savePlace(u.id, { collection_id: col.id, name: 'Gate' }).place!;
svc.updatePlace(u.id, p.id, { label_ids: [l.id] });
svc.deleteLabel(u.id, l.id);
expect(svc.getCollection(u.id, col.id).collection.labels).toHaveLength(0);
expect(svc.getCollection(u.id, col.id).places.find(x => x.id === p.id)!.label_ids).toEqual([]);
});
it('COLLECTIONS-SVC-055: moving a place to another list drops its labels', () => {
const u = createUser(testDb).user;
const a = svc.createCollection(u.id, { name: 'A' });
const b = svc.createCollection(u.id, { name: 'B' });
const l = svc.createLabel(u.id, a.id, 'Berlin');
const p = svc.savePlace(u.id, { collection_id: a.id, name: 'Gate' }).place!;
svc.updatePlace(u.id, p.id, { label_ids: [l.id] });
svc.updatePlace(u.id, p.id, { collection_id: b.id });
expect(svc.getCollection(u.id, b.id).places.find(x => x.id === p.id)!.label_ids).toEqual([]);
});
});
@@ -22,6 +22,17 @@ export const collectionLinkSchema = z.object({
export type CollectionLink = z.infer<typeof collectionLinkSchema>;
export const collectionLinksSchema = z.array(collectionLinkSchema).max(30);
/** A custom label defined per-collection (distinct from the instance-wide tags).
* Members group and filter a list's places by these. */
export const collectionLabelSchema = z.object({
id: z.number(),
collection_id: z.number(),
name: z.string(),
color: z.string().nullable().optional(),
sort_order: z.number().optional(),
});
export type CollectionLabel = z.infer<typeof collectionLabelSchema>;
/** A saved place — assignmentPlace minus itinerary, plus status + provenance. */
export const collectionPlaceSchema = z.object({
id: z.number(),
@@ -52,6 +63,8 @@ export const collectionPlaceSchema = z.object({
links: collectionLinksSchema.optional(),
category: placeCategorySchema.optional(),
tags: z.array(tagSchema.partial()).optional(),
/** Ids of the per-collection labels assigned to this place. */
label_ids: z.array(z.number()).optional(),
});
export type CollectionPlace = z.infer<typeof collectionPlaceSchema>;
@@ -81,6 +94,7 @@ export const collectionSchema = z.object({
place_count: z.number().optional(),
is_owner: z.boolean().optional(),
members: z.array(collectionMemberSchema).optional(),
labels: z.array(collectionLabelSchema).optional(),
created_at: z.string().optional(),
updated_at: z.string().optional(),
});
@@ -156,6 +170,8 @@ export const collectionPlaceUpdateRequestSchema = z.object({
collection_id: z.number().optional(), // move to another list
links: collectionLinksSchema.optional(),
tag_ids: z.array(z.number()).optional(),
// Replace the place's per-collection label assignments (omit to leave unchanged).
label_ids: z.array(z.number()).optional(),
});
export type CollectionPlaceUpdateRequest = z.infer<typeof collectionPlaceUpdateRequestSchema>;
@@ -202,6 +218,32 @@ export const collectionSetMemberRoleRequestSchema = z.object({
});
export type CollectionSetMemberRoleRequest = z.infer<typeof collectionSetMemberRoleRequestSchema>;
// ── Labels ──────────────────────────────────────────────────────────────────
const labelColorSchema = z.string().regex(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/);
/** Create a custom label in a list. */
export const collectionLabelCreateRequestSchema = z.object({
collection_id: z.number(),
name: z.string().trim().min(1).max(60),
color: labelColorSchema.optional(),
});
export type CollectionLabelCreateRequest = z.infer<typeof collectionLabelCreateRequestSchema>;
/** Rename / recolor a label (all fields optional). */
export const collectionLabelUpdateRequestSchema = z.object({
name: z.string().trim().min(1).max(60).optional(),
color: labelColorSchema.optional(),
sort_order: z.number().optional(),
});
export type CollectionLabelUpdateRequest = z.infer<typeof collectionLabelUpdateRequestSchema>;
/** Bulk add or remove one/several labels across many selected places. */
export const collectionLabelAssignRequestSchema = z.object({
label_ids: z.array(z.number()).min(1).max(50),
place_ids: z.array(z.number()).min(1).max(1000),
});
export type CollectionLabelAssignRequest = z.infer<typeof collectionLabelAssignRequestSchema>;
// ── Responses ─────────────────────────────────────────────────────────────
export const collectionListResponseSchema = z.object({
collections: z.array(collectionSchema),
+10
View File
@@ -144,6 +144,16 @@ const collection: TranslationStrings = {
'collections.widget.title': 'المجموعات',
'collections.widget.empty': 'لا توجد أماكن محفوظة بعد',
'collections.widget.savedCount': '{count} محفوظة',
'collections.labels.title': 'التسميات',
'collections.labels.manage': 'إدارة التسميات',
'collections.labels.add': 'إضافة تسمية',
'collections.labels.name': 'اسم التسمية',
'collections.labels.namePlaceholder': 'مثال: برلين',
'collections.labels.assign': 'تعيين تسمية',
'collections.labels.assignN': 'إضافة تسميات إلى {count} أماكن',
'collections.labels.assignedCount': 'تمت تسمية {count} أماكن',
'collections.labels.empty': 'لا توجد تسميات بعد',
'collections.labels.emptyHint': 'أنشئ تسمية أولًا لتجميع الأماكن في هذه القائمة.',
};
export default collection;
+10
View File
@@ -144,6 +144,16 @@ const collection: TranslationStrings = {
'collections.widget.title': 'Coleções',
'collections.widget.empty': 'Nenhum lugar salvo ainda',
'collections.widget.savedCount': '{count} salvos',
'collections.labels.title': 'Rótulos',
'collections.labels.manage': 'Gerenciar rótulos',
'collections.labels.add': 'Adicionar rótulo',
'collections.labels.name': 'Nome do rótulo',
'collections.labels.namePlaceholder': 'ex.: Berlin',
'collections.labels.assign': 'Atribuir rótulo',
'collections.labels.assignN': 'Adicionar rótulos a {count} lugares',
'collections.labels.assignedCount': '{count} lugares rotulados',
'collections.labels.empty': 'Nenhum rótulo ainda',
'collections.labels.emptyHint': 'Crie um rótulo primeiro para agrupar lugares nesta lista.',
};
export default collection;
+10
View File
@@ -144,6 +144,16 @@ const collection: TranslationStrings = {
'collections.widget.title': 'Sbírky',
'collections.widget.empty': 'Zatím žádná uložená místa',
'collections.widget.savedCount': 'Uloženo: {count}',
'collections.labels.title': 'Štítky',
'collections.labels.manage': 'Spravovat štítky',
'collections.labels.add': 'Přidat štítek',
'collections.labels.name': 'Název štítku',
'collections.labels.namePlaceholder': 'např. Berlin',
'collections.labels.assign': 'Přiřadit štítek',
'collections.labels.assignN': 'Přidat štítky k {count} místům',
'collections.labels.assignedCount': 'Označeno {count} míst',
'collections.labels.empty': 'Zatím žádné štítky',
'collections.labels.emptyHint': 'Nejprve vytvořte štítek, abyste mohli seskupit místa v tomto seznamu.',
};
export default collection;
+10
View File
@@ -144,6 +144,16 @@ const collection: TranslationStrings = {
'collections.widget.title': 'Sammlungen',
'collections.widget.empty': 'Noch keine gespeicherten Orte',
'collections.widget.savedCount': '{count} gespeichert',
'collections.labels.title': 'Labels',
'collections.labels.manage': 'Labels verwalten',
'collections.labels.add': 'Label hinzufügen',
'collections.labels.name': 'Labelname',
'collections.labels.namePlaceholder': 'z. B. Berlin',
'collections.labels.assign': 'Label zuweisen',
'collections.labels.assignN': 'Labels zu {count} Orten hinzufügen',
'collections.labels.assignedCount': '{count} Orte gelabelt',
'collections.labels.empty': 'Noch keine Labels',
'collections.labels.emptyHint': 'Erstelle zuerst ein Label, um Orte in dieser Liste zu gruppieren.',
};
export default collection;
+11
View File
@@ -144,6 +144,17 @@ const collection: TranslationStrings = {
'collections.widget.title': 'Collections',
'collections.widget.empty': 'No saved places yet',
'collections.widget.savedCount': '{count} saved',
'collections.labels.title': 'Labels',
'collections.labels.manage': 'Manage labels',
'collections.labels.add': 'Add label',
'collections.labels.name': 'Label name',
'collections.labels.namePlaceholder': 'e.g. Berlin',
'collections.labels.assign': 'Assign label',
'collections.labels.assignN': 'Add labels to {count} places',
'collections.labels.assignedCount': 'Labelled {count} places',
'collections.labels.empty': 'No labels yet',
'collections.labels.emptyHint': 'Create a label first to group places in this list.',
};
export default collection;
+10
View File
@@ -144,6 +144,16 @@ const collection: TranslationStrings = {
'collections.widget.title': 'Colecciones',
'collections.widget.empty': 'Aún no hay lugares guardados',
'collections.widget.savedCount': '{count} guardados',
'collections.labels.title': 'Etiquetas',
'collections.labels.manage': 'Gestionar etiquetas',
'collections.labels.add': 'Añadir etiqueta',
'collections.labels.name': 'Nombre de la etiqueta',
'collections.labels.namePlaceholder': 'p. ej. Berlin',
'collections.labels.assign': 'Asignar etiqueta',
'collections.labels.assignN': 'Añadir etiquetas a {count} lugares',
'collections.labels.assignedCount': 'Etiquetados {count} lugares',
'collections.labels.empty': 'Aún no hay etiquetas',
'collections.labels.emptyHint': 'Crea primero una etiqueta para agrupar lugares en esta lista.',
};
export default collection;
+10
View File
@@ -144,6 +144,16 @@ const collection: TranslationStrings = {
'collections.widget.title': 'Collections',
'collections.widget.empty': 'Aucun lieu enregistré pour le moment',
'collections.widget.savedCount': '{count} enregistrés',
'collections.labels.title': 'Libellés',
'collections.labels.manage': 'Gérer les libellés',
'collections.labels.add': 'Ajouter un libellé',
'collections.labels.name': 'Nom du libellé',
'collections.labels.namePlaceholder': 'p. ex. Berlin',
'collections.labels.assign': 'Attribuer un libellé',
'collections.labels.assignN': 'Ajouter des libellés à {count} lieux',
'collections.labels.assignedCount': 'Libellés ajoutés à {count} lieux',
'collections.labels.empty': 'Aucun libellé pour le moment',
'collections.labels.emptyHint': 'Créez d’abord un libellé pour regrouper les lieux de cette liste.',
};
export default collection;
+11
View File
@@ -144,6 +144,17 @@ const collection: TranslationStrings = {
'collections.widget.title': 'Συλλογές',
'collections.widget.empty': 'Δεν υπάρχουν αποθηκευμένα μέρη ακόμα',
'collections.widget.savedCount': '{count} αποθηκευμένα',
'collections.labels.title': 'Ετικέτες',
'collections.labels.manage': 'Διαχείριση ετικετών',
'collections.labels.add': 'Προσθήκη ετικέτας',
'collections.labels.name': 'Όνομα ετικέτας',
'collections.labels.namePlaceholder': 'π.χ. Berlin',
'collections.labels.assign': 'Αντιστοίχιση ετικέτας',
'collections.labels.assignN': 'Προσθήκη ετικετών σε {count} μέρη',
'collections.labels.assignedCount': 'Προστέθηκαν ετικέτες σε {count} μέρη',
'collections.labels.empty': 'Δεν υπάρχουν ετικέτες ακόμα',
'collections.labels.emptyHint': 'Δημιούργησε πρώτα μια ετικέτα για να ομαδοποιήσεις μέρη σε αυτή τη λίστα.',
};
export default collection;
+10
View File
@@ -144,6 +144,16 @@ const collection: TranslationStrings = {
'collections.widget.title': 'Gyűjtemények',
'collections.widget.empty': 'Még nincsenek mentett helyek',
'collections.widget.savedCount': '{count} mentve',
'collections.labels.title': 'Címkék',
'collections.labels.manage': 'Címkék kezelése',
'collections.labels.add': 'Címke hozzáadása',
'collections.labels.name': 'Címke neve',
'collections.labels.namePlaceholder': 'pl. Berlin',
'collections.labels.assign': 'Címke hozzárendelése',
'collections.labels.assignN': 'Címkék hozzáadása {count} helyhez',
'collections.labels.assignedCount': '{count} hely felcímkézve',
'collections.labels.empty': 'Még nincsenek címkék',
'collections.labels.emptyHint': 'Előbb hozz létre egy címkét a helyek csoportosításához ebben a listában.',
};
export default collection;
+10
View File
@@ -144,6 +144,16 @@ const collection: TranslationStrings = {
'collections.widget.title': 'Koleksi',
'collections.widget.empty': 'Belum ada tempat tersimpan',
'collections.widget.savedCount': '{count} tersimpan',
'collections.labels.title': 'Label',
'collections.labels.manage': 'Kelola label',
'collections.labels.add': 'Tambah label',
'collections.labels.name': 'Nama label',
'collections.labels.namePlaceholder': 'mis. Berlin',
'collections.labels.assign': 'Beri label',
'collections.labels.assignN': 'Beri label ke {count} tempat',
'collections.labels.assignedCount': '{count} tempat diberi label',
'collections.labels.empty': 'Belum ada label',
'collections.labels.emptyHint': 'Buat label dulu untuk mengelompokkan tempat di daftar ini.',
};
export default collection;
+11
View File
@@ -144,6 +144,17 @@ const collection: TranslationStrings = {
'collections.widget.title': 'Raccolte',
'collections.widget.empty': 'Ancora nessun luogo salvato',
'collections.widget.savedCount': '{count} salvati',
'collections.labels.title': 'Etichette',
'collections.labels.manage': 'Gestisci etichette',
'collections.labels.add': 'Aggiungi etichetta',
'collections.labels.name': 'Nome etichetta',
'collections.labels.namePlaceholder': 'es. Berlin',
'collections.labels.assign': 'Assegna etichetta',
'collections.labels.assignN': 'Aggiungi etichette a {count} luoghi',
'collections.labels.assignedCount': 'Etichettati {count} luoghi',
'collections.labels.empty': 'Ancora nessuna etichetta',
'collections.labels.emptyHint': 'Crea prima un\'etichetta per raggruppare i luoghi in questa lista.',
};
export default collection;
+10
View File
@@ -144,6 +144,16 @@ const collection: TranslationStrings = {
'collections.widget.title': 'コレクション',
'collections.widget.empty': 'まだ保存した場所がありません',
'collections.widget.savedCount': '{count}件保存済み',
'collections.labels.title': 'ラベル',
'collections.labels.manage': 'ラベルを管理',
'collections.labels.add': 'ラベルを追加',
'collections.labels.name': 'ラベル名',
'collections.labels.namePlaceholder': '例: Berlin',
'collections.labels.assign': 'ラベルを割り当て',
'collections.labels.assignN': '{count} 件の場所にラベルを追加',
'collections.labels.assignedCount': '{count} 件の場所にラベルを付けました',
'collections.labels.empty': 'ラベルがまだありません',
'collections.labels.emptyHint': 'まずラベルを作成して、このリスト内の場所をグループ化しましょう。',
};
export default collection;
+10
View File
@@ -144,6 +144,16 @@ const collection: TranslationStrings = {
'collections.widget.title': '컬렉션',
'collections.widget.empty': '아직 저장한 장소가 없습니다',
'collections.widget.savedCount': '{count}개 저장됨',
'collections.labels.title': '레이블',
'collections.labels.manage': '레이블 관리',
'collections.labels.add': '레이블 추가',
'collections.labels.name': '레이블 이름',
'collections.labels.namePlaceholder': '예: Berlin',
'collections.labels.assign': '레이블 지정',
'collections.labels.assignN': '{count}개 장소에 레이블 추가',
'collections.labels.assignedCount': '{count}개 장소에 레이블 지정됨',
'collections.labels.empty': '아직 레이블이 없습니다',
'collections.labels.emptyHint': '먼저 레이블을 만들어 이 목록의 장소를 그룹화하세요.',
};
export default collection;
+10
View File
@@ -144,6 +144,16 @@ const collection: TranslationStrings = {
'collections.widget.title': 'Collecties',
'collections.widget.empty': 'Nog geen opgeslagen plekken',
'collections.widget.savedCount': '{count} opgeslagen',
'collections.labels.title': 'Labels',
'collections.labels.manage': 'Labels beheren',
'collections.labels.add': 'Label toevoegen',
'collections.labels.name': 'Labelnaam',
'collections.labels.namePlaceholder': 'bijv. Berlin',
'collections.labels.assign': 'Label toewijzen',
'collections.labels.assignN': 'Labels toevoegen aan {count} plaatsen',
'collections.labels.assignedCount': '{count} plaatsen gelabeld',
'collections.labels.empty': 'Nog geen labels',
'collections.labels.emptyHint': 'Maak eerst een label om plekken in deze lijst te groeperen.',
};
export default collection;
+10
View File
@@ -144,6 +144,16 @@ const collection: TranslationStrings = {
'collections.widget.title': 'Kolekcje',
'collections.widget.empty': 'Brak zapisanych miejsc',
'collections.widget.savedCount': '{count} zapisanych',
'collections.labels.title': 'Etykiety',
'collections.labels.manage': 'Zarządzaj etykietami',
'collections.labels.add': 'Dodaj etykietę',
'collections.labels.name': 'Nazwa etykiety',
'collections.labels.namePlaceholder': 'np. Berlin',
'collections.labels.assign': 'Przypisz etykietę',
'collections.labels.assignN': 'Dodaj etykiety do {count} miejsc',
'collections.labels.assignedCount': 'Oznaczono {count} miejsc',
'collections.labels.empty': 'Brak etykiet',
'collections.labels.emptyHint': 'Najpierw utwórz etykietę, aby pogrupować miejsca na tej liście.',
};
export default collection;
+10
View File
@@ -144,6 +144,16 @@ const collection: TranslationStrings = {
'collections.widget.title': 'Коллекции',
'collections.widget.empty': 'Пока нет сохранённых мест',
'collections.widget.savedCount': 'Сохранено: {count}',
'collections.labels.title': 'Метки',
'collections.labels.manage': 'Управление метками',
'collections.labels.add': 'Добавить метку',
'collections.labels.name': 'Название метки',
'collections.labels.namePlaceholder': 'например, Berlin',
'collections.labels.assign': 'Назначить метку',
'collections.labels.assignN': 'Добавить метки к {count} местам',
'collections.labels.assignedCount': 'Отмечено мест: {count}',
'collections.labels.empty': 'Пока нет меток',
'collections.labels.emptyHint': 'Сначала создайте метку, чтобы группировать места в этом списке.',
};
export default collection;
+10
View File
@@ -144,6 +144,16 @@ const collection: TranslationStrings = {
'collections.widget.title': 'Samlingar',
'collections.widget.empty': 'Inga sparade platser ännu',
'collections.widget.savedCount': '{count} sparade',
'collections.labels.title': 'Etiketter',
'collections.labels.manage': 'Hantera etiketter',
'collections.labels.add': 'Lägg till etikett',
'collections.labels.name': 'Etikettnamn',
'collections.labels.namePlaceholder': 't.ex. Berlin',
'collections.labels.assign': 'Tilldela etikett',
'collections.labels.assignN': 'Lägg till etiketter på {count} platser',
'collections.labels.assignedCount': 'Etiketterade {count} platser',
'collections.labels.empty': 'Inga etiketter ännu',
'collections.labels.emptyHint': 'Skapa en etikett först för att gruppera platser i den här listan.',
};
export default collection;
+10
View File
@@ -144,6 +144,16 @@ const collection: TranslationStrings = {
'collections.widget.title': 'Koleksiyonlar',
'collections.widget.empty': 'Henüz kaydedilmiş yer yok',
'collections.widget.savedCount': '{count} kayıtlı',
'collections.labels.title': 'Etiketler',
'collections.labels.manage': 'Etiketleri yönet',
'collections.labels.add': 'Etiket ekle',
'collections.labels.name': 'Etiket adı',
'collections.labels.namePlaceholder': 'örn. Berlin',
'collections.labels.assign': 'Etiket ata',
'collections.labels.assignN': '{count} yere etiket ekle',
'collections.labels.assignedCount': '{count} yer etiketlendi',
'collections.labels.empty': 'Henüz etiket yok',
'collections.labels.emptyHint': 'Bu listedeki yerleri gruplamak için önce bir etiket oluştur.',
};
export default collection;
+10
View File
@@ -144,6 +144,16 @@ const collection: TranslationStrings = {
'collections.widget.title': 'Колекції',
'collections.widget.empty': 'Поки немає збережених місць',
'collections.widget.savedCount': 'Збережено: {count}',
'collections.labels.title': 'Мітки',
'collections.labels.manage': 'Керувати мітками',
'collections.labels.add': 'Додати мітку',
'collections.labels.name': 'Назва мітки',
'collections.labels.namePlaceholder': 'напр., Berlin',
'collections.labels.assign': 'Призначити мітку',
'collections.labels.assignN': 'Додати мітки до {count} місць',
'collections.labels.assignedCount': 'Позначено місць: {count}',
'collections.labels.empty': 'Поки немає міток',
'collections.labels.emptyHint': 'Спершу створіть мітку, щоб групувати місця в цьому списку.',
};
export default collection;
+10
View File
@@ -144,6 +144,16 @@ const collection: TranslationStrings = {
'collections.widget.title': 'Bộ sưu tập',
'collections.widget.empty': 'Chưa có địa điểm đã lưu',
'collections.widget.savedCount': 'Đã lưu {count}',
'collections.labels.title': 'Nhãn',
'collections.labels.manage': 'Quản lý nhãn',
'collections.labels.add': 'Thêm nhãn',
'collections.labels.name': 'Tên nhãn',
'collections.labels.namePlaceholder': 'vd. Berlin',
'collections.labels.assign': 'Gán nhãn',
'collections.labels.assignN': 'Thêm nhãn cho {count} địa điểm',
'collections.labels.assignedCount': 'Đã gán nhãn {count} địa điểm',
'collections.labels.empty': 'Chưa có nhãn nào',
'collections.labels.emptyHint': 'Tạo một nhãn trước để nhóm các địa điểm trong danh sách này.',
};
export default collection;
+11
View File
@@ -144,6 +144,17 @@ const collection: TranslationStrings = {
'collections.widget.title': '收藏',
'collections.widget.empty': '尚無已儲存的地點',
'collections.widget.savedCount': '已儲存 {count} 個',
'collections.labels.title': '標籤',
'collections.labels.manage': '管理標籤',
'collections.labels.add': '新增標籤',
'collections.labels.name': '標籤名稱',
'collections.labels.namePlaceholder': '例如:柏林',
'collections.labels.assign': '指派標籤',
'collections.labels.assignN': '為 {count} 個地點新增標籤',
'collections.labels.assignedCount': '已為 {count} 個地點加上標籤',
'collections.labels.empty': '尚無標籤',
'collections.labels.emptyHint': '請先建立標籤,以將此清單中的地點分組。',
};
export default collection;
+11
View File
@@ -144,6 +144,17 @@ const collection: TranslationStrings = {
'collections.widget.title': '收藏',
'collections.widget.empty': '还没有保存的地点',
'collections.widget.savedCount': '已保存 {count} 个',
'collections.labels.title': '标签',
'collections.labels.manage': '管理标签',
'collections.labels.add': '添加标签',
'collections.labels.name': '标签名称',
'collections.labels.namePlaceholder': '例如:Berlin',
'collections.labels.assign': '分配标签',
'collections.labels.assignN': '为 {count} 个地点添加标签',
'collections.labels.assignedCount': '已为 {count} 个地点添加标签',
'collections.labels.empty': '还没有标签',
'collections.labels.emptyHint': '请先创建一个标签来对此列表中的地点分组。',
};
export default collection;