From a5522e99d87b0d1d7e11951a73b37bad42139727 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 5 Jul 2026 00:19:51 +0200 Subject: [PATCH] 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. --- client/src/api/collections.ts | 14 ++ .../Collections/BulkAssignLabelModal.tsx | 90 ++++++++++++ .../Collections/CollectionFilterBar.test.tsx | 6 + .../Collections/CollectionFilterBar.tsx | 39 ++++- .../Collections/CollectionList.test.tsx | 1 + .../components/Collections/CollectionList.tsx | 15 +- .../Collections/CollectionPlaceDetail.tsx | 43 +++++- .../components/Collections/LabelManager.tsx | 139 ++++++++++++++++++ client/src/pages/CollectionsPage.tsx | 46 +++++- .../collections/collectionsModel.test.ts | 45 ++++++ .../src/pages/collections/collectionsModel.ts | 23 ++- .../src/pages/collections/useCollections.ts | 60 +++++++- client/src/store/collectionStore.ts | 68 ++++++++- client/src/styles/collections.css | 14 ++ server/src/db/migrations.ts | 21 +++ server/src/db/schema.ts | 21 +++ .../collections/collections.controller.ts | 53 +++++++ .../nest/collections/collections.service.ts | 5 + server/src/services/collectionsService.ts | 135 ++++++++++++++++- server/tests/e2e/collections.e2e.test.ts | 33 +++++ .../unit/nest/collections.controller.test.ts | 26 ++++ .../unit/services/collectionsService.test.ts | 86 +++++++++++ shared/src/collection/collection.schema.ts | 42 ++++++ shared/src/i18n/ar/collection.ts | 10 ++ shared/src/i18n/br/collection.ts | 10 ++ shared/src/i18n/cs/collection.ts | 10 ++ shared/src/i18n/de/collection.ts | 10 ++ shared/src/i18n/en/collection.ts | 11 ++ shared/src/i18n/es/collection.ts | 10 ++ shared/src/i18n/fr/collection.ts | 10 ++ shared/src/i18n/gr/collection.ts | 11 ++ shared/src/i18n/hu/collection.ts | 10 ++ shared/src/i18n/id/collection.ts | 10 ++ shared/src/i18n/it/collection.ts | 11 ++ shared/src/i18n/ja/collection.ts | 10 ++ shared/src/i18n/ko/collection.ts | 10 ++ shared/src/i18n/nl/collection.ts | 10 ++ shared/src/i18n/pl/collection.ts | 10 ++ shared/src/i18n/ru/collection.ts | 10 ++ shared/src/i18n/sv/collection.ts | 10 ++ shared/src/i18n/tr/collection.ts | 10 ++ shared/src/i18n/uk/collection.ts | 10 ++ shared/src/i18n/vi/collection.ts | 10 ++ shared/src/i18n/zh-TW/collection.ts | 11 ++ shared/src/i18n/zh/collection.ts | 11 ++ 45 files changed, 1224 insertions(+), 26 deletions(-) create mode 100644 client/src/components/Collections/BulkAssignLabelModal.tsx create mode 100644 client/src/components/Collections/LabelManager.tsx diff --git a/client/src/api/collections.ts b/client/src/api/collections.ts index 62713ad6c..f16ae4dbb 100644 --- a/client/src/api/collections.ts +++ b/client/src/api/collections.ts @@ -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 => + ax.post(`${base}/labels`, { collection_id: collectionId, name, color } satisfies CollectionLabelCreateRequest).then((r: AxiosResponse) => r.data), + updateLabel: (labelId: number, body: CollectionLabelUpdateRequest): Promise => + ax.patch(`${base}/labels/${labelId}`, body satisfies CollectionLabelUpdateRequest).then((r: AxiosResponse) => r.data), + deleteLabel: (labelId: number): Promise => + 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), } diff --git a/client/src/components/Collections/BulkAssignLabelModal.tsx b/client/src/components/Collections/BulkAssignLabelModal.tsx new file mode 100644 index 000000000..2e05368c7 --- /dev/null +++ b/client/src/components/Collections/BulkAssignLabelModal.tsx @@ -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 + /** 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([]) + 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 ( + + {labels.length === 0 ? ( +
+ +

{t('collections.labels.emptyHint')}

+ +
+ ) : ( +
+
+ {labels.map(l => { + const on = picked.includes(l.id) + return ( + + ) + })} +
+
+ + + +
+
+ )} +
+ ) +} diff --git a/client/src/components/Collections/CollectionFilterBar.test.tsx b/client/src/components/Collections/CollectionFilterBar.test.tsx index 3f3182b3b..87f5b769a 100644 --- a/client/src/components/Collections/CollectionFilterBar.test.tsx +++ b/client/src/components/Collections/CollectionFilterBar.test.tsx @@ -29,6 +29,12 @@ function makeProps(overrides: Partial = {}): 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(), diff --git a/client/src/components/Collections/CollectionFilterBar.tsx b/client/src/components/Collections/CollectionFilterBar.tsx index 809cf1fa9..e0a093caf 100644 --- a/client/src/components/Collections/CollectionFilterBar.tsx +++ b/client/src/components/Collections/CollectionFilterBar.tsx @@ -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({ {t('collections.select')} )} + {showLabels && (labelOptions.length > 0 || canManageLabels) && ( +
+ {labelOptions.map(l => { + const on = labelFilter.includes(l.id) + return ( + + ) + })} + {canManageLabels && ( + + )} +
+ )} ) } diff --git a/client/src/components/Collections/CollectionList.test.tsx b/client/src/components/Collections/CollectionList.test.tsx index 8535a7547..b56114655 100644 --- a/client/src/components/Collections/CollectionList.test.tsx +++ b/client/src/components/Collections/CollectionList.test.tsx @@ -65,6 +65,7 @@ function renderList(over: Partial<{ render( 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(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 (
+ {placeLabels.slice(0, 2).map(l => ( + + {l.name} + + ))} + {placeLabels.length > 2 && l.name).join(', ')}>+{placeLabels.length - 2}} {place.category?.name && (() => { const CatIcon = getCategoryIcon(place.category.icon ?? undefined) return ( diff --git a/client/src/components/Collections/CollectionPlaceDetail.tsx b/client/src/components/Collections/CollectionPlaceDetail.tsx index 1df880d7b..23354dca5 100644 --- a/client/src/components/Collections/CollectionPlaceDetail.tsx +++ b/client/src/components/Collections/CollectionPlaceDetail.tsx @@ -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 + onSave: (patch: { name?: string; description?: string | null; links?: CollectionLink[]; category_id?: number | null; label_ids?: number[] }) => Promise 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(place.category_id ?? null) const [description, setDescription] = useState(place.description ?? '') const [links, setLinks] = useState(place.links ?? []) + const [labelIds, setLabelIds] = useState(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) => 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({ })}
+ {/* Labels */} + {labels.length > 0 && ( +
+
{t('collections.labels.title')}
+
+ {labels.map(l => { + const on = labelIds.includes(l.id) + return ( + + ) + })} +
+
+ )} {/* Description */}
{t('collections.description')}
@@ -184,6 +206,15 @@ export default function CollectionPlaceDetail({
) : ( <> + {assignedLabels.length > 0 && ( +
+ {assignedLabels.map(l => ( + + {l.name} + + ))} +
+ )} {place.description && (
{place.description} diff --git a/client/src/components/Collections/LabelManager.tsx b/client/src/components/Collections/LabelManager.tsx new file mode 100644 index 000000000..d64eed9d6 --- /dev/null +++ b/client/src/components/Collections/LabelManager.tsx @@ -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 + onUpdate: (labelId: number, body: CollectionLabelUpdateRequest) => Promise | void + onDelete: (labelId: number) => Promise | 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 ( +
+ {SWATCHES.map(c => ( +
+ ) +} + +/** 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 ( +
+ + 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')} + /> + + {busy && } + +
+ ) +} + +/** + * 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 ( + +
+ {labels.length === 0 ? ( +

{t('collections.labels.empty')}

+ ) : ( +
+ {labels.map(l => )} +
+ )} + +
+
+ + 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" + /> + +
+ +
+
+
+ ) +} diff --git a/client/src/pages/CollectionsPage.tsx b/client/src/pages/CollectionsPage.tsx index 2b17d7816..12726121f 100644 --- a/client/src/pages/CollectionsPage.tsx +++ b/client/src/pages/CollectionsPage.tsx @@ -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 = ( c.setShowLabelManager(true)} showSelect={showSelect} selectMode={c.selectMode} onToggleSelect={() => c.setSelectMode(!c.selectMode)} @@ -231,6 +243,11 @@ export default function CollectionsPage(): React.ReactElement { {t('collections.selectedCount', { count: c.selectedIds.length })}
+ {c.canEdit && isRealList && ( + + )} {c.canEdit && (