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
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:
parent
a3a012c41d
commit
03e72242d3
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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
139
apps/web/e2e/labels.spec.ts
Normal 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();
|
||||
});
|
||||
@ -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 />}>
|
||||
|
||||
@ -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',
|
||||
|
||||
37
apps/web/src/labels/LabelChips.tsx
Normal file
37
apps/web/src/labels/LabelChips.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
297
apps/web/src/labels/LabelManager.tsx
Normal file
297
apps/web/src/labels/LabelManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
114
apps/web/src/labels/LabelPicker.tsx
Normal file
114
apps/web/src/labels/LabelPicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
apps/web/src/labels/label-color.test.ts
Normal file
18
apps/web/src/labels/label-color.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
19
apps/web/src/labels/label-color.ts
Normal file
19
apps/web/src/labels/label-color.ts
Normal 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';
|
||||
}
|
||||
85
apps/web/src/labels/use-pond-labels.ts
Normal file
85
apps/web/src/labels/use-pond-labels.ts
Normal 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 id→label 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -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 ? (
|
||||
|
||||
@ -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>
|
||||
|
||||
48
apps/web/src/pages/PondSettingsPage.tsx
Normal file
48
apps/web/src/pages/PondSettingsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
43
packages/shared/i18n/de/labels.json
Normal file
43
packages/shared/i18n/de/labels.json
Normal 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"
|
||||
}
|
||||
43
packages/shared/i18n/en/labels.json
Normal file
43
packages/shared/i18n/en/labels.json
Normal 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"
|
||||
}
|
||||
@ -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']));
|
||||
|
||||
@ -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>();
|
||||
|
||||
@ -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';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user