diff --git a/web/src/components/LibraryTagBar.tsx b/web/src/components/LibraryTagBar.tsx index 89d7362..6b870ee 100644 --- a/web/src/components/LibraryTagBar.tsx +++ b/web/src/components/LibraryTagBar.tsx @@ -1,14 +1,13 @@ -import { useEffect, useMemo, useRef, useState, type DragEvent } from 'react' -import { GripVertical, Layers, Plus, Settings2, X } from 'lucide-react' +import { useEffect, useMemo, useRef, useState } from 'react' +import { Layers, Plus, Settings2, X } from 'lucide-react' -import { ALL_TAG_ID, MAX_LIBRARY_TAGS } from '../utils/libraryTags' +import { MAX_LIBRARY_TAGS } from '../utils/libraryTags' import type { LibraryTagTab } from '../utils/libraryTags' /** * 媒体库标签栏:固定「全部」+ 每个标签一个页签,选中后只展示该标签下的媒体库。 * 只是管理员/普通用户都能用的展示层过滤,不改动媒体库本身。 - * 传入 onReorder 时标签页签可拖拽排序:拖拽只作用于「全部」之后的标签, - * 「全部」永远固定在第一位,不参与拖动。 + * 标签顺序由「管理标签」里的拖拽决定,这里只负责展示与选中。 */ export function LibraryTagBar({ tabs, @@ -16,7 +15,6 @@ export function LibraryTagBar({ onSelect, onCreate, onManage, - onReorder, manageLabel = '管理标签', showCreate = true, busy = false, @@ -26,8 +24,6 @@ export function LibraryTagBar({ onSelect: (tagId: string) => void onCreate?: (name: string) => void onManage?: () => void - /** 拖拽排序的落点:收到的是拖拽后的标签名顺序(不含「全部」)。 */ - onReorder?: (orderedNames: string[]) => void | Promise manageLabel?: string showCreate?: boolean busy?: boolean @@ -37,8 +33,6 @@ export function LibraryTagBar({ const inputRef = useRef(null) // 同一帧内 blur 与 Enter 都可能触发提交:用一个标记保证只提交一次。 const submittedRef = useRef(false) - const [draggingId, setDraggingId] = useState(null) - const [dragOverId, setDragOverId] = useState(null) useEffect(() => { if (creating) { @@ -49,25 +43,6 @@ export function LibraryTagBar({ const hasTags = useMemo(() => tabs.some((tab) => !tab.isAll), [tabs]) - /** 拖拽结束后计算落点顺序并回调。 */ - const handleDrop = (e: DragEvent, overTabId: string) => { - e.preventDefault() - const fromId = draggingId ?? e.dataTransfer.getData('text/plain') - setDraggingId(null) - setDragOverId(null) - if (!onReorder || !fromId || fromId === ALL_TAG_ID || fromId === overTabId) return - const names = tabs - .filter((tab) => !tab.isAll) - .map((tab) => tab.id) - const fromIndex = names.indexOf(fromId) - const overIndex = names.indexOf(overTabId) - if (fromIndex < 0 || overIndex < 0) return - const next = [...names] - const [moved] = next.splice(fromIndex, 1) - next.splice(overIndex, 0, moved) - void onReorder(next) - } - const submit = () => { if (submittedRef.current) return submittedRef.current = true @@ -89,64 +64,24 @@ export function LibraryTagBar({
{tabs.map((tab) => { const active = tab.id === selectedTagId - const draggable = !tab.isAll && Boolean(onReorder) return ( ) })} @@ -217,7 +152,7 @@ export function LibraryTagBar({ type="button" onClick={onManage} className="inline-flex items-center gap-1 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-2.5 py-1.5 text-xs font-bold text-[var(--app-subtle)] transition-colors hover:border-brand-500/40 hover:text-brand-500" - title="重命名 / 删除标签,调整标签下的媒体库" + title="重命名 / 删除标签,调整标签下的媒体库,拖拽排序" > {manageLabel} @@ -227,4 +162,4 @@ export function LibraryTagBar({ )}
) -} +} \ No newline at end of file diff --git a/web/src/components/ManageLibraryTagsDialogView.tsx b/web/src/components/ManageLibraryTagsDialogView.tsx index 5dee860..49de28e 100644 --- a/web/src/components/ManageLibraryTagsDialogView.tsx +++ b/web/src/components/ManageLibraryTagsDialogView.tsx @@ -1,9 +1,10 @@ import { useCallback, useEffect, useMemo, useState } from 'react' -import { Check, Loader2, Pencil, Plus, Tag, Trash2, X } from 'lucide-react' +import { Check, GripVertical, Loader2, Pencil, Plus, Tag, Trash2, X } from 'lucide-react' import toast from 'react-hot-toast' import { confirmAction } from './confirmAction' import type { Library } from '../types' +import { useDragReorder } from '../hooks/useDragReorder' import { MAX_LIBRARY_TAGS, filterLibrariesForTagging, @@ -19,6 +20,8 @@ export type ManageLibraryTagsDialogProps = { onCreate: (name: string) => Promise onRename: (from: string, to: string) => Promise onRemove: (name: string) => Promise + /** 按给定标签名顺序重排标签(拖拽排序的落点)。 */ + onReorder: (orderedNames: string[]) => Promise /** 单个媒体库归类;tagName 为空表示移出所有标签。 */ onAssign: (libraryId: string, tagName: string) => Promise /** 批量归类;tagName 为空表示批量移出所有标签。一次写入。 */ @@ -42,6 +45,7 @@ export function ManageLibraryTagsDialogView({ onCreate, onRename, onRemove, + onReorder, onAssign, onAssignBatch, onClose, @@ -55,6 +59,10 @@ export function ManageLibraryTagsDialogView({ const [selectedIds, setSelectedIds] = useState([]) const [batchTag, setBatchTag] = useState('') const [applying, setApplying] = useState(false) + const { draggingId, dragOverId, dragProps } = useDragReorder( + tags.map((tag) => tag.name), + onReorder, + ) useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { @@ -254,86 +262,110 @@ export function ManageLibraryTagsDialogView({ 还没有标签。先创建一个,再把媒体库归入其中。

) : ( - tags.map((tag) => ( -
- {renaming === tag.name ? ( - <> - setRenameDraft(event.target.value)} - onKeyDown={(event) => { - if (event.key === 'Enter') { - event.preventDefault() - void handleRename(tag.name) - } - if (event.key === 'Escape') { + tags.map((tag) => { + const drag = dragProps(tag.name) + const isDragOver = dragOverId === tag.name && draggingId !== tag.name + return ( +
+ {renaming === tag.name ? ( + <> + setRenameDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + void handleRename(tag.name) + } + if (event.key === 'Escape') { + setRenaming('') + setRenameDraft('') + } + }} + maxLength={24} + className="min-w-0 flex-1 rounded-lg border border-[var(--app-border)] bg-[var(--app-panel)] px-2 py-1 text-xs font-semibold text-[var(--app-text)] outline-none focus:border-brand-500" + /> + + - - - ) : ( - <> - - - {tag.library_ids.length} - - - - - )} -
- )) + }} + className="rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)]" + title="取消" + > + + + + ) : ( + <> + + + + {tag.library_ids.length} + + + + + )} +
+ ) + }) )} diff --git a/web/src/hooks/useDragReorder.ts b/web/src/hooks/useDragReorder.ts new file mode 100644 index 0000000..2282484 --- /dev/null +++ b/web/src/hooks/useDragReorder.ts @@ -0,0 +1,86 @@ +import { useCallback, useState, type DragEvent } from 'react' + +/** 一组可直接展开到可拖拽元素上的事件处理器。 */ +export type DragReorderHandlers = { + draggable: boolean + onDragStart: (event: DragEvent) => void + onDragOver: (event: DragEvent) => void + onDragLeave: (event: DragEvent) => void + onDrop: (event: DragEvent) => void + onDragEnd: () => void +} + +/** + * 把 `fromId` 移动到 `toId` 当前所在的位置,返回新数组;不改动入参。 + * 任一 id 不存在,或两者相同时返回 null,表示无需重排。 + */ +export function moveIdToPosition(ids: string[], fromId: string, toId: string): string[] | null { + const fromIndex = ids.indexOf(fromId) + const toIndex = ids.indexOf(toId) + if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) return null + const next = [...ids] + const [moved] = next.splice(fromIndex, 1) + next.splice(toIndex, 0, moved) + return next +} + +/** + * 列表拖拽排序的通用状态机:只负责「一串稳定 id」的拖拽与重排计算, + * 不关心渲染。调用方把 `dragProps(id)` 展开到拖拽柄上,并用返回的 + * `draggingId` / `dragOverId` 决定高亮样式。 + * + * 落点语义:把被拖项插入到目标项所在的位置。未传 `onReorder` 时整体禁用。 + */ +export function useDragReorder( + ids: string[], + onReorder?: (orderedIds: string[]) => void | Promise, +): { + draggingId: string | null + dragOverId: string | null + dragProps: (id: string) => DragReorderHandlers +} { + const [draggingId, setDraggingId] = useState(null) + const [dragOverId, setDragOverId] = useState(null) + + const enabled = Boolean(onReorder) + + const dragProps = useCallback( + (id: string): DragReorderHandlers => ({ + draggable: enabled, + onDragStart: (event) => { + if (!enabled) return + event.dataTransfer.effectAllowed = 'move' + event.dataTransfer.setData('text/plain', id) + setDraggingId(id) + setDragOverId(null) + }, + onDragOver: (event) => { + if (!enabled || draggingId === id) return + event.preventDefault() + event.dataTransfer.dropEffect = 'move' + setDragOverId((prev) => (prev === id ? prev : id)) + }, + onDragLeave: () => { + setDragOverId((prev) => (prev === id ? null : prev)) + }, + onDrop: (event) => { + if (!enabled) return + event.preventDefault() + // 拖拽期间 state 可能尚未提交,优先信任 dataTransfer 里的来源 id。 + const fromId = draggingId ?? event.dataTransfer.getData('text/plain') + setDraggingId(null) + setDragOverId(null) + if (!fromId || fromId === id) return + const next = moveIdToPosition(ids, fromId, id) + if (next) void onReorder?.(next) + }, + onDragEnd: () => { + setDraggingId(null) + setDragOverId(null) + }, + }), + [enabled, draggingId, ids, onReorder], + ) + + return { draggingId, dragOverId, dragProps } +} \ No newline at end of file diff --git a/web/src/pages/HomePage.tsx b/web/src/pages/HomePage.tsx index 5ade32b..58c2c9f 100644 --- a/web/src/pages/HomePage.tsx +++ b/web/src/pages/HomePage.tsx @@ -347,9 +347,6 @@ export function HomePage() { onCreate={(name) => { void libraryTags.createTag(name) }} - onReorder={(names) => { - void libraryTags.reorderTags(names) - }} onManage={() => setLibraryTagsOpen(true)} busy={libraryTags.saving} /> @@ -361,6 +358,7 @@ export function HomePage() { onCreate={libraryTags.createTag} onRename={libraryTags.renameTag} onRemove={libraryTags.removeTag} + onReorder={libraryTags.reorderTags} onAssign={libraryTags.assignLibrary} onAssignBatch={libraryTags.assignLibraries} onClose={() => setLibraryTagsOpen(false)} diff --git a/web/src/pages/LibrariesPage.tsx b/web/src/pages/LibrariesPage.tsx index 9c4986f..a9b9684 100644 --- a/web/src/pages/LibrariesPage.tsx +++ b/web/src/pages/LibrariesPage.tsx @@ -210,9 +210,6 @@ export function LibrariesPage() { onCreate={(name) => { void libraryTags.createTag(name) }} - onReorder={(names) => { - void libraryTags.reorderTags(names) - }} onManage={() => setLibraryTagsOpen(true)} busy={libraryTags.saving} /> @@ -225,6 +222,7 @@ export function LibrariesPage() { onCreate={libraryTags.createTag} onRename={libraryTags.renameTag} onRemove={libraryTags.removeTag} + onReorder={libraryTags.reorderTags} onAssign={libraryTags.assignLibrary} onAssignBatch={libraryTags.assignLibraries} onClose={() => setLibraryTagsOpen(false)}