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 \
|
E2E_BASE_URL=http://localhost:5173 \
|
||||||
pnpm --filter @dorfteich/web exec playwright test e2e/offline.spec.ts
|
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
|
- name: Dump server logs on failure
|
||||||
if: failure()
|
if: failure()
|
||||||
run: tail -50 /tmp/api.log /tmp/collab.log /tmp/web.log || true
|
run: tail -50 /tmp/api.log /tmp/collab.log /tmp/web.log || true
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
CollabTokenResponse,
|
CollabTokenResponse,
|
||||||
CreatePageInput,
|
CreatePageInput,
|
||||||
|
PageListItemView,
|
||||||
PageStateView,
|
PageStateView,
|
||||||
PageView,
|
PageView,
|
||||||
UpdatePageInput,
|
UpdatePageInput,
|
||||||
@ -42,7 +43,10 @@ export class PagesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('ponds/:pondId/pages')
|
@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);
|
return this.pages.list(request.user!, pondId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { ConflictException, Injectable, NotFoundException } from '@nestjs/common
|
|||||||
import {
|
import {
|
||||||
CollabTokenResponse,
|
CollabTokenResponse,
|
||||||
CreatePageInput,
|
CreatePageInput,
|
||||||
|
PageListItemView,
|
||||||
PageStateView,
|
PageStateView,
|
||||||
PageView,
|
PageView,
|
||||||
SidebarSortMode,
|
SidebarSortMode,
|
||||||
@ -110,16 +111,21 @@ export class PagesService {
|
|||||||
manual: { sortKey: 'asc' },
|
manual: { sortKey: 'asc' },
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Sidebar page list, ordered per the pond's persisted sort mode (issue #26). */
|
/** Sidebar page list, ordered per the pond's persisted sort mode (issue #26),
|
||||||
async list(user: User, pondId: string): Promise<PageView[]> {
|
* 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 } });
|
const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } });
|
||||||
this.access.assertCanSee(user, pond);
|
this.access.assertCanSee(user, pond);
|
||||||
const settings = pondSettingsSchema.parse(pond.settings ?? {});
|
const settings = pondSettingsSchema.parse(pond.settings ?? {});
|
||||||
const pages = await this.prisma.page.findMany({
|
const pages = await this.prisma.page.findMany({
|
||||||
where: { pondId, deletedAt: null },
|
where: { pondId, deletedAt: null },
|
||||||
orderBy: PagesService.SORT_ORDER[settings.sidebarSort],
|
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> {
|
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 { NotFoundPage } from './pages/NotFoundPage';
|
||||||
import { PageEditorPage } from './pages/PageEditorPage';
|
import { PageEditorPage } from './pages/PageEditorPage';
|
||||||
import { PondHomePage } from './pages/PondHomePage';
|
import { PondHomePage } from './pages/PondHomePage';
|
||||||
|
import { PondSettingsPage } from './pages/PondSettingsPage';
|
||||||
import { SettingsPage } from './pages/SettingsPage';
|
import { SettingsPage } from './pages/SettingsPage';
|
||||||
import { TrashPage } from './pages/TrashPage';
|
import { TrashPage } from './pages/TrashPage';
|
||||||
import { ForgotPasswordPage } from './pages/auth/ForgotPasswordPage';
|
import { ForgotPasswordPage } from './pages/auth/ForgotPasswordPage';
|
||||||
@ -37,6 +38,8 @@ export function App(): React.JSX.Element {
|
|||||||
dynamic :pageSlug sibling below — a page slugged "trash"
|
dynamic :pageSlug sibling below — a page slugged "trash"
|
||||||
would be unreachable via direct URL, an accepted v1 gap. */}
|
would be unreachable via direct URL, an accepted v1 gap. */}
|
||||||
<Route path="p/:pondSlug/trash" element={<TrashPage />} />
|
<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 path="p/:pondSlug/:pageSlug" element={<PageEditorPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route element={<RequireSiteAdmin />}>
|
<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 deCommon from '@dorfteich/shared/i18n/de/common.json';
|
||||||
import deEditor from '@dorfteich/shared/i18n/de/editor.json';
|
import deEditor from '@dorfteich/shared/i18n/de/editor.json';
|
||||||
import deErrors from '@dorfteich/shared/i18n/de/errors.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 deSettings from '@dorfteich/shared/i18n/de/settings.json';
|
||||||
import enAuth from '@dorfteich/shared/i18n/en/auth.json';
|
import enAuth from '@dorfteich/shared/i18n/en/auth.json';
|
||||||
import enCommon from '@dorfteich/shared/i18n/en/common.json';
|
import enCommon from '@dorfteich/shared/i18n/en/common.json';
|
||||||
import enEditor from '@dorfteich/shared/i18n/en/editor.json';
|
import enEditor from '@dorfteich/shared/i18n/en/editor.json';
|
||||||
import enErrors from '@dorfteich/shared/i18n/en/errors.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 enSettings from '@dorfteich/shared/i18n/en/settings.json';
|
||||||
import i18n from 'i18next';
|
import i18n from 'i18next';
|
||||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||||
@ -29,6 +31,7 @@ void i18n
|
|||||||
auth: enAuth,
|
auth: enAuth,
|
||||||
settings: enSettings,
|
settings: enSettings,
|
||||||
editor: enEditor,
|
editor: enEditor,
|
||||||
|
labels: enLabels,
|
||||||
},
|
},
|
||||||
de: {
|
de: {
|
||||||
common: deCommon,
|
common: deCommon,
|
||||||
@ -36,6 +39,7 @@ void i18n
|
|||||||
auth: deAuth,
|
auth: deAuth,
|
||||||
settings: deSettings,
|
settings: deSettings,
|
||||||
editor: deEditor,
|
editor: deEditor,
|
||||||
|
labels: deLabels,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultNS: 'common',
|
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 { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
import { useAuth } from '../auth/auth-context';
|
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 { apiGet, apiPatch } from '../lib/api';
|
||||||
import { NewPageForm } from './NewPageForm';
|
import { NewPageForm } from './NewPageForm';
|
||||||
import { useCurrentPondRoute } from './use-pond-route';
|
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
|
* 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.
|
* layout contract (`nav.sidebar`, `aria-hidden`) are unchanged from #4/#25.
|
||||||
*/
|
*/
|
||||||
export function Sidebar({ collapsed }: SidebarProps): React.JSX.Element {
|
export function Sidebar({ collapsed }: SidebarProps): React.JSX.Element {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { t: tLabels } = useTranslation('labels');
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { pondSlug, pageSlug } = useCurrentPondRoute();
|
const { pondSlug, pageSlug } = useCurrentPondRoute();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [filterIds, setFilterIds] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
const pond = useQuery({
|
const pond = useQuery({
|
||||||
queryKey: ['pond', pondSlug],
|
queryKey: ['pond', pondSlug],
|
||||||
@ -36,12 +42,36 @@ export function Sidebar({ collapsed }: SidebarProps): React.JSX.Element {
|
|||||||
|
|
||||||
const pages = useQuery({
|
const pages = useQuery({
|
||||||
queryKey: ['pages', pond.data?.id, pond.data?.settings.sidebarSort],
|
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),
|
enabled: Boolean(pond.data),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { flat, byId } = usePondLabels(pond.data?.id);
|
||||||
|
|
||||||
const isOwner = Boolean(user && pond.data && user.id === pond.data.ownerId);
|
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> {
|
async function setSortMode(mode: SidebarSortMode): Promise<void> {
|
||||||
if (!pond.data) return;
|
if (!pond.data) return;
|
||||||
await apiPatch(`/ponds/${pond.data.id}`, { sidebarSort: mode });
|
await apiPatch(`/ponds/${pond.data.id}`, { sidebarSort: mode });
|
||||||
@ -74,6 +104,11 @@ export function Sidebar({ collapsed }: SidebarProps): React.JSX.Element {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
)}
|
)}
|
||||||
|
{isOwner && (
|
||||||
|
<Link to={`/p/${pondSlug}/settings`} className="linklike sidebar__settings-link">
|
||||||
|
{tLabels('link')}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
{isOwner && (
|
{isOwner && (
|
||||||
<Link to={`/p/${pondSlug}/trash`} className="linklike sidebar__trash-link">
|
<Link to={`/p/${pondSlug}/trash`} className="linklike sidebar__trash-link">
|
||||||
{t('editor:trash.link')}
|
{t('editor:trash.link')}
|
||||||
@ -81,9 +116,49 @@ export function Sidebar({ collapsed }: SidebarProps): React.JSX.Element {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<ul className="sidebar__pages">
|
||||||
{pages.data.map((p) => (
|
{visiblePages.map((p) => (
|
||||||
<li key={p.id}>
|
<li key={p.id}>
|
||||||
<Link
|
<Link
|
||||||
to={`/p/${pondSlug}/${p.slug}`}
|
to={`/p/${pondSlug}/${p.slug}`}
|
||||||
@ -94,11 +169,14 @@ export function Sidebar({ collapsed }: SidebarProps): React.JSX.Element {
|
|||||||
>
|
>
|
||||||
{p.title}
|
{p.title}
|
||||||
</Link>
|
</Link>
|
||||||
|
<LabelChips labelIds={p.labelIds} byId={byId} />
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</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 ? (
|
{creating ? (
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import { useAuth } from '../auth/auth-context';
|
|||||||
import { FormError } from '../components/forms';
|
import { FormError } from '../components/forms';
|
||||||
import { AccessRevokedDialog } from '../editor/AccessRevokedDialog';
|
import { AccessRevokedDialog } from '../editor/AccessRevokedDialog';
|
||||||
import { HistoryPanel } from '../editor/HistoryPanel';
|
import { HistoryPanel } from '../editor/HistoryPanel';
|
||||||
|
import { LabelPicker } from '../labels/LabelPicker';
|
||||||
import { collaborationCaretFor } from '../editor/collaboration-caret';
|
import { collaborationCaretFor } from '../editor/collaboration-caret';
|
||||||
import { documentExtensions } from '../editor/document-extensions';
|
import { documentExtensions } from '../editor/document-extensions';
|
||||||
import { ImageUpload } from '../editor/image-upload';
|
import { ImageUpload } from '../editor/image-upload';
|
||||||
@ -142,11 +143,13 @@ function PageMenu({
|
|||||||
slug,
|
slug,
|
||||||
pondSlug,
|
pondSlug,
|
||||||
onToggleHistory,
|
onToggleHistory,
|
||||||
|
onToggleLabels,
|
||||||
}: {
|
}: {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
pondSlug: string;
|
pondSlug: string;
|
||||||
onToggleHistory: () => void;
|
onToggleHistory: () => void;
|
||||||
|
onToggleLabels: () => void;
|
||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
const { t } = useTranslation('editor');
|
const { t } = useTranslation('editor');
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@ -183,6 +186,9 @@ function PageMenu({
|
|||||||
>
|
>
|
||||||
{t('page.downloadMarkdown')}
|
{t('page.downloadMarkdown')}
|
||||||
</a>
|
</a>
|
||||||
|
<button type="button" className="button editor-page__labels-toggle" onClick={onToggleLabels}>
|
||||||
|
{t('labels:picker.open')}
|
||||||
|
</button>
|
||||||
<button type="button" className="button" onClick={onToggleHistory}>
|
<button type="button" className="button" onClick={onToggleHistory}>
|
||||||
{t('history.open')}
|
{t('history.open')}
|
||||||
</button>
|
</button>
|
||||||
@ -199,6 +205,7 @@ export function PageEditorPage(): React.JSX.Element {
|
|||||||
const [mode, setMode] = useState<Mode>('view');
|
const [mode, setMode] = useState<Mode>('view');
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [showHistory, setShowHistory] = useState(false);
|
const [showHistory, setShowHistory] = useState(false);
|
||||||
|
const [showLabels, setShowLabels] = useState(false);
|
||||||
|
|
||||||
useForceSidebarHidden(mode === 'edit');
|
useForceSidebarHidden(mode === 'edit');
|
||||||
|
|
||||||
@ -290,10 +297,19 @@ export function PageEditorPage(): React.JSX.Element {
|
|||||||
slug={resolved.slug}
|
slug={resolved.slug}
|
||||||
pondSlug={pondSlug}
|
pondSlug={pondSlug}
|
||||||
onToggleHistory={() => setShowHistory((open) => !open)}
|
onToggleHistory={() => setShowHistory((open) => !open)}
|
||||||
|
onToggleLabels={() => setShowLabels((open) => !open)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="editor-page__body">
|
<div className="editor-page__body">
|
||||||
<PageEditor page={resolved} mode={mode} pondSlug={pondSlug} />
|
<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)} />}
|
{showHistory && <HistoryPanel pageId={resolved.id} onClose={() => setShowHistory(false)} />}
|
||||||
</div>
|
</div>
|
||||||
</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);
|
color: var(--color-text-muted);
|
||||||
font-size: 0.85rem;
|
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,
|
buildLabelTree,
|
||||||
collectAncestorIds,
|
collectAncestorIds,
|
||||||
collectSubtreeIds,
|
collectSubtreeIds,
|
||||||
|
flattenLabelTree,
|
||||||
labelDepth,
|
labelDepth,
|
||||||
subtreeHeight,
|
subtreeHeight,
|
||||||
} from './labels';
|
} 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)', () => {
|
describe('collectSubtreeIds (issue #43)', () => {
|
||||||
it('returns the label and all its descendants', () => {
|
it('returns the label and all its descendants', () => {
|
||||||
expect(collectSubtreeIds(tree, 'a')).toEqual(new Set(['a', 'b', 'c', 'd']));
|
expect(collectSubtreeIds(tree, 'a')).toEqual(new Set(['a', 'b', 'c', 'd']));
|
||||||
|
|||||||
@ -105,6 +105,23 @@ export function buildLabelTree(labels: LabelView[]): LabelTreeNode[] {
|
|||||||
return roots;
|
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. */
|
/** Indexes labels by id → parentId for the ancestor/descendant walks below. */
|
||||||
function parentIndex(labels: LabelView[]): Map<string, string | null> {
|
function parentIndex(labels: LabelView[]): Map<string, string | null> {
|
||||||
const index = new Map<string, string | null>();
|
const index = new Map<string, string | null>();
|
||||||
|
|||||||
@ -57,6 +57,16 @@ export interface PageStateView extends PageView {
|
|||||||
state: string;
|
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
|
/** Why a version snapshot exists (ADR 0013): automatic (session end / active
|
||||||
* interval), a named manual snapshot, or the automatic pre-restore snapshot. */
|
* interval), a named manual snapshot, or the automatic pre-restore snapshot. */
|
||||||
export type PageVersionTrigger = 'auto' | 'manual' | 'pre_restore';
|
export type PageVersionTrigger = 'auto' | 'manual' | 'pre_restore';
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user