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
/p/:pondSlug/graph (static segment ranked above :pageSlug, same documented reserved-slug gap as trash/settings) renders the pond's readable wikilink graph from GET /ponds/:id/links: pages as nodes colored by their first label (legend included, DEFAULT_LABEL_COLOR for unlabeled), resolved links as edges, phantom targets as dashed nodes — clicking one offers to create the page, which resolves its links. Rendering is a self-contained SVG force graph: only d3-force is bundled (no d3 DOM/zoom modules, zero external requests); the layout runs synchronously to rest, zoom/pan/node-drag are plain pointer math. SVG over canvas deliberately — every node carries a data-testid the e2e packs can click. Ponds beyond 500 pages get a capped-view notice. Sidebar footer links every member to the graph (trash stays owner-only). New i18n namespace graph (de+en). Verified live: nodes/edges/legend render, node click opens the page, phantom click creates it and the node turns solid. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
635 lines
22 KiB
TypeScript
635 lines
22 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 { 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 { 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;
|
|
}
|
|
|
|
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 }: 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}
|
|
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 [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 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 visiblePages =
|
|
filterIds.size === 0
|
|
? pages.data
|
|
: 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 {
|
|
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;
|
|
|
|
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>
|
|
))}
|
|
</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' ? (
|
|
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">
|
|
{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">{tLabels('filter.none')}</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}
|
|
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)}
|
|
/>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
className="linklike sidebar__new-page"
|
|
onClick={() => setCreating(true)}
|
|
>
|
|
{t('layout.sidebar.newPage')}
|
|
</button>
|
|
)}
|
|
|
|
<ImportControl pondId={pond.id} pondSlug={pondSlug} />
|
|
|
|
{/* Keyboard/drag reordering announcements for screen readers. */}
|
|
<p className="visually-hidden sidebar__announce" role="status" aria-live="polite">
|
|
{announcement}
|
|
</p>
|
|
|
|
{/* Graph + trash stay text links, pinned to the sidebar's bottom
|
|
(M10 follow-up; pond settings moved to the TopBar gear icon).
|
|
The graph is for every member (#112); the trash is owner-only. */}
|
|
<div className="sidebar__footer">
|
|
<Link to={`/p/${pondSlug}/graph`} className="linklike sidebar__graph-link">
|
|
{t('graph:link')}
|
|
</Link>
|
|
{isOwner && (
|
|
<Link to={`/p/${pondSlug}/trash`} className="linklike sidebar__trash-link">
|
|
{t('editor:trash.link')}
|
|
</Link>
|
|
)}
|
|
</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>;
|
|
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,
|
|
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)}
|
|
>
|
|
▸
|
|
</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>
|
|
);
|
|
}
|