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 { Link } from 'react-router-dom';
|
||||||
|
|
||||||
import { useAuth } from '../auth/auth-context';
|
import { useAuth } from '../auth/auth-context';
|
||||||
|
import { FormError } from '../components/forms';
|
||||||
import { ImportControl } from '../import/ImportControl';
|
import { ImportControl } from '../import/ImportControl';
|
||||||
import { LabelChips } from '../labels/LabelChips';
|
import { LabelChips } from '../labels/LabelChips';
|
||||||
import { usePondLabels } from '../labels/use-pond-labels';
|
import { usePondLabels } from '../labels/use-pond-labels';
|
||||||
@ -80,6 +81,8 @@ function SidebarContent({
|
|||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [filterIds, setFilterIds] = useState<Set<string>>(new Set());
|
const [filterIds, setFilterIds] = useState<Set<string>>(new Set());
|
||||||
const [draggedId, setDraggedId] = useState<string | null>(null);
|
const [draggedId, setDraggedId] = useState<string | null>(null);
|
||||||
|
const [dropIntoId, setDropIntoId] = useState<string | null>(null);
|
||||||
|
const [moveError, setMoveError] = useState<unknown>(null);
|
||||||
const [announcement, setAnnouncement] = useState('');
|
const [announcement, setAnnouncement] = useState('');
|
||||||
|
|
||||||
// Per-user view override (issue #108); `null` follows the pond default.
|
// Per-user view override (issue #108); `null` follows the pond default.
|
||||||
@ -155,6 +158,7 @@ function SidebarContent({
|
|||||||
newIndex: number,
|
newIndex: number,
|
||||||
title: string,
|
title: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
setMoveError(null);
|
||||||
const { afterId, beforeId } = neighborsForMove(siblingIds, movedId, newIndex);
|
const { afterId, beforeId } = neighborsForMove(siblingIds, movedId, newIndex);
|
||||||
await apiPatch(`/pages/${movedId}/position`, { afterId, beforeId });
|
await apiPatch(`/pages/${movedId}/position`, { afterId, beforeId });
|
||||||
await queryClient.invalidateQueries({ queryKey: ['pages', pond.id] });
|
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;
|
const showFlatFallback = view === 'folders' && filterIds.size > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -280,13 +316,18 @@ function SidebarContent({
|
|||||||
canReorder={canReorder}
|
canReorder={canReorder}
|
||||||
draggedId={draggedId}
|
draggedId={draggedId}
|
||||||
setDraggedId={setDraggedId}
|
setDraggedId={setDraggedId}
|
||||||
|
dropIntoId={dropIntoId}
|
||||||
|
setDropIntoId={setDropIntoId}
|
||||||
onMove={moveWithinSiblings}
|
onMove={moveWithinSiblings}
|
||||||
|
onReparent={reparentTo}
|
||||||
/>
|
/>
|
||||||
</ul>
|
</ul>
|
||||||
) : (
|
) : (
|
||||||
<p className="sidebar__hint">{t('layout.sidebar.empty')}</p>
|
<p className="sidebar__hint">{t('layout.sidebar.empty')}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{moveError !== null && <FormError error={moveError} />}
|
||||||
|
|
||||||
{creating ? (
|
{creating ? (
|
||||||
<NewPageForm
|
<NewPageForm
|
||||||
pondId={pond.id}
|
pondId={pond.id}
|
||||||
@ -360,12 +401,19 @@ interface PageTreeLevelProps {
|
|||||||
canReorder: boolean;
|
canReorder: boolean;
|
||||||
draggedId: string | null;
|
draggedId: string | null;
|
||||||
setDraggedId: (id: string | null) => void;
|
setDraggedId: (id: string | null) => void;
|
||||||
|
dropIntoId: string | null;
|
||||||
|
setDropIntoId: (id: string | null) => void;
|
||||||
onMove: (movedId: string, siblingIds: string[], newIndex: number, title: string) => Promise<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.
|
/** One sibling group of the folder view (issue #108), rendered recursively.
|
||||||
* Reordering (buttons and drag-between) stays within the group; dropping
|
* Reordering (buttons and drag-between at the row edges) stays within the
|
||||||
* onto a page to reparent arrives with #109. */
|
* group; dropping onto a row's middle nests the dragged page under it. */
|
||||||
function PageTreeLevel(props: PageTreeLevelProps): React.JSX.Element {
|
function PageTreeLevel(props: PageTreeLevelProps): React.JSX.Element {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const {
|
const {
|
||||||
@ -378,21 +426,35 @@ function PageTreeLevel(props: PageTreeLevelProps): React.JSX.Element {
|
|||||||
canReorder,
|
canReorder,
|
||||||
draggedId,
|
draggedId,
|
||||||
setDraggedId,
|
setDraggedId,
|
||||||
|
dropIntoId,
|
||||||
|
setDropIntoId,
|
||||||
onMove,
|
onMove,
|
||||||
|
onReparent,
|
||||||
} = props;
|
} = props;
|
||||||
const siblingIds = nodes.map((n) => n.id);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{nodes.map((node, index) => {
|
{nodes.map((node, index) => {
|
||||||
const hasChildren = node.children.length > 0;
|
const hasChildren = node.children.length > 0;
|
||||||
const isCollapsed = collapsedIds.includes(node.id);
|
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 (
|
return (
|
||||||
<li
|
<li
|
||||||
key={node.id}
|
key={node.id}
|
||||||
className={
|
className={itemClasses}
|
||||||
canReorder ? 'sidebar__page-item sidebar__page-item--draggable' : 'sidebar__page-item'
|
|
||||||
}
|
|
||||||
draggable={canReorder}
|
draggable={canReorder}
|
||||||
onDragStart={
|
onDragStart={
|
||||||
canReorder
|
canReorder
|
||||||
@ -403,22 +465,43 @@ function PageTreeLevel(props: PageTreeLevelProps): React.JSX.Element {
|
|||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
onDragEnd={canReorder ? () => setDraggedId(null) : undefined}
|
onDragEnd={
|
||||||
onDragOver={canReorder ? (event) => event.preventDefault() : undefined}
|
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={
|
onDrop={
|
||||||
canReorder
|
canReorder
|
||||||
? (event) => {
|
? (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
setDropIntoId(null);
|
||||||
if (!draggedId || draggedId === node.id) return;
|
if (!draggedId || draggedId === node.id) return;
|
||||||
// Drag-between stays inside one sibling group (#108);
|
if (dropZone(event) === 'into') {
|
||||||
// cross-group drops become reparenting with #109.
|
// Middle band: nest the dragged page under this one.
|
||||||
if (!siblingIds.includes(draggedId)) return;
|
void onReparent(draggedId, node.id);
|
||||||
const rect = event.currentTarget.getBoundingClientRect();
|
} else {
|
||||||
const after = event.clientY - rect.top > rect.height / 2;
|
// Edge bands reorder — inside one sibling group only.
|
||||||
const target = dropIndex(siblingIds, draggedId, node.id, after);
|
if (!siblingIds.includes(draggedId)) return;
|
||||||
const title = nodes.find((n) => n.id === draggedId)?.title ?? '';
|
const rect = event.currentTarget.getBoundingClientRect();
|
||||||
void onMove(draggedId, siblingIds, target, title);
|
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);
|
setDraggedId(null);
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
@ -432,9 +515,11 @@ function PageTreeLevel(props: PageTreeLevelProps): React.JSX.Element {
|
|||||||
isCollapsed ? 'sidebar__caret sidebar__caret--collapsed' : 'sidebar__caret'
|
isCollapsed ? 'sidebar__caret sidebar__caret--collapsed' : 'sidebar__caret'
|
||||||
}
|
}
|
||||||
aria-expanded={!isCollapsed}
|
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(
|
aria-label={t(
|
||||||
isCollapsed ? 'layout.sidebar.view.expand' : 'layout.sidebar.view.collapseNode',
|
isCollapsed ? 'layout.sidebar.view.expand' : 'layout.sidebar.view.collapseNode',
|
||||||
{ title: node.title },
|
|
||||||
)}
|
)}
|
||||||
onClick={() => onToggleCollapsed(node.id)}
|
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 { EXPORT_FORMATS, ExportFormat, PageListItemView, PondView } from '@dorfteich/shared';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
BookOpen,
|
BookOpen,
|
||||||
Copy,
|
Copy,
|
||||||
Download,
|
Download,
|
||||||
Ellipsis,
|
Ellipsis,
|
||||||
|
FolderInput,
|
||||||
History,
|
History,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Paperclip,
|
Paperclip,
|
||||||
@ -20,9 +21,11 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
|
|
||||||
import { IconButton } from '../components/IconButton';
|
import { IconButton } from '../components/IconButton';
|
||||||
import { useDocumentExport } from '../export/use-document-export';
|
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 { useDismissable } from '../lib/use-dismissable';
|
||||||
import { WatchToggle } from '../watches/WatchToggle';
|
import { WatchToggle } from '../watches/WatchToggle';
|
||||||
|
import { DeletePageDialog } from './DeletePageDialog';
|
||||||
|
import { MovePageDialog } from './MovePageDialog';
|
||||||
|
|
||||||
interface PageActionsProps {
|
interface PageActionsProps {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
@ -154,7 +157,9 @@ function SaveVersionButton({ pageId }: { pageId: string }): React.JSX.Element {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Overflow "…" menu: Markdown copy/download (#30), office/PDF export
|
/** 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({
|
function PageOverflowMenu({
|
||||||
pageId,
|
pageId,
|
||||||
slug,
|
slug,
|
||||||
@ -166,12 +171,28 @@ function PageOverflowMenu({
|
|||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
const { t } = useTranslation('editor');
|
const { t } = useTranslation('editor');
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [open, setOpen] = useState(false);
|
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 [copyStatus, setCopyStatus] = useState<'idle' | 'copied' | 'error'>('idle');
|
||||||
const { status, exportPage } = useDocumentExport();
|
const { status, exportPage } = useDocumentExport();
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
useDismissable(menuRef, open, () => setOpen(false));
|
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> {
|
async function copyMarkdown(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const markdown = await apiGetText(`/pages/${pageId}/export/markdown`);
|
const markdown = await apiGetText(`/pages/${pageId}/export/markdown`);
|
||||||
@ -184,8 +205,21 @@ function PageOverflowMenu({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deletePage(): Promise<void> {
|
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;
|
if (!window.confirm(t('page.deleteConfirm'))) return;
|
||||||
await apiDelete(`/pages/${pageId}`);
|
await apiDelete(`/pages/${pageId}`);
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['pages'] });
|
||||||
navigate(`/p/${pondSlug}`);
|
navigate(`/p/${pondSlug}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -237,6 +271,18 @@ function PageOverflowMenu({
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
className="page-actions__move"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false);
|
||||||
|
setMoving(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FolderInput aria-hidden />
|
||||||
|
{t('page.moveTo')}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
@ -248,6 +294,22 @@ function PageOverflowMenu({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -250,6 +250,76 @@ button {
|
|||||||
color: var(--color-text-muted);
|
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 {
|
.sidebar__page {
|
||||||
display: block;
|
display: block;
|
||||||
padding: var(--space-1) var(--space-2);
|
padding: var(--space-1) var(--space-2);
|
||||||
|
|||||||
@ -17,7 +17,8 @@
|
|||||||
"up": "Nach oben",
|
"up": "Nach oben",
|
||||||
"down": "Nach unten",
|
"down": "Nach unten",
|
||||||
"dragHint": "Zum Umsortieren ziehen",
|
"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",
|
"newPage": "+ Neue Seite",
|
||||||
"newPageTitle": "Titel",
|
"newPageTitle": "Titel",
|
||||||
@ -29,8 +30,8 @@
|
|||||||
"folders": "Ordner",
|
"folders": "Ordner",
|
||||||
"labels": "Labels",
|
"labels": "Labels",
|
||||||
"unlabeled": "Ohne Label",
|
"unlabeled": "Ohne Label",
|
||||||
"expand": "{{title}} ausklappen",
|
"expand": "Unterseiten ausklappen",
|
||||||
"collapseNode": "{{title}} einklappen",
|
"collapseNode": "Unterseiten einklappen",
|
||||||
"defaultTitle": "Seitenleisten-Ansicht",
|
"defaultTitle": "Seitenleisten-Ansicht",
|
||||||
"defaultLabel": "Standard-Ansicht",
|
"defaultLabel": "Standard-Ansicht",
|
||||||
"defaultHint": "Der Standard für alle Mitglieder; jeder kann seine eigene Seitenleiste lokal umschalten.",
|
"defaultHint": "Der Standard für alle Mitglieder; jeder kann seine eigene Seitenleiste lokal umschalten.",
|
||||||
|
|||||||
@ -126,7 +126,19 @@
|
|||||||
"downloadMarkdown": "Als Markdown herunterladen",
|
"downloadMarkdown": "Als Markdown herunterladen",
|
||||||
"moreActions": "Weitere Aktionen",
|
"moreActions": "Weitere Aktionen",
|
||||||
"delete": "In den Papierkorb verschieben",
|
"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": {
|
"trash": {
|
||||||
"link": "Papierkorb",
|
"link": "Papierkorb",
|
||||||
|
|||||||
@ -17,7 +17,8 @@
|
|||||||
"up": "Move up",
|
"up": "Move up",
|
||||||
"down": "Move down",
|
"down": "Move down",
|
||||||
"dragHint": "Drag to reorder",
|
"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",
|
"newPage": "+ New page",
|
||||||
"newPageTitle": "Title",
|
"newPageTitle": "Title",
|
||||||
@ -29,8 +30,8 @@
|
|||||||
"folders": "Folders",
|
"folders": "Folders",
|
||||||
"labels": "Labels",
|
"labels": "Labels",
|
||||||
"unlabeled": "Unlabeled",
|
"unlabeled": "Unlabeled",
|
||||||
"expand": "Expand {{title}}",
|
"expand": "Expand subpages",
|
||||||
"collapseNode": "Collapse {{title}}",
|
"collapseNode": "Collapse subpages",
|
||||||
"defaultTitle": "Sidebar view",
|
"defaultTitle": "Sidebar view",
|
||||||
"defaultLabel": "Default view",
|
"defaultLabel": "Default view",
|
||||||
"defaultHint": "The default for all members; everyone can switch their own sidebar locally.",
|
"defaultHint": "The default for all members; everyone can switch their own sidebar locally.",
|
||||||
|
|||||||
@ -126,7 +126,19 @@
|
|||||||
"downloadMarkdown": "Download as Markdown",
|
"downloadMarkdown": "Download as Markdown",
|
||||||
"moreActions": "More actions",
|
"moreActions": "More actions",
|
||||||
"delete": "Move to trash",
|
"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": {
|
"trash": {
|
||||||
"link": "Trash",
|
"link": "Trash",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user