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
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:
parent
e957c2a28f
commit
0308bc712d
@ -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)}
|
||||
>
|
||||
|
||||
75
apps/web/src/pages/DeletePageDialog.tsx
Normal file
75
apps/web/src/pages/DeletePageDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
125
apps/web/src/pages/MovePageDialog.tsx
Normal file
125
apps/web/src/pages/MovePageDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user