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 { 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(`/ponds/${pondSlug}`), enabled: pondSlug !== null, }); return ( ); } 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>(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(null); const [dropIntoId, setDropIntoId] = useState(null); const [moveError, setMoveError] = useState(null); const [announcement, setAnnouncement] = useState(''); // Per-user view override (issue #108); `null` follows the pond default. const [viewOverride, setViewOverride] = usePersistentState( `ui.sidebar.view.${pond.id}`, null, ); const view: SidebarViewMode = viewOverride ?? pond.settings.sidebarView; const [collapsedIds, setCollapsedIds] = usePersistentState( `ui.sidebar.collapsedPages.${pond.id}`, [], ); const pages = useQuery({ queryKey: ['pages', pond.id, pond.settings.sidebarSort], queryFn: () => apiGet(`/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(); 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 { 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 { 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 { 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 ( <>

{pond.name}

{isOwner && ( )}
{VIEW_MODES.map((mode) => ( ))} {/* Latching favorites filter (issue #132) — narrows either view. */}
{view === 'folders' && flat.length > 0 && (
{tLabels('filter.toggle')} {filterIds.size > 0 && ` (${filterIds.size})`}
    {flat.map((label) => (
  • ))}
{filterIds.size > 0 && ( )}
)} {view === 'labels' ? ( labelViewPages && labelViewPages.length > 0 ? ( ) : (

{favoritesOnly ? t('layout.sidebar.favorites.empty') : t('layout.sidebar.empty')}

) ) : showFlatFallback ? ( visiblePages && visiblePages.length > 0 ? (
    {visiblePages.map((p) => (
  • ))}
) : (

{filterIds.size > 0 ? tLabels('filter.none') : t('layout.sidebar.favorites.empty')}

) ) : tree.length > 0 ? (
) : (

{t('layout.sidebar.empty')}

)} {moveError !== null && } {creating && ( { setCreating(false); void queryClient.invalidateQueries({ queryKey: ['pages', pond.id] }); }} onCancel={() => setCreating(false)} /> )} {/* Keyboard/drag reordering announcements for screen readers. */}

{announcement}

{/* 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. */}
{isOwner && ( )}
); } function PageLink({ page, pondSlug, pageSlug, }: { page: PageListItemView; pondSlug: string; pageSlug: string | null; }): React.JSX.Element { return ( {page.title} ); } interface PageTreeLevelProps { nodes: TreeNode[]; pondSlug: string; pageSlug: string | null; byId: Map; favoriteIds: Set; 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; onReparent: (movedId: string, newParentId: string) => Promise; } /** 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): '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 (
  • { 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 } > {hasChildren ? ( ) : ( )} {/* Favorites carry a golden icon (issue #132). */} {hasChildren ? isCollapsed ? : : } {canReorder && ( )} {hasChildren && !isCollapsed && (
    )}
  • ); })} ); } /** * 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 (
    {labels.map((label) => { const tagged = pages.filter((p) => p.labelIds.includes(label.id)); if (tagged.length === 0) return null; return (

    {label.name}

      {tagged.map((p) => (
    • ))}
    ); })} {unlabeled.length > 0 && (

    {t('layout.sidebar.view.unlabeled')}

      {unlabeled.map((p) => (
    • ))}
    )}
    ); }