Add file move capabilities to docs editor file tree
Drag-and-drop tree nodes to move files/folders between directories (desktop), and right-click "Move to..." with searchable directory picker modal (desktop + mobile). Bunker Admin
This commit is contained in:
parent
c306e061ab
commit
d7ab8f0d99
@ -30,6 +30,7 @@ import {
|
||||
PlusOutlined,
|
||||
CloseOutlined,
|
||||
FileOutlined,
|
||||
FolderOpenOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { UseDocsEditorReturn } from '@/hooks/useDocsEditor';
|
||||
import { isImageFile } from '@/hooks/useDocsEditor';
|
||||
@ -52,6 +53,7 @@ import { AdPickerModal } from '@/components/media/AdPickerModal';
|
||||
import type { AdInsertResult } from '@/components/media/AdPickerModal';
|
||||
import { PollInsertModal } from '@/components/scheduling/PollInsertModal';
|
||||
import { WikiLinkPickerModal } from '@/components/docs/WikiLinkPickerModal';
|
||||
import { MoveToModal } from '@/components/docs/MoveToModal';
|
||||
import { useDocsCollaboration } from '@/hooks/useDocsCollaboration';
|
||||
import { CollaboratorAvatars } from '@/components/docs/CollaboratorAvatars';
|
||||
import { YTextareaBinding } from '@/lib/y-textarea';
|
||||
@ -259,6 +261,8 @@ export function MobileDocsEditor({ editor, collabEnabled = false }: MobileDocsEd
|
||||
const [adPickerOpen, setAdPickerOpen] = useState(false);
|
||||
const [pollInsertOpen, setPollInsertOpen] = useState(false);
|
||||
const [wikiLinkPickerOpen, setWikiLinkPickerOpen] = useState(false);
|
||||
const [moveToModalOpen, setMoveToModalOpen] = useState(false);
|
||||
const [moveSourcePath, setMoveSourcePath] = useState('');
|
||||
|
||||
const {
|
||||
fileTree,
|
||||
@ -287,6 +291,7 @@ export function MobileDocsEditor({ editor, collabEnabled = false }: MobileDocsEd
|
||||
onContentChange,
|
||||
handleDelete,
|
||||
handleModalOk,
|
||||
handleMoveFile,
|
||||
handleNewFileRoot,
|
||||
handleNewFolderRoot,
|
||||
refreshTree,
|
||||
@ -430,6 +435,7 @@ export function MobileDocsEditor({ editor, collabEnabled = false }: MobileDocsEd
|
||||
}
|
||||
items.push(
|
||||
{ key: 'rename', icon: <EditOutlined />, label: 'Rename', onClick: () => { setContextPath(nodePath); setModalInput(nodePath.split('/').pop() || ''); setModalType('rename'); } },
|
||||
{ key: 'moveTo', icon: <FolderOpenOutlined />, label: 'Move to...', onClick: () => { setMoveSourcePath(nodePath); setMoveToModalOpen(true); } },
|
||||
{ key: 'delete', icon: <DeleteOutlined />, label: 'Delete', danger: true, onClick: () => handleDelete(nodePath) },
|
||||
);
|
||||
return items;
|
||||
@ -910,6 +916,14 @@ export function MobileDocsEditor({ editor, collabEnabled = false }: MobileDocsEd
|
||||
setWikiLinkPickerOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<MoveToModal
|
||||
open={moveToModalOpen}
|
||||
fileTree={fileTree}
|
||||
sourcePath={moveSourcePath}
|
||||
onMove={(targetDir) => { setMoveToModalOpen(false); handleMoveFile(moveSourcePath, targetDir); }}
|
||||
onClose={() => setMoveToModalOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
154
admin/src/components/docs/MoveToModal.tsx
Normal file
154
admin/src/components/docs/MoveToModal.tsx
Normal file
@ -0,0 +1,154 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Modal, Input, List, theme, Typography } from 'antd';
|
||||
import { FolderOutlined, HomeOutlined } from '@ant-design/icons';
|
||||
import type { FileNode } from '@/types/api';
|
||||
|
||||
interface DirEntry {
|
||||
path: string;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
function collectDirs(nodes: FileNode[], exclude?: string, depth = 0): DirEntry[] {
|
||||
const dirs: DirEntry[] = [];
|
||||
for (const node of nodes) {
|
||||
if (!node.isDirectory) continue;
|
||||
if (exclude && (node.path === exclude || node.path.startsWith(exclude + '/'))) continue;
|
||||
dirs.push({ path: node.path, depth });
|
||||
if (node.children) dirs.push(...collectDirs(node.children, exclude, depth + 1));
|
||||
}
|
||||
return dirs;
|
||||
}
|
||||
|
||||
interface MoveToModalProps {
|
||||
open: boolean;
|
||||
fileTree: FileNode[];
|
||||
sourcePath: string;
|
||||
onMove: (targetDir: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function MoveToModal({ open, fileTree, sourcePath, onMove, onClose }: MoveToModalProps) {
|
||||
const { token } = theme.useToken();
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const currentParent = useMemo(() => {
|
||||
const lastSlash = sourcePath.lastIndexOf('/');
|
||||
return lastSlash >= 0 ? sourcePath.substring(0, lastSlash) : '';
|
||||
}, [sourcePath]);
|
||||
|
||||
const isSourceDir = useMemo(() => {
|
||||
function find(nodes: FileNode[]): boolean {
|
||||
for (const n of nodes) {
|
||||
if (n.path === sourcePath) return n.isDirectory;
|
||||
if (n.isDirectory && n.children && find(n.children)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return find(fileTree);
|
||||
}, [fileTree, sourcePath]);
|
||||
|
||||
const allDirs = useMemo(
|
||||
() => collectDirs(fileTree, isSourceDir ? sourcePath : undefined),
|
||||
[fileTree, sourcePath, isSourceDir],
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!search.trim()) return allDirs;
|
||||
const q = search.toLowerCase();
|
||||
return allDirs.filter(d => d.path.toLowerCase().includes(q));
|
||||
}, [allDirs, search]);
|
||||
|
||||
const handleSelect = (dir: string) => {
|
||||
onMove(dir);
|
||||
setSearch('');
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
setSearch('');
|
||||
};
|
||||
|
||||
const fileName = sourcePath.split('/').pop() || sourcePath;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={`Move "${fileName}"`}
|
||||
open={open}
|
||||
onCancel={handleClose}
|
||||
footer={null}
|
||||
destroyOnHidden
|
||||
width={420}
|
||||
>
|
||||
<Input.Search
|
||||
placeholder="Search directories..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
allowClear
|
||||
autoFocus
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
|
||||
<div style={{ maxHeight: 360, overflow: 'auto' }}>
|
||||
{/* Root directory option */}
|
||||
{(!search.trim() || '/ (root)'.includes(search.toLowerCase())) && (
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
cursor: currentParent === '' ? 'not-allowed' : 'pointer',
|
||||
opacity: currentParent === '' ? 0.5 : 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
borderRadius: 4,
|
||||
transition: 'background 0.15s',
|
||||
}}
|
||||
onClick={() => currentParent !== '' && handleSelect('')}
|
||||
onMouseEnter={e => { if (currentParent !== '') (e.currentTarget.style.background = token.colorBgTextHover); }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; }}
|
||||
>
|
||||
<HomeOutlined style={{ color: token.colorTextSecondary }} />
|
||||
<span style={{ flex: 1 }}>/ (root)</span>
|
||||
{currentParent === '' && (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 11 }}>current</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<List
|
||||
size="small"
|
||||
dataSource={filtered}
|
||||
locale={{ emptyText: 'No matching directories' }}
|
||||
renderItem={item => {
|
||||
const isCurrent = item.path === currentParent;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
paddingLeft: 12 + item.depth * 16,
|
||||
cursor: isCurrent ? 'not-allowed' : 'pointer',
|
||||
opacity: isCurrent ? 0.5 : 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
borderRadius: 4,
|
||||
transition: 'background 0.15s',
|
||||
}}
|
||||
onClick={() => !isCurrent && handleSelect(item.path)}
|
||||
onMouseEnter={e => { if (!isCurrent) (e.currentTarget.style.background = token.colorBgTextHover); }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; }}
|
||||
>
|
||||
<FolderOutlined style={{ color: token.colorTextSecondary, flexShrink: 0 }} />
|
||||
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{item.path}
|
||||
</div>
|
||||
{isCurrent && (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 11, flexShrink: 0 }}>current</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@ -121,6 +121,7 @@ export interface UseDocsEditorReturn {
|
||||
onContentChange: (value: string) => void;
|
||||
handleDelete: (filePath: string) => void;
|
||||
handleModalOk: () => Promise<void>;
|
||||
handleMoveFile: (sourcePath: string, targetDir: string) => Promise<void>;
|
||||
handleNewFileRoot: () => void;
|
||||
handleNewFolderRoot: () => void;
|
||||
refreshTree: () => void;
|
||||
@ -377,6 +378,45 @@ export function useDocsEditor(): UseDocsEditorReturn {
|
||||
setModalType(null);
|
||||
}, [modalType, modalInput, contextPath, messageApi, fetchTree, loadFile, selectedFile]);
|
||||
|
||||
const handleMoveFile = useCallback(async (sourcePath: string, targetDir: string) => {
|
||||
const fileName = sourcePath.split('/').pop() || '';
|
||||
const newPath = targetDir ? `${targetDir}/${fileName}` : fileName;
|
||||
if (newPath === sourcePath) return;
|
||||
if (sourcePath === targetDir || targetDir.startsWith(sourcePath + '/')) {
|
||||
messageApi.warning('Cannot move a folder into itself');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.post('/docs/files/rename', { from: sourcePath, to: newPath });
|
||||
messageApi.success(`Moved to ${targetDir || '/'}`);
|
||||
invalidateTreeCache();
|
||||
setFileContentCache(prev => {
|
||||
const next = new Map(prev);
|
||||
// Move exact match
|
||||
const cached = next.get(sourcePath);
|
||||
next.delete(sourcePath);
|
||||
if (cached) next.set(newPath, cached);
|
||||
// Move children (for directory moves)
|
||||
const prefix = sourcePath + '/';
|
||||
for (const [key, val] of Array.from(next.entries())) {
|
||||
if (key.startsWith(prefix)) {
|
||||
next.delete(key);
|
||||
next.set(newPath + key.substring(sourcePath.length), val);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
if (selectedFile === sourcePath) {
|
||||
setSelectedFile(newPath);
|
||||
} else if (selectedFile && selectedFile.startsWith(sourcePath + '/')) {
|
||||
setSelectedFile(newPath + selectedFile.substring(sourcePath.length));
|
||||
}
|
||||
fetchTree();
|
||||
} catch {
|
||||
messageApi.error('Failed to move');
|
||||
}
|
||||
}, [selectedFile, messageApi, fetchTree]);
|
||||
|
||||
const handleNewFileRoot = useCallback(() => { setContextPath(''); setModalInput(''); setModalType('newFile'); }, []);
|
||||
const handleNewFolderRoot = useCallback(() => { setContextPath(''); setModalInput(''); setModalType('newFolder'); }, []);
|
||||
|
||||
@ -513,6 +553,7 @@ export function useDocsEditor(): UseDocsEditorReturn {
|
||||
onContentChange,
|
||||
handleDelete,
|
||||
handleModalOk,
|
||||
handleMoveFile,
|
||||
handleNewFileRoot,
|
||||
handleNewFolderRoot,
|
||||
refreshTree,
|
||||
|
||||
@ -63,6 +63,7 @@ import {
|
||||
ShareAltOutlined,
|
||||
LockOutlined,
|
||||
HistoryOutlined,
|
||||
FolderOpenOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import Editor from '@monaco-editor/react';
|
||||
import type { OnMount } from '@monaco-editor/react';
|
||||
@ -72,6 +73,7 @@ import { buildServiceUrl } from '@/lib/service-url';
|
||||
import { useMkDocsBuild } from '@/hooks/useMkDocsBuild';
|
||||
import { useDocsEditor } from '@/hooks/useDocsEditor';
|
||||
import { MobileDocsEditor } from '@/components/docs/MobileDocsEditor';
|
||||
import { MoveToModal } from '@/components/docs/MoveToModal';
|
||||
import type { FileNode, ServicesConfig } from '@/types/api';
|
||||
import type { AppOutletContext } from '@/components/AppLayout';
|
||||
import { VideoPickerModal } from '@/components/media/VideoPickerModal';
|
||||
@ -623,6 +625,9 @@ export default function DocsPage() {
|
||||
const [modalInput, setModalInput] = useState('');
|
||||
const [contextPath, setContextPath] = useState<string>('');
|
||||
|
||||
const [moveToModalOpen, setMoveToModalOpen] = useState(false);
|
||||
const [moveSourcePath, setMoveSourcePath] = useState('');
|
||||
|
||||
const [videoPickerOpen, setVideoPickerOpen] = useState(false);
|
||||
const [photoPickerOpen, setPhotoPickerOpen] = useState(false);
|
||||
const [photoInsertOpen, setPhotoInsertOpen] = useState(false);
|
||||
@ -1412,6 +1417,7 @@ export default function DocsPage() {
|
||||
}
|
||||
items.push(
|
||||
{ key: 'rename', icon: <EditOutlined />, label: 'Rename', onClick: () => { setContextPath(nodePath); setModalInput(nodePath.split('/').pop() || ''); setModalType('rename'); } },
|
||||
{ key: 'moveTo', icon: <FolderOpenOutlined />, label: 'Move to...', onClick: () => { setMoveSourcePath(nodePath); setMoveToModalOpen(true); } },
|
||||
{ key: 'delete', icon: <DeleteOutlined />, label: 'Delete', danger: true, onClick: () => handleDelete(nodePath) },
|
||||
);
|
||||
return items;
|
||||
@ -1496,6 +1502,68 @@ export default function DocsPage() {
|
||||
setModalType(null);
|
||||
}, [modalType, modalInput, contextPath, messageApi, fetchTree, loadFile, selectedFile]);
|
||||
|
||||
const handleMoveFile = useCallback(async (sourcePath: string, targetDir: string) => {
|
||||
const fileName = sourcePath.split('/').pop() || '';
|
||||
const newPath = targetDir ? `${targetDir}/${fileName}` : fileName;
|
||||
if (newPath === sourcePath) return;
|
||||
if (sourcePath === targetDir || targetDir.startsWith(sourcePath + '/')) {
|
||||
messageApi.warning('Cannot move a folder into itself');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.post('/docs/files/rename', { from: sourcePath, to: newPath });
|
||||
messageApi.success(`Moved to ${targetDir || '/'}`);
|
||||
invalidateTreeCache();
|
||||
setFileContentCache(prev => {
|
||||
const next = new Map(prev);
|
||||
const cached = next.get(sourcePath);
|
||||
next.delete(sourcePath);
|
||||
if (cached) next.set(newPath, cached);
|
||||
const prefix = sourcePath + '/';
|
||||
for (const [key, val] of Array.from(next.entries())) {
|
||||
if (key.startsWith(prefix)) {
|
||||
next.delete(key);
|
||||
next.set(newPath + key.substring(sourcePath.length), val);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
if (selectedFile === sourcePath) {
|
||||
setSelectedFile(newPath);
|
||||
} else if (selectedFile && selectedFile.startsWith(sourcePath + '/')) {
|
||||
setSelectedFile(newPath + selectedFile.substring(sourcePath.length));
|
||||
}
|
||||
fetchTree();
|
||||
} catch {
|
||||
messageApi.error('Failed to move');
|
||||
}
|
||||
}, [selectedFile, messageApi, fetchTree]);
|
||||
|
||||
const handleTreeNodeDrop = useCallback((info: {
|
||||
node: TreeDataNode;
|
||||
dragNode: TreeDataNode;
|
||||
dropToGap: boolean;
|
||||
}) => {
|
||||
const dragPath = info.dragNode.key as string;
|
||||
let targetDir: string;
|
||||
|
||||
if (info.dropToGap) {
|
||||
const targetPath = info.node.key as string;
|
||||
const lastSlash = targetPath.lastIndexOf('/');
|
||||
targetDir = lastSlash >= 0 ? targetPath.substring(0, lastSlash) : '';
|
||||
} else {
|
||||
const targetPath = info.node.key as string;
|
||||
if (info.node.isLeaf) {
|
||||
const lastSlash = targetPath.lastIndexOf('/');
|
||||
targetDir = lastSlash >= 0 ? targetPath.substring(0, lastSlash) : '';
|
||||
} else {
|
||||
targetDir = targetPath;
|
||||
}
|
||||
}
|
||||
|
||||
handleMoveFile(dragPath, targetDir);
|
||||
}, [handleMoveFile]);
|
||||
|
||||
const handleNewFileRoot = useCallback(() => { setContextPath(''); setModalInput(''); setModalType('newFile'); }, []);
|
||||
const handleNewFolderRoot = useCallback(() => { setContextPath(''); setModalInput(''); setModalType('newFolder'); }, []);
|
||||
|
||||
@ -1703,6 +1771,7 @@ export default function DocsPage() {
|
||||
}, []);
|
||||
|
||||
const handleTreeDragOver = useCallback((e: React.DragEvent) => {
|
||||
if (!e.dataTransfer.types.includes('Files')) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
@ -1801,6 +1870,15 @@ export default function DocsPage() {
|
||||
.docs-tree .ant-tree-list-holder-inner {
|
||||
padding: 2px 0 !important;
|
||||
}
|
||||
.docs-tree .ant-tree-treenode.drag-over > .ant-tree-node-content-wrapper,
|
||||
.docs-tree .ant-tree-treenode.drop-target:not(.drag-over-gap-top):not(.drag-over-gap-bottom) > .ant-tree-node-content-wrapper {
|
||||
background: rgba(22, 119, 255, 0.15) !important;
|
||||
outline: 1px solid rgba(22, 119, 255, 0.4);
|
||||
border-radius: 3px !important;
|
||||
}
|
||||
.docs-tree .ant-tree-treenode.dragging {
|
||||
opacity: 0.4;
|
||||
}
|
||||
.docs-filter .ant-input-affix-wrapper {
|
||||
border-radius: 0 !important;
|
||||
border: none !important;
|
||||
@ -1960,6 +2038,15 @@ export default function DocsPage() {
|
||||
showIcon={false}
|
||||
showLine={false}
|
||||
motion={false}
|
||||
draggable={!filterQuery.trim() ? { icon: false, nodeDraggable: () => true } : false}
|
||||
allowDrop={({ dragNode, dropNode }) => {
|
||||
const dragPath = dragNode.key as string;
|
||||
const dropPath = dropNode.key as string;
|
||||
if (dragPath === dropPath) return false;
|
||||
if (dropPath.startsWith(dragPath + '/')) return false;
|
||||
return true;
|
||||
}}
|
||||
onDrop={handleTreeNodeDrop}
|
||||
selectedKeys={selectedFile ? [selectedFile] : []}
|
||||
expandedKeys={expandedKeys}
|
||||
onExpand={(keys) => setExpandedKeys(keys)}
|
||||
@ -2434,6 +2521,14 @@ export default function DocsPage() {
|
||||
}}
|
||||
/>
|
||||
|
||||
<MoveToModal
|
||||
open={moveToModalOpen}
|
||||
fileTree={fileTree}
|
||||
sourcePath={moveSourcePath}
|
||||
onMove={(targetDir) => { setMoveToModalOpen(false); handleMoveFile(moveSourcePath, targetDir); }}
|
||||
onClose={() => setMoveToModalOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Custom right-click context menu with submenus */}
|
||||
{ctxMenu && (
|
||||
<div
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user