You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1163 lines
41 KiB
1163 lines
41 KiB
"use client";
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState, type Key } from "react";
|
|
import { useReactFlow } from "@xyflow/react";
|
|
import { Button, Drawer, Empty, Input, Modal, Popover, Spin, Tree, message } from "antd";
|
|
import type { InputRef, TreeProps } from "antd";
|
|
import {
|
|
AudioOutlined,
|
|
FileImageOutlined,
|
|
FolderAddOutlined,
|
|
FolderOpenOutlined,
|
|
FolderOutlined,
|
|
PictureOutlined,
|
|
PlayCircleOutlined,
|
|
PlusOutlined,
|
|
LeftOutlined
|
|
} from "@ant-design/icons";
|
|
|
|
import { useI18n } from "@/i18n";
|
|
import { useWorkflowStore } from "@/store/workflowStore";
|
|
import { authFetch } from "@/utils/authFetch";
|
|
import { createSmartMediaNode, detectSmartMediaKind } from "@/utils/smartMediaNode";
|
|
import {
|
|
ASSET_LIBRARY_ITEM_DRAG_MIME,
|
|
ASSET_LIBRARY_ITEM_KIND_DRAG_MIME_PREFIX,
|
|
getUserFileFolderKey,
|
|
getUserFileItemKey,
|
|
getUserFileList,
|
|
ROOT_PARENT_ID,
|
|
USER_FILE_PAGE_SIZE,
|
|
type UserFileItem,
|
|
type UserFileListResponse,
|
|
type UserFileMutationResponse,
|
|
} from "@/utils/userFileLibrary";
|
|
import { AssetLibraryFolderPickerModal } from "./AssetLibraryFolderPickerModal";
|
|
import { AssetLibraryItemActionsPopover } from "./AssetLibraryItemActionsPopover";
|
|
import { AssetLibraryManagerModal } from "./AssetLibraryManagerModal";
|
|
|
|
type AssetLibraryFileKind = "image" | "video" | "audio" | "other";
|
|
|
|
type AssetTreeNode = {
|
|
key: string;
|
|
title: string;
|
|
item?: UserFileItem;
|
|
isLeaf: boolean;
|
|
depth: number;
|
|
isCreateInput?: boolean;
|
|
isEmpty?: boolean;
|
|
createParentId?: number;
|
|
children?: AssetTreeNode[];
|
|
};
|
|
|
|
interface AssetLibraryDrawerProps {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
}
|
|
|
|
interface AssetLibraryPanelProps {
|
|
active?: boolean;
|
|
className?: string;
|
|
}
|
|
|
|
const treeClassName = [
|
|
"!bg-transparent !text-[var(--text-secondary)]",
|
|
"[&_.ant-tree-list-holder-inner]:gap-y-0.5",
|
|
"[&_.ant-tree-treenode]:!w-full [&_.ant-tree-treenode]:!items-center [&_.ant-tree-treenode]:!p-0",
|
|
"[&_.ant-tree-indent-unit]:!w-5",
|
|
"[&_.ant-tree-switcher]:!hidden",
|
|
"[&_.ant-tree-node-content-wrapper]:!m-0 [&_.ant-tree-node-content-wrapper]:!min-w-0 [&_.ant-tree-node-content-wrapper]:!flex-1 [&_.ant-tree-node-content-wrapper]:!bg-transparent [&_.ant-tree-node-content-wrapper]:!p-0 [&_.ant-tree-node-content-wrapper]:!text-inherit",
|
|
"[&_.ant-tree-node-content-wrapper:hover]:!bg-transparent [&_.ant-tree-node-content-wrapper.ant-tree-node-selected]:!bg-transparent",
|
|
"[&_.ant-tree-title]:!block [&_.ant-tree-title]:!w-full",
|
|
].join(" ");
|
|
|
|
function getPaneCenter() {
|
|
const pane = document.querySelector(".react-flow");
|
|
if (pane) {
|
|
const rect = pane.getBoundingClientRect();
|
|
return {
|
|
x: rect.left + rect.width / 2,
|
|
y: rect.top + rect.height / 2,
|
|
};
|
|
}
|
|
return { x: window.innerWidth / 2, y: window.innerHeight / 2 };
|
|
}
|
|
|
|
function getFileKind(item: UserFileItem): AssetLibraryFileKind {
|
|
if (item.fileCategory === "image") return "image";
|
|
if (item.fileCategory === "video") return "video";
|
|
if (item.fileCategory === "audio") return "audio";
|
|
|
|
return detectSmartMediaKind({
|
|
mimeType: item.mimeType,
|
|
fileExt: item.fileExt,
|
|
url: item.url,
|
|
}) ?? "other";
|
|
}
|
|
|
|
function getTreeThumbUrl(item: UserFileItem): string | null {
|
|
const kind = getFileKind(item);
|
|
if (kind === "image") return item.url || null;
|
|
if (kind === "video") return item.coverUrl || null;
|
|
return null;
|
|
}
|
|
|
|
function getHoverMediaUrl(item: UserFileItem): string | null {
|
|
const kind = getFileKind(item);
|
|
if (kind === "image" || kind === "video" || kind === "audio") return item.url || null;
|
|
return null;
|
|
}
|
|
|
|
function getVideoPosterUrl(item: UserFileItem): string | null {
|
|
return item.coverUrl || null;
|
|
}
|
|
|
|
function formatCreateTime(value?: string) {
|
|
if (!value) return "";
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) return value;
|
|
const year = date.getFullYear();
|
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
const day = String(date.getDate()).padStart(2, "0");
|
|
const hours = String(date.getHours()).padStart(2, "0");
|
|
const minutes = String(date.getMinutes()).padStart(2, "0");
|
|
const seconds = String(date.getSeconds()).padStart(2, "0");
|
|
return `${year}/${month}/${day} ${hours}:${minutes}:${seconds}`;
|
|
}
|
|
|
|
function MediaThumb({ item }: { item: UserFileItem }) {
|
|
const kind = getFileKind(item);
|
|
const thumbUrl = getTreeThumbUrl(item);
|
|
|
|
if (kind === "image" && thumbUrl) {
|
|
return <img src={thumbUrl} alt="" className="h-full w-full object-cover" />;
|
|
}
|
|
|
|
if (kind === "video") {
|
|
return (
|
|
<div className="relative flex h-full w-full items-center justify-center bg-black">
|
|
{thumbUrl ? (
|
|
<img src={thumbUrl} alt="" className="h-full w-full object-cover" />
|
|
) : (
|
|
<PlayCircleOutlined className="text-[14px] text-white/70" />
|
|
)}
|
|
<span className="absolute inset-0 flex items-center justify-center bg-black/10">
|
|
<PlayCircleOutlined className="text-[13px] text-white/90" />
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (kind === "audio") {
|
|
return <AudioOutlined className="text-[14px] text-[var(--text-secondary)]" />;
|
|
}
|
|
|
|
return <FileImageOutlined className="text-[14px] text-[var(--text-secondary)]" />;
|
|
}
|
|
|
|
function PreviewMedia({ item }: { item: UserFileItem }) {
|
|
const kind = getFileKind(item);
|
|
const mediaUrl = getHoverMediaUrl(item);
|
|
|
|
if (kind === "image" && mediaUrl) {
|
|
return <img src={mediaUrl} alt={item.name} className="h-full w-full object-cover" />;
|
|
}
|
|
|
|
if (kind === "video" && mediaUrl) {
|
|
return (
|
|
<video
|
|
src={mediaUrl}
|
|
poster={getVideoPosterUrl(item) || undefined}
|
|
className="h-full w-full object-cover"
|
|
muted
|
|
autoPlay
|
|
loop
|
|
playsInline
|
|
controls
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (kind === "audio") {
|
|
return (
|
|
<div className="flex h-full w-full flex-col items-center justify-center bg-[#111]">
|
|
<div className="flex h-24 items-center gap-2">
|
|
{[18, 42, 64, 52, 34, 46, 26, 16, 18, 12].map((height, index) => (
|
|
<span
|
|
key={index}
|
|
className="w-2 rounded-full bg-neutral-400"
|
|
style={{ height }}
|
|
/>
|
|
))}
|
|
</div>
|
|
<AudioOutlined className="mt-3 text-[18px] text-neutral-300" />
|
|
{mediaUrl && (
|
|
<audio
|
|
src={mediaUrl}
|
|
controls
|
|
className="mt-5 w-[220px]"
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex h-full w-full items-center justify-center bg-[#111] text-[var(--text-disabled)]">
|
|
<FileImageOutlined className="text-[28px]" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AssetPreviewCard({
|
|
item,
|
|
applying,
|
|
canApply,
|
|
onApply,
|
|
t,
|
|
}: {
|
|
item: UserFileItem;
|
|
applying: boolean;
|
|
canApply: boolean;
|
|
onApply: () => void;
|
|
t: ReturnType<typeof useI18n>["t"];
|
|
}) {
|
|
const kind = getFileKind(item);
|
|
|
|
return (
|
|
<div className="nodrag nopan nowheel w-[280px] overflow-hidden rounded-xl border border-[var(--border-subtle)] bg-[var(--surface-2)] text-[var(--text-secondary)] shadow-[var(--shadow-panel)]">
|
|
<div className="h-[226px] overflow-hidden rounded-t-[10px] bg-[var(--surface-1)]">
|
|
<PreviewMedia item={item} />
|
|
</div>
|
|
<div className="bg-[var(--surface-3)] px-2.5 py-2">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<span className="min-w-0 truncate text-sm font-semibold text-[var(--text-primary)]">{item.name}</span>
|
|
{kind === "image" ? (
|
|
<PictureOutlined className="text-[var(--text-muted)]" />
|
|
) : kind === "video" ? (
|
|
<PlayCircleOutlined className="text-[var(--text-muted)]" />
|
|
) : (
|
|
<AudioOutlined className="text-[var(--text-muted)]" />
|
|
)}
|
|
</div>
|
|
<div className="mt-1 truncate text-xs text-[var(--text-muted)]">
|
|
{t("assetLibrary.createdAt")} {formatCreateTime(item.createTime)}
|
|
</div>
|
|
<Button
|
|
block
|
|
disabled={!canApply}
|
|
loading={applying}
|
|
onClick={onApply}
|
|
className="mt-2 !h-10 !rounded-md !border-[var(--border-strong)] !bg-[var(--surface-active)] !text-sm !font-semibold !text-[var(--text-primary)] hover:!border-[var(--border-strong)] hover:!bg-[var(--surface-hover)]"
|
|
>
|
|
{t("assetLibrary.applyToCanvas")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function AssetLibraryPanel({ active = true, className = "" }: AssetLibraryPanelProps) {
|
|
const { t } = useI18n();
|
|
const addNode = useWorkflowStore((state) => state.addNode);
|
|
const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
|
|
const selectSingleNode = useWorkflowStore((state) => state.selectSingleNode);
|
|
const { screenToFlowPosition } = useReactFlow();
|
|
const [folders, setFolders] = useState<UserFileItem[]>([]);
|
|
const [childrenByFolderId, setChildrenByFolderId] = useState<Record<number, UserFileItem[]>>({});
|
|
const [expandedKeys, setExpandedKeys] = useState<Key[]>([]);
|
|
const [loadingRoot, setLoadingRoot] = useState(false);
|
|
const [loadingFolderIds, setLoadingFolderIds] = useState<Set<number>>(() => new Set());
|
|
const [loadedFolderIds, setLoadedFolderIds] = useState<Set<number>>(() => new Set());
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [selectedFile, setSelectedFile] = useState<UserFileItem | null>(null);
|
|
const [hoveredFileId, setHoveredFileId] = useState<number | null>(null);
|
|
const [applying, setApplying] = useState(false);
|
|
const [addPopoverOpen, setAddPopoverOpen] = useState(false);
|
|
const [creatingFolderParentId, setCreatingFolderParentId] = useState<number | null>(null);
|
|
const [newFolderName, setNewFolderName] = useState("");
|
|
const [submittingFolder, setSubmittingFolder] = useState(false);
|
|
const [managerModalOpen, setManagerModalOpen] = useState(false);
|
|
const [actionItemKey, setActionItemKey] = useState<string | null>(null);
|
|
const [renamingItemKey, setRenamingItemKey] = useState<string | null>(null);
|
|
const [renameValue, setRenameValue] = useState("");
|
|
const [submittingRename, setSubmittingRename] = useState(false);
|
|
const [movingItem, setMovingItem] = useState<UserFileItem | null>(null);
|
|
const [submittingMove, setSubmittingMove] = useState(false);
|
|
const newFolderInputRef = useRef<InputRef>(null);
|
|
const renameInputRef = useRef<InputRef>(null);
|
|
const folderCommitInFlightRef = useRef(false);
|
|
const renameCommitInFlightRef = useRef(false);
|
|
|
|
const fetchUserFiles = useCallback(async (parentId: number, signal?: AbortSignal) => {
|
|
const response = await authFetch(`/api/popi/user-file/list?parentId=${parentId}&page=1&pageSize=${USER_FILE_PAGE_SIZE}`, {
|
|
signal,
|
|
});
|
|
const payload = (await response.json().catch(() => null)) as UserFileListResponse | null;
|
|
if (!response.ok || !payload?.success) {
|
|
throw new Error(payload?.error || t("assetLibrary.loadFailed"));
|
|
}
|
|
return getUserFileList(payload.data);
|
|
}, [t]);
|
|
|
|
const loadRootFolders = useCallback(async (signal?: AbortSignal) => {
|
|
setLoadingRoot(true);
|
|
setError(null);
|
|
try {
|
|
const list = await fetchUserFiles(ROOT_PARENT_ID, signal);
|
|
setFolders(list.filter((item) => item.nodeType === "folder"));
|
|
} catch (err) {
|
|
if (err instanceof DOMException && err.name === "AbortError") return;
|
|
setError(err instanceof Error ? err.message : t("assetLibrary.loadFailed"));
|
|
setFolders([]);
|
|
} finally {
|
|
if (!signal?.aborted) setLoadingRoot(false);
|
|
}
|
|
}, [fetchUserFiles, t]);
|
|
|
|
const loadFolderChildren = useCallback(async (folderId: number, force = false) => {
|
|
if (!force && (loadedFolderIds.has(folderId) || loadingFolderIds.has(folderId))) return;
|
|
|
|
setLoadingFolderIds((current) => new Set(current).add(folderId));
|
|
setError(null);
|
|
try {
|
|
const list = await fetchUserFiles(folderId);
|
|
setChildrenByFolderId((current) => ({
|
|
...current,
|
|
[folderId]: list.filter((item) => item.nodeType === "folder" || getFileKind(item) !== "other"),
|
|
}));
|
|
setLoadedFolderIds((current) => new Set(current).add(folderId));
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : t("assetLibrary.loadFailed"));
|
|
} finally {
|
|
setLoadingFolderIds((current) => {
|
|
const next = new Set(current);
|
|
next.delete(folderId);
|
|
return next;
|
|
});
|
|
}
|
|
}, [fetchUserFiles, loadedFolderIds, loadingFolderIds, t]);
|
|
|
|
useEffect(() => {
|
|
if (!active) return;
|
|
const controller = new AbortController();
|
|
void loadRootFolders(controller.signal);
|
|
return () => controller.abort();
|
|
}, [active, loadRootFolders]);
|
|
|
|
useEffect(() => {
|
|
if (creatingFolderParentId === null) return;
|
|
window.setTimeout(() => newFolderInputRef.current?.focus(), 0);
|
|
}, [creatingFolderParentId]);
|
|
|
|
useEffect(() => {
|
|
if (renamingItemKey === null) return;
|
|
window.setTimeout(() => renameInputRef.current?.focus(), 0);
|
|
}, [renamingItemKey]);
|
|
|
|
const buildTreeNodes = useCallback((items: UserFileItem[], depth: number): AssetTreeNode[] => {
|
|
return items.map((item) => {
|
|
const isFolder = item.nodeType === "folder";
|
|
const itemChildren = isFolder ? childrenByFolderId[item.id] || [] : [];
|
|
const children = isFolder ? buildTreeNodes(itemChildren, depth + 1) : undefined;
|
|
const isLoadedEmpty = isFolder
|
|
&& loadedFolderIds.has(item.id)
|
|
&& !loadingFolderIds.has(item.id)
|
|
&& itemChildren.length === 0;
|
|
const emptyChildren = isLoadedEmpty
|
|
? [{
|
|
key: `${getUserFileFolderKey(item.id)}-empty`,
|
|
title: "",
|
|
isLeaf: true,
|
|
depth: depth + 1,
|
|
isEmpty: true,
|
|
}]
|
|
: children;
|
|
const nextChildren = isFolder && creatingFolderParentId === item.id
|
|
? [{
|
|
key: `create-folder-${item.id}`,
|
|
title: "",
|
|
isLeaf: true,
|
|
depth: depth + 1,
|
|
isCreateInput: true,
|
|
createParentId: item.id,
|
|
}, ...(itemChildren.length === 0 ? [] : (children || []))]
|
|
: emptyChildren;
|
|
|
|
return {
|
|
key: getUserFileItemKey(item),
|
|
title: item.name,
|
|
item,
|
|
isLeaf: !isFolder,
|
|
depth,
|
|
children: nextChildren,
|
|
};
|
|
});
|
|
}, [childrenByFolderId, creatingFolderParentId, loadedFolderIds, loadingFolderIds]);
|
|
|
|
const treeData = useMemo<AssetTreeNode[]>(() => buildTreeNodes(folders, 0), [buildTreeNodes, folders]);
|
|
|
|
const disabledMoveFolderIds = useMemo(() => {
|
|
if (!movingItem || movingItem.nodeType !== "folder") return undefined;
|
|
|
|
const disabledIds = new Set<number>([movingItem.id]);
|
|
const collectDescendantFolderIds = (folderId: number) => {
|
|
const children = childrenByFolderId[folderId] || [];
|
|
children.forEach((child) => {
|
|
if (child.nodeType !== "folder" || disabledIds.has(child.id)) return;
|
|
disabledIds.add(child.id);
|
|
collectDescendantFolderIds(child.id);
|
|
});
|
|
};
|
|
collectDescendantFolderIds(movingItem.id);
|
|
return disabledIds;
|
|
}, [childrenByFolderId, movingItem]);
|
|
|
|
const handleExpand: TreeProps<AssetTreeNode>["onExpand"] = (keys, info) => {
|
|
setExpandedKeys(keys);
|
|
const item = info.node.item;
|
|
if (item?.nodeType === "folder" && info.expanded) {
|
|
void loadFolderChildren(item.id);
|
|
}
|
|
};
|
|
|
|
const toggleFolderExpansion = useCallback((node: AssetTreeNode) => {
|
|
const item = node.item;
|
|
if (!item || item.nodeType !== "folder") return;
|
|
|
|
setExpandedKeys((current) => {
|
|
const isExpanded = current.includes(node.key);
|
|
if (isExpanded) {
|
|
return current.filter((key) => key !== node.key);
|
|
}
|
|
|
|
void loadFolderChildren(item.id);
|
|
return [...current, node.key];
|
|
});
|
|
}, [loadFolderChildren]);
|
|
|
|
const refreshFolderChildren = useCallback(async (folderId: number) => {
|
|
setLoadedFolderIds((current) => {
|
|
const next = new Set(current);
|
|
next.delete(folderId);
|
|
return next;
|
|
});
|
|
setChildrenByFolderId((current) => {
|
|
const next = { ...current };
|
|
delete next[folderId];
|
|
return next;
|
|
});
|
|
await loadFolderChildren(folderId, true);
|
|
}, [loadFolderChildren]);
|
|
|
|
const clearFolderCache = useCallback((folderId: number) => {
|
|
setLoadedFolderIds((current) => {
|
|
const next = new Set(current);
|
|
next.delete(folderId);
|
|
return next;
|
|
});
|
|
setChildrenByFolderId((current) => {
|
|
const next = { ...current };
|
|
delete next[folderId];
|
|
return next;
|
|
});
|
|
setExpandedKeys((current) => current.filter((key) => key !== getUserFileFolderKey(folderId)));
|
|
setSelectedFile((current) => current?.parentId === folderId ? null : current);
|
|
}, []);
|
|
|
|
const removeFileFromFolder = useCallback((file: UserFileItem) => {
|
|
setChildrenByFolderId((current) => {
|
|
const files = current[file.parentId];
|
|
if (!files) return current;
|
|
return {
|
|
...current,
|
|
[file.parentId]: files.filter((item) => item.id !== file.id),
|
|
};
|
|
});
|
|
setSelectedFile((current) => current?.id === file.id ? null : current);
|
|
setHoveredFileId((current) => current === file.id ? null : current);
|
|
}, []);
|
|
|
|
const renameItemLocally = useCallback((item: UserFileItem, name: string) => {
|
|
if (item.parentId === ROOT_PARENT_ID) {
|
|
setFolders((current) => current.map((folder) => (
|
|
folder.id === item.id ? { ...folder, name } : folder
|
|
)));
|
|
} else {
|
|
setChildrenByFolderId((current) => {
|
|
const children = current[item.parentId];
|
|
if (!children) return current;
|
|
return {
|
|
...current,
|
|
[item.parentId]: children.map((child) => (
|
|
child.id === item.id ? { ...child, name } : child
|
|
)),
|
|
};
|
|
});
|
|
}
|
|
|
|
setSelectedFile((current) => current?.id === item.id ? { ...current, name } : current);
|
|
}, []);
|
|
|
|
const handleStartCreateFolder = useCallback((parentId = ROOT_PARENT_ID) => {
|
|
setAddPopoverOpen(false);
|
|
setActionItemKey(null);
|
|
setError(null);
|
|
setNewFolderName("");
|
|
setCreatingFolderParentId(parentId);
|
|
if (parentId !== ROOT_PARENT_ID) {
|
|
const key = getUserFileFolderKey(parentId);
|
|
setExpandedKeys((current) => current.includes(key) ? current : [...current, key]);
|
|
void loadFolderChildren(parentId);
|
|
}
|
|
}, [loadFolderChildren]);
|
|
|
|
const handleCommitNewFolder = useCallback(async (parentId: number) => {
|
|
if (folderCommitInFlightRef.current) return;
|
|
const name = newFolderName.trim();
|
|
if (!name) {
|
|
setCreatingFolderParentId(null);
|
|
setNewFolderName("");
|
|
return;
|
|
}
|
|
|
|
folderCommitInFlightRef.current = true;
|
|
setSubmittingFolder(true);
|
|
setError(null);
|
|
try {
|
|
const response = await authFetch("/api/popi/user-file/create-folder", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ parentId, name }),
|
|
});
|
|
const payload = (await response.json().catch(() => null)) as UserFileMutationResponse | null;
|
|
if (!response.ok || !payload?.success) {
|
|
throw new Error(payload?.error || t("assetLibrary.createFolderFailed"));
|
|
}
|
|
setCreatingFolderParentId(null);
|
|
setNewFolderName("");
|
|
if (parentId === ROOT_PARENT_ID) {
|
|
await loadRootFolders();
|
|
} else {
|
|
await refreshFolderChildren(parentId);
|
|
setExpandedKeys((current) => {
|
|
const key = getUserFileFolderKey(parentId);
|
|
return current.includes(key) ? current : [...current, key];
|
|
});
|
|
}
|
|
} catch (err) {
|
|
const messageText = err instanceof Error ? err.message : t("assetLibrary.createFolderFailed");
|
|
setError(messageText);
|
|
void message.error(messageText);
|
|
} finally {
|
|
folderCommitInFlightRef.current = false;
|
|
setSubmittingFolder(false);
|
|
}
|
|
}, [loadRootFolders, newFolderName, refreshFolderChildren, t]);
|
|
|
|
const handleOpenManagerModal = useCallback(() => {
|
|
setAddPopoverOpen(false);
|
|
setError(null);
|
|
setManagerModalOpen(true);
|
|
}, []);
|
|
|
|
const handleUploadedAsset = useCallback(async (folderId: number) => {
|
|
await refreshFolderChildren(folderId);
|
|
setExpandedKeys((current) => {
|
|
const key = getUserFileFolderKey(folderId);
|
|
return current.includes(key) ? current : [...current, key];
|
|
});
|
|
}, [refreshFolderChildren]);
|
|
|
|
const handleManagerChanged = useCallback(async (folderId: number) => {
|
|
if (folderId === ROOT_PARENT_ID) {
|
|
await loadRootFolders();
|
|
return;
|
|
}
|
|
await handleUploadedAsset(folderId);
|
|
}, [handleUploadedAsset, loadRootFolders]);
|
|
|
|
const handleStartRenameItem = useCallback((item: UserFileItem) => {
|
|
setActionItemKey(null);
|
|
setHoveredFileId(null);
|
|
setError(null);
|
|
setRenamingItemKey(getUserFileItemKey(item));
|
|
setRenameValue(item.name);
|
|
}, []);
|
|
|
|
const handleStartMoveItem = useCallback((item: UserFileItem) => {
|
|
setActionItemKey(null);
|
|
setHoveredFileId(null);
|
|
setError(null);
|
|
setMovingItem(item);
|
|
}, []);
|
|
|
|
const refreshAfterMove = useCallback(async (sourceParentId: number, targetParentId: number) => {
|
|
if (sourceParentId === ROOT_PARENT_ID || targetParentId === ROOT_PARENT_ID) {
|
|
await loadRootFolders();
|
|
}
|
|
if (sourceParentId !== ROOT_PARENT_ID) {
|
|
await refreshFolderChildren(sourceParentId);
|
|
}
|
|
if (targetParentId !== ROOT_PARENT_ID && targetParentId !== sourceParentId) {
|
|
await refreshFolderChildren(targetParentId);
|
|
}
|
|
}, [loadRootFolders, refreshFolderChildren]);
|
|
|
|
const handleConfirmMove = useCallback(async (targetId: number) => {
|
|
if (!movingItem) return;
|
|
if (movingItem.parentId === targetId) {
|
|
setMovingItem(null);
|
|
return;
|
|
}
|
|
if (disabledMoveFolderIds?.has(targetId)) return;
|
|
|
|
setSubmittingMove(true);
|
|
setError(null);
|
|
try {
|
|
const response = await authFetch("/api/popi/user-file/move", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
id: movingItem.id,
|
|
targetId,
|
|
}),
|
|
});
|
|
const payload = (await response.json().catch(() => null)) as UserFileMutationResponse | null;
|
|
if (!response.ok || !payload?.success) {
|
|
throw new Error(payload?.error || t("assetLibrary.moveFailed"));
|
|
}
|
|
|
|
await refreshAfterMove(movingItem.parentId, targetId);
|
|
setMovingItem(null);
|
|
setSelectedFile((current) => current?.id === movingItem.id ? null : current);
|
|
void message.success(t("assetLibrary.moveSuccess"));
|
|
} catch (err) {
|
|
const messageText = err instanceof Error ? err.message : t("assetLibrary.moveFailed");
|
|
setError(messageText);
|
|
void message.error(messageText);
|
|
} finally {
|
|
setSubmittingMove(false);
|
|
}
|
|
}, [disabledMoveFolderIds, movingItem, refreshAfterMove, t]);
|
|
|
|
const handleCancelRename = useCallback(() => {
|
|
setRenamingItemKey(null);
|
|
setRenameValue("");
|
|
}, []);
|
|
|
|
const handleCommitRename = useCallback(async (item: UserFileItem) => {
|
|
if (renameCommitInFlightRef.current) return;
|
|
const name = renameValue.trim();
|
|
if (!name || name === item.name) {
|
|
handleCancelRename();
|
|
return;
|
|
}
|
|
|
|
renameCommitInFlightRef.current = true;
|
|
setSubmittingRename(true);
|
|
setError(null);
|
|
try {
|
|
const response = await authFetch("/api/popi/user-file/rename", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ id: item.id, name }),
|
|
});
|
|
const payload = (await response.json().catch(() => null)) as UserFileMutationResponse | null;
|
|
if (!response.ok || !payload?.success) {
|
|
throw new Error(payload?.error || t("assetLibrary.renameFailed"));
|
|
}
|
|
handleCancelRename();
|
|
renameItemLocally(item, name);
|
|
} catch (err) {
|
|
const messageText = err instanceof Error ? err.message : t("assetLibrary.renameFailed");
|
|
setError(messageText);
|
|
void message.error(messageText);
|
|
} finally {
|
|
renameCommitInFlightRef.current = false;
|
|
setSubmittingRename(false);
|
|
}
|
|
}, [handleCancelRename, renameItemLocally, renameValue, t]);
|
|
|
|
const handleDeleteItem = useCallback((item: UserFileItem) => {
|
|
const isFolder = item.nodeType === "folder";
|
|
setActionItemKey(null);
|
|
setHoveredFileId(null);
|
|
Modal.confirm({
|
|
title: isFolder ? t("assetLibrary.deleteFolderTitle") : t("assetLibrary.deleteAssetTitle"),
|
|
content: isFolder ? t("assetLibrary.deleteFolderConfirm") : t("assetLibrary.deleteAssetConfirm"),
|
|
okText: t("assetLibrary.delete"),
|
|
cancelText: t("common.cancel"),
|
|
okButtonProps: { danger: true },
|
|
centered: true,
|
|
async onOk() {
|
|
setError(null);
|
|
try {
|
|
const response = await authFetch(`/api/popi/user-file/delete?id=${item.id}`);
|
|
const payload = (await response.json().catch(() => null)) as UserFileMutationResponse | null;
|
|
if (!response.ok || !payload?.success) {
|
|
throw new Error(payload?.error || t("assetLibrary.deleteFailed"));
|
|
}
|
|
if (isFolder) {
|
|
clearFolderCache(item.id);
|
|
if (item.parentId === ROOT_PARENT_ID) {
|
|
await loadRootFolders();
|
|
} else {
|
|
await refreshFolderChildren(item.parentId);
|
|
}
|
|
} else {
|
|
removeFileFromFolder(item);
|
|
await refreshFolderChildren(item.parentId);
|
|
}
|
|
} catch (err) {
|
|
const messageText = err instanceof Error ? err.message : t("assetLibrary.deleteFailed");
|
|
setError(messageText);
|
|
void message.error(messageText);
|
|
throw err;
|
|
}
|
|
},
|
|
});
|
|
}, [clearFolderCache, loadRootFolders, refreshFolderChildren, removeFileFromFolder, t]);
|
|
|
|
const addMenu = (
|
|
<div className="nodrag nopan nowheel flex min-w-[176px] flex-col rounded-lg bg-[var(--surface-2)] p-1 text-sm text-[var(--text-secondary)] shadow-[var(--shadow-panel)]">
|
|
<button
|
|
type="button"
|
|
className="flex h-9 items-center gap-2 rounded-md px-3 text-left transition-colors hover:bg-[var(--surface-hover)] hover:text-[var(--text-primary)]"
|
|
onClick={handleOpenManagerModal}
|
|
>
|
|
<PictureOutlined className="text-[14px]" />
|
|
<span>{t("assetLibrary.manage")}</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="flex h-9 items-center gap-2 rounded-md px-3 text-left transition-colors hover:bg-[var(--surface-hover)] hover:text-[var(--text-primary)]"
|
|
onClick={() => handleStartCreateFolder(ROOT_PARENT_ID)}
|
|
>
|
|
<FolderAddOutlined className="text-[14px]" />
|
|
<span>{t("assetLibrary.newFolder")}</span>
|
|
</button>
|
|
</div>
|
|
);
|
|
|
|
const handleApplyToCanvas = useCallback(async (file: UserFileItem) => {
|
|
if (!file.url || applying) return;
|
|
const kind = getFileKind(file);
|
|
if (kind === "other") return;
|
|
|
|
setApplying(true);
|
|
try {
|
|
const center = getPaneCenter();
|
|
const position = screenToFlowPosition({
|
|
x: center.x + Math.random() * 100 - 50,
|
|
y: center.y + Math.random() * 100 - 50,
|
|
});
|
|
|
|
await createSmartMediaNode({
|
|
source: {
|
|
kind,
|
|
url: file.url,
|
|
filename: file.name || `${t("assetLibrary.asset")} ${file.id}`,
|
|
mimeType: file.mimeType,
|
|
previewUrl: kind === "video" ? getVideoPosterUrl(file) : kind === "image" ? file.coverUrl || file.url : null,
|
|
assetId: kind === "image" ? file.mediaId : file.id,
|
|
resolver: kind === "image" && file.mediaId
|
|
? {
|
|
type: "popi-media",
|
|
id: file.mediaId,
|
|
name: file.name,
|
|
url: file.url,
|
|
thumbUrl: file.coverUrl,
|
|
}
|
|
: null,
|
|
},
|
|
position,
|
|
addNode,
|
|
updateNodeData,
|
|
getNodeData: (nodeId) => useWorkflowStore.getState().getNodeById(nodeId)?.data,
|
|
selectSingleNode,
|
|
});
|
|
} finally {
|
|
setApplying(false);
|
|
}
|
|
}, [addNode, applying, screenToFlowPosition, selectSingleNode, t, updateNodeData]);
|
|
|
|
const titleRender = useCallback((node: AssetTreeNode) => {
|
|
if (node.isEmpty) {
|
|
return (
|
|
<div className="flex h-8 items-center px-3 text-xs text-[var(--text-disabled)]">
|
|
{t("assetLibrary.folderEmpty")}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (node.isCreateInput) {
|
|
return (
|
|
<div className="text-[var(--text-secondary)]">
|
|
<Input
|
|
ref={newFolderInputRef}
|
|
value={newFolderName}
|
|
disabled={submittingFolder}
|
|
onClick={(event) => event.stopPropagation()}
|
|
onChange={(event) => setNewFolderName(event.target.value)}
|
|
onBlur={() => void handleCommitNewFolder(node.createParentId ?? ROOT_PARENT_ID)}
|
|
onPressEnter={() => void handleCommitNewFolder(node.createParentId ?? ROOT_PARENT_ID)}
|
|
onKeyDown={(event) => {
|
|
event.stopPropagation();
|
|
if (event.key === "Escape") {
|
|
setCreatingFolderParentId(null);
|
|
setNewFolderName("");
|
|
}
|
|
}}
|
|
placeholder={t("assetLibrary.newFolderPlaceholder")}
|
|
className="!h-8 !rounded-lg !border-[var(--border-subtle)] !bg-[var(--surface-1)] !text-sm [&_input]:!text-[var(--text-secondary)] [&_input::placeholder]:!text-[var(--text-muted)]"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const item = node.item;
|
|
if (!item) return null;
|
|
const itemKey = getUserFileItemKey(item);
|
|
if (item.nodeType === "folder") {
|
|
const isExpanded = expandedKeys.includes(node.key);
|
|
const isRenaming = renamingItemKey === itemKey;
|
|
if (isRenaming) {
|
|
return (
|
|
<Input
|
|
ref={renameInputRef}
|
|
value={renameValue}
|
|
disabled={submittingRename}
|
|
onChange={(event) => setRenameValue(event.target.value)}
|
|
onBlur={() => void handleCommitRename(item)}
|
|
onPressEnter={() => void handleCommitRename(item)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Escape") {
|
|
event.stopPropagation();
|
|
handleCancelRename();
|
|
}
|
|
}}
|
|
className="!h-8 !rounded-lg !border-[var(--border-subtle)] !bg-[var(--surface-1)] !text-sm [&_input]:!text-[var(--text-secondary)]"
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
role="button"
|
|
tabIndex={0}
|
|
className="group flex h-8 w-full min-w-0 cursor-pointer items-center gap-2 rounded-md border-0 bg-transparent py-0 pr-2 pl-1 text-left text-sm font-semibold text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-hover)] hover:text-[var(--text-primary)]"
|
|
onClick={() => toggleFolderExpansion(node)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Enter" || event.key === " ") {
|
|
event.preventDefault();
|
|
toggleFolderExpansion(node);
|
|
}
|
|
}}
|
|
>
|
|
<span className="flex h-6 w-6 shrink-0 items-center justify-center">
|
|
{isExpanded ? <FolderOpenOutlined /> : <FolderOutlined />}
|
|
</span>
|
|
<span className="min-w-0 flex-1 truncate">{item.name}</span>
|
|
{loadingFolderIds.has(item.id) && <Spin size="small" className="ml-auto" />}
|
|
{!loadingFolderIds.has(item.id) && (
|
|
<AssetLibraryItemActionsPopover
|
|
open={actionItemKey === itemKey}
|
|
onOpenChange={(nextOpen) => setActionItemKey(nextOpen ? itemKey : null)}
|
|
ariaLabel={t("assetLibrary.itemActions")}
|
|
showCreateFolder={node.depth === 0}
|
|
onCreateFolder={() => handleStartCreateFolder(item.id)}
|
|
onMove={() => handleStartMoveItem(item)}
|
|
onRename={() => handleStartRenameItem(item)}
|
|
onDelete={() => handleDeleteItem(item)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const isRenaming = renamingItemKey === itemKey;
|
|
if (isRenaming) {
|
|
return (
|
|
<Input
|
|
ref={renameInputRef}
|
|
value={renameValue}
|
|
disabled={submittingRename}
|
|
onChange={(event) => setRenameValue(event.target.value)}
|
|
onBlur={() => void handleCommitRename(item)}
|
|
onPressEnter={() => void handleCommitRename(item)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Escape") {
|
|
event.stopPropagation();
|
|
handleCancelRename();
|
|
}
|
|
}}
|
|
className="!h-8 !rounded-lg !border-[var(--border-subtle)] !bg-[var(--surface-1)] !text-sm [&_input]:!text-[var(--text-secondary)]"
|
|
/>
|
|
);
|
|
}
|
|
|
|
const isSelected = selectedFile?.id === item.id;
|
|
const isHovered = hoveredFileId === item.id;
|
|
const isActionOpen = actionItemKey === itemKey;
|
|
const isActive = isSelected || isHovered || isActionOpen;
|
|
const kind = getFileKind(item);
|
|
const canApplyItem = kind !== "other" && Boolean(item.url);
|
|
const row = (
|
|
<div
|
|
role="button"
|
|
tabIndex={0}
|
|
draggable={canApplyItem}
|
|
className={`group flex h-9 w-full min-w-0 cursor-pointer items-center gap-2 rounded-md border-0 py-0.5 pr-2 pl-1 text-sm transition-colors ${
|
|
isActive
|
|
? "bg-[var(--surface-active)] text-[var(--text-primary)]"
|
|
: "bg-transparent text-[var(--text-muted)] hover:bg-[var(--surface-hover)] hover:text-[var(--text-primary)]"
|
|
}`}
|
|
onClick={() => setSelectedFile(item)}
|
|
onDragStart={(event) => {
|
|
if (!canApplyItem || !item.url) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
event.stopPropagation();
|
|
event.dataTransfer.effectAllowed = "copy";
|
|
event.dataTransfer.setData(ASSET_LIBRARY_ITEM_DRAG_MIME, JSON.stringify(item));
|
|
event.dataTransfer.setData(`${ASSET_LIBRARY_ITEM_KIND_DRAG_MIME_PREFIX}${kind}`, "1");
|
|
event.dataTransfer.setData("text/plain", item.name);
|
|
setSelectedFile(item);
|
|
}}
|
|
onDragEnd={() => setHoveredFileId(null)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Enter" || event.key === " ") {
|
|
event.preventDefault();
|
|
setSelectedFile(item);
|
|
}
|
|
}}
|
|
>
|
|
<span className="flex h-6 w-6 shrink-0 items-center justify-center overflow-hidden rounded bg-[var(--surface-3)]">
|
|
<MediaThumb item={item} />
|
|
</span>
|
|
<span className="min-w-0 flex-1 truncate text-left">{item.name}</span>
|
|
<AssetLibraryItemActionsPopover
|
|
open={isActionOpen}
|
|
onOpenChange={(nextOpen) => {
|
|
setActionItemKey(nextOpen ? itemKey : null);
|
|
if (nextOpen) setHoveredFileId(null);
|
|
}}
|
|
ariaLabel={t("assetLibrary.itemActions")}
|
|
onMove={() => handleStartMoveItem(item)}
|
|
onRename={() => handleStartRenameItem(item)}
|
|
onDelete={() => handleDeleteItem(item)}
|
|
/>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<Popover
|
|
open={isHovered && !isActionOpen}
|
|
onOpenChange={(nextOpen) => {
|
|
if (actionItemKey !== null) return;
|
|
setHoveredFileId(nextOpen ? item.id : null);
|
|
}}
|
|
content={(
|
|
<AssetPreviewCard
|
|
item={item}
|
|
applying={applying && isHovered}
|
|
canApply={canApplyItem}
|
|
onApply={() => handleApplyToCanvas(item)}
|
|
t={t}
|
|
/>
|
|
)}
|
|
trigger="hover"
|
|
placement="right"
|
|
align={{ offset: [20, 0] }}
|
|
arrow={false}
|
|
mouseEnterDelay={0.08}
|
|
mouseLeaveDelay={0.12}
|
|
getPopupContainer={() => document.body}
|
|
styles={{
|
|
container: { padding: 0 },
|
|
content: {
|
|
padding: 0,
|
|
background: "transparent",
|
|
boxShadow: "none",
|
|
},
|
|
}}
|
|
>
|
|
<div className="flex w-full min-w-0">{row}</div>
|
|
</Popover>
|
|
);
|
|
}, [
|
|
actionItemKey,
|
|
applying,
|
|
expandedKeys,
|
|
handleApplyToCanvas,
|
|
handleCancelRename,
|
|
handleCommitRename,
|
|
handleCommitNewFolder,
|
|
handleDeleteItem,
|
|
handleStartCreateFolder,
|
|
handleStartMoveItem,
|
|
handleStartRenameItem,
|
|
hoveredFileId,
|
|
loadingFolderIds,
|
|
renameValue,
|
|
renamingItemKey,
|
|
selectedFile,
|
|
newFolderName,
|
|
submittingRename,
|
|
submittingFolder,
|
|
t,
|
|
toggleFolderExpansion,
|
|
]);
|
|
|
|
return (
|
|
<>
|
|
<div className={`flex h-full min-h-0 flex-col bg-[var(--surface-1)] text-[var(--text-primary)] ${className}`}>
|
|
<div className="flex items-center justify-between gap-3 px-4 pt-4">
|
|
<div className="text-xs text-[var(--text-muted)]">{t("assetLibrary.folders")}</div>
|
|
<Popover
|
|
open={addPopoverOpen}
|
|
onOpenChange={setAddPopoverOpen}
|
|
content={addMenu}
|
|
trigger="click"
|
|
placement="bottomRight"
|
|
arrow={false}
|
|
getPopupContainer={() => document.body}
|
|
styles={{
|
|
container: {
|
|
padding: 0,
|
|
},
|
|
content: {
|
|
padding: 0,
|
|
background: "transparent",
|
|
boxShadow: "none",
|
|
},
|
|
}}
|
|
>
|
|
<Button
|
|
type="text"
|
|
shape="default"
|
|
icon={<PlusOutlined />}
|
|
aria-label={t("assetLibrary.add")}
|
|
className="!flex !h-8 !w-8 !items-center !justify-center !rounded-lg !bg-[var(--surface-3)] !text-[var(--text-primary)] hover:!bg-[var(--surface-hover)]"
|
|
/>
|
|
</Popover>
|
|
</div>
|
|
|
|
<div className="px-4 pt-2">
|
|
{creatingFolderParentId === ROOT_PARENT_ID && (
|
|
<Input
|
|
ref={newFolderInputRef}
|
|
value={newFolderName}
|
|
disabled={submittingFolder}
|
|
onChange={(event) => setNewFolderName(event.target.value)}
|
|
onBlur={() => void handleCommitNewFolder(ROOT_PARENT_ID)}
|
|
onPressEnter={() => void handleCommitNewFolder(ROOT_PARENT_ID)}
|
|
placeholder={t("assetLibrary.newFolderPlaceholder")}
|
|
className="mb-2 !h-8 !rounded-lg !border-[var(--border-subtle)] !bg-[var(--surface-1)] !text-sm [&_input]:!text-[var(--text-secondary)] [&_input::placeholder]:!text-[var(--text-muted)]"
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-5">
|
|
{loadingRoot ? (
|
|
<div className="flex h-40 items-center justify-center">
|
|
<Spin />
|
|
</div>
|
|
) : error ? (
|
|
<div className="flex h-40 flex-col items-center justify-center gap-3 text-xs text-[var(--text-muted)]">
|
|
<span>{error}</span>
|
|
<Button size="small" onClick={() => void loadRootFolders()}>
|
|
{t("assetLibrary.retry")}
|
|
</Button>
|
|
</div>
|
|
) : treeData.length === 0 ? (
|
|
<Empty
|
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
|
description={<span className="text-xs text-[var(--text-disabled)]">{t("assetLibrary.empty")}</span>}
|
|
/>
|
|
) : (
|
|
<Tree<AssetTreeNode>
|
|
blockNode
|
|
showIcon={false}
|
|
showLine={false}
|
|
className={treeClassName}
|
|
treeData={treeData}
|
|
expandedKeys={expandedKeys}
|
|
selectedKeys={selectedFile ? [`file-${selectedFile.id}`] : []}
|
|
onExpand={handleExpand}
|
|
titleRender={titleRender}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<AssetLibraryManagerModal
|
|
open={managerModalOpen}
|
|
onClose={() => setManagerModalOpen(false)}
|
|
onChanged={handleManagerChanged}
|
|
/>
|
|
<AssetLibraryFolderPickerModal
|
|
open={movingItem !== null}
|
|
title={t("assetLibrary.moveTitle")}
|
|
confirmText={t("assetLibrary.move")}
|
|
folders={folders}
|
|
childrenByFolderId={childrenByFolderId}
|
|
loadingFolderIds={loadingFolderIds}
|
|
loadedFolderIds={loadedFolderIds}
|
|
initialFolderId={movingItem?.parentId === ROOT_PARENT_ID ? null : movingItem?.parentId ?? null}
|
|
disabledFolderIds={disabledMoveFolderIds}
|
|
confirmLoading={submittingMove}
|
|
onLoadFolderChildren={loadFolderChildren}
|
|
onClose={() => {
|
|
if (!submittingMove) setMovingItem(null);
|
|
}}
|
|
onConfirm={handleConfirmMove}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function AssetLibraryDrawer({ open, onClose }: AssetLibraryDrawerProps) {
|
|
const { t } = useI18n();
|
|
|
|
return (
|
|
<Drawer
|
|
open={open}
|
|
onClose={onClose}
|
|
placement="left"
|
|
mask={false}
|
|
closable={false}
|
|
zIndex={60}
|
|
rootClassName="nodrag nopan nowheel w-[352px]"
|
|
styles={{
|
|
section: {
|
|
overflow: "visible",
|
|
borderRight: "1px solid var(--border-subtle)",
|
|
borderRadius: "0 10px 10px 0",
|
|
background: "var(--surface-1)",
|
|
boxShadow: "none",
|
|
},
|
|
body: {
|
|
padding: 0,
|
|
overflow: "visible",
|
|
},
|
|
}}
|
|
>
|
|
<div className="flex h-full min-h-0 flex-col bg-[var(--surface-1)] text-[var(--text-primary)]">
|
|
<div className="flex h-[52px] items-center gap-2 px-4 pt-3">
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
aria-label={t("common.close")}
|
|
className="flex h-7 w-7 items-center justify-center rounded-md text-[var(--text-muted)] transition-colors hover:bg-[var(--surface-hover)] hover:text-[var(--text-primary)]"
|
|
>
|
|
<LeftOutlined />
|
|
</button>
|
|
<h2 className="text-[22px] font-semibold leading-7 text-[var(--text-primary)]">{t("assetLibrary.title")}</h2>
|
|
</div>
|
|
<AssetLibraryPanel active={open} />
|
|
</div>
|
|
</Drawer>
|
|
);
|
|
}
|
|
|