From 15184876bd4553ff804797eed14f180f22907d08 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Tue, 14 Jul 2026 10:17:36 +0200 Subject: [PATCH] Sidebar folder view, label view, and the view toggle (#108) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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.), 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 --- apps/api/src/ponds/ponds.service.ts | 2 + apps/web/src/layout/NewPageForm.tsx | 13 +- apps/web/src/layout/Sidebar.tsx | 684 ++++++++++++++------- apps/web/src/layout/SidebarViewSetting.tsx | 61 ++ apps/web/src/pages/PondSettingsPage.tsx | 12 + apps/web/src/styles/base.css | 91 +++ packages/shared/i18n/de/common.json | 15 +- packages/shared/i18n/en/common.json | 15 +- packages/shared/src/ponds.ts | 9 + 9 files changed, 685 insertions(+), 217 deletions(-) create mode 100644 apps/web/src/layout/SidebarViewSetting.tsx diff --git a/apps/api/src/ponds/ponds.service.ts b/apps/api/src/ponds/ponds.service.ts index ea27083..60384cf 100644 --- a/apps/api/src/ponds/ponds.service.ts +++ b/apps/api/src/ponds/ponds.service.ts @@ -150,6 +150,7 @@ export class PondsService { // whichever of the settings keys this request changes (#26/#66/#91/#104). const settingsChanged = input.sidebarSort !== undefined || + input.sidebarView !== undefined || input.fonts !== undefined || input.commentPolicy !== undefined || input.apiEnabled !== undefined || @@ -159,6 +160,7 @@ export class PondsService { : { ...(pond.settings as object), ...(input.sidebarSort !== undefined ? { sidebarSort: input.sidebarSort } : {}), + ...(input.sidebarView !== undefined ? { sidebarView: input.sidebarView } : {}), ...(input.fonts !== undefined ? { fonts: input.fonts } : {}), ...(input.commentPolicy !== undefined ? { commentPolicy: input.commentPolicy } : {}), ...(input.apiEnabled !== undefined ? { apiEnabled: input.apiEnabled } : {}), diff --git a/apps/web/src/layout/NewPageForm.tsx b/apps/web/src/layout/NewPageForm.tsx index 5df0935..c78b295 100644 --- a/apps/web/src/layout/NewPageForm.tsx +++ b/apps/web/src/layout/NewPageForm.tsx @@ -11,6 +11,10 @@ import { apiPost } from '../lib/api'; interface NewPageFormProps { pondId: 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; onCancel: () => void; } @@ -19,6 +23,8 @@ interface NewPageFormProps { export function NewPageForm({ pondId, pondSlug, + parentId = null, + parentTitle, onCreated, onCancel, }: NewPageFormProps): React.JSX.Element { @@ -30,7 +36,7 @@ export function NewPageForm({ const onSubmit = form.handleSubmit(async (input) => { setError(null); try { - const page = await apiPost(`/ponds/${pondId}/pages`, input); + const page = await apiPost(`/ponds/${pondId}/pages`, { ...input, parentId }); onCreated(); navigate(`/p/${pondSlug}/${page.slug}`); } catch (err) { @@ -51,6 +57,11 @@ export function NewPageForm({ }} /> + {parentId && parentTitle && ( +

+ {t('layout.sidebar.newPageUnder', { title: parentTitle })} +

+ )}
+ ))} +
+ + {view === 'folders' && flat.length > 0 && ( +
+ + {tLabels('filter.toggle')} + {filterIds.size > 0 && ` (${filterIds.size})`} + +
    + {flat.map((label) => ( +
  • + +
  • + ))} +
+ {filterIds.size > 0 && ( + + )} +
+ )} + + {view === 'labels' ? ( + pages.data && pages.data.length > 0 ? ( + + ) : ( +

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

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

{tLabels('filter.none')}

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

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

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

+ {announcement} +

+ + {/* The trash stays a text link, pinned to the sidebar's bottom + (M10 follow-up; pond settings moved to the TopBar gear icon). */} + {isOwner && ( +
+ + {t('editor:trash.link')} + +
+ )} + + ); +} + +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; + 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; +} + +/** 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 ( +
  • { + 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 + } + > + + {hasChildren ? ( - )} - - )} - - {visiblePages && visiblePages.length > 0 ? ( -
      - {visiblePages.map((p, index) => ( -
    • { - 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 - } - > - - {p.title} - - {canReorder && ( - - - - + aria-expanded={!isCollapsed} + aria-label={t( + isCollapsed ? 'layout.sidebar.view.expand' : 'layout.sidebar.view.collapseNode', + { title: node.title }, )} - + onClick={() => onToggleCollapsed(node.id)} + > + ▸ + + ) : ( + + )} + + {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) => ( +
      • +
      • ))}
      - ) : ( -

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

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

      - {announcement} -

      - - {/* The trash stays a text link, pinned to the sidebar's bottom - (M10 follow-up; pond settings moved to the TopBar gear icon). */} - {isOwner && ( -
      - - {t('editor:trash.link')} - -
      - )} - +
      + ); + })} + {unlabeled.length > 0 && ( +
      +

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

      +
        + {unlabeled.map((p) => ( +
      • + +
      • + ))} +
      +
      )} - +
      ); } diff --git a/apps/web/src/layout/SidebarViewSetting.tsx b/apps/web/src/layout/SidebarViewSetting.tsx new file mode 100644 index 0000000..113d402 --- /dev/null +++ b/apps/web/src/layout/SidebarViewSetting.tsx @@ -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(null); + const [saved, setSaved] = useState(false); + + const save = async (view: SidebarViewMode): Promise => { + setError(null); + setSaved(false); + try { + await apiPatch(`/ponds/${pondId}`, { sidebarView: view }); + await queryClient.invalidateQueries({ queryKey: ['pond', pondSlug] }); + setSaved(true); + } catch (err) { + setError(err); + } + }; + + return ( +
      + +

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

      + + {saved && } +
      + ); +} diff --git a/apps/web/src/pages/PondSettingsPage.tsx b/apps/web/src/pages/PondSettingsPage.tsx index 988062b..5491cdd 100644 --- a/apps/web/src/pages/PondSettingsPage.tsx +++ b/apps/web/src/pages/PondSettingsPage.tsx @@ -15,6 +15,7 @@ import { AccessRulesManager } from '../access/AccessRulesManager'; import { EffectivePermissionsInspector } from '../access/EffectivePermissionsInspector'; import { PondFileManager } from '../files/PondFileManager'; import { apiGet } from '../lib/api'; +import { SidebarViewSetting } from '../layout/SidebarViewSetting'; import { MemberManager } from '../members/MemberManager'; import { DeletePondSection } from '../ponds/DeletePondSection'; import { PondPluginSettings } from '../plugins/PondPluginSettings'; @@ -37,6 +38,7 @@ export function PondSettingsPage(): React.JSX.Element { const { t: tComments } = useTranslation('comments'); const { t: tApiTokens } = useTranslation('apiTokens'); const { t: tFont } = useTranslation('font'); + const { t: tCommon } = useTranslation(); const { pondSlug = '' } = useParams<{ pondSlug: string }>(); const { user } = useAuth(); @@ -95,6 +97,16 @@ export function PondSettingsPage(): React.JSX.Element { )} {canModify && } + {canModify && ( +
      +

      {tCommon('layout.sidebar.view.defaultTitle')}

      + +
      + )} {canModify && (

      {tComments('policy.title')}

      diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css index 68e9b25..4cc3aa0 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -159,6 +159,97 @@ button { 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 { display: block; padding: var(--space-1) var(--space-2); diff --git a/packages/shared/i18n/de/common.json b/packages/shared/i18n/de/common.json index b0b8587..e6ea359 100644 --- a/packages/shared/i18n/de/common.json +++ b/packages/shared/i18n/de/common.json @@ -21,8 +21,21 @@ }, "newPage": "+ Neue Seite", "newPageTitle": "Titel", + "newPageUnder": "Wird unter „{{title}}“ angelegt.", "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": { "label": "Teich wechseln", diff --git a/packages/shared/i18n/en/common.json b/packages/shared/i18n/en/common.json index 616c8d7..b493b61 100644 --- a/packages/shared/i18n/en/common.json +++ b/packages/shared/i18n/en/common.json @@ -21,8 +21,21 @@ }, "newPage": "+ New page", "newPageTitle": "Title", + "newPageUnder": "Will be created under “{{title}}”.", "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": { "label": "Switch pond", diff --git a/packages/shared/src/ponds.ts b/packages/shared/src/ponds.ts index 78f6c0d..0946615 100644 --- a/packages/shared/src/ponds.ts +++ b/packages/shared/src/ponds.ts @@ -14,6 +14,11 @@ export type PondType = (typeof POND_TYPES)[number]; export const SIDEBAR_SORT_MODES = ['alpha', 'created', 'manual'] as const; 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. */ const fontSlotSchema = z.object({ family: z.string().min(1).max(80), @@ -36,6 +41,9 @@ export type PondFonts = z.infer; */ export const pondSettingsSchema = z.object({ 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.`). */ + sidebarView: z.enum(SIDEBAR_VIEW_MODES).default('folders'), fonts: pondFontsSchema.default({}), /** Who may write comments (issue #91): every reader, or editors only. */ commentPolicy: z.enum(COMMENT_POLICIES).default('readers'), @@ -73,6 +81,7 @@ export const updatePondInputSchema = z name: pondNameSchema, description: z.string().trim().max(500, 'validation.tooLong'), sidebarSort: z.enum(SIDEBAR_SORT_MODES), + sidebarView: z.enum(SIDEBAR_VIEW_MODES), fonts: pondFontsSchema, commentPolicy: z.enum(COMMENT_POLICIES), apiEnabled: z.boolean(),