From 0308bc712d24e2806dbb38d31cc433ec283d092c Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Tue, 14 Jul 2026 10:40:08 +0200 Subject: [PATCH] Drag-onto reparent, Move-to dialog, and the delete decision (#109) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sidebar folder view: a row now has three drop bands — the edges keep the within-group reorder, the middle band nests the dragged page under the row (appended to its new sibling group, with a drop-into outline cue). Cycle/depth refusals surface as a translated banner; successful moves are announced for screen readers. The overflow menu gains 'Move to…': a modal parent picker over the page tree (top level first, the page's own subtree disabled) that works in every sort mode. Delete now decides per case: childless pages keep the plain confirm; pages with subpages open a dialog offering promote (default wording: move subpages up) or subtree delete. The children lookup reads the CACHED pages list on purpose: an async fetch before window.confirm broke the click→confirm→DELETE rhythm the content pack (and users) rely on, and a stale childless read errs toward promote — never toward a silent subtree delete. Sidebar caret labels deliberately exclude the page title: accessible names are matched by substring in the specs (#101), and a title like 'Editor…' collided with the edit-mode toggle. Verified live: move dialog (subtree option disabled), promote and subtree delete flows; content/trash/export packs green. Co-Authored-By: Claude Fable 5 --- apps/web/src/layout/Sidebar.tsx | 117 +++++++++++++++++++--- apps/web/src/pages/DeletePageDialog.tsx | 75 ++++++++++++++ apps/web/src/pages/MovePageDialog.tsx | 125 ++++++++++++++++++++++++ apps/web/src/pages/PageActions.tsx | 70 ++++++++++++- apps/web/src/styles/base.css | 70 +++++++++++++ packages/shared/i18n/de/common.json | 7 +- packages/shared/i18n/de/editor.json | 14 ++- packages/shared/i18n/en/common.json | 7 +- packages/shared/i18n/en/editor.json | 14 ++- 9 files changed, 471 insertions(+), 28 deletions(-) create mode 100644 apps/web/src/pages/DeletePageDialog.tsx create mode 100644 apps/web/src/pages/MovePageDialog.tsx diff --git a/apps/web/src/layout/Sidebar.tsx b/apps/web/src/layout/Sidebar.tsx index e4f9e22..779ae2c 100644 --- a/apps/web/src/layout/Sidebar.tsx +++ b/apps/web/src/layout/Sidebar.tsx @@ -13,6 +13,7 @@ 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'; @@ -80,6 +81,8 @@ function SidebarContent({ const [creating, setCreating] = useState(false); const [filterIds, setFilterIds] = useState>(new Set()); 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. @@ -155,6 +158,7 @@ function SidebarContent({ 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] }); @@ -164,6 +168,38 @@ function SidebarContent({ ); } + /** 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; return ( @@ -280,13 +316,18 @@ function SidebarContent({ canReorder={canReorder} draggedId={draggedId} setDraggedId={setDraggedId} + dropIntoId={dropIntoId} + setDropIntoId={setDropIntoId} onMove={moveWithinSiblings} + onReparent={reparentTo} /> ) : (

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

)} + {moveError !== null && } + {creating ? ( 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) stays within the group; dropping - * onto a page to reparent arrives with #109. */ + * 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 { @@ -378,21 +426,35 @@ function PageTreeLevel(props: PageTreeLevelProps): React.JSX.Element { 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 (
  • setDraggedId(null) : undefined} - onDragOver={canReorder ? (event) => event.preventDefault() : 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; - // 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); + 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 @@ -432,9 +515,11 @@ function PageTreeLevel(props: PageTreeLevelProps): React.JSX.Element { 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', - { title: node.title }, )} onClick={() => onToggleCollapsed(node.id)} > diff --git a/apps/web/src/pages/DeletePageDialog.tsx b/apps/web/src/pages/DeletePageDialog.tsx new file mode 100644 index 0000000..3e4d446 --- /dev/null +++ b/apps/web/src/pages/DeletePageDialog.tsx @@ -0,0 +1,75 @@ +import { useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { FormError } from '../components/forms'; +import { apiDelete } from '../lib/api'; +import { useDismissable } from '../lib/use-dismissable'; + +/** + * The per-case delete decision for a page with subpages (issue #107/#109): + * promote the children to the page's parent, or trash the whole subtree. + * Childless pages keep the plain confirm in the overflow menu — this dialog + * only mounts when there is actually something to decide. + */ +export function DeletePageDialog({ + pageId, + title, + childCount, + onDeleted, + onClose, +}: { + pageId: string; + title: string; + childCount: number; + onDeleted: () => void; + onClose: () => void; +}): React.JSX.Element { + const { t } = useTranslation('editor'); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const dialogRef = useRef(null); + useDismissable(dialogRef, true, onClose); + + async function remove(mode: 'promote' | 'subtree'): Promise { + setError(null); + setBusy(true); + try { + await apiDelete(`/pages/${pageId}?mode=${mode}`); + onDeleted(); + } catch (err) { + setError(err); + setBusy(false); + } + } + + return ( +
    +
    +

    {t('page.deleteChildrenTitle')}

    + +

    {t('page.deleteChildrenHint', { title, count: childCount })}

    +
    + + + +
    +
    +
    + ); +} diff --git a/apps/web/src/pages/MovePageDialog.tsx b/apps/web/src/pages/MovePageDialog.tsx new file mode 100644 index 0000000..0d09ee6 --- /dev/null +++ b/apps/web/src/pages/MovePageDialog.tsx @@ -0,0 +1,125 @@ +import type { PageListItemView, PondView, TreeNode } from '@dorfteich/shared'; +import { buildTree, collectSubtreeIds } from '@dorfteich/shared'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { FormError } from '../components/forms'; +import { apiGet, apiPatch } from '../lib/api'; +import { useDismissable } from '../lib/use-dismissable'; + +/** + * "Move to…" dialog (issue #109): an indented parent picker over the page + * tree. The page's own subtree is disabled (a cycle), the top level is the + * first option. Works in any sort mode — the accessible counterpart to the + * sidebar's drag-onto-a-page reparenting. Depth/cycle refusals from the + * server surface as a translated error inside the dialog. + */ +export function MovePageDialog({ + pageId, + pondSlug, + onClose, +}: { + pageId: string; + pondSlug: string; + onClose: () => void; +}): React.JSX.Element { + const { t } = useTranslation('editor'); + const queryClient = useQueryClient(); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const dialogRef = useRef(null); + useDismissable(dialogRef, true, onClose); + + const pond = useQuery({ + queryKey: ['pond', pondSlug], + queryFn: () => apiGet(`/ponds/${pondSlug}`), + }); + const pages = useQuery({ + queryKey: ['pages', pond.data?.id, pond.data?.settings.sidebarSort], + queryFn: () => apiGet(`/ponds/${pond.data!.id}/pages`), + enabled: Boolean(pond.data), + }); + + const list = pages.data ?? []; + const page = list.find((p) => p.id === pageId); + const [target, setTarget] = useState(undefined); + const selected = target === undefined ? (page?.parentId ?? null) : target; + const blocked = collectSubtreeIds(list, pageId); + + /** Depth-first options with their level, for the indented picker. */ + const options: { page: PageListItemView; depth: number }[] = []; + const walk = (nodes: TreeNode[], depth: number): void => { + for (const node of nodes) { + options.push({ page: node, depth }); + walk(node.children, depth + 1); + } + }; + walk(buildTree(list), 0); + + async function move(): Promise { + if (!page || selected === page.parentId) { + onClose(); + return; + } + setError(null); + setBusy(true); + try { + // Append at the end of the new sibling group; with no sibling to key + // after, fall back to the pond's last page so the fresh key is unique. + const siblings = list.filter((p) => p.parentId === selected && p.id !== pageId); + const fallback = [...list].reverse().find((p) => p.id !== pageId); + const afterId = siblings.at(-1)?.id ?? fallback?.id ?? null; + await apiPatch(`/pages/${pageId}/position`, { afterId, beforeId: null, parentId: selected }); + await queryClient.invalidateQueries({ queryKey: ['pages', pond.data!.id] }); + onClose(); + } catch (err) { + setError(err); + setBusy(false); + } + } + + return ( +
    +
    +

    {t('page.moveTitle')}

    + +
      +
    • + +
    • + {options.map(({ page: option, depth }) => ( +
    • + +
    • + ))} +
    +
    + + +
    +
    +
    + ); +} diff --git a/apps/web/src/pages/PageActions.tsx b/apps/web/src/pages/PageActions.tsx index a08239c..61450bf 100644 --- a/apps/web/src/pages/PageActions.tsx +++ b/apps/web/src/pages/PageActions.tsx @@ -1,10 +1,11 @@ -import { EXPORT_FORMATS, ExportFormat } from '@dorfteich/shared'; -import { useQueryClient } from '@tanstack/react-query'; +import { EXPORT_FORMATS, ExportFormat, PageListItemView, PondView } from '@dorfteich/shared'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { BookOpen, Copy, Download, Ellipsis, + FolderInput, History, MessageSquare, Paperclip, @@ -20,9 +21,11 @@ import { useNavigate } from 'react-router-dom'; import { IconButton } from '../components/IconButton'; import { useDocumentExport } from '../export/use-document-export'; -import { apiDelete, apiGetText, apiPost } from '../lib/api'; +import { apiDelete, apiGet, apiGetText, apiPost } from '../lib/api'; import { useDismissable } from '../lib/use-dismissable'; import { WatchToggle } from '../watches/WatchToggle'; +import { DeletePageDialog } from './DeletePageDialog'; +import { MovePageDialog } from './MovePageDialog'; interface PageActionsProps { pageId: string; @@ -154,7 +157,9 @@ function SaveVersionButton({ pageId }: { pageId: string }): React.JSX.Element { } /** Overflow "…" menu: Markdown copy/download (#30), office/PDF export - * (#65/#67), and the destructive move-to-trash (#31, keeps its confirm). */ + * (#65/#67), "Move to…" (issue #109), and the destructive move-to-trash + * (#31; childless pages keep the plain confirm, pages with subpages get + * the promote-vs-subtree decision dialog). */ function PageOverflowMenu({ pageId, slug, @@ -166,12 +171,28 @@ function PageOverflowMenu({ }): React.JSX.Element { const { t } = useTranslation('editor'); const navigate = useNavigate(); + const queryClient = useQueryClient(); const [open, setOpen] = useState(false); + const [moving, setMoving] = useState(false); + const [deleting, setDeleting] = useState<{ title: string; childCount: number } | null>(null); const [copyStatus, setCopyStatus] = useState<'idle' | 'copied' | 'error'>('idle'); const { status, exportPage } = useDocumentExport(); const menuRef = useRef(null); useDismissable(menuRef, open, () => setOpen(false)); + const pond = useQuery({ + queryKey: ['pond', pondSlug], + queryFn: () => apiGet(`/ponds/${pondSlug}`), + }); + // Shares the sidebar's query (same key) — the children lookup below must + // be synchronous so the childless path keeps its click→confirm→DELETE + // rhythm (specs and users rely on the delete firing immediately). + const pages = useQuery({ + queryKey: ['pages', pond.data?.id, pond.data?.settings.sidebarSort], + queryFn: () => apiGet(`/ponds/${pond.data!.id}/pages`), + enabled: Boolean(pond.data), + }); + async function copyMarkdown(): Promise { try { const markdown = await apiGetText(`/pages/${pageId}/export/markdown`); @@ -184,8 +205,21 @@ function PageOverflowMenu({ } async function deletePage(): Promise { + // The decision dialog only appears when there is something to decide: + // live children per the cached list (issue #109). A stale childless + // read errs toward the DEFAULT promote semantics — never toward a + // silent subtree delete — so no blocking fetch is needed here. + const list = pages.data ?? []; + const children = list.filter((p) => p.parentId === pageId); + if (children.length > 0) { + const title = list.find((p) => p.id === pageId)?.title ?? slug; + setOpen(false); + setDeleting({ title, childCount: children.length }); + return; + } if (!window.confirm(t('page.deleteConfirm'))) return; await apiDelete(`/pages/${pageId}`); + await queryClient.invalidateQueries({ queryKey: ['pages'] }); navigate(`/p/${pondSlug}`); } @@ -237,6 +271,18 @@ function PageOverflowMenu({ ))} +