This commit is contained in:
truewhile
2026-09-16 22:20:23 +08:00
parent 1f14a1e85b
commit fc89d23867
5 changed files with 210 additions and 161 deletions
+11 -76
View File
@@ -1,14 +1,13 @@
import { useEffect, useMemo, useRef, useState, type DragEvent } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { GripVertical, Layers, Plus, Settings2, X } from 'lucide-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' import type { LibraryTagTab } from '../utils/libraryTags'
/** /**
* 媒体库标签栏:固定「全部」+ 每个标签一个页签,选中后只展示该标签下的媒体库。 * 媒体库标签栏:固定「全部」+ 每个标签一个页签,选中后只展示该标签下的媒体库。
* 只是管理员/普通用户都能用的展示层过滤,不改动媒体库本身。 * 只是管理员/普通用户都能用的展示层过滤,不改动媒体库本身。
* 传入 onReorder 时标签页签可拖拽排序:拖拽只作用于「全部」之后的标签, * 标签顺序由「管理标签」里的拖拽决定,这里只负责展示与选中。
* 「全部」永远固定在第一位,不参与拖动。
*/ */
export function LibraryTagBar({ export function LibraryTagBar({
tabs, tabs,
@@ -16,7 +15,6 @@ export function LibraryTagBar({
onSelect, onSelect,
onCreate, onCreate,
onManage, onManage,
onReorder,
manageLabel = '管理标签', manageLabel = '管理标签',
showCreate = true, showCreate = true,
busy = false, busy = false,
@@ -26,8 +24,6 @@ export function LibraryTagBar({
onSelect: (tagId: string) => void onSelect: (tagId: string) => void
onCreate?: (name: string) => void onCreate?: (name: string) => void
onManage?: () => void onManage?: () => void
/** 拖拽排序的落点:收到的是拖拽后的标签名顺序(不含「全部」)。 */
onReorder?: (orderedNames: string[]) => void | Promise<void>
manageLabel?: string manageLabel?: string
showCreate?: boolean showCreate?: boolean
busy?: boolean busy?: boolean
@@ -37,8 +33,6 @@ export function LibraryTagBar({
const inputRef = useRef<HTMLInputElement | null>(null) const inputRef = useRef<HTMLInputElement | null>(null)
// 同一帧内 blur 与 Enter 都可能触发提交:用一个标记保证只提交一次。 // 同一帧内 blur 与 Enter 都可能触发提交:用一个标记保证只提交一次。
const submittedRef = useRef(false) const submittedRef = useRef(false)
const [draggingId, setDraggingId] = useState<string | null>(null)
const [dragOverId, setDragOverId] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
if (creating) { if (creating) {
@@ -49,25 +43,6 @@ export function LibraryTagBar({
const hasTags = useMemo(() => tabs.some((tab) => !tab.isAll), [tabs]) 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 = () => { const submit = () => {
if (submittedRef.current) return if (submittedRef.current) return
submittedRef.current = true submittedRef.current = true
@@ -89,64 +64,24 @@ export function LibraryTagBar({
<div className="flex flex-wrap items-center gap-1.5"> <div className="flex flex-wrap items-center gap-1.5">
{tabs.map((tab) => { {tabs.map((tab) => {
const active = tab.id === selectedTagId const active = tab.id === selectedTagId
const draggable = !tab.isAll && Boolean(onReorder)
return ( return (
<button <button
key={tab.id} key={tab.id}
type="button" type="button"
onClick={() => onSelect(tab.id)} onClick={() => onSelect(tab.id)}
aria-pressed={active} aria-pressed={active}
draggable={draggable} className={`inline-flex items-center gap-1.5 rounded-xl border px-3 py-1.5 text-xs font-bold transition-colors ${
onDragStart={(e) => { active
if (!draggable) return ? 'border-brand-500/60 bg-brand-500/10 text-brand-600'
e.dataTransfer.effectAllowed = 'move' : 'border-[var(--app-border)] bg-[var(--app-panel-soft)] text-[var(--app-subtle)] hover:border-brand-500/40 hover:text-[var(--app-text)]'
e.dataTransfer.setData('text/plain', tab.id)
setDraggingId(tab.id)
setDragOverId(null)
}}
onDragOver={(e) => {
if (draggingId === tab.id || tab.isAll) return
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
if (dragOverId !== tab.id) setDragOverId(tab.id)
}}
onDragLeave={() => {
setDragOverId((prev) => (prev === tab.id ? null : prev))
}}
onDrop={(e) => handleDrop(e, tab.id)}
onDragEnd={() => {
setDraggingId(null)
setDragOverId(null)
}}
className={`group inline-flex cursor-default items-center gap-1.5 rounded-xl border px-3 py-1.5 text-xs font-bold transition-colors ${
draggingId === tab.id
? 'opacity-40'
: dragOverId === tab.id
? 'border-brand-500 bg-brand-500/10 text-brand-600'
: active
? 'border-brand-500/60 bg-brand-500/10 text-brand-600'
: 'border-[var(--app-border)] bg-[var(--app-panel-soft)] text-[var(--app-subtle)] hover:border-brand-500/40 hover:text-[var(--app-text)]'
}`} }`}
title={ title={tab.isAll ? '显示全部媒体库' : `只看「${tab.name}」标签下的媒体库`}
tab.isAll
? '显示全部媒体库'
: draggable
? `只看「${tab.name}」标签下的媒体库(拖拽可调整顺序)`
: `只看「${tab.name}」标签下的媒体库`
}
> >
{tab.isAll && <Layers size={13} />} {tab.isAll && <Layers size={13} />}
<span className="max-w-[8rem] truncate">{tab.name}</span> <span className="max-w-[8rem] truncate">{tab.name}</span>
<span className={`font-mono text-[10px] ${active ? 'text-brand-500' : 'text-[var(--app-muted)]'}`}> <span className={`font-mono text-[10px] ${active ? 'text-brand-500' : 'text-[var(--app-muted)]'}`}>
{tab.count} {tab.count}
</span> </span>
{draggable && (
<GripVertical
size={12}
className="shrink-0 cursor-grab text-[var(--app-muted)] opacity-0 transition-opacity group-hover:opacity-100 active:cursor-grabbing"
aria-hidden
/>
)}
</button> </button>
) )
})} })}
@@ -217,7 +152,7 @@ export function LibraryTagBar({
type="button" type="button"
onClick={onManage} 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" 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="重命名 / 删除标签,调整标签下的媒体库,拖拽排序"
> >
<Settings2 size={13} /> <Settings2 size={13} />
<span>{manageLabel}</span> <span>{manageLabel}</span>
@@ -227,4 +162,4 @@ export function LibraryTagBar({
)} )}
</div> </div>
) )
} }
@@ -1,9 +1,10 @@
import { useCallback, useEffect, useMemo, useState } from 'react' 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 toast from 'react-hot-toast'
import { confirmAction } from './confirmAction' import { confirmAction } from './confirmAction'
import type { Library } from '../types' import type { Library } from '../types'
import { useDragReorder } from '../hooks/useDragReorder'
import { import {
MAX_LIBRARY_TAGS, MAX_LIBRARY_TAGS,
filterLibrariesForTagging, filterLibrariesForTagging,
@@ -19,6 +20,8 @@ export type ManageLibraryTagsDialogProps = {
onCreate: (name: string) => Promise<string> onCreate: (name: string) => Promise<string>
onRename: (from: string, to: string) => Promise<void> onRename: (from: string, to: string) => Promise<void>
onRemove: (name: string) => Promise<void> onRemove: (name: string) => Promise<void>
/** 按给定标签名顺序重排标签(拖拽排序的落点)。 */
onReorder: (orderedNames: string[]) => Promise<void>
/** 单个媒体库归类;tagName 为空表示移出所有标签。 */ /** 单个媒体库归类;tagName 为空表示移出所有标签。 */
onAssign: (libraryId: string, tagName: string) => Promise<void> onAssign: (libraryId: string, tagName: string) => Promise<void>
/** 批量归类;tagName 为空表示批量移出所有标签。一次写入。 */ /** 批量归类;tagName 为空表示批量移出所有标签。一次写入。 */
@@ -42,6 +45,7 @@ export function ManageLibraryTagsDialogView({
onCreate, onCreate,
onRename, onRename,
onRemove, onRemove,
onReorder,
onAssign, onAssign,
onAssignBatch, onAssignBatch,
onClose, onClose,
@@ -55,6 +59,10 @@ export function ManageLibraryTagsDialogView({
const [selectedIds, setSelectedIds] = useState<string[]>([]) const [selectedIds, setSelectedIds] = useState<string[]>([])
const [batchTag, setBatchTag] = useState('') const [batchTag, setBatchTag] = useState('')
const [applying, setApplying] = useState(false) const [applying, setApplying] = useState(false)
const { draggingId, dragOverId, dragProps } = useDragReorder(
tags.map((tag) => tag.name),
onReorder,
)
useEffect(() => { useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
@@ -254,86 +262,110 @@ export function ManageLibraryTagsDialogView({
还没有标签。先创建一个,再把媒体库归入其中。 还没有标签。先创建一个,再把媒体库归入其中。
</p> </p>
) : ( ) : (
tags.map((tag) => ( tags.map((tag) => {
<div const drag = dragProps(tag.name)
key={tag.name} const isDragOver = dragOverId === tag.name && draggingId !== tag.name
className="flex items-center gap-2 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-2.5 py-2" return (
> <div
{renaming === tag.name ? ( key={tag.name}
<> onDragOver={drag.onDragOver}
<input onDragLeave={drag.onDragLeave}
autoFocus onDrop={drag.onDrop}
value={renameDraft} className={`flex items-center gap-2 rounded-xl border px-2.5 py-2 transition-colors ${
onChange={(event) => setRenameDraft(event.target.value)} isDragOver
onKeyDown={(event) => { ? 'border-brand-500/60 bg-brand-500/5'
if (event.key === 'Enter') { : 'border-[var(--app-border)] bg-[var(--app-panel-soft)]'
event.preventDefault() }`}
void handleRename(tag.name) >
} {renaming === tag.name ? (
if (event.key === 'Escape') { <>
<input
autoFocus
value={renameDraft}
onChange={(event) => 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"
/>
<button
type="button"
onClick={() => void handleRename(tag.name)}
className="rounded-lg p-1 text-brand-500 transition-colors hover:bg-[var(--app-hover)]"
title="保存名称"
>
<Check size={14} />
</button>
<button
type="button"
onClick={() => {
setRenaming('') setRenaming('')
setRenameDraft('') setRenameDraft('')
} }}
}} className="rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)]"
maxLength={24} title="取消"
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" >
/> <X size={14} />
<button </button>
type="button" </>
onClick={() => void handleRename(tag.name)} ) : (
className="rounded-lg p-1 text-brand-500 transition-colors hover:bg-[var(--app-hover)]" <>
title="保存名称" <button
> type="button"
<Check size={14} /> draggable={drag.draggable}
</button> onDragStart={drag.onDragStart}
<button onDragEnd={drag.onDragEnd}
type="button" className={`-ml-1 shrink-0 cursor-grab rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)] ${
onClick={() => { draggingId === tag.name ? 'opacity-40' : ''
setRenaming('') }`}
setRenameDraft('') title="拖拽调整标签顺序"
}} aria-label="拖拽调整标签顺序"
className="rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)]" >
title="取消" <GripVertical size={13} />
> </button>
<X size={14} /> <button
</button> type="button"
</> onClick={() => setTagFilter(tag.name)}
) : ( className="min-w-0 flex-1 truncate text-left text-sm font-bold text-[var(--app-text)] transition-colors hover:text-brand-500"
<> title={`只看「${tag.name}」标签下的媒体库`}
<button >
type="button" {tag.name}
onClick={() => setTagFilter(tag.name)} </button>
className="min-w-0 flex-1 truncate text-left text-sm font-bold text-[var(--app-text)] transition-colors hover:text-brand-500" <span className="shrink-0 font-mono text-[10px] text-[var(--app-muted)]">
title={`只看「${tag.name}」标签下的媒体库`} {tag.library_ids.length}
> </span>
{tag.name} <button
</button> type="button"
<span className="shrink-0 font-mono text-[10px] text-[var(--app-muted)]"> onClick={() => {
{tag.library_ids.length} setRenaming(tag.name)
</span> setRenameDraft(tag.name)
<button }}
type="button" className="rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)] hover:text-brand-500"
onClick={() => { title="重命名"
setRenaming(tag.name) >
setRenameDraft(tag.name) <Pencil size={13} />
}} </button>
className="rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)] hover:text-brand-500" <button
title="重命名" type="button"
> onClick={() => void handleRemove(tag)}
<Pencil size={13} /> className="rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)] hover:text-red-500"
</button> title="删除标签"
<button >
type="button" <Trash2 size={13} />
onClick={() => void handleRemove(tag)} </button>
className="rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)] hover:text-red-500" </>
title="删除标签" )}
> </div>
<Trash2 size={13} /> )
</button> })
</>
)}
</div>
))
)} )}
</div> </div>
</section> </section>
+86
View File
@@ -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<void>,
): {
draggingId: string | null
dragOverId: string | null
dragProps: (id: string) => DragReorderHandlers
} {
const [draggingId, setDraggingId] = useState<string | null>(null)
const [dragOverId, setDragOverId] = useState<string | null>(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 }
}
+1 -3
View File
@@ -347,9 +347,6 @@ export function HomePage() {
onCreate={(name) => { onCreate={(name) => {
void libraryTags.createTag(name) void libraryTags.createTag(name)
}} }}
onReorder={(names) => {
void libraryTags.reorderTags(names)
}}
onManage={() => setLibraryTagsOpen(true)} onManage={() => setLibraryTagsOpen(true)}
busy={libraryTags.saving} busy={libraryTags.saving}
/> />
@@ -361,6 +358,7 @@ export function HomePage() {
onCreate={libraryTags.createTag} onCreate={libraryTags.createTag}
onRename={libraryTags.renameTag} onRename={libraryTags.renameTag}
onRemove={libraryTags.removeTag} onRemove={libraryTags.removeTag}
onReorder={libraryTags.reorderTags}
onAssign={libraryTags.assignLibrary} onAssign={libraryTags.assignLibrary}
onAssignBatch={libraryTags.assignLibraries} onAssignBatch={libraryTags.assignLibraries}
onClose={() => setLibraryTagsOpen(false)} onClose={() => setLibraryTagsOpen(false)}
+1 -3
View File
@@ -210,9 +210,6 @@ export function LibrariesPage() {
onCreate={(name) => { onCreate={(name) => {
void libraryTags.createTag(name) void libraryTags.createTag(name)
}} }}
onReorder={(names) => {
void libraryTags.reorderTags(names)
}}
onManage={() => setLibraryTagsOpen(true)} onManage={() => setLibraryTagsOpen(true)}
busy={libraryTags.saving} busy={libraryTags.saving}
/> />
@@ -225,6 +222,7 @@ export function LibrariesPage() {
onCreate={libraryTags.createTag} onCreate={libraryTags.createTag}
onRename={libraryTags.renameTag} onRename={libraryTags.renameTag}
onRemove={libraryTags.removeTag} onRemove={libraryTags.removeTag}
onReorder={libraryTags.reorderTags}
onAssign={libraryTags.assignLibrary} onAssign={libraryTags.assignLibrary}
onAssignBatch={libraryTags.assignLibraries} onAssignBatch={libraryTags.assignLibraries}
onClose={() => setLibraryTagsOpen(false)} onClose={() => setLibraryTagsOpen(false)}