import type { PageListItemView, PondView, TreeNode } from '@dorfteich/shared'; import { buildTree, collectSubtreeIds } from '@dorfteich/shared'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useId, 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'; import { useModalFocus } from '../lib/use-modal-focus'; /** * "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, returnFocusRef, }: { pageId: string; pondSlug: string; onClose: () => void; /** Where focus goes after closing when the opening control unmounted. */ returnFocusRef?: React.RefObject; }): React.JSX.Element { const { t } = useTranslation('editor'); const queryClient = useQueryClient(); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); const dialogRef = useRef(null); const titleId = useId(); useDismissable(dialogRef, true, onClose); useModalFocus(dialogRef, returnFocusRef); 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 }) => (
  • ))}
); }