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
+10 -75
View File
@@ -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<void>
manageLabel?: string
showCreate?: boolean
busy?: boolean
@@ -37,8 +33,6 @@ export function LibraryTagBar({
const inputRef = useRef<HTMLInputElement | null>(null)
// 同一帧内 blur 与 Enter 都可能触发提交:用一个标记保证只提交一次。
const submittedRef = useRef(false)
const [draggingId, setDraggingId] = useState<string | null>(null)
const [dragOverId, setDragOverId] = useState<string | null>(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({
<div className="flex flex-wrap items-center gap-1.5">
{tabs.map((tab) => {
const active = tab.id === selectedTagId
const draggable = !tab.isAll && Boolean(onReorder)
return (
<button
key={tab.id}
type="button"
onClick={() => onSelect(tab.id)}
aria-pressed={active}
draggable={draggable}
onDragStart={(e) => {
if (!draggable) return
e.dataTransfer.effectAllowed = 'move'
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)]'
className={`inline-flex items-center gap-1.5 rounded-xl border px-3 py-1.5 text-xs font-bold transition-colors ${
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={
tab.isAll
? '显示全部媒体库'
: draggable
? `只看「${tab.name}」标签下的媒体库(拖拽可调整顺序)`
: `只看「${tab.name}」标签下的媒体库`
}
title={tab.isAll ? '显示全部媒体库' : `只看「${tab.name}」标签下的媒体库`}
>
{tab.isAll && <Layers size={13} />}
<span className="max-w-[8rem] truncate">{tab.name}</span>
<span className={`font-mono text-[10px] ${active ? 'text-brand-500' : 'text-[var(--app-muted)]'}`}>
{tab.count}
</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>
)
})}
@@ -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="重命名 / 删除标签,调整标签下的媒体库,拖拽排序"
>
<Settings2 size={13} />
<span>{manageLabel}</span>
@@ -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<string>
onRename: (from: string, to: string) => Promise<void>
onRemove: (name: string) => Promise<void>
/** 按给定标签名顺序重排标签(拖拽排序的落点)。 */
onReorder: (orderedNames: string[]) => Promise<void>
/** 单个媒体库归类;tagName 为空表示移出所有标签。 */
onAssign: (libraryId: string, tagName: string) => Promise<void>
/** 批量归类;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<string[]>([])
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({
还没有标签。先创建一个,再把媒体库归入其中。
</p>
) : (
tags.map((tag) => (
<div
key={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"
>
{renaming === tag.name ? (
<>
<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') {
tags.map((tag) => {
const drag = dragProps(tag.name)
const isDragOver = dragOverId === tag.name && draggingId !== tag.name
return (
<div
key={tag.name}
onDragOver={drag.onDragOver}
onDragLeave={drag.onDragLeave}
onDrop={drag.onDrop}
className={`flex items-center gap-2 rounded-xl border px-2.5 py-2 transition-colors ${
isDragOver
? 'border-brand-500/60 bg-brand-500/5'
: 'border-[var(--app-border)] bg-[var(--app-panel-soft)]'
}`}
>
{renaming === tag.name ? (
<>
<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('')
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('')
setRenameDraft('')
}}
className="rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)]"
title="取消"
>
<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}」标签下的媒体库`}
>
{tag.name}
</button>
<span className="shrink-0 font-mono text-[10px] text-[var(--app-muted)]">
{tag.library_ids.length}
</span>
<button
type="button"
onClick={() => {
setRenaming(tag.name)
setRenameDraft(tag.name)
}}
className="rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)] hover:text-brand-500"
title="重命名"
>
<Pencil size={13} />
</button>
<button
type="button"
onClick={() => void handleRemove(tag)}
className="rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)] hover:text-red-500"
title="删除标签"
>
<Trash2 size={13} />
</button>
</>
)}
</div>
))
}}
className="rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)]"
title="取消"
>
<X size={14} />
</button>
</>
) : (
<>
<button
type="button"
draggable={drag.draggable}
onDragStart={drag.onDragStart}
onDragEnd={drag.onDragEnd}
className={`-ml-1 shrink-0 cursor-grab rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)] ${
draggingId === tag.name ? 'opacity-40' : ''
}`}
title="拖拽调整标签顺序"
aria-label="拖拽调整标签顺序"
>
<GripVertical size={13} />
</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}」标签下的媒体库`}
>
{tag.name}
</button>
<span className="shrink-0 font-mono text-[10px] text-[var(--app-muted)]">
{tag.library_ids.length}
</span>
<button
type="button"
onClick={() => {
setRenaming(tag.name)
setRenameDraft(tag.name)
}}
className="rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)] hover:text-brand-500"
title="重命名"
>
<Pencil size={13} />
</button>
<button
type="button"
onClick={() => void handleRemove(tag)}
className="rounded-lg p-1 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)] hover:text-red-500"
title="删除标签"
>
<Trash2 size={13} />
</button>
</>
)}
</div>
)
})
)}
</div>
</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) => {
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)}
+1 -3
View File
@@ -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)}