Drag-onto reparent, Move-to dialog, and the delete decision (#109)
Some checks failed
CI / Import/export fidelity gate (push) Blocked by required conditions
CD / Build and push images (push) Successful in 3m58s
CD / Deploy to Test (push) Successful in 9s
CI / Lint, typecheck, test (push) Successful in 4m23s
CI / Build container images (push) Has been skipped
CD / Smoke tests against Test (push) Successful in 1m16s
CD / Promote to Int (push) Successful in 11s
CI / Auth e2e pack (push) Has been cancelled

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 <noreply@anthropic.com>
This commit is contained in:
Claude Fable 5 2026-07-14 10:40:08 +02:00
parent e957c2a28f
commit 0308bc712d
9 changed files with 471 additions and 28 deletions

View File

@ -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<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.
@ -155,6 +158,7 @@ function SidebarContent({
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] });
@ -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<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 (
@ -280,13 +316,18 @@ function SidebarContent({
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}
@ -360,12 +401,19 @@ interface PageTreeLevelProps {
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) 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<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={
canReorder ? 'sidebar__page-item sidebar__page-item--draggable' : 'sidebar__page-item'
}
className={itemClasses}
draggable={canReorder}
onDragStart={
canReorder
@ -403,22 +465,43 @@ function PageTreeLevel(props: PageTreeLevelProps): React.JSX.Element {
}
: undefined
}
onDragEnd={canReorder ? () => 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 (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)}
>

View File

@ -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<unknown>(null);
const [busy, setBusy] = useState(false);
const dialogRef = useRef<HTMLDivElement>(null);
useDismissable(dialogRef, true, onClose);
async function remove(mode: 'promote' | 'subtree'): Promise<void> {
setError(null);
setBusy(true);
try {
await apiDelete(`/pages/${pageId}?mode=${mode}`);
onDeleted();
} catch (err) {
setError(err);
setBusy(false);
}
}
return (
<div className="modal-overlay">
<div className="modal delete-page-dialog" role="dialog" aria-modal="true" ref={dialogRef}>
<h2 className="modal__title">{t('page.deleteChildrenTitle')}</h2>
<FormError error={error} />
<p>{t('page.deleteChildrenHint', { title, count: childCount })}</p>
<div className="modal__actions modal__actions--stacked">
<button
type="button"
className="button delete-page-dialog__promote"
disabled={busy}
onClick={() => void remove('promote')}
>
{t('page.deletePromote')}
</button>
<button
type="button"
className="button button--danger delete-page-dialog__subtree"
disabled={busy}
onClick={() => void remove('subtree')}
>
{t('page.deleteSubtree', { count: childCount })}
</button>
<button type="button" className="linklike" onClick={onClose}>
{t('page.deleteCancel')}
</button>
</div>
</div>
</div>
);
}

View File

@ -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<unknown>(null);
const [busy, setBusy] = useState(false);
const dialogRef = useRef<HTMLDivElement>(null);
useDismissable(dialogRef, true, onClose);
const pond = useQuery({
queryKey: ['pond', pondSlug],
queryFn: () => apiGet<PondView>(`/ponds/${pondSlug}`),
});
const pages = useQuery({
queryKey: ['pages', pond.data?.id, pond.data?.settings.sidebarSort],
queryFn: () => apiGet<PageListItemView[]>(`/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<string | null | undefined>(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<PageListItemView>[], 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<void> {
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 (
<div className="modal-overlay">
<div className="modal move-dialog" role="dialog" aria-modal="true" ref={dialogRef}>
<h2 className="modal__title">{t('page.moveTitle')}</h2>
<FormError error={error} />
<ul className="move-dialog__options">
<li>
<label className="move-dialog__option">
<input
type="radio"
name="move-target"
checked={selected === null}
onChange={() => setTarget(null)}
/>
<span>{t('page.moveRoot')}</span>
</label>
</li>
{options.map(({ page: option, depth }) => (
<li key={option.id} style={{ paddingInlineStart: `${depth * 1}rem` }}>
<label className="move-dialog__option">
<input
type="radio"
name="move-target"
disabled={blocked.has(option.id)}
checked={selected === option.id}
onChange={() => setTarget(option.id)}
/>
<span>{option.title}</span>
</label>
</li>
))}
</ul>
<div className="modal__actions">
<button type="button" className="button" disabled={busy} onClick={() => void move()}>
{t('page.moveAction')}
</button>
<button type="button" className="linklike" onClick={onClose}>
{t('page.moveCancel')}
</button>
</div>
</div>
</div>
);
}

View File

@ -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<HTMLDivElement>(null);
useDismissable(menuRef, open, () => setOpen(false));
const pond = useQuery({
queryKey: ['pond', pondSlug],
queryFn: () => apiGet<PondView>(`/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<PageListItemView[]>(`/ponds/${pond.data!.id}/pages`),
enabled: Boolean(pond.data),
});
async function copyMarkdown(): Promise<void> {
try {
const markdown = await apiGetText(`/pages/${pageId}/export/markdown`);
@ -184,8 +205,21 @@ function PageOverflowMenu({
}
async function deletePage(): Promise<void> {
// 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({
</button>
))}
</span>
<button
type="button"
role="menuitem"
className="page-actions__move"
onClick={() => {
setOpen(false);
setMoving(true);
}}
>
<FolderInput aria-hidden />
{t('page.moveTo')}
</button>
<button
type="button"
role="menuitem"
@ -248,6 +294,22 @@ function PageOverflowMenu({
</button>
</div>
)}
{moving && (
<MovePageDialog pageId={pageId} pondSlug={pondSlug} onClose={() => setMoving(false)} />
)}
{deleting && (
<DeletePageDialog
pageId={pageId}
title={deleting.title}
childCount={deleting.childCount}
onDeleted={() => {
setDeleting(null);
void queryClient.invalidateQueries({ queryKey: ['pages'] });
navigate(`/p/${pondSlug}`);
}}
onClose={() => setDeleting(null)}
/>
)}
</div>
);
}

View File

@ -250,6 +250,76 @@ button {
color: var(--color-text-muted);
}
/* Drop-into cue while dragging a page onto another (reparent, issue #109). */
.sidebar__page-item--drop-into > .sidebar__tree-row {
outline: 2px solid var(--color-accent);
outline-offset: -2px;
border-radius: var(--radius);
}
/* Minimal modal (issue #109: "Move to…" and the delete-choice dialog). */
.modal-overlay {
position: fixed;
inset: 0;
z-index: 1100;
background: rgb(0 0 0 / 40%);
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-4);
}
.modal {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: var(--space-4);
max-width: 28rem;
width: 100%;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 8px 24px rgb(0 0 0 / 20%);
}
.modal__title {
margin: 0 0 var(--space-3);
font-size: 1.1rem;
}
.modal__actions {
display: flex;
align-items: center;
gap: var(--space-3);
margin-top: var(--space-3);
}
.modal__actions--stacked {
flex-direction: column;
align-items: stretch;
gap: var(--space-2);
}
.move-dialog__options {
list-style: none;
margin: 0;
padding: 0;
max-height: 40vh;
overflow-y: auto;
}
.move-dialog__option {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-1) 0;
cursor: pointer;
}
.move-dialog__option:has(input:disabled) {
color: var(--color-text-muted);
cursor: not-allowed;
}
.sidebar__page {
display: block;
padding: var(--space-1) var(--space-2);

View File

@ -17,7 +17,8 @@
"up": "Nach oben",
"down": "Nach unten",
"dragHint": "Zum Umsortieren ziehen",
"moved": "{{title}} an Position {{position}} von {{count}} verschoben."
"moved": "{{title}} an Position {{position}} von {{count}} verschoben.",
"reparented": "{{title}} unter {{parent}} eingeordnet."
},
"newPage": "+ Neue Seite",
"newPageTitle": "Titel",
@ -29,8 +30,8 @@
"folders": "Ordner",
"labels": "Labels",
"unlabeled": "Ohne Label",
"expand": "{{title}} ausklappen",
"collapseNode": "{{title}} einklappen",
"expand": "Unterseiten ausklappen",
"collapseNode": "Unterseiten einklappen",
"defaultTitle": "Seitenleisten-Ansicht",
"defaultLabel": "Standard-Ansicht",
"defaultHint": "Der Standard für alle Mitglieder; jeder kann seine eigene Seitenleiste lokal umschalten.",

View File

@ -126,7 +126,19 @@
"downloadMarkdown": "Als Markdown herunterladen",
"moreActions": "Weitere Aktionen",
"delete": "In den Papierkorb verschieben",
"deleteConfirm": "Diese Seite in den Papierkorb verschieben? Du kannst sie über den Papierkorb des Teichs wiederherstellen."
"deleteConfirm": "Diese Seite in den Papierkorb verschieben? Du kannst sie über den Papierkorb des Teichs wiederherstellen.",
"moveTo": "Verschieben nach…",
"moveTitle": "Seite verschieben",
"moveRoot": "Oberste Ebene",
"moveAction": "Verschieben",
"moveCancel": "Abbrechen",
"deleteChildrenTitle": "Seite mit Unterseiten löschen",
"deleteChildrenHint_one": "„{{title}}“ hat eine Unterseite — was soll mit ihr passieren?",
"deleteChildrenHint_other": "„{{title}}“ hat {{count}} Unterseiten — was soll mit ihnen passieren?",
"deletePromote": "Nur diese Seite löschen — Unterseiten rücken hoch",
"deleteSubtree_one": "Seite samt Unterseite löschen",
"deleteSubtree_other": "Seite samt allen {{count}} Unterseiten löschen",
"deleteCancel": "Abbrechen"
},
"trash": {
"link": "Papierkorb",

View File

@ -17,7 +17,8 @@
"up": "Move up",
"down": "Move down",
"dragHint": "Drag to reorder",
"moved": "Moved {{title}} to position {{position}} of {{count}}."
"moved": "Moved {{title}} to position {{position}} of {{count}}.",
"reparented": "Moved {{title}} under {{parent}}."
},
"newPage": "+ New page",
"newPageTitle": "Title",
@ -29,8 +30,8 @@
"folders": "Folders",
"labels": "Labels",
"unlabeled": "Unlabeled",
"expand": "Expand {{title}}",
"collapseNode": "Collapse {{title}}",
"expand": "Expand subpages",
"collapseNode": "Collapse subpages",
"defaultTitle": "Sidebar view",
"defaultLabel": "Default view",
"defaultHint": "The default for all members; everyone can switch their own sidebar locally.",

View File

@ -126,7 +126,19 @@
"downloadMarkdown": "Download as Markdown",
"moreActions": "More actions",
"delete": "Move to trash",
"deleteConfirm": "Move this page to the trash? You can restore it from the pond's trash view."
"deleteConfirm": "Move this page to the trash? You can restore it from the pond's trash view.",
"moveTo": "Move to…",
"moveTitle": "Move page",
"moveRoot": "Top level",
"moveAction": "Move",
"moveCancel": "Cancel",
"deleteChildrenTitle": "Delete page with subpages",
"deleteChildrenHint_one": "“{{title}}” has one subpage — what should happen to it?",
"deleteChildrenHint_other": "“{{title}}” has {{count}} subpages — what should happen to them?",
"deletePromote": "Delete only this page — move the subpages up",
"deleteSubtree_one": "Delete the page and its subpage",
"deleteSubtree_other": "Delete the page and all {{count}} subpages",
"deleteCancel": "Cancel"
},
"trash": {
"link": "Trash",