Add label UI: tree management, page assignment, and sidebar filter (#44)
All checks were successful
CD / Build and push images (push) Successful in 2m53s
CI / Lint, typecheck, test (push) Successful in 2m8s
CI / Auth e2e pack (push) Successful in 2m31s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m13s
CD / Promote to Int (push) Successful in 11s

Build the M4 label experience on top of the #43 label API.

- shared: `flattenLabelTree` (tree → depth-first list) for chip lookup,
  filtering, and the picker; `PageListItemView` adds each page's `labelIds`
  to the sidebar list response.
- api: `GET /ponds/:id/pages` now includes `labelIds` per page (one grouped
  query), so the sidebar can render chips and filter without extra calls.
- web:
  - Pond settings page (`/p/:pondSlug/settings`) with a `LabelManager`
    tree: inline create, rename, recolour (`<input type=color>`), move via a
    parent picker that excludes the label's own subtree, and delete that
    confirms then force-detaches assigned pages. Every control is a native
    button/input/select — the tree is fully keyboard-operable.
  - `LabelPicker` panel on the page editor: searchable, hierarchy-indented
    multi-select that assigns/unassigns immediately and refreshes the page's
    labels and the sidebar.
  - Sidebar: colored label chips on page entries (readable text via a
    luminance-based contrast helper) and a descendant-inclusive label filter
    (selecting a parent matches pages tagged with its children, via the
    shared `collectSubtreeIds`). Owner link to pond settings.
  - i18n `labels` namespace (de + en).
- e2e `labels.spec.ts` (new CI pack): full lifecycle from the settings UI
  and picker-assign + parent-filter-includes-child. Selectors are
  language-independent because the UI language follows the user's locale.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
This commit is contained in:
Claude Opus 4.8 2026-07-09 11:41:42 +02:00
parent a3a012c41d
commit 03e72242d3
21 changed files with 1211 additions and 11 deletions

View File

@ -164,6 +164,16 @@ jobs:
E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/offline.spec.ts
- name: Reset login rate limit before labels pack
run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
- name: Run labels pack
run: |
E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/labels.spec.ts
- name: Dump server logs on failure
if: failure()
run: tail -50 /tmp/api.log /tmp/collab.log /tmp/web.log || true

View File

@ -15,6 +15,7 @@ import {
import {
CollabTokenResponse,
CreatePageInput,
PageListItemView,
PageStateView,
PageView,
UpdatePageInput,
@ -42,7 +43,10 @@ export class PagesController {
}
@Get('ponds/:pondId/pages')
async list(@Param('pondId') pondId: string, @Req() request: AuthedRequest): Promise<PageView[]> {
async list(
@Param('pondId') pondId: string,
@Req() request: AuthedRequest,
): Promise<PageListItemView[]> {
return this.pages.list(request.user!, pondId);
}

View File

@ -2,6 +2,7 @@ import { ConflictException, Injectable, NotFoundException } from '@nestjs/common
import {
CollabTokenResponse,
CreatePageInput,
PageListItemView,
PageStateView,
PageView,
SidebarSortMode,
@ -110,16 +111,21 @@ export class PagesService {
manual: { sortKey: 'asc' },
};
/** Sidebar page list, ordered per the pond's persisted sort mode (issue #26). */
async list(user: User, pondId: string): Promise<PageView[]> {
/** Sidebar page list, ordered per the pond's persisted sort mode (issue #26),
* each with its assigned label ids for chips and filtering (issue #44). */
async list(user: User, pondId: string): Promise<PageListItemView[]> {
const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } });
this.access.assertCanSee(user, pond);
const settings = pondSettingsSchema.parse(pond.settings ?? {});
const pages = await this.prisma.page.findMany({
where: { pondId, deletedAt: null },
orderBy: PagesService.SORT_ORDER[settings.sidebarSort],
include: { labels: { select: { labelId: true } } },
});
return pages.map((page) => this.viewOf(page));
return pages.map((page) => ({
...this.viewOf(page),
labelIds: page.labels.map((l) => l.labelId),
}));
}
async create(user: User, pondId: string, input: CreatePageInput): Promise<PageView> {

139
apps/web/e2e/labels.spec.ts Normal file
View File

@ -0,0 +1,139 @@
import { expect, test } from '@playwright/test';
import { contextForUser } from './helpers';
/**
* Label UI pack (issue #44). Runs against the local dev stack (api + web).
* Selectors are language-independent (CSS classes + label names) because the
* UI language follows the signed-in user's profile locale, not the browser
* so text-based selectors would be locale-dependent. Creates uniquely-named
* labels/pages per test and removes the labels via the api afterwards so the
* shared fixture pond does not accumulate them.
*/
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
type Ctx = Awaited<ReturnType<typeof contextForUser>>;
async function personalPond(context: Ctx): Promise<{ id: string; slug: string }> {
const ponds = await context.request.get('/api/v1/ponds');
const pond = (await ponds.json()).find((p: { type: string }) => p.type === 'personal');
return { id: pond.id, slug: pond.slug };
}
async function createPage(
context: Ctx,
pondId: string,
title: string,
): Promise<{ id: string; slug: string }> {
const created = await context.request.post(`/api/v1/ponds/${pondId}/pages`, { data: { title } });
return created.json();
}
async function createLabel(
context: Ctx,
pondId: string,
name: string,
parentId?: string,
): Promise<{ id: string }> {
const created = await context.request.post(`/api/v1/ponds/${pondId}/labels`, {
data: { name, ...(parentId ? { parentId } : {}) },
});
return created.json();
}
test('label lifecycle works from the pond settings UI', async ({ browser }) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const pond = await personalPond(context);
const ts = Date.now();
const root = `Alpha ${ts}`;
const renamed = `Alpha2 ${ts}`;
const child = `Sub ${ts}`;
const page = await context.newPage();
// Delete confirmations are window.confirm dialogs — accept them.
page.on('dialog', (dialog) => void dialog.accept());
await page.goto(`/p/${pond.slug}/settings`);
// Create a root label using only the keyboard (type + Enter submits the form).
const newInput = page.locator('.label-manager__new-root input');
await newInput.fill(root);
await newInput.press('Enter');
await expect(page.locator('.label-node__name', { hasText: root })).toBeVisible();
// Locate a row by its name span — NOT getByText, which would also match the
// move-dropdown <option>s that list every label's name in other rows.
const rowByName = (name: string) =>
page.locator('.label-node', { has: page.locator('.label-node__name', { hasText: name }) });
// Add a sub-label under it.
await rowByName(root).locator('.label-node__add-child').click();
const childForm = page.locator('.label-tree__child-form');
await childForm.locator('input').fill(child);
await childForm.locator('button[type="submit"]').click();
await expect(page.locator('.label-node__name', { hasText: child })).toBeVisible();
// Rename the root label. Entering rename mode replaces the name span with an
// input, so the row can no longer be found by name — grab the sole open
// rename input globally.
await rowByName(root).locator('.label-node__rename').click();
const nameInput = page.locator('.label-node__name-input');
await nameInput.fill(renamed);
await nameInput.press('Enter');
await expect(page.locator('.label-node__name', { hasText: renamed })).toBeVisible();
// Delete the child, then the root — both disappear from the tree.
await rowByName(child).locator('.label-node__delete').click();
await expect(page.locator('.label-node__name', { hasText: child })).toHaveCount(0);
await rowByName(renamed).locator('.label-node__delete').click();
await expect(page.locator('.label-node__name', { hasText: renamed })).toHaveCount(0);
await context.close();
});
test('page picker assigns a label and the sidebar filter includes descendants', async ({
browser,
}) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const pond = await personalPond(context);
const ts = Date.now();
// A parent label with one child, set up via the api for speed.
const parent = await createLabel(context, pond.id, `Cat ${ts}`);
await createLabel(context, pond.id, `Sub ${ts}`, parent.id);
const tagged = await createPage(context, pond.id, `Tagged ${ts}`);
await createPage(context, pond.id, `Untagged ${ts}`);
const page = await context.newPage();
await page.goto(`/p/${pond.slug}/${tagged.slug}`);
// Open the label picker and assign the child label. The checkbox is
// controlled and only flips after the save round-trip, so click (don't
// .check(), which asserts the state changed synchronously).
await page.locator('.editor-page__labels-toggle').click();
const subOption = page
.locator('.label-picker__option', { hasText: `Sub ${ts}` })
.getByRole('checkbox');
await subOption.click();
await expect(subOption).toBeChecked();
// The sidebar chip for the tagged page appears without a reload.
const taggedEntry = page.locator('.sidebar__pages li', { hasText: `Tagged ${ts}` });
await expect(taggedEntry.locator('.label-chip', { hasText: `Sub ${ts}` })).toBeVisible();
// Filter by the PARENT label: the page tagged with the child must still match.
await page.locator('.sidebar__filter > summary').click();
await page
.locator('.sidebar__filter-option', { hasText: `Cat ${ts}` })
.getByRole('checkbox')
.check();
await expect(page.locator('.sidebar__page', { hasText: `Tagged ${ts}` })).toBeVisible();
await expect(page.locator('.sidebar__page', { hasText: `Untagged ${ts}` })).toHaveCount(0);
// Clearing the filter brings the untagged page back.
await page.locator('.sidebar__filter-clear').click();
await expect(page.locator('.sidebar__page', { hasText: `Untagged ${ts}` })).toBeVisible();
await context.request.delete(`/api/v1/labels/${parent.id}?force=true`); // cascades to the child
await context.close();
});

View File

@ -7,6 +7,7 @@ import { HomePage } from './pages/HomePage';
import { NotFoundPage } from './pages/NotFoundPage';
import { PageEditorPage } from './pages/PageEditorPage';
import { PondHomePage } from './pages/PondHomePage';
import { PondSettingsPage } from './pages/PondSettingsPage';
import { SettingsPage } from './pages/SettingsPage';
import { TrashPage } from './pages/TrashPage';
import { ForgotPasswordPage } from './pages/auth/ForgotPasswordPage';
@ -37,6 +38,8 @@ export function App(): React.JSX.Element {
dynamic :pageSlug sibling below a page slugged "trash"
would be unreachable via direct URL, an accepted v1 gap. */}
<Route path="p/:pondSlug/trash" element={<TrashPage />} />
{/* Static "settings" wins over :pageSlug, like "trash" above. */}
<Route path="p/:pondSlug/settings" element={<PondSettingsPage />} />
<Route path="p/:pondSlug/:pageSlug" element={<PageEditorPage />} />
</Route>
<Route element={<RequireSiteAdmin />}>

View File

@ -2,11 +2,13 @@ import deAuth from '@dorfteich/shared/i18n/de/auth.json';
import deCommon from '@dorfteich/shared/i18n/de/common.json';
import deEditor from '@dorfteich/shared/i18n/de/editor.json';
import deErrors from '@dorfteich/shared/i18n/de/errors.json';
import deLabels from '@dorfteich/shared/i18n/de/labels.json';
import deSettings from '@dorfteich/shared/i18n/de/settings.json';
import enAuth from '@dorfteich/shared/i18n/en/auth.json';
import enCommon from '@dorfteich/shared/i18n/en/common.json';
import enEditor from '@dorfteich/shared/i18n/en/editor.json';
import enErrors from '@dorfteich/shared/i18n/en/errors.json';
import enLabels from '@dorfteich/shared/i18n/en/labels.json';
import enSettings from '@dorfteich/shared/i18n/en/settings.json';
import i18n from 'i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
@ -29,6 +31,7 @@ void i18n
auth: enAuth,
settings: enSettings,
editor: enEditor,
labels: enLabels,
},
de: {
common: deCommon,
@ -36,6 +39,7 @@ void i18n
auth: deAuth,
settings: deSettings,
editor: deEditor,
labels: deLabels,
},
},
defaultNS: 'common',

View File

@ -0,0 +1,37 @@
import type { LabelView } from '@dorfteich/shared';
import { useTranslation } from 'react-i18next';
import { contrastingTextColor } from './label-color';
/**
* Colored label chips for a page (issue #44). Labels are looked up by id in the
* pond's label tree, so a chip renders only for a label still present. Purely
* presentational used on sidebar page entries.
*/
export function LabelChips({
labelIds,
byId,
}: {
labelIds: string[];
byId: Map<string, LabelView>;
}): React.JSX.Element | null {
const { t } = useTranslation('labels');
const labels = labelIds.map((id) => byId.get(id)).filter((l): l is LabelView => Boolean(l));
if (labels.length === 0) return null;
return (
<span className="label-chips">
{labels.map((label) => (
<span
key={label.id}
className="label-chip"
style={{ backgroundColor: label.color, color: contrastingTextColor(label.color) }}
title={label.name}
aria-label={t('chip.ariaLabel', { name: label.name })}
>
{label.name}
</span>
))}
</span>
);
}

View File

@ -0,0 +1,297 @@
import type { LabelTreeNode, LabelView } from '@dorfteich/shared';
import { collectSubtreeIds } from '@dorfteich/shared';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ApiError } from '../lib/api';
import { useLabelMutations, usePondLabels } from './use-pond-labels';
/** Turns an ApiError code into a translated message; other errors are generic. */
function useErrorText(): (error: unknown) => string {
const { t } = useTranslation('errors');
return (error) => {
if (error instanceof ApiError) {
return t(error.body.code, {
defaultValue: error.body.message,
...(error.body.details ?? {}),
});
}
return t('internal_error');
};
}
/**
* Pond-settings label management (issue #44): a tree of the pond's labels with
* inline create, rename, recolour, move (via a parent picker), and delete.
* Every operation is a native button/input/select, so the tree is fully
* keyboard-operable. Access is enforced by the api this UI is only shown to
* users who may modify the pond.
*/
export function LabelManager({ pondId }: { pondId: string }): React.JSX.Element {
const { t } = useTranslation('labels');
const { tree, flat, isLoading } = usePondLabels(pondId);
const mutations = useLabelMutations(pondId);
const errorText = useErrorText();
const [error, setError] = useState<string | null>(null);
const [newRoot, setNewRoot] = useState('');
const run = async (action: () => Promise<void>): Promise<void> => {
setError(null);
try {
await action();
} catch (err) {
setError(errorText(err));
}
};
async function addRoot(): Promise<void> {
const name = newRoot.trim();
if (!name) return;
await run(async () => {
await mutations.create(name, null);
setNewRoot('');
});
}
return (
<section className="label-manager" aria-label={t('settings.title')}>
<p className="label-manager__description">{t('settings.description')}</p>
<form
className="label-manager__new-root"
onSubmit={(event) => {
event.preventDefault();
void addRoot();
}}
>
<input
type="text"
value={newRoot}
onChange={(event) => setNewRoot(event.target.value)}
placeholder={t('settings.newRootPlaceholder')}
aria-label={t('settings.newRootPlaceholder')}
/>
<button type="submit" className="button" disabled={!newRoot.trim()}>
{t('settings.add')}
</button>
</form>
{error && (
<p className="label-manager__error" role="alert">
{error}
</p>
)}
{isLoading ? null : tree.length === 0 ? (
<p className="label-manager__empty">{t('settings.empty')}</p>
) : (
<ul className="label-tree" role="tree">
{tree.map((node) => (
<LabelNode
key={node.id}
node={node}
flat={flat}
mutations={mutations}
onError={setError}
errorText={errorText}
/>
))}
</ul>
)}
</section>
);
}
type Mutations = ReturnType<typeof useLabelMutations>;
function LabelNode({
node,
flat,
mutations,
onError,
errorText,
}: {
node: LabelTreeNode;
flat: LabelView[];
mutations: Mutations;
onError: (message: string | null) => void;
errorText: (error: unknown) => string;
}): React.JSX.Element {
const { t } = useTranslation('labels');
const [renaming, setRenaming] = useState(false);
const [name, setName] = useState(node.name);
const [addingChild, setAddingChild] = useState(false);
const [childName, setChildName] = useState('');
const run = async (action: () => Promise<void>): Promise<void> => {
onError(null);
try {
await action();
} catch (err) {
onError(errorText(err));
}
};
// A label cannot move under itself or one of its descendants.
const subtree = collectSubtreeIds(flat, node.id);
const moveTargets = flat.filter((l) => !subtree.has(l.id));
async function submitRename(): Promise<void> {
const next = name.trim();
if (!next || next === node.name) {
setRenaming(false);
setName(node.name);
return;
}
await run(async () => {
await mutations.rename(node.id, next);
setRenaming(false);
});
}
async function addChild(): Promise<void> {
const value = childName.trim();
if (!value) return;
await run(async () => {
await mutations.create(value, node.id);
setChildName('');
setAddingChild(false);
});
}
async function remove(): Promise<void> {
if (!window.confirm(t('settings.deleteConfirm'))) return;
await run(async () => {
try {
await mutations.remove(node.id, false);
} catch (err) {
// The api refuses to drop a label with assigned pages unless forced;
// confirm the detach, then retry with force.
if (err instanceof ApiError && err.body.code === 'label_has_pages') {
const count = err.body.details?.count?.[0] ?? '?';
if (window.confirm(t('settings.detachConfirm', { count }))) {
await mutations.remove(node.id, true);
}
return;
}
throw err;
}
});
}
return (
<li className="label-tree__item" role="treeitem">
<div className="label-node">
<span className="label-node__swatch" style={{ backgroundColor: node.color }} aria-hidden />
{renaming ? (
<input
type="text"
className="label-node__name-input"
value={name}
autoFocus
aria-label={t('settings.nameLabel')}
onChange={(event) => setName(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') void submitRename();
if (event.key === 'Escape') {
setRenaming(false);
setName(node.name);
}
}}
onBlur={() => void submitRename()}
/>
) : (
<span className="label-node__name">{node.name}</span>
)}
<div className="label-node__actions">
<input
type="color"
className="label-node__color"
value={node.color}
aria-label={t('settings.colorLabel')}
onChange={(event) => void run(() => mutations.recolor(node.id, event.target.value))}
/>
<button
type="button"
className="linklike label-node__rename"
onClick={() => (renaming ? void submitRename() : setRenaming(true))}
>
{renaming ? t('settings.save') : t('settings.rename')}
</button>
<label className="label-node__move">
<span className="visually-hidden">{t('settings.moveTo')}</span>
<select
value={node.parentId ?? ''}
aria-label={t('settings.moveTo')}
onChange={(event) =>
void run(() => mutations.move(node.id, event.target.value || null))
}
>
<option value="">{t('settings.root')}</option>
{moveTargets.map((target) => (
<option key={target.id} value={target.id}>
{target.name}
</option>
))}
</select>
</label>
<button
type="button"
className="linklike label-node__add-child"
onClick={() => setAddingChild((v) => !v)}
>
{t('settings.addChild')}
</button>
<button
type="button"
className="linklike label-node__delete"
onClick={() => void remove()}
>
{t('settings.delete')}
</button>
</div>
</div>
{addingChild && (
<form
className="label-tree__child-form"
onSubmit={(event) => {
event.preventDefault();
void addChild();
}}
>
<input
type="text"
value={childName}
autoFocus
placeholder={t('settings.addChild')}
aria-label={t('settings.addChild')}
onChange={(event) => setChildName(event.target.value)}
/>
<button type="submit" className="button" disabled={!childName.trim()}>
{t('settings.add')}
</button>
<button type="button" className="linklike" onClick={() => setAddingChild(false)}>
{t('settings.cancel')}
</button>
</form>
)}
{node.children.length > 0 && (
<ul className="label-tree" role="group">
{node.children.map((child) => (
<LabelNode
key={child.id}
node={child}
flat={flat}
mutations={mutations}
onError={onError}
errorText={errorText}
/>
))}
</ul>
)}
</li>
);
}

View File

@ -0,0 +1,114 @@
import type { LabelView } from '@dorfteich/shared';
import { labelDepth } from '@dorfteich/shared';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { apiDelete, apiGet, apiPost } from '../lib/api';
import { usePondLabels } from './use-pond-labels';
/**
* Page label picker (issue #44): a searchable, hierarchy-aware multi-select of
* the pond's labels. Toggling a label assigns/unassigns it immediately and
* refreshes both the page's labels and the sidebar (chips + filter). Shown only
* to users who may edit the page; the api enforces the permission.
*/
export function LabelPicker({
pageId,
pondId,
pondSlug,
onClose,
}: {
pageId: string;
pondId: string;
pondSlug: string;
onClose: () => void;
}): React.JSX.Element {
const { t } = useTranslation('labels');
const queryClient = useQueryClient();
const { flat, isLoading } = usePondLabels(pondId);
const [search, setSearch] = useState('');
const [busy, setBusy] = useState<string | null>(null);
const assigned = useQuery({
queryKey: ['page-labels', pageId],
queryFn: () => apiGet<LabelView[]>(`/pages/${pageId}/labels`),
});
const assignedIds = new Set((assigned.data ?? []).map((l) => l.id));
async function toggle(label: LabelView, checked: boolean): Promise<void> {
setBusy(label.id);
try {
if (checked) {
await apiPost(`/pages/${pageId}/labels`, { labelId: label.id });
} else {
await apiDelete(`/pages/${pageId}/labels/${label.id}`);
}
// Refresh the page's own labels and the sidebar list (chips + filter).
await queryClient.invalidateQueries({ queryKey: ['page-labels', pageId] });
await queryClient.invalidateQueries({ queryKey: ['pages', pondId] });
} finally {
setBusy(null);
}
}
const term = search.trim().toLowerCase();
const shown = term ? flat.filter((l) => l.name.toLowerCase().includes(term)) : flat;
return (
<aside className="label-picker" aria-label={t('picker.title')}>
<header className="label-picker__header">
<h2>{t('picker.title')}</h2>
<button type="button" className="button" onClick={onClose}>
{t('picker.close')}
</button>
</header>
{isLoading ? null : flat.length === 0 ? (
<p className="label-picker__empty">
{t('picker.empty')} <Link to={`/p/${pondSlug}/settings`}>{t('picker.manageHint')}</Link>
</p>
) : (
<>
<input
type="search"
className="label-picker__search"
value={search}
placeholder={t('picker.searchPlaceholder')}
aria-label={t('picker.searchPlaceholder')}
onChange={(event) => setSearch(event.target.value)}
/>
{shown.length === 0 ? (
<p className="label-picker__empty">{t('picker.noMatches')}</p>
) : (
<ul className="label-picker__list">
{shown.map((label) => {
const indent = term ? 0 : labelDepth(flat, label.id) - 1;
const checked = assignedIds.has(label.id);
return (
<li key={label.id} style={{ paddingInlineStart: `${indent * 1.25}rem` }}>
<label className="label-picker__option">
<input
type="checkbox"
checked={checked}
disabled={busy === label.id}
onChange={(event) => void toggle(label, event.target.checked)}
/>
<span
className="label-chip__swatch"
style={{ backgroundColor: label.color }}
aria-hidden
/>
<span>{label.name}</span>
</label>
</li>
);
})}
</ul>
)}
</>
)}
</aside>
);
}

View File

@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { contrastingTextColor } from './label-color';
describe('contrastingTextColor (issue #44)', () => {
it('uses black text on light backgrounds and white on dark', () => {
expect(contrastingTextColor('#ffffff')).toBe('#000000');
expect(contrastingTextColor('#f5f7fa')).toBe('#000000');
expect(contrastingTextColor('#000000')).toBe('#ffffff');
expect(contrastingTextColor('#1a5fb4')).toBe('#ffffff');
expect(contrastingTextColor('#64748b')).toBe('#ffffff'); // default label slate
});
it('tolerates a missing hash and falls back on malformed input', () => {
expect(contrastingTextColor('ffffff')).toBe('#000000');
expect(contrastingTextColor('nope')).toBe('#000000');
});
});

View File

@ -0,0 +1,19 @@
/**
* Label colours are user-chosen (any `#rrggbb`), so chip text must pick black
* or white per background to stay legible (issue #44). Uses the relative
* luminance of the background against a mid threshold the same idea as WCAG
* contrast, kept dependency-free.
*/
export function contrastingTextColor(hex: string): '#000000' | '#ffffff' {
const match = /^#?([0-9a-fA-F]{6})$/.exec(hex.trim());
if (!match?.[1]) return '#000000';
const value = match[1];
const channel = (offset: number): number => {
const c = parseInt(value.slice(offset, offset + 2), 16) / 255;
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
};
const luminance = 0.2126 * channel(0) + 0.7152 * channel(2) + 0.0722 * channel(4);
// Black text on light backgrounds, white on dark — 0.4 sits near the
// crossover where both options are acceptable.
return luminance > 0.4 ? '#000000' : '#ffffff';
}

View File

@ -0,0 +1,85 @@
import type { LabelTreeNode, LabelView } from '@dorfteich/shared';
import { flattenLabelTree } from '@dorfteich/shared';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo } from 'react';
import { apiDelete, apiGet, apiPatch, apiPost } from '../lib/api';
/** Query key for a pond's label tree — shared by the manager, picker, and sidebar. */
export const labelsKey = (pondId: string): (string | undefined)[] => ['labels', pondId];
/**
* The pond's label tree plus a flat view and an idlabel index derived from it
* (issue #44). The sidebar and page chips look labels up by id; filtering and
* pickers need the flat list. All three read the same cached tree.
*/
export function usePondLabels(pondId: string | undefined): {
tree: LabelTreeNode[];
flat: LabelView[];
byId: Map<string, LabelView>;
isLoading: boolean;
} {
const query = useQuery({
queryKey: labelsKey(pondId ?? ''),
queryFn: () => apiGet<LabelTreeNode[]>(`/ponds/${pondId}/labels`),
enabled: Boolean(pondId),
});
return useMemo(() => {
const tree = query.data ?? [];
const flat = flattenLabelTree(tree);
return {
tree,
flat,
byId: new Map(flat.map((label) => [label.id, label])),
isLoading: query.isLoading,
};
}, [query.data, query.isLoading]);
}
/**
* Label mutations for one pond, each invalidating the caches it touches:
* the label tree always, and for changes that affect page chips/filters
* the pond's page list too (issue #44).
*/
export function useLabelMutations(pondId: string): {
create: (name: string, parentId?: string | null) => Promise<void>;
rename: (id: string, name: string) => Promise<void>;
recolor: (id: string, color: string) => Promise<void>;
move: (id: string, parentId: string | null) => Promise<void>;
remove: (id: string, force: boolean) => Promise<void>;
} {
const queryClient = useQueryClient();
const invalidateLabels = (): Promise<void> =>
queryClient.invalidateQueries({ queryKey: labelsKey(pondId) });
// Renames/recolours/deletes change how chips render or which pages match a
// filter, so refresh the page list too.
const invalidateAll = async (): Promise<void> => {
await invalidateLabels();
await queryClient.invalidateQueries({ queryKey: ['pages', pondId] });
};
return {
create: async (name, parentId = null) => {
await apiPost(`/ponds/${pondId}/labels`, { name, ...(parentId ? { parentId } : {}) });
await invalidateLabels();
},
rename: async (id, name) => {
await apiPatch(`/labels/${id}`, { name });
await invalidateAll();
},
recolor: async (id, color) => {
await apiPatch(`/labels/${id}`, { color });
await invalidateAll();
},
move: async (id, parentId) => {
await apiPost(`/labels/${id}/move`, { parentId });
await invalidateLabels();
},
remove: async (id, force) => {
await apiDelete(`/labels/${id}${force ? '?force=true' : ''}`);
await invalidateAll();
},
};
}

View File

@ -1,10 +1,13 @@
import type { PageView, PondView, SidebarSortMode } from '@dorfteich/shared';
import type { PageListItemView, PondView, SidebarSortMode } from '@dorfteich/shared';
import { collectSubtreeIds, labelDepth } from '@dorfteich/shared';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { useAuth } from '../auth/auth-context';
import { LabelChips } from '../labels/LabelChips';
import { usePondLabels } from '../labels/use-pond-labels';
import { apiGet, apiPatch } from '../lib/api';
import { NewPageForm } from './NewPageForm';
import { useCurrentPondRoute } from './use-pond-route';
@ -18,15 +21,18 @@ const SORT_MODES: SidebarSortMode[] = ['alpha', 'created'];
/**
* Left sidebar: the current pond's page list, sort mode, active-page
* highlight, and "new page" flow (issue #26). Collapse behavior and the
* highlight, "new page" flow (issue #26), and label chips + a
* descendant-inclusive label filter (issue #44). Collapse behavior and the
* layout contract (`nav.sidebar`, `aria-hidden`) are unchanged from #4/#25.
*/
export function Sidebar({ collapsed }: SidebarProps): React.JSX.Element {
const { t } = useTranslation();
const { t: tLabels } = useTranslation('labels');
const { user } = useAuth();
const { pondSlug, pageSlug } = useCurrentPondRoute();
const queryClient = useQueryClient();
const [creating, setCreating] = useState(false);
const [filterIds, setFilterIds] = useState<Set<string>>(new Set());
const pond = useQuery({
queryKey: ['pond', pondSlug],
@ -36,12 +42,36 @@ export function Sidebar({ collapsed }: SidebarProps): React.JSX.Element {
const pages = useQuery({
queryKey: ['pages', pond.data?.id, pond.data?.settings.sidebarSort],
queryFn: () => apiGet<PageView[]>(`/ponds/${pond.data!.id}/pages`),
queryFn: () => apiGet<PageListItemView[]>(`/ponds/${pond.data!.id}/pages`),
enabled: Boolean(pond.data),
});
const { flat, byId } = usePondLabels(pond.data?.id);
const isOwner = Boolean(user && pond.data && user.id === pond.data.ownerId);
// A filter on a label matches pages tagged with that label or any of its
// descendants (permissions/vision: sub-labels belong to their parent).
const expandedFilter = useMemo(() => {
const acc = new Set<string>();
for (const id of filterIds) for (const d of collectSubtreeIds(flat, id)) acc.add(d);
return acc;
}, [filterIds, flat]);
const visiblePages =
filterIds.size === 0
? pages.data
: pages.data?.filter((p) => p.labelIds.some((id) => expandedFilter.has(id)));
function toggleFilter(id: string, on: boolean): void {
setFilterIds((prev) => {
const next = new Set(prev);
if (on) next.add(id);
else next.delete(id);
return next;
});
}
async function setSortMode(mode: SidebarSortMode): Promise<void> {
if (!pond.data) return;
await apiPatch(`/ponds/${pond.data.id}`, { sidebarSort: mode });
@ -74,6 +104,11 @@ export function Sidebar({ collapsed }: SidebarProps): React.JSX.Element {
))}
</select>
)}
{isOwner && (
<Link to={`/p/${pondSlug}/settings`} className="linklike sidebar__settings-link">
{tLabels('link')}
</Link>
)}
{isOwner && (
<Link to={`/p/${pondSlug}/trash`} className="linklike sidebar__trash-link">
{t('editor:trash.link')}
@ -81,9 +116,49 @@ export function Sidebar({ collapsed }: SidebarProps): React.JSX.Element {
)}
</div>
{pages.data && pages.data.length > 0 ? (
{flat.length > 0 && (
<details className="sidebar__filter">
<summary>
{tLabels('filter.toggle')}
{filterIds.size > 0 && ` (${filterIds.size})`}
</summary>
<ul className="sidebar__filter-list">
{flat.map((label) => (
<li
key={label.id}
style={{ paddingInlineStart: `${(labelDepth(flat, label.id) - 1) * 1}rem` }}
>
<label className="sidebar__filter-option">
<input
type="checkbox"
checked={filterIds.has(label.id)}
onChange={(event) => toggleFilter(label.id, event.target.checked)}
/>
<span
className="label-chip__swatch"
style={{ backgroundColor: label.color }}
aria-hidden
/>
<span>{label.name}</span>
</label>
</li>
))}
</ul>
{filterIds.size > 0 && (
<button
type="button"
className="linklike sidebar__filter-clear"
onClick={() => setFilterIds(new Set())}
>
{tLabels('filter.clear')}
</button>
)}
</details>
)}
{visiblePages && visiblePages.length > 0 ? (
<ul className="sidebar__pages">
{pages.data.map((p) => (
{visiblePages.map((p) => (
<li key={p.id}>
<Link
to={`/p/${pondSlug}/${p.slug}`}
@ -94,11 +169,14 @@ export function Sidebar({ collapsed }: SidebarProps): React.JSX.Element {
>
{p.title}
</Link>
<LabelChips labelIds={p.labelIds} byId={byId} />
</li>
))}
</ul>
) : (
<p className="sidebar__hint">{t('layout.sidebar.empty')}</p>
<p className="sidebar__hint">
{filterIds.size > 0 ? tLabels('filter.none') : t('layout.sidebar.empty')}
</p>
)}
{creating ? (

View File

@ -11,6 +11,7 @@ import { useAuth } from '../auth/auth-context';
import { FormError } from '../components/forms';
import { AccessRevokedDialog } from '../editor/AccessRevokedDialog';
import { HistoryPanel } from '../editor/HistoryPanel';
import { LabelPicker } from '../labels/LabelPicker';
import { collaborationCaretFor } from '../editor/collaboration-caret';
import { documentExtensions } from '../editor/document-extensions';
import { ImageUpload } from '../editor/image-upload';
@ -142,11 +143,13 @@ function PageMenu({
slug,
pondSlug,
onToggleHistory,
onToggleLabels,
}: {
pageId: string;
slug: string;
pondSlug: string;
onToggleHistory: () => void;
onToggleLabels: () => void;
}): React.JSX.Element {
const { t } = useTranslation('editor');
const navigate = useNavigate();
@ -183,6 +186,9 @@ function PageMenu({
>
{t('page.downloadMarkdown')}
</a>
<button type="button" className="button editor-page__labels-toggle" onClick={onToggleLabels}>
{t('labels:picker.open')}
</button>
<button type="button" className="button" onClick={onToggleHistory}>
{t('history.open')}
</button>
@ -199,6 +205,7 @@ export function PageEditorPage(): React.JSX.Element {
const [mode, setMode] = useState<Mode>('view');
const [title, setTitle] = useState('');
const [showHistory, setShowHistory] = useState(false);
const [showLabels, setShowLabels] = useState(false);
useForceSidebarHidden(mode === 'edit');
@ -290,10 +297,19 @@ export function PageEditorPage(): React.JSX.Element {
slug={resolved.slug}
pondSlug={pondSlug}
onToggleHistory={() => setShowHistory((open) => !open)}
onToggleLabels={() => setShowLabels((open) => !open)}
/>
</div>
<div className="editor-page__body">
<PageEditor page={resolved} mode={mode} pondSlug={pondSlug} />
{showLabels && (
<LabelPicker
pageId={resolved.id}
pondId={resolved.pondId}
pondSlug={pondSlug}
onClose={() => setShowLabels(false)}
/>
)}
{showHistory && <HistoryPanel pageId={resolved.id} onClose={() => setShowHistory(false)} />}
</div>
</div>

View File

@ -0,0 +1,48 @@
import type { PondView } from '@dorfteich/shared';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import { useAuth } from '../auth/auth-context';
import { FormError } from '../components/forms';
import { LabelManager } from '../labels/LabelManager';
import { apiGet } from '../lib/api';
/**
* Pond settings (issue #44). Currently hosts the 'Labels' section; future
* pond-level configuration (fonts, etc.) joins it here. Label management needs
* modify rights the manager's api calls enforce it, and the sidebar only
* links here for the pond owner (Site Admins can still navigate directly).
*/
export function PondSettingsPage(): React.JSX.Element {
const { t } = useTranslation('labels');
const { t: tErrors } = useTranslation('errors');
const { pondSlug = '' } = useParams<{ pondSlug: string }>();
const { user } = useAuth();
const pond = useQuery({
queryKey: ['pond', pondSlug],
queryFn: () => apiGet<PondView>(`/ponds/${pondSlug}`),
});
if (pond.error) return <FormError error={pond.error} />;
if (!pond.data) return <></>;
const canModify = Boolean(user && (user.isSiteAdmin || user.id === pond.data.ownerId));
return (
<div className="pond-settings-page">
<h1>{pond.data.name}</h1>
<section>
<h2>{t('settings.title')}</h2>
{canModify ? (
<LabelManager pondId={pond.data.id} />
) : (
<p className="form-banner form-banner--error" role="alert">
{tErrors('forbidden')}
</p>
)}
</section>
</div>
);
}

View File

@ -931,3 +931,201 @@ button {
color: var(--color-text-muted);
font-size: 0.85rem;
}
/* Labels (issue #44) --------------------------------------------------- */
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Colored chips on sidebar page entries. */
.label-chips {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
margin: var(--space-1) 0 0 var(--space-2);
}
.label-chip {
display: inline-block;
padding: 0 var(--space-2);
border-radius: 999px;
font-size: 0.72rem;
line-height: 1.5;
white-space: nowrap;
}
.label-chip__swatch {
display: inline-block;
width: 0.75rem;
height: 0.75rem;
border-radius: 50%;
border: 1px solid var(--color-border);
flex: 0 0 auto;
}
/* Sidebar label filter. */
.sidebar__filter {
margin-bottom: var(--space-3);
font-size: 0.9rem;
}
.sidebar__filter > summary {
cursor: pointer;
color: var(--color-text-muted);
padding: var(--space-1) 0;
}
.sidebar__filter-list {
list-style: none;
margin: var(--space-1) 0;
padding: 0;
max-height: 14rem;
overflow-y: auto;
}
.sidebar__filter-option {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-1) 0;
cursor: pointer;
}
.sidebar__settings-link {
font-size: 0.85rem;
}
/* Pond settings page + label manager. */
.pond-settings-page {
max-width: 48rem;
}
.label-manager__description {
color: var(--color-text-muted);
margin-bottom: var(--space-3);
}
.label-manager__new-root {
display: flex;
gap: var(--space-2);
margin-bottom: var(--space-3);
}
.label-manager__error {
color: var(--color-danger);
margin-bottom: var(--space-3);
}
.label-manager__empty {
color: var(--color-text-muted);
}
.label-tree {
list-style: none;
margin: 0;
padding: 0;
}
.label-tree .label-tree {
margin-left: var(--space-4);
border-left: 1px solid var(--color-border);
padding-left: var(--space-3);
}
.label-node {
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
padding: var(--space-1) 0;
}
.label-node__swatch {
display: inline-block;
width: 0.9rem;
height: 0.9rem;
border-radius: 50%;
border: 1px solid var(--color-border);
flex: 0 0 auto;
}
.label-node__name {
font-weight: 600;
}
.label-node__actions {
display: flex;
align-items: center;
gap: var(--space-2);
margin-left: auto;
}
.label-node__color {
width: 1.75rem;
height: 1.5rem;
padding: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: none;
cursor: pointer;
}
.label-node__delete {
color: var(--color-danger);
}
.label-tree__child-form {
display: flex;
gap: var(--space-2);
margin: var(--space-1) 0 var(--space-2) var(--space-4);
}
/* Page label picker panel. */
.label-picker {
flex: 0 0 20rem;
max-width: 20rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm, 4px);
padding: var(--space-3);
max-height: 80vh;
overflow-y: auto;
}
.label-picker__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-2);
}
.label-picker__search {
width: 100%;
margin-bottom: var(--space-2);
}
.label-picker__list {
list-style: none;
margin: 0;
padding: 0;
}
.label-picker__option {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-1) 0;
cursor: pointer;
}
.label-picker__empty {
color: var(--color-text-muted);
}

View File

@ -0,0 +1,43 @@
{
"settings": {
"title": "Labels",
"description": "Seiten mit hierarchischen Labels organisieren. Unter-Labels werden beim Filtern mit einbezogen.",
"newRootPlaceholder": "Name des neuen Labels",
"add": "Label hinzufügen",
"addChild": "Unter-Label hinzufügen",
"rename": "Umbenennen",
"nameLabel": "Label-Name",
"colorLabel": "Label-Farbe",
"move": "Verschieben",
"moveTo": "Verschieben nach",
"root": "Oberste Ebene",
"delete": "Löschen",
"save": "Speichern",
"cancel": "Abbrechen",
"empty": "Noch keine Labels. Füge eines hinzu, um Seiten zu organisieren.",
"deleteConfirm": "Dieses Label und alle Unter-Labels löschen?",
"detachConfirm": "Diesem Label oder seinen Unter-Labels sind {{count}} Seite(n) zugeordnet. Trotzdem löschen und die Zuordnungen entfernen?",
"childrenCount": "{{count}} Unter-Labels"
},
"picker": {
"open": "Labels",
"title": "Labels",
"close": "Schließen",
"searchPlaceholder": "Labels suchen",
"empty": "Dieser Teich hat noch keine Labels.",
"manageHint": "Labels in den Teich-Einstellungen verwalten.",
"noMatches": "Keine Labels passen zur Suche."
},
"filter": {
"label": "Nach Label filtern",
"toggle": "Nach Label filtern",
"clear": "Filter zurücksetzen",
"none": "Keine Seiten passen zu den gewählten Labels.",
"active": "{{count}} Label-Filter aktiv",
"active_other": "{{count}} Label-Filter aktiv"
},
"chip": {
"ariaLabel": "Label: {{name}}"
},
"link": "Teich-Einstellungen"
}

View File

@ -0,0 +1,43 @@
{
"settings": {
"title": "Labels",
"description": "Organize pages with hierarchical labels. Sub-labels inherit their parent when filtering.",
"newRootPlaceholder": "New label name",
"add": "Add label",
"addChild": "Add sub-label",
"rename": "Rename",
"nameLabel": "Label name",
"colorLabel": "Label colour",
"move": "Move",
"moveTo": "Move to",
"root": "Top level",
"delete": "Delete",
"save": "Save",
"cancel": "Cancel",
"empty": "No labels yet. Add one to start organizing pages.",
"deleteConfirm": "Delete this label and all its sub-labels?",
"detachConfirm": "This label or its sub-labels are assigned to {{count}} page(s). Delete anyway and remove those assignments?",
"childrenCount": "{{count}} sub-labels"
},
"picker": {
"open": "Labels",
"title": "Labels",
"close": "Close",
"searchPlaceholder": "Search labels",
"empty": "This pond has no labels yet.",
"manageHint": "Manage labels in pond settings.",
"noMatches": "No labels match your search."
},
"filter": {
"label": "Filter by label",
"toggle": "Filter by label",
"clear": "Clear filter",
"none": "No pages match the selected labels.",
"active": "{{count}} label filter active",
"active_other": "{{count}} label filters active"
},
"chip": {
"ariaLabel": "Label: {{name}}"
},
"link": "Pond settings"
}

View File

@ -5,6 +5,7 @@ import {
buildLabelTree,
collectAncestorIds,
collectSubtreeIds,
flattenLabelTree,
labelDepth,
subtreeHeight,
} from './labels';
@ -55,6 +56,16 @@ describe('buildLabelTree (issue #43)', () => {
});
});
describe('flattenLabelTree (issue #44)', () => {
it('round-trips buildLabelTree back into a depth-first list', () => {
const flat = flattenLabelTree(buildLabelTree(tree));
// Depth-first, siblings by name: a, b, d, c, z, y.
expect(flat.map((l) => l.id)).toEqual(['a', 'b', 'd', 'c', 'z', 'y']);
// Views carry no `children` field.
expect(flat.every((l) => !('children' in l))).toBe(true);
});
});
describe('collectSubtreeIds (issue #43)', () => {
it('returns the label and all its descendants', () => {
expect(collectSubtreeIds(tree, 'a')).toEqual(new Set(['a', 'b', 'c', 'd']));

View File

@ -105,6 +105,23 @@ export function buildLabelTree(labels: LabelView[]): LabelTreeNode[] {
return roots;
}
/**
* Flattens a label tree back into a depth-first list of {@link LabelView}s, in
* the same sibling order {@link buildLabelTree} produced. Handy where a flat
* list is needed again (chip lookup, subtree filtering, an indented picker)
* after fetching the tree from `GET /ponds/:id/labels`.
*/
export function flattenLabelTree(nodes: LabelTreeNode[]): LabelView[] {
const flat: LabelView[] = [];
const walk = (node: LabelTreeNode): void => {
const { children: _children, ...view } = node;
flat.push(view);
for (const child of node.children) walk(child);
};
for (const node of nodes) walk(node);
return flat;
}
/** Indexes labels by id → parentId for the ancestor/descendant walks below. */
function parentIndex(labels: LabelView[]): Map<string, string | null> {
const index = new Map<string, string | null>();

View File

@ -57,6 +57,16 @@ export interface PageStateView extends PageView {
state: string;
}
/**
* A page in the sidebar list (`GET /ponds/:id/pages`) with the ids of the
* labels assigned to it (issue #44). The sidebar maps these to colours/names
* via the pond's label tree for chips and filters by them (descendant-inclusive
* via the shared tree helpers) so no per-label detail is repeated here.
*/
export interface PageListItemView extends PageView {
labelIds: string[];
}
/** Why a version snapshot exists (ADR 0013): automatic (session end / active
* interval), a named manual snapshot, or the automatic pre-restore snapshot. */
export type PageVersionTrigger = 'auto' | 'manual' | 'pre_restore';