Sidebar folder view, label view, and the view toggle (#108)
Some checks failed
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CD / Build and push images (push) Has been cancelled
CI / Lint, typecheck, test (push) Has been cancelled

The sidebar now presents pages as a collapsible tree built from parentId
(folder view) or grouped under the hierarchical label tree (label view,
read-only; multi-label pages appear under each label, untagged ones in
an 'unlabeled' group). The pond owner sets the default via a new
sidebarView pond setting (PATCH-merged like the other keys); every user
can override it locally (ui.sidebar.view.<pondId>), and the toggle sits
above the page list. Collapse state persists per pond.

New pages created while a page is open become its children — the inline
form says so and sends parentId. Reordering (buttons and drag-between)
now operates within one sibling group; the label filter stays a
folder-view feature and falls back to the flat list while active, so
the filtered order is never mistaken for a partial tree.

SidebarContent is keyed by pond id so the per-pond localStorage hooks
mount with the right key. e2e hooks (.sidebar__pages, .sidebar__page,
reorder buttons) kept; reorder/labels/content packs green locally, plus
a live smoke of nesting, collapse persistence, and both views.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Claude Fable 5 2026-07-14 10:17:36 +02:00
parent 12ff3c099f
commit 15184876bd
9 changed files with 685 additions and 217 deletions

View File

@ -150,6 +150,7 @@ export class PondsService {
// whichever of the settings keys this request changes (#26/#66/#91/#104). // whichever of the settings keys this request changes (#26/#66/#91/#104).
const settingsChanged = const settingsChanged =
input.sidebarSort !== undefined || input.sidebarSort !== undefined ||
input.sidebarView !== undefined ||
input.fonts !== undefined || input.fonts !== undefined ||
input.commentPolicy !== undefined || input.commentPolicy !== undefined ||
input.apiEnabled !== undefined || input.apiEnabled !== undefined ||
@ -159,6 +160,7 @@ export class PondsService {
: { : {
...(pond.settings as object), ...(pond.settings as object),
...(input.sidebarSort !== undefined ? { sidebarSort: input.sidebarSort } : {}), ...(input.sidebarSort !== undefined ? { sidebarSort: input.sidebarSort } : {}),
...(input.sidebarView !== undefined ? { sidebarView: input.sidebarView } : {}),
...(input.fonts !== undefined ? { fonts: input.fonts } : {}), ...(input.fonts !== undefined ? { fonts: input.fonts } : {}),
...(input.commentPolicy !== undefined ? { commentPolicy: input.commentPolicy } : {}), ...(input.commentPolicy !== undefined ? { commentPolicy: input.commentPolicy } : {}),
...(input.apiEnabled !== undefined ? { apiEnabled: input.apiEnabled } : {}), ...(input.apiEnabled !== undefined ? { apiEnabled: input.apiEnabled } : {}),

View File

@ -11,6 +11,10 @@ import { apiPost } from '../lib/api';
interface NewPageFormProps { interface NewPageFormProps {
pondId: string; pondId: string;
pondSlug: string; pondSlug: string;
/** Parent for the new page (issue #108): the currently open page, so new
* pages nest under where the user is; `null` creates at the root. */
parentId?: string | null;
parentTitle?: string;
onCreated: () => void; onCreated: () => void;
onCancel: () => void; onCancel: () => void;
} }
@ -19,6 +23,8 @@ interface NewPageFormProps {
export function NewPageForm({ export function NewPageForm({
pondId, pondId,
pondSlug, pondSlug,
parentId = null,
parentTitle,
onCreated, onCreated,
onCancel, onCancel,
}: NewPageFormProps): React.JSX.Element { }: NewPageFormProps): React.JSX.Element {
@ -30,7 +36,7 @@ export function NewPageForm({
const onSubmit = form.handleSubmit(async (input) => { const onSubmit = form.handleSubmit(async (input) => {
setError(null); setError(null);
try { try {
const page = await apiPost<PageView>(`/ponds/${pondId}/pages`, input); const page = await apiPost<PageView>(`/ponds/${pondId}/pages`, { ...input, parentId });
onCreated(); onCreated();
navigate(`/p/${pondSlug}/${page.slug}`); navigate(`/p/${pondSlug}/${page.slug}`);
} catch (err) { } catch (err) {
@ -51,6 +57,11 @@ export function NewPageForm({
}} }}
/> />
</Field> </Field>
{parentId && parentTitle && (
<p className="sidebar__new-page-parent">
{t('layout.sidebar.newPageUnder', { title: parentTitle })}
</p>
)}
<div className="sidebar__new-page-actions"> <div className="sidebar__new-page-actions">
<button type="submit" className="button" disabled={form.formState.isSubmitting}> <button type="submit" className="button" disabled={form.formState.isSubmitting}>
{t('layout.sidebar.create')} {t('layout.sidebar.create')}

View File

@ -1,5 +1,12 @@
import type { PageListItemView, PondView, SidebarSortMode } from '@dorfteich/shared'; import type {
import { collectSubtreeIds, labelDepth } from '@dorfteich/shared'; LabelView,
PageListItemView,
PondView,
SidebarSortMode,
SidebarViewMode,
TreeNode,
} from '@dorfteich/shared';
import { buildTree, collectSubtreeIds, labelDepth } from '@dorfteich/shared';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -10,6 +17,7 @@ import { ImportControl } from '../import/ImportControl';
import { LabelChips } from '../labels/LabelChips'; import { LabelChips } from '../labels/LabelChips';
import { usePondLabels } from '../labels/use-pond-labels'; import { usePondLabels } from '../labels/use-pond-labels';
import { apiGet, apiPatch } from '../lib/api'; import { apiGet, apiPatch } from '../lib/api';
import { usePersistentState } from '../lib/use-persistent-state';
import { NewPageForm } from './NewPageForm'; import { NewPageForm } from './NewPageForm';
import { dropIndex, neighborsForMove } from './reorder'; import { dropIndex, neighborsForMove } from './reorder';
import { useCurrentPondRoute } from './use-pond-route'; import { useCurrentPondRoute } from './use-pond-route';
@ -19,23 +27,20 @@ interface SidebarProps {
} }
const SORT_MODES: SidebarSortMode[] = ['alpha', 'created', 'manual']; 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 * Left sidebar: the current pond's page list, sort mode, active-page
* highlight, "new page" flow (issue #26), and label chips + a * highlight, "new page" flow (issue #26), and label chips + a
* descendant-inclusive label filter (issue #44). Collapse behavior and the * descendant-inclusive label filter (issue #44). Since issue #108 pages
* layout contract (`nav.sidebar`, `aria-hidden`) are unchanged from #4/#25. * 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 }: SidebarProps): React.JSX.Element { export function Sidebar({ collapsed }: SidebarProps): React.JSX.Element {
const { t } = useTranslation(); const { t } = useTranslation();
const { t: tLabels } = useTranslation('labels'); const { pondSlug } = useCurrentPondRoute();
const { user } = useAuth();
const { pondSlug, pageSlug } = useCurrentPondRoute();
const queryClient = useQueryClient();
const [creating, setCreating] = useState(false);
const [filterIds, setFilterIds] = useState<Set<string>>(new Set());
const [draggedId, setDraggedId] = useState<string | null>(null);
const [announcement, setAnnouncement] = useState('');
const pond = useQuery({ const pond = useQuery({
queryKey: ['pond', pondSlug], queryKey: ['pond', pondSlug],
@ -43,15 +48,59 @@ export function Sidebar({ collapsed }: SidebarProps): React.JSX.Element {
enabled: pondSlug !== null, enabled: pondSlug !== null,
}); });
return (
<nav
className={collapsed ? 'sidebar sidebar--collapsed' : 'sidebar'}
aria-hidden={collapsed}
aria-label={t('layout.sidebar.label')}
>
{!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());
const [draggedId, setDraggedId] = useState<string | null>(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({ const pages = useQuery({
queryKey: ['pages', pond.data?.id, pond.data?.settings.sidebarSort], queryKey: ['pages', pond.id, pond.settings.sidebarSort],
queryFn: () => apiGet<PageListItemView[]>(`/ponds/${pond.data!.id}/pages`), queryFn: () => apiGet<PageListItemView[]>(`/ponds/${pond.id}/pages`),
enabled: Boolean(pond.data),
}); });
const { flat, byId } = usePondLabels(pond.data?.id); const { flat, byId } = usePondLabels(pond.id);
const isOwner = Boolean(user && pond.data && user.id === pond.data.ownerId); const isOwner = Boolean(user && user.id === pond.ownerId);
// A filter on a label matches pages tagged with that label or any of its // A filter on a label matches pages tagged with that label or any of its
// descendants (permissions/vision: sub-labels belong to their parent). // descendants (permissions/vision: sub-labels belong to their parent).
@ -66,6 +115,12 @@ export function Sidebar({ collapsed }: SidebarProps): React.JSX.Element {
? pages.data ? pages.data
: pages.data?.filter((p) => p.labelIds.some((id) => expandedFilter.has(id))); : pages.data?.filter((p) => p.labelIds.some((id) => expandedFilter.has(id)));
// 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 { function toggleFilter(id: string, on: boolean): void {
setFilterIds((prev) => { setFilterIds((prev) => {
const next = new Set(prev); const next = new Set(prev);
@ -75,48 +130,51 @@ export function Sidebar({ collapsed }: SidebarProps): React.JSX.Element {
}); });
} }
async function setSortMode(mode: SidebarSortMode): Promise<void> { function toggleCollapsed(id: string): void {
if (!pond.data) return; setCollapsedIds(
await apiPatch(`/ponds/${pond.data.id}`, { sidebarSort: mode }); collapsedIds.includes(id) ? collapsedIds.filter((c) => c !== id) : [...collapsedIds, id],
await queryClient.invalidateQueries({ queryKey: ['pond', pondSlug] });
}
// Reordering is offered only in manual mode, to the owner, and while no label
// filter narrows the list (the visible order would then be a subset).
const canReorder =
isOwner && pond.data?.settings.sidebarSort === 'manual' && filterIds.size === 0;
/** Move `movedId` to `newIndex` (in the list with itself removed), persist the
* new position server-side, and announce it for screen readers. */
async function moveTo(movedId: string, newIndex: number, title: string): Promise<void> {
if (!pond.data || !pages.data) return;
const orderedIds = pages.data.map((p) => p.id);
const { afterId, beforeId } = neighborsForMove(orderedIds, movedId, newIndex);
await apiPatch(`/pages/${movedId}/position`, { afterId, beforeId });
await queryClient.invalidateQueries({ queryKey: ['pages', pond.data.id] });
const position = Math.max(0, Math.min(newIndex, orderedIds.length - 1)) + 1;
setAnnouncement(
t('layout.sidebar.reorder.moved', { title, position, count: orderedIds.length }),
); );
} }
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> {
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 }),
);
}
const showFlatFallback = view === 'folders' && filterIds.size > 0;
return ( return (
<nav
className={collapsed ? 'sidebar sidebar--collapsed' : 'sidebar'}
aria-hidden={collapsed}
aria-label={t('layout.sidebar.label')}
>
{!pond.data ? (
<p className="sidebar__hint">{t('layout.sidebar.placeholder')}</p>
) : (
<> <>
<div className="sidebar__header"> <div className="sidebar__header">
<h2 className="sidebar__pond-name">{pond.data.name}</h2> <h2 className="sidebar__pond-name">{pond.name}</h2>
{isOwner && ( {isOwner && (
<select <select
className="sidebar__sort" className="sidebar__sort"
aria-label={t('layout.sidebar.sortLabel')} aria-label={t('layout.sidebar.sortLabel')}
value={pond.data.settings.sidebarSort} value={pond.settings.sidebarSort}
onChange={(event) => void setSortMode(event.target.value as SidebarSortMode)} onChange={(event) => void setSortMode(event.target.value as SidebarSortMode)}
> >
{SORT_MODES.map((mode) => ( {SORT_MODES.map((mode) => (
@ -128,7 +186,25 @@ export function Sidebar({ collapsed }: SidebarProps): React.JSX.Element {
)} )}
</div> </div>
{flat.length > 0 && ( <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>
))}
</div>
{view === 'folders' && flat.length > 0 && (
<details className="sidebar__filter"> <details className="sidebar__filter">
<summary> <summary>
{tLabels('filter.toggle')} {tLabels('filter.toggle')}
@ -168,91 +244,58 @@ export function Sidebar({ collapsed }: SidebarProps): React.JSX.Element {
</details> </details>
)} )}
{visiblePages && visiblePages.length > 0 ? ( {view === 'labels' ? (
pages.data && pages.data.length > 0 ? (
<LabelGroupedPages
pages={pages.data}
labels={flat}
pondSlug={pondSlug}
pageSlug={pageSlug}
/>
) : (
<p className="sidebar__hint">{t('layout.sidebar.empty')}</p>
)
) : showFlatFallback ? (
visiblePages && visiblePages.length > 0 ? (
<ul className="sidebar__pages"> <ul className="sidebar__pages">
{visiblePages.map((p, index) => ( {visiblePages.map((p) => (
<li <li key={p.id} className="sidebar__page-item">
key={p.id} <PageLink page={p} pondSlug={pondSlug} pageSlug={pageSlug} />
className={
canReorder ? 'sidebar__page-item sidebar__page-item--draggable' : undefined
}
draggable={canReorder}
onDragStart={
canReorder
? (event) => {
setDraggedId(p.id);
event.dataTransfer.effectAllowed = 'move';
}
: undefined
}
onDragEnd={canReorder ? () => setDraggedId(null) : undefined}
onDragOver={canReorder ? (event) => event.preventDefault() : undefined}
onDrop={
canReorder
? (event) => {
event.preventDefault();
if (!draggedId || draggedId === p.id || !pages.data) return;
const rect = event.currentTarget.getBoundingClientRect();
const after = event.clientY - rect.top > rect.height / 2;
const orderedIds = pages.data.map((page) => page.id);
const target = dropIndex(orderedIds, draggedId, p.id, after);
const title =
pages.data.find((page) => page.id === draggedId)?.title ?? '';
void moveTo(draggedId, target, title);
setDraggedId(null);
}
: undefined
}
>
<Link
to={`/p/${pondSlug}/${p.slug}`}
className={
p.slug === pageSlug ? 'sidebar__page sidebar__page--active' : 'sidebar__page'
}
aria-current={p.slug === pageSlug ? 'page' : undefined}
draggable={false}
>
{p.title}
</Link>
{canReorder && (
<span className="sidebar__reorder">
<button
type="button"
className="sidebar__reorder-btn"
aria-label={t('layout.sidebar.reorder.up')}
disabled={index === 0}
onClick={() => void moveTo(p.id, index - 1, p.title)}
>
</button>
<button
type="button"
className="sidebar__reorder-btn"
aria-label={t('layout.sidebar.reorder.down')}
disabled={index === visiblePages.length - 1}
onClick={() => void moveTo(p.id, index + 1, p.title)}
>
</button>
</span>
)}
<LabelChips labelIds={p.labelIds} byId={byId} /> <LabelChips labelIds={p.labelIds} byId={byId} />
</li> </li>
))} ))}
</ul> </ul>
) : ( ) : (
<p className="sidebar__hint"> <p className="sidebar__hint">{tLabels('filter.none')}</p>
{filterIds.size > 0 ? tLabels('filter.none') : t('layout.sidebar.empty')} )
</p> ) : tree.length > 0 ? (
<ul className="sidebar__pages sidebar__pages--tree">
<PageTreeLevel
nodes={tree}
pondSlug={pondSlug}
pageSlug={pageSlug}
byId={byId}
collapsedIds={collapsedIds}
onToggleCollapsed={toggleCollapsed}
canReorder={canReorder}
draggedId={draggedId}
setDraggedId={setDraggedId}
onMove={moveWithinSiblings}
/>
</ul>
) : (
<p className="sidebar__hint">{t('layout.sidebar.empty')}</p>
)} )}
{creating ? ( {creating ? (
<NewPageForm <NewPageForm
pondId={pond.data.id} pondId={pond.id}
pondSlug={pondSlug!} pondSlug={pondSlug}
parentId={currentPage?.id ?? null}
parentTitle={currentPage?.title}
onCreated={() => { onCreated={() => {
setCreating(false); setCreating(false);
void queryClient.invalidateQueries({ queryKey: ['pages', pond.data!.id] }); void queryClient.invalidateQueries({ queryKey: ['pages', pond.id] });
}} }}
onCancel={() => setCreating(false)} onCancel={() => setCreating(false)}
/> />
@ -266,7 +309,7 @@ export function Sidebar({ collapsed }: SidebarProps): React.JSX.Element {
</button> </button>
)} )}
<ImportControl pondId={pond.data.id} pondSlug={pondSlug!} /> <ImportControl pondId={pond.id} pondSlug={pondSlug} />
{/* Keyboard/drag reordering announcements for screen readers. */} {/* Keyboard/drag reordering announcements for screen readers. */}
<p className="visually-hidden sidebar__announce" role="status" aria-live="polite"> <p className="visually-hidden sidebar__announce" role="status" aria-live="polite">
@ -283,7 +326,220 @@ export function Sidebar({ collapsed }: SidebarProps): React.JSX.Element {
</div> </div>
)} )}
</> </>
)} );
</nav> }
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>;
collapsedIds: string[];
onToggleCollapsed: (id: string) => void;
canReorder: boolean;
draggedId: string | null;
setDraggedId: (id: string | null) => void;
onMove: (movedId: string, siblingIds: string[], newIndex: number, title: string) => Promise<void>;
}
/** One sibling group of the folder view (issue #108), rendered recursively.
* Reordering (buttons and drag-between) stays within the group; dropping
* onto a page to reparent arrives with #109. */
function PageTreeLevel(props: PageTreeLevelProps): React.JSX.Element {
const { t } = useTranslation();
const {
nodes,
pondSlug,
pageSlug,
byId,
collapsedIds,
onToggleCollapsed,
canReorder,
draggedId,
setDraggedId,
onMove,
} = props;
const siblingIds = nodes.map((n) => n.id);
return (
<>
{nodes.map((node, index) => {
const hasChildren = node.children.length > 0;
const isCollapsed = collapsedIds.includes(node.id);
return (
<li
key={node.id}
className={
canReorder ? 'sidebar__page-item sidebar__page-item--draggable' : 'sidebar__page-item'
}
draggable={canReorder}
onDragStart={
canReorder
? (event) => {
event.stopPropagation();
setDraggedId(node.id);
event.dataTransfer.effectAllowed = 'move';
}
: undefined
}
onDragEnd={canReorder ? () => setDraggedId(null) : undefined}
onDragOver={canReorder ? (event) => event.preventDefault() : undefined}
onDrop={
canReorder
? (event) => {
event.preventDefault();
event.stopPropagation();
if (!draggedId || draggedId === node.id) return;
// Drag-between stays inside one sibling group (#108);
// cross-group drops become reparenting with #109.
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}
aria-label={t(
isCollapsed ? 'layout.sidebar.view.expand' : 'layout.sidebar.view.collapseNode',
{ title: node.title },
)}
onClick={() => onToggleCollapsed(node.id)}
>
</button>
) : (
<span className="sidebar__caret sidebar__caret--leaf" aria-hidden />
)}
<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>
); );
} }

View File

@ -0,0 +1,61 @@
import type { SidebarViewMode } from '@dorfteich/shared';
import { SIDEBAR_VIEW_MODES } from '@dorfteich/shared';
import { useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FormError, FormSuccess } from '../components/forms';
import { apiPatch } from '../lib/api';
/**
* The pond's default sidebar view (issue #108): folders (page tree) or
* labels. Owner-set; every member can still override it locally via the
* sidebar toggle. Rides the generic pond PATCH like CommentPolicySetting.
*/
export function SidebarViewSetting({
pondId,
pondSlug,
value,
}: {
pondId: string;
pondSlug: string;
value: SidebarViewMode;
}): React.JSX.Element {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [error, setError] = useState<unknown>(null);
const [saved, setSaved] = useState(false);
const save = async (view: SidebarViewMode): Promise<void> => {
setError(null);
setSaved(false);
try {
await apiPatch(`/ponds/${pondId}`, { sidebarView: view });
await queryClient.invalidateQueries({ queryKey: ['pond', pondSlug] });
setSaved(true);
} catch (err) {
setError(err);
}
};
return (
<div className="sidebar-view-setting">
<FormError error={error} />
<p className="sidebar-view-setting__hint">{t('layout.sidebar.view.defaultHint')}</p>
<label>
{t('layout.sidebar.view.defaultLabel')}{' '}
<select
value={value}
onChange={(event) => void save(event.target.value as SidebarViewMode)}
>
{SIDEBAR_VIEW_MODES.map((mode) => (
<option key={mode} value={mode}>
{t(`layout.sidebar.view.${mode}`)}
</option>
))}
</select>
</label>
{saved && <FormSuccess message={t('layout.sidebar.view.saved')} />}
</div>
);
}

View File

@ -15,6 +15,7 @@ import { AccessRulesManager } from '../access/AccessRulesManager';
import { EffectivePermissionsInspector } from '../access/EffectivePermissionsInspector'; import { EffectivePermissionsInspector } from '../access/EffectivePermissionsInspector';
import { PondFileManager } from '../files/PondFileManager'; import { PondFileManager } from '../files/PondFileManager';
import { apiGet } from '../lib/api'; import { apiGet } from '../lib/api';
import { SidebarViewSetting } from '../layout/SidebarViewSetting';
import { MemberManager } from '../members/MemberManager'; import { MemberManager } from '../members/MemberManager';
import { DeletePondSection } from '../ponds/DeletePondSection'; import { DeletePondSection } from '../ponds/DeletePondSection';
import { PondPluginSettings } from '../plugins/PondPluginSettings'; import { PondPluginSettings } from '../plugins/PondPluginSettings';
@ -37,6 +38,7 @@ export function PondSettingsPage(): React.JSX.Element {
const { t: tComments } = useTranslation('comments'); const { t: tComments } = useTranslation('comments');
const { t: tApiTokens } = useTranslation('apiTokens'); const { t: tApiTokens } = useTranslation('apiTokens');
const { t: tFont } = useTranslation('font'); const { t: tFont } = useTranslation('font');
const { t: tCommon } = useTranslation();
const { pondSlug = '' } = useParams<{ pondSlug: string }>(); const { pondSlug = '' } = useParams<{ pondSlug: string }>();
const { user } = useAuth(); const { user } = useAuth();
@ -95,6 +97,16 @@ export function PondSettingsPage(): React.JSX.Element {
</section> </section>
)} )}
{canModify && <PondPluginSettings pondId={pond.data.id} />} {canModify && <PondPluginSettings pondId={pond.data.id} />}
{canModify && (
<section>
<h2>{tCommon('layout.sidebar.view.defaultTitle')}</h2>
<SidebarViewSetting
pondId={pond.data.id}
pondSlug={pondSlug}
value={pond.data.settings.sidebarView}
/>
</section>
)}
{canModify && ( {canModify && (
<section> <section>
<h2>{tComments('policy.title')}</h2> <h2>{tComments('policy.title')}</h2>

View File

@ -159,6 +159,97 @@ button {
padding: 0; padding: 0;
} }
/* Folder view (issue #108): the page tree with collapsible nodes. */
.sidebar__view-toggle {
display: flex;
gap: var(--space-1);
margin-bottom: var(--space-2);
}
.sidebar__view-btn {
font: inherit;
font-size: 0.8rem;
padding: var(--space-1) var(--space-2);
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-bg);
color: var(--color-text-muted);
cursor: pointer;
}
.sidebar__view-btn--active {
color: var(--color-text);
font-weight: 600;
background: var(--color-surface);
}
.sidebar__tree-row {
display: flex;
align-items: center;
gap: var(--space-1);
min-width: 0;
}
.sidebar__tree-row .sidebar__page {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar__tree-children {
margin: 0 0 0 var(--space-3);
}
.sidebar__caret {
flex: 0 0 auto;
width: 1rem;
border: 0;
background: none;
padding: 0;
cursor: pointer;
color: var(--color-text-muted);
font-size: 0.7rem;
transform: rotate(90deg);
transition: transform 0.12s ease;
}
.sidebar__caret--collapsed {
transform: rotate(0deg);
}
.sidebar__caret--leaf {
cursor: default;
}
/* Label view (issue #108): pages grouped under the label tree. */
.sidebar__label-group {
margin-bottom: var(--space-2);
}
.sidebar__label-heading {
display: flex;
align-items: center;
gap: var(--space-1);
margin: 0 0 var(--space-1);
font-size: 0.8rem;
font-weight: 600;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.sidebar__label-group .sidebar__pages {
margin-bottom: 0;
}
.sidebar__new-page-parent {
margin: 0 0 var(--space-2);
font-size: 0.8rem;
color: var(--color-text-muted);
}
.sidebar__page { .sidebar__page {
display: block; display: block;
padding: var(--space-1) var(--space-2); padding: var(--space-1) var(--space-2);

View File

@ -21,8 +21,21 @@
}, },
"newPage": "+ Neue Seite", "newPage": "+ Neue Seite",
"newPageTitle": "Titel", "newPageTitle": "Titel",
"newPageUnder": "Wird unter „{{title}}“ angelegt.",
"create": "Erstellen", "create": "Erstellen",
"cancel": "Abbrechen" "cancel": "Abbrechen",
"view": {
"toggleLabel": "Ansicht der Seitenleiste",
"folders": "Ordner",
"labels": "Labels",
"unlabeled": "Ohne Label",
"expand": "{{title}} ausklappen",
"collapseNode": "{{title}} einklappen",
"defaultTitle": "Seitenleisten-Ansicht",
"defaultLabel": "Standard-Ansicht",
"defaultHint": "Der Standard für alle Mitglieder; jeder kann seine eigene Seitenleiste lokal umschalten.",
"saved": "Gespeichert."
}
}, },
"pondSwitcher": { "pondSwitcher": {
"label": "Teich wechseln", "label": "Teich wechseln",

View File

@ -21,8 +21,21 @@
}, },
"newPage": "+ New page", "newPage": "+ New page",
"newPageTitle": "Title", "newPageTitle": "Title",
"newPageUnder": "Will be created under “{{title}}”.",
"create": "Create", "create": "Create",
"cancel": "Cancel" "cancel": "Cancel",
"view": {
"toggleLabel": "Sidebar view",
"folders": "Folders",
"labels": "Labels",
"unlabeled": "Unlabeled",
"expand": "Expand {{title}}",
"collapseNode": "Collapse {{title}}",
"defaultTitle": "Sidebar view",
"defaultLabel": "Default view",
"defaultHint": "The default for all members; everyone can switch their own sidebar locally.",
"saved": "Saved."
}
}, },
"pondSwitcher": { "pondSwitcher": {
"label": "Switch pond", "label": "Switch pond",

View File

@ -14,6 +14,11 @@ export type PondType = (typeof POND_TYPES)[number];
export const SIDEBAR_SORT_MODES = ['alpha', 'created', 'manual'] as const; export const SIDEBAR_SORT_MODES = ['alpha', 'created', 'manual'] as const;
export type SidebarSortMode = (typeof SIDEBAR_SORT_MODES)[number]; export type SidebarSortMode = (typeof SIDEBAR_SORT_MODES)[number];
/** How the sidebar presents a pond's pages (issue #108): as the page tree
* (`folders`) or grouped under the label tree (`labels`). */
export const SIDEBAR_VIEW_MODES = ['folders', 'labels'] as const;
export type SidebarViewMode = (typeof SIDEBAR_VIEW_MODES)[number];
/** One font slot per ADR 0016; values reference the curated catalog. */ /** One font slot per ADR 0016; values reference the curated catalog. */
const fontSlotSchema = z.object({ const fontSlotSchema = z.object({
family: z.string().min(1).max(80), family: z.string().min(1).max(80),
@ -36,6 +41,9 @@ export type PondFonts = z.infer<typeof pondFontsSchema>;
*/ */
export const pondSettingsSchema = z.object({ export const pondSettingsSchema = z.object({
sidebarSort: z.enum(SIDEBAR_SORT_MODES).default('alpha'), sidebarSort: z.enum(SIDEBAR_SORT_MODES).default('alpha'),
/** The pond default for the sidebar's page presentation (issue #108);
* every member can override it locally (`ui.sidebar.view.<pondId>`). */
sidebarView: z.enum(SIDEBAR_VIEW_MODES).default('folders'),
fonts: pondFontsSchema.default({}), fonts: pondFontsSchema.default({}),
/** Who may write comments (issue #91): every reader, or editors only. */ /** Who may write comments (issue #91): every reader, or editors only. */
commentPolicy: z.enum(COMMENT_POLICIES).default('readers'), commentPolicy: z.enum(COMMENT_POLICIES).default('readers'),
@ -73,6 +81,7 @@ export const updatePondInputSchema = z
name: pondNameSchema, name: pondNameSchema,
description: z.string().trim().max(500, 'validation.tooLong'), description: z.string().trim().max(500, 'validation.tooLong'),
sidebarSort: z.enum(SIDEBAR_SORT_MODES), sidebarSort: z.enum(SIDEBAR_SORT_MODES),
sidebarView: z.enum(SIDEBAR_VIEW_MODES),
fonts: pondFontsSchema, fonts: pondFontsSchema,
commentPolicy: z.enum(COMMENT_POLICIES), commentPolicy: z.enum(COMMENT_POLICIES),
apiEnabled: z.boolean(), apiEnabled: z.boolean(),