diff --git a/admin/src/components/docs/MobileDocsEditor.tsx b/admin/src/components/docs/MobileDocsEditor.tsx index e0d17ab..cf99b01 100644 --- a/admin/src/components/docs/MobileDocsEditor.tsx +++ b/admin/src/components/docs/MobileDocsEditor.tsx @@ -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: , label: 'Rename', onClick: () => { setContextPath(nodePath); setModalInput(nodePath.split('/').pop() || ''); setModalType('rename'); } }, + { key: 'moveTo', icon: , label: 'Move to...', onClick: () => { setMoveSourcePath(nodePath); setMoveToModalOpen(true); } }, { key: 'delete', icon: , label: 'Delete', danger: true, onClick: () => handleDelete(nodePath) }, ); return items; @@ -910,6 +916,14 @@ export function MobileDocsEditor({ editor, collabEnabled = false }: MobileDocsEd setWikiLinkPickerOpen(false); }} /> + + { setMoveToModalOpen(false); handleMoveFile(moveSourcePath, targetDir); }} + onClose={() => setMoveToModalOpen(false)} + /> ); } diff --git a/admin/src/components/docs/MoveToModal.tsx b/admin/src/components/docs/MoveToModal.tsx new file mode 100644 index 0000000..86c3261 --- /dev/null +++ b/admin/src/components/docs/MoveToModal.tsx @@ -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 ( + + setSearch(e.target.value)} + allowClear + autoFocus + style={{ marginBottom: 12 }} + /> + +
+ {/* Root directory option */} + {(!search.trim() || '/ (root)'.includes(search.toLowerCase())) && ( +
currentParent !== '' && handleSelect('')} + onMouseEnter={e => { if (currentParent !== '') (e.currentTarget.style.background = token.colorBgTextHover); }} + onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; }} + > + + / (root) + {currentParent === '' && ( + current + )} +
+ )} + + { + const isCurrent = item.path === currentParent; + return ( +
!isCurrent && handleSelect(item.path)} + onMouseEnter={e => { if (!isCurrent) (e.currentTarget.style.background = token.colorBgTextHover); }} + onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; }} + > + +
+ {item.path} +
+ {isCurrent && ( + current + )} +
+ ); + }} + /> +
+
+ ); +} diff --git a/admin/src/hooks/useDocsEditor.ts b/admin/src/hooks/useDocsEditor.ts index dd40a54..64ab028 100644 --- a/admin/src/hooks/useDocsEditor.ts +++ b/admin/src/hooks/useDocsEditor.ts @@ -121,6 +121,7 @@ export interface UseDocsEditorReturn { onContentChange: (value: string) => void; handleDelete: (filePath: string) => void; handleModalOk: () => Promise; + handleMoveFile: (sourcePath: string, targetDir: string) => Promise; 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, diff --git a/admin/src/pages/DocsPage.tsx b/admin/src/pages/DocsPage.tsx index 80aa883..55b9823 100644 --- a/admin/src/pages/DocsPage.tsx +++ b/admin/src/pages/DocsPage.tsx @@ -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(''); + 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: , label: 'Rename', onClick: () => { setContextPath(nodePath); setModalInput(nodePath.split('/').pop() || ''); setModalType('rename'); } }, + { key: 'moveTo', icon: , label: 'Move to...', onClick: () => { setMoveSourcePath(nodePath); setMoveToModalOpen(true); } }, { key: 'delete', icon: , 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() { }} /> + { setMoveToModalOpen(false); handleMoveFile(moveSourcePath, targetDir); }} + onClose={() => setMoveToModalOpen(false)} + /> + {/* Custom right-click context menu with submenus */} {ctxMenu && (