学 MP 文件管理:鼠标悬停或键盘焦点所在行在行尾浮出 2–3 个图标按钮(未入库视频 / 目录:整理、识别、刮削;已入库:打开作品、刷新元数据、重命名;其余:重命名、移动到),带 tooltip 与 aria-label,被状态阻止的灰显写原因;浮出时让出修改时间列;焦点行可 Tab 进入、Esc 回列表;窄屏仍只用 ⋯ 菜单。 面包屑前加「下载目录 / 媒体库」说明段。顺带修正 Shift+F10 菜单定位按行键找行时被 CSS.escape 改坏的问题。 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
96 lines
3.6 KiB
TypeScript
96 lines
3.6 KiB
TypeScript
import { ChevronRight, HardDrive } from "lucide-react";
|
|
import { useState } from "react";
|
|
import type { FileRoot } from "~/api/files.ts";
|
|
import { cn } from "~/lib/utils.ts";
|
|
import { breadcrumbItems, collapseCrumbs } from "./model.ts";
|
|
|
|
/**
|
|
* 路径面包屑(照 MP「本地 > MEDIA > 亚洲剧集」):登记目录类型(下载目录 / 媒体库,只作说明)> 登记目录 > 各级子目录。
|
|
* 目录各段可点击跳转,也是拖放目标(拖入 = 移动到该目录)。窄屏去掉类型,折叠为"根 / … / 父 / 当前"。
|
|
*/
|
|
export function PathBreadcrumb({
|
|
root,
|
|
path,
|
|
onNavigate,
|
|
onDropTo,
|
|
className,
|
|
}: {
|
|
root: Pick<FileRoot, "name" | "kind">;
|
|
path: string;
|
|
onNavigate: (path: string) => void;
|
|
/** 有值时每段可作为拖放目标 */
|
|
onDropTo?: (path: string, e: React.DragEvent) => void;
|
|
className?: string;
|
|
}) {
|
|
const { group, crumbs } = breadcrumbItems(root, path);
|
|
const [over, setOver] = useState<string | null>(null);
|
|
|
|
const render = (items: ((typeof crumbs)[number] | null)[], key: string, extra?: string, withGroup = false) => (
|
|
<ol key={key} className={cn("flex min-w-0 items-center gap-0.5", extra)}>
|
|
{withGroup ? (
|
|
<li className="flex shrink-0 items-center gap-0.5 pl-1.5 pr-0.5 text-body text-muted-foreground">
|
|
{group}
|
|
<ChevronRight aria-hidden className="h-3.5 w-3.5 shrink-0" />
|
|
</li>
|
|
) : null}
|
|
{items.map((c, i) => {
|
|
const last = i === items.length - 1;
|
|
return (
|
|
<li
|
|
key={c ? c.path || "__root" : `gap-${i}`}
|
|
className={cn("flex min-w-0 items-center gap-0.5", last && "shrink")}
|
|
>
|
|
{i > 0 ? <ChevronRight aria-hidden className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null}
|
|
{c === null ? (
|
|
<span className="px-1 text-muted-foreground">…</span>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={() => onNavigate(c.path)}
|
|
aria-current={last ? "page" : undefined}
|
|
onDragOver={
|
|
onDropTo
|
|
? (e) => {
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = e.altKey ? "copy" : "move";
|
|
setOver(c.path);
|
|
}
|
|
: undefined
|
|
}
|
|
onDragLeave={onDropTo ? () => setOver(null) : undefined}
|
|
onDrop={
|
|
onDropTo
|
|
? (e) => {
|
|
e.preventDefault();
|
|
setOver(null);
|
|
onDropTo(c.path, e);
|
|
}
|
|
: undefined
|
|
}
|
|
title={c.label}
|
|
className={cn(
|
|
"flex min-w-0 items-center gap-1.5 truncate rounded-md px-1.5 py-1 text-body transition-colors duration-micro",
|
|
last
|
|
? "font-medium text-foreground"
|
|
: "text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground",
|
|
over === c.path && "bg-foreground/[0.08] ring-1 ring-ring",
|
|
)}
|
|
>
|
|
{i === 0 ? <HardDrive className="h-3.5 w-3.5 shrink-0" /> : null}
|
|
<span className="truncate">{c.label}</span>
|
|
</button>
|
|
)}
|
|
</li>
|
|
);
|
|
})}
|
|
</ol>
|
|
);
|
|
|
|
return (
|
|
<nav aria-label="当前路径" className={cn("min-w-0", className)}>
|
|
{render(crumbs, "full", "hidden md:flex", true)}
|
|
{render(collapseCrumbs(crumbs, 3), "short", "flex md:hidden")}
|
|
</nav>
|
|
);
|
|
}
|