dorfteich/apps/web/src/layout/Sidebar.tsx
Claude Opus 5 5a4a99196e
All checks were successful
CI / Auth e2e pack (pull_request) Successful in 8m36s
CI / Import/export fidelity gate (pull_request) Successful in 58s
CI / Lint, typecheck, test (pull_request) Successful in 6m22s
CI / Build container images (pull_request) Successful in 3m51s
CD / Build and push images (push) Successful in 15s
CD / Deploy to Test (push) Successful in 16s
CD / Smoke tests against Test (push) Successful in 1m16s
CD / Promote to Int (push) Successful in 13s
CI / Lint, typecheck, test (push) Successful in 6m32s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 8m25s
CI / Import/export fidelity gate (push) Successful in 58s
#300: route icon-only controls through IconButton/IconLink
The notification bell sat higher and larger than search and the theme
toggle next to it. The cause was not the glyph: `.notifications-bell__button`
carried its own rules with neither flex centring nor an icon size, so the
svg was laid out inline on the text baseline and rendered at lucide's
24px default instead of the 1.15rem the shared `.icon-button` enforces.

Route every icon-only control through the shared components instead:

- `IconLink` joins `IconButton`, sharing one class helper. Three controls
  navigate (pond settings, graph, trash) and are links, not buttons —
  without a link twin they would have stayed the one group gluing the
  class on by hand.
- 17 hand-applied `className="icon-button …"` usages across nine files
  now go through the components, which is what enforces the accessible
  name on a control that shows only an icon.
- The bell's unread count reaches assistive technology. The badge sits
  inside the control, so `aria-label` hid it and a screen reader
  announced "Notifications" without ever saying how many.

An ESLint rule keeps it that way: `icon-button` on a raw button, anchor
or Link is now an error, in both string and template-literal form.

The plugin uninstall button keeps a title that differs from its name (it
explains why a required plugin is locked); IconButton spreads rest last,
so the explicit title still wins.

Also drops the graphify block from CLAUDE.md — it duplicates the
workspace-level instructions.
2026-08-01 06:56:13 +02:00

700 lines
24 KiB
TypeScript

import type {
LabelView,
PageListItemView,
PondView,
SidebarSortMode,
SidebarViewMode,
TreeNode,
} from '@dorfteich/shared';
import { buildTree, collectSubtreeIds, labelDepth } from '@dorfteich/shared';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import {
ChevronRight,
FilePlus,
FileText,
Folder,
FolderOpen,
Star,
Trash2,
Waypoints,
} from 'lucide-react';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { useAuth } from '../auth/auth-context';
import { FormError } from '../components/forms';
import { IconButton, IconLink } from '../components/IconButton';
import { usePageFavorites } from '../favorites/use-favorites';
import { ImportControl } from '../import/ImportControl';
import { LabelChips } from '../labels/LabelChips';
import { usePondLabels } from '../labels/use-pond-labels';
import { apiGet, apiPatch } from '../lib/api';
import { usePersistentState } from '../lib/use-persistent-state';
import { NewPageForm } from './NewPageForm';
import { dropIndex, neighborsForMove } from './reorder';
import { useCurrentPondRoute } from './use-pond-route';
interface SidebarProps {
collapsed: boolean;
/** The resize handle, rendered inside the nav landmark (#166). */
resizer?: React.ReactNode;
}
const SORT_MODES: SidebarSortMode[] = ['alpha', 'created', 'manual'];
const VIEW_MODES: SidebarViewMode[] = ['folders', 'labels'];
/**
* Left sidebar: the current pond's page list, sort mode, active-page
* highlight, "new page" flow (issue #26), and label chips + a
* descendant-inclusive label filter (issue #44). Since issue #108 pages
* render as a collapsible tree ("folders") or grouped under the label tree
* ("labels") — the pond default (`settings.sidebarView`) is owner-set and
* every user can override it locally. Collapse behavior and the layout
* contract (`nav.sidebar`, `aria-hidden`) are unchanged from #4/#25.
*/
export function Sidebar({ collapsed, resizer }: SidebarProps): React.JSX.Element {
const { t } = useTranslation();
const { pondSlug } = useCurrentPondRoute();
const pond = useQuery({
queryKey: ['pond', pondSlug],
queryFn: () => apiGet<PondView>(`/ponds/${pondSlug}`),
enabled: pondSlug !== null,
});
return (
<nav
className={collapsed ? 'sidebar sidebar--collapsed' : 'sidebar'}
aria-hidden={collapsed}
inert={collapsed}
aria-label={t('layout.sidebar.label')}
>
{resizer}
{!pond.data ? (
<p className="sidebar__hint">{t('layout.sidebar.placeholder')}</p>
) : (
// Keyed by pond so the per-pond persistent hooks (view override,
// collapsed nodes) mount with the right localStorage key.
<SidebarContent key={pond.data.id} pond={pond.data} pondSlug={pondSlug!} />
)}
</nav>
);
}
function SidebarContent({
pond,
pondSlug,
}: {
pond: PondView;
pondSlug: string;
}): React.JSX.Element {
const { t } = useTranslation();
const { t: tLabels } = useTranslation('labels');
const { user } = useAuth();
const { pageSlug } = useCurrentPondRoute();
const queryClient = useQueryClient();
const [creating, setCreating] = useState(false);
const [filterIds, setFilterIds] = useState<Set<string>>(new Set());
// The favorites filter (issue #132) — a latching push button, combinable
// with the label filter; both narrow the same list.
const [favoritesOnly, setFavoritesOnly] = useState(false);
const [draggedId, setDraggedId] = useState<string | null>(null);
const [dropIntoId, setDropIntoId] = useState<string | null>(null);
const [moveError, setMoveError] = useState<unknown>(null);
const [announcement, setAnnouncement] = useState('');
// Per-user view override (issue #108); `null` follows the pond default.
const [viewOverride, setViewOverride] = usePersistentState<SidebarViewMode | null>(
`ui.sidebar.view.${pond.id}`,
null,
);
const view: SidebarViewMode = viewOverride ?? pond.settings.sidebarView;
const [collapsedIds, setCollapsedIds] = usePersistentState<string[]>(
`ui.sidebar.collapsedPages.${pond.id}`,
[],
);
const pages = useQuery({
queryKey: ['pages', pond.id, pond.settings.sidebarSort],
queryFn: () => apiGet<PageListItemView[]>(`/ponds/${pond.id}/pages`),
});
const { flat, byId } = usePondLabels(pond.id);
const { ids: favoriteIds } = usePageFavorites(pond.id);
const isOwner = Boolean(user && user.id === pond.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 labelFiltered =
filterIds.size === 0
? pages.data
: pages.data?.filter((p) => p.labelIds.some((id) => expandedFilter.has(id)));
const visiblePages = favoritesOnly
? labelFiltered?.filter((p) => favoriteIds.has(p.id))
: labelFiltered;
// The page tree, built from the list's global sort order (issue #108); a
// filtered list falls back to the flat rendering, so no filter here.
const tree = useMemo(() => buildTree(pages.data ?? []), [pages.data]);
const currentPage = pageSlug ? pages.data?.find((p) => p.slug === pageSlug) : undefined;
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;
});
}
function toggleCollapsed(id: string): void {
setCollapsedIds(
collapsedIds.includes(id) ? collapsedIds.filter((c) => c !== id) : [...collapsedIds, id],
);
}
async function setSortMode(mode: SidebarSortMode): Promise<void> {
await apiPatch(`/ponds/${pond.id}`, { sidebarSort: mode });
await queryClient.invalidateQueries({ queryKey: ['pond', pondSlug] });
}
// Reordering is offered only in manual mode, to the owner, while no label
// filter narrows the list, and in folder view (label view is read-only).
const canReorder =
isOwner && pond.settings.sidebarSort === 'manual' && filterIds.size === 0 && view === 'folders';
/** Move `movedId` to `newIndex` within its sibling group and persist it.
* Fractional keys between two siblings keep the group order intact under
* the pond-wide sequence (issue #106), so no other page is touched. */
async function moveWithinSiblings(
movedId: string,
siblingIds: string[],
newIndex: number,
title: string,
): Promise<void> {
setMoveError(null);
const { afterId, beforeId } = neighborsForMove(siblingIds, movedId, newIndex);
await apiPatch(`/pages/${movedId}/position`, { afterId, beforeId });
await queryClient.invalidateQueries({ queryKey: ['pages', pond.id] });
const position = Math.max(0, Math.min(newIndex, siblingIds.length - 1)) + 1;
setAnnouncement(
t('layout.sidebar.reorder.moved', { title, position, count: siblingIds.length }),
);
}
/** Nest `movedId` under `newParentId` (drop onto a row, issue #109),
* appended at the end of the new sibling group. Cycle/depth refusals from
* the server show as a translated banner under the tree. */
async function reparentTo(movedId: string, newParentId: string): Promise<void> {
setMoveError(null);
const list = pages.data ?? [];
const moved = list.find((p) => p.id === movedId);
const parent = list.find((p) => p.id === newParentId);
if (!moved || moved.parentId === newParentId) return;
try {
// Key after the last sibling of the new group; an empty group falls
// back to the pond's last page so the fresh key is unique.
const siblings = list.filter((p) => p.parentId === newParentId && p.id !== movedId);
const fallback = [...list].reverse().find((p) => p.id !== movedId);
const afterId = siblings.at(-1)?.id ?? fallback?.id ?? null;
await apiPatch(`/pages/${movedId}/position`, {
afterId,
beforeId: null,
parentId: newParentId,
});
await queryClient.invalidateQueries({ queryKey: ['pages', pond.id] });
setAnnouncement(
t('layout.sidebar.reorder.reparented', {
title: moved.title,
parent: parent?.title ?? '',
}),
);
} catch (err) {
setMoveError(err);
}
}
const showFlatFallback = view === 'folders' && (filterIds.size > 0 || favoritesOnly);
// The label view groups the (possibly favorites-narrowed) list; the label
// filter stays a folder-view affordance as before (#108).
const labelViewPages = favoritesOnly
? pages.data?.filter((p) => favoriteIds.has(p.id))
: pages.data;
return (
<>
<div className="sidebar__header">
<h2 className="sidebar__pond-name">{pond.name}</h2>
{isOwner && (
<select
className="sidebar__sort"
aria-label={t('layout.sidebar.sortLabel')}
value={pond.settings.sidebarSort}
onChange={(event) => void setSortMode(event.target.value as SidebarSortMode)}
>
{SORT_MODES.map((mode) => (
<option key={mode} value={mode}>
{t(`layout.sidebar.sortMode.${mode}`)}
</option>
))}
</select>
)}
</div>
<div
className="sidebar__view-toggle"
role="group"
aria-label={t('layout.sidebar.view.toggleLabel')}
>
{VIEW_MODES.map((mode) => (
<button
key={mode}
type="button"
className={`sidebar__view-btn${view === mode ? ' sidebar__view-btn--active' : ''}`}
aria-pressed={view === mode}
onClick={() => setViewOverride(mode)}
>
{t(`layout.sidebar.view.${mode}`)}
</button>
))}
{/* Latching favorites filter (issue #132) — narrows either view. */}
<button
type="button"
className={`sidebar__view-btn sidebar__view-btn--favorites${
favoritesOnly ? ' sidebar__view-btn--active' : ''
}`}
aria-pressed={favoritesOnly}
onClick={() => setFavoritesOnly(!favoritesOnly)}
>
<Star aria-hidden fill={favoritesOnly ? 'currentColor' : 'none'} />
{t('layout.sidebar.favorites.filter')}
</button>
</div>
{view === 'folders' && 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>
)}
{view === 'labels' ? (
labelViewPages && labelViewPages.length > 0 ? (
<LabelGroupedPages
pages={labelViewPages}
labels={flat}
pondSlug={pondSlug}
pageSlug={pageSlug}
/>
) : (
<p className="sidebar__hint">
{favoritesOnly ? t('layout.sidebar.favorites.empty') : t('layout.sidebar.empty')}
</p>
)
) : showFlatFallback ? (
visiblePages && visiblePages.length > 0 ? (
<ul className="sidebar__pages">
{visiblePages.map((p) => (
<li key={p.id} className="sidebar__page-item">
<PageLink page={p} pondSlug={pondSlug} pageSlug={pageSlug} />
<LabelChips labelIds={p.labelIds} byId={byId} />
</li>
))}
</ul>
) : (
<p className="sidebar__hint">
{filterIds.size > 0 ? tLabels('filter.none') : t('layout.sidebar.favorites.empty')}
</p>
)
) : tree.length > 0 ? (
<ul className="sidebar__pages sidebar__pages--tree">
<PageTreeLevel
nodes={tree}
pondSlug={pondSlug}
pageSlug={pageSlug}
byId={byId}
favoriteIds={favoriteIds}
collapsedIds={collapsedIds}
onToggleCollapsed={toggleCollapsed}
canReorder={canReorder}
draggedId={draggedId}
setDraggedId={setDraggedId}
dropIntoId={dropIntoId}
setDropIntoId={setDropIntoId}
onMove={moveWithinSiblings}
onReparent={reparentTo}
/>
</ul>
) : (
<p className="sidebar__hint">{t('layout.sidebar.empty')}</p>
)}
{moveError !== null && <FormError error={moveError} />}
{creating && (
<NewPageForm
pondId={pond.id}
pondSlug={pondSlug}
parentId={currentPage?.id ?? null}
parentTitle={currentPage?.title}
onCreated={() => {
setCreating(false);
void queryClient.invalidateQueries({ queryKey: ['pages', pond.id] });
}}
onCancel={() => setCreating(false)}
/>
)}
{/* Keyboard/drag reordering announcements for screen readers. */}
<p className="visually-hidden sidebar__announce" role="status" aria-live="polite">
{announcement}
</p>
{/* All four actions live as an icon row pinned to the sidebar's
bottom (#124): graph, new page, import, trash — hover hints via
title. The graph is for every member (#112); trash is owner-only. */}
<div className="sidebar__footer">
<IconLink
to={`/p/${pondSlug}/graph`}
className="sidebar__graph-link"
label={t('graph:link')}
>
<Waypoints aria-hidden />
</IconLink>
<IconButton
className="sidebar__new-page"
label={t('layout.sidebar.newPageHint')}
aria-expanded={creating}
onClick={() => setCreating(!creating)}
>
<FilePlus aria-hidden />
</IconButton>
<ImportControl pondId={pond.id} pondSlug={pondSlug} />
{isOwner && (
<IconLink
to={`/p/${pondSlug}/trash`}
className="sidebar__trash-link"
label={t('editor:trash.showLink')}
>
<Trash2 aria-hidden />
</IconLink>
)}
</div>
</>
);
}
function PageLink({
page,
pondSlug,
pageSlug,
}: {
page: PageListItemView;
pondSlug: string;
pageSlug: string | null;
}): React.JSX.Element {
return (
<Link
to={`/p/${pondSlug}/${page.slug}`}
className={page.slug === pageSlug ? 'sidebar__page sidebar__page--active' : 'sidebar__page'}
aria-current={page.slug === pageSlug ? 'page' : undefined}
draggable={false}
>
{page.title}
</Link>
);
}
interface PageTreeLevelProps {
nodes: TreeNode<PageListItemView>[];
pondSlug: string;
pageSlug: string | null;
byId: Map<string, LabelView>;
favoriteIds: Set<string>;
collapsedIds: string[];
onToggleCollapsed: (id: string) => void;
canReorder: boolean;
draggedId: string | null;
setDraggedId: (id: string | null) => void;
dropIntoId: string | null;
setDropIntoId: (id: string | null) => void;
onMove: (movedId: string, siblingIds: string[], newIndex: number, title: string) => Promise<void>;
onReparent: (movedId: string, newParentId: string) => Promise<void>;
}
/** Fraction of a row's height (top and bottom) that reads as "drop between";
* the middle band drops INTO the page (reparent, issue #109). */
const EDGE_ZONE = 0.3;
/** One sibling group of the folder view (issue #108), rendered recursively.
* Reordering (buttons and drag-between at the row edges) stays within the
* group; dropping onto a row's middle nests the dragged page under it. */
function PageTreeLevel(props: PageTreeLevelProps): React.JSX.Element {
const { t } = useTranslation();
const {
nodes,
pondSlug,
pageSlug,
byId,
favoriteIds,
collapsedIds,
onToggleCollapsed,
canReorder,
draggedId,
setDraggedId,
dropIntoId,
setDropIntoId,
onMove,
onReparent,
} = props;
const siblingIds = nodes.map((n) => n.id);
function dropZone(event: React.DragEvent<HTMLLIElement>): 'into' | 'between' {
const rect = event.currentTarget.getBoundingClientRect();
const y = event.clientY - rect.top;
return y > rect.height * EDGE_ZONE && y < rect.height * (1 - EDGE_ZONE) ? 'into' : 'between';
}
return (
<>
{nodes.map((node, index) => {
const hasChildren = node.children.length > 0;
const isCollapsed = collapsedIds.includes(node.id);
const itemClasses = [
'sidebar__page-item',
canReorder ? 'sidebar__page-item--draggable' : '',
dropIntoId === node.id ? 'sidebar__page-item--drop-into' : '',
]
.filter(Boolean)
.join(' ');
return (
<li
key={node.id}
className={itemClasses}
draggable={canReorder}
onDragStart={
canReorder
? (event) => {
event.stopPropagation();
setDraggedId(node.id);
event.dataTransfer.effectAllowed = 'move';
}
: undefined
}
onDragEnd={
canReorder
? () => {
setDraggedId(null);
setDropIntoId(null);
}
: undefined
}
onDragOver={
canReorder
? (event) => {
event.preventDefault();
event.stopPropagation();
const into = draggedId && draggedId !== node.id && dropZone(event) === 'into';
setDropIntoId(into ? node.id : null);
}
: undefined
}
onDrop={
canReorder
? (event) => {
event.preventDefault();
event.stopPropagation();
setDropIntoId(null);
if (!draggedId || draggedId === node.id) return;
if (dropZone(event) === 'into') {
// Middle band: nest the dragged page under this one.
void onReparent(draggedId, node.id);
} else {
// Edge bands reorder — inside one sibling group only.
if (!siblingIds.includes(draggedId)) return;
const rect = event.currentTarget.getBoundingClientRect();
const after = event.clientY - rect.top > rect.height / 2;
const target = dropIndex(siblingIds, draggedId, node.id, after);
const title = nodes.find((n) => n.id === draggedId)?.title ?? '';
void onMove(draggedId, siblingIds, target, title);
}
setDraggedId(null);
}
: undefined
}
>
<span className="sidebar__tree-row">
{hasChildren ? (
<button
type="button"
className={
isCollapsed ? 'sidebar__caret sidebar__caret--collapsed' : 'sidebar__caret'
}
aria-expanded={!isCollapsed}
// Deliberately WITHOUT the page title: e2e locators match
// accessible names by substring (#101 convention), and a
// title like "Editor" would collide with the mode toggle.
aria-label={t(
isCollapsed ? 'layout.sidebar.view.expand' : 'layout.sidebar.view.collapseNode',
)}
onClick={() => onToggleCollapsed(node.id)}
>
<ChevronRight aria-hidden />
</button>
) : (
<span className="sidebar__caret sidebar__caret--leaf" aria-hidden />
)}
{/* Favorites carry a golden icon (issue #132). */}
<span
className={
favoriteIds.has(node.id)
? 'sidebar__page-icon sidebar__page-icon--favorite'
: 'sidebar__page-icon'
}
aria-hidden
>
{hasChildren ? isCollapsed ? <Folder /> : <FolderOpen /> : <FileText />}
</span>
<PageLink page={node} pondSlug={pondSlug} pageSlug={pageSlug} />
{canReorder && (
<span className="sidebar__reorder">
<button
type="button"
className="sidebar__reorder-btn"
aria-label={t('layout.sidebar.reorder.up')}
disabled={index === 0}
onClick={() => void onMove(node.id, siblingIds, index - 1, node.title)}
>
</button>
<button
type="button"
className="sidebar__reorder-btn"
aria-label={t('layout.sidebar.reorder.down')}
disabled={index === nodes.length - 1}
onClick={() => void onMove(node.id, siblingIds, index + 1, node.title)}
>
</button>
</span>
)}
<LabelChips labelIds={node.labelIds} byId={byId} />
</span>
{hasChildren && !isCollapsed && (
<ul className="sidebar__pages sidebar__tree-children">
<PageTreeLevel {...props} nodes={node.children} />
</ul>
)}
</li>
);
})}
</>
);
}
/**
* The label view (issue #108): pages grouped under the hierarchical label
* tree, read-only. A page tagged with several labels appears under each;
* untagged pages collect in a trailing "unlabeled" group.
*/
function LabelGroupedPages({
pages,
labels,
pondSlug,
pageSlug,
}: {
pages: PageListItemView[];
labels: LabelView[];
pondSlug: string;
pageSlug: string | null;
}): React.JSX.Element {
const { t } = useTranslation();
const unlabeled = pages.filter((p) => p.labelIds.length === 0);
return (
<div className="sidebar__label-view">
{labels.map((label) => {
const tagged = pages.filter((p) => p.labelIds.includes(label.id));
if (tagged.length === 0) return null;
return (
<section
key={label.id}
className="sidebar__label-group"
style={{ paddingInlineStart: `${(labelDepth(labels, label.id) - 1) * 0.75}rem` }}
>
<h3 className="sidebar__label-heading">
<span
className="label-chip__swatch"
style={{ backgroundColor: label.color }}
aria-hidden
/>
{label.name}
</h3>
<ul className="sidebar__pages">
{tagged.map((p) => (
<li key={p.id} className="sidebar__page-item">
<PageLink page={p} pondSlug={pondSlug} pageSlug={pageSlug} />
</li>
))}
</ul>
</section>
);
})}
{unlabeled.length > 0 && (
<section className="sidebar__label-group sidebar__label-group--unlabeled">
<h3 className="sidebar__label-heading">{t('layout.sidebar.view.unlabeled')}</h3>
<ul className="sidebar__pages">
{unlabeled.map((p) => (
<li key={p.id} className="sidebar__page-item">
<PageLink page={p} pondSlug={pondSlug} pageSlug={pageSlug} />
</li>
))}
</ul>
</section>
)}
</div>
);
}