import { useEffect, useRef, useState, type DragEvent, type MouseEvent, type ReactNode } from 'react' import { createPortal } from 'react-dom' import { Folder, GripVertical, Image, MoreVertical, Plus, Power, PowerOff, RefreshCw, Save, Trash2 } from 'lucide-react' import { imageURL } from '../api/client' import { LocalDirBrowserDialog } from '../components/LocalDirBrowserDialog' import type { Library, LibraryRoot } from '../types' import type { RootDraft } from './adminLibraryPanelModel' import { displayLibraryRootName, displayLibraryRootPath, fallbackLibraryRoot } from './adminLibraryPanelModel' type LibraryTableProps = { libs: Library[] editableRootDraft: (libraryID: string, root: LibraryRoot) => RootDraft onEditableRootChange: (libraryID: string, root: LibraryRoot, patch: Partial) => void onSaveRoot: (libraryID: string, root: LibraryRoot) => void onScanRoot: (libraryID: string, root: LibraryRoot) => void onToggleRoot: (libraryID: string, root: LibraryRoot) => void onRemoveRoot: (library: Library, root: LibraryRoot) => void onScanLibrary: (library: Library) => void onRemoveLibrary: (library: Library) => void onAddLibraryRoot: (library: Library, path?: string, name?: string) => void onEditLibraryCover: (library: Library) => void onToggleCarousel: (library: Library) => void onReorder: (orderedLibs: Library[]) => void } export function AdminLibraryTable({ libs, ...actions }: LibraryTableProps) { const [browsingRoot, setBrowsingRoot] = useState<{ libraryID: string; root: LibraryRoot; initialPath?: string } | null>(null) const [addingRootLib, setAddingRootLib] = useState(null) const [draggingId, setDraggingId] = useState(null) const [dragOverId, setDragOverId] = useState(null) const handleSelectRootPath = (selectedPath: string) => { if (browsingRoot) { actions.onEditableRootChange(browsingRoot.libraryID, browsingRoot.root, { path: selectedPath }) setBrowsingRoot(null) } } const handleSelectAddRoot = (selectedPath: string) => { if (addingRootLib) { const segments = selectedPath.split(/[/\\]/).filter(Boolean) const baseName = segments.length > 0 ? segments[segments.length - 1] : '' actions.onAddLibraryRoot(addingRootLib, selectedPath, baseName) setAddingRootLib(null) } } const handleReorder = (fromId: string, overId: string) => { if (fromId === overId) return const copy = [...libs] const fromIndex = copy.findIndex((l) => l.id === fromId) const overIndex = copy.findIndex((l) => l.id === overId) if (fromIndex < 0 || overIndex < 0) return const [moved] = copy.splice(fromIndex, 1) copy.splice(overIndex, 0, moved) actions.onReorder(copy) } const handleDrop = (e: DragEvent, overId: string) => { e.preventDefault() setDragOverId(null) setDraggingId(null) if (draggingId && overId !== draggingId) { handleReorder(draggingId, overId) } } return ( <>
{libs.map((library) => ( setBrowsingRoot({ libraryID: library.id, root, initialPath: root.path })} onOpenAddRoot={() => setAddingRootLib(library)} dragging={draggingId === library.id} dragOver={dragOverId === library.id} onDragStart={(e) => { e.dataTransfer.effectAllowed = 'move' setDragOverId(null) setDraggingId(library.id) }} onDragOver={(e) => { e.preventDefault() e.dataTransfer.dropEffect = 'move' if (dragOverId !== library.id) setDragOverId(library.id) }} onDragEnd={() => { setDragOverId(null) setDraggingId(null) }} onDrop={(e) => handleDrop(e, library.id)} {...actions} /> ))}
名称 路径 类型 轮播 操作
{browsingRoot && ( setBrowsingRoot(null)} /> )} {addingRootLib && ( setAddingRootLib(null)} /> )} ) } type LibraryTableRowProps = Omit & { library: Library onBrowseRoot: (root: LibraryRoot) => void onOpenAddRoot: () => void dragging?: boolean dragOver?: boolean onDragStart?: (e: DragEvent) => void onDragOver?: (e: DragEvent) => void onDragEnd?: () => void onDrop?: (e: DragEvent) => void } function LibraryTableRow({ library, dragging, dragOver, onDragStart, onDragOver, onDragEnd, onDrop, ...actions }: LibraryTableRowProps) { return (
{library.cover_url && ( { e.currentTarget.style.visibility = 'hidden' }} /> )} {library.name}
{library.type} ) } function CarouselToggle({ library, onToggleCarousel }: { library: Library; onToggleCarousel: (library: Library) => void }) { const on = Boolean(library.carousel_enabled) return ( ) } function LibraryRootsCell({ library, ...actions }: LibraryTableRowProps) { const roots = library.roots?.length ? library.roots : [fallbackLibraryRoot(library)] return (
{roots.map((root) => ( ))}
) } type RootEditorProps = Omit & { library: Library root: LibraryRoot } function ExistingRootEditor({ library, root, ...actions }: RootEditorProps) { const draft = actions.editableRootDraft(library.id, root) return (
{root.id ? : }
) } function ReadonlyRootFields({ root }: { root: LibraryRoot }) { return ( <> {displayLibraryRootName(root.name, root.path)} {displayLibraryRootPath(root.path)} ) } function EditableRootFields({ library, root, draft, onEditableRootChange, onBrowseRoot }: RootEditorProps & { draft: RootDraft }) { return ( <> onEditableRootChange(library.id, root, { name: e.target.value })} />
onEditableRootChange(library.id, root, { path: e.target.value })} />
) } function RootStatus({ enabled }: { enabled: boolean }) { return ( {enabled ? '启用' : '禁用'} ) } function RootActionButtons({ library, root, draft, ...actions }: RootEditorProps & { draft: RootDraft }) { const enabled = draft.enabled ?? root.enabled return ( {root.id && ( } label="保存" onClick={() => actions.onSaveRoot(library.id, root)} > 保存 )} } label="扫描" onClick={() => actions.onScanRoot(library.id, root)} > 扫描 {root.id && ( : } label={enabled ? '禁用' : '启用'} onClick={() => actions.onToggleRoot(library.id, root)} > {enabled ? '禁用' : '启用'} )} {root.id && ( } label="删除" onClick={() => actions.onRemoveRoot(library, root)} > 删除 )} ) } function LibraryActionsCell({ library, onScanLibrary, onRemoveLibrary, onOpenAddRoot, onEditLibraryCover }: LibraryTableRowProps) { return ( } label="扫描" onClick={() => onScanLibrary(library)}> 扫描 } label="添加来源" onClick={onOpenAddRoot}> 添加来源 } label="自定义封面" onClick={() => onEditLibraryCover(library)}> 自定义封面 } label="删除" onClick={() => onRemoveLibrary(library)}> 删除 ) } function ActionMenu({ label, children }: { label: string; children: ReactNode }) { const [isOpen, setIsOpen] = useState(false) const [coords, setCoords] = useState<{ top?: number; bottom?: number; right: number } | null>(null) const triggerRef = useRef(null) const menuRef = useRef(null) const toggleMenu = (e: MouseEvent) => { e.stopPropagation() if (isOpen) { setIsOpen(false) return } if (triggerRef.current) { const rect = triggerRef.current.getBoundingClientRect() const spaceBelow = window.innerHeight - rect.bottom const estimatedHeight = 180 const openUpward = spaceBelow < estimatedHeight && rect.top > estimatedHeight setCoords({ top: openUpward ? undefined : rect.bottom + 4, bottom: openUpward ? window.innerHeight - rect.top + 4 : undefined, right: window.innerWidth - rect.right, }) setIsOpen(true) } } useEffect(() => { if (!isOpen) return const handleClickOutside = (e: globalThis.MouseEvent) => { if ( menuRef.current && !menuRef.current.contains(e.target as Node) && triggerRef.current && !triggerRef.current.contains(e.target as Node) ) { setIsOpen(false) } } const handleScrollOrResize = () => setIsOpen(false) window.addEventListener('mousedown', handleClickOutside) window.addEventListener('scroll', handleScrollOrResize, true) window.addEventListener('resize', handleScrollOrResize) return () => { window.removeEventListener('mousedown', handleClickOutside) window.removeEventListener('scroll', handleScrollOrResize, true) window.removeEventListener('resize', handleScrollOrResize) } }, [isOpen]) return ( <> {isOpen && coords && createPortal(
setIsOpen(false)} > {children}
, document.body, )} ) } function MenuButton({ icon, label, danger, onClick, children, }: { icon: ReactNode label: string danger?: boolean onClick: () => void children: ReactNode }) { return ( ) }