mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 03:06:38 +08:00
优化
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user