"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
;
}
if (kind === "video") {
return (
{thumbUrl ? (

) : (
)}
);
}
if (kind === "audio") {
return ;
}
return ;
}
function PreviewMedia({ item }: { item: UserFileItem }) {
const kind = getFileKind(item);
const mediaUrl = getHoverMediaUrl(item);
if (kind === "image" && mediaUrl) {
return
;
}
if (kind === "video" && mediaUrl) {
return (
);
}
if (kind === "audio") {
return (
{[18, 42, 64, 52, 34, 46, 26, 16, 18, 12].map((height, index) => (
))}
{mediaUrl && (
)}
);
}
return (
);
}
function AssetPreviewCard({
item,
applying,
canApply,
onApply,
t,
}: {
item: UserFileItem;
applying: boolean;
canApply: boolean;
onApply: () => void;
t: ReturnType["t"];
}) {
const kind = getFileKind(item);
return (
{item.name}
{kind === "image" ? (
) : kind === "video" ? (
) : (
)}
{t("assetLibrary.createdAt")} {formatCreateTime(item.createTime)}
);
}
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([]);
const [childrenByFolderId, setChildrenByFolderId] = useState>({});
const [expandedKeys, setExpandedKeys] = useState([]);
const [loadingRoot, setLoadingRoot] = useState(false);
const [loadingFolderIds, setLoadingFolderIds] = useState>(() => new Set());
const [loadedFolderIds, setLoadedFolderIds] = useState>(() => new Set());
const [error, setError] = useState(null);
const [selectedFile, setSelectedFile] = useState(null);
const [hoveredFileId, setHoveredFileId] = useState(null);
const [applying, setApplying] = useState(false);
const [addPopoverOpen, setAddPopoverOpen] = useState(false);
const [creatingFolderParentId, setCreatingFolderParentId] = useState(null);
const [newFolderName, setNewFolderName] = useState("");
const [submittingFolder, setSubmittingFolder] = useState(false);
const [managerModalOpen, setManagerModalOpen] = useState(false);
const [actionItemKey, setActionItemKey] = useState(null);
const [renamingItemKey, setRenamingItemKey] = useState(null);
const [renameValue, setRenameValue] = useState("");
const [submittingRename, setSubmittingRename] = useState(false);
const [movingItem, setMovingItem] = useState(null);
const [submittingMove, setSubmittingMove] = useState(false);
const newFolderInputRef = useRef(null);
const renameInputRef = useRef(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(() => buildTreeNodes(folders, 0), [buildTreeNodes, folders]);
const disabledMoveFolderIds = useMemo(() => {
if (!movingItem || movingItem.nodeType !== "folder") return undefined;
const disabledIds = new Set([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["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 = (
);
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 (
{t("assetLibrary.folderEmpty")}
);
}
if (node.isCreateInput) {
return (
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)]"
/>
);
}
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 (
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 (
toggleFolderExpansion(node)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
toggleFolderExpansion(node);
}
}}
>
{isExpanded ? : }
{item.name}
{loadingFolderIds.has(item.id) &&
}
{!loadingFolderIds.has(item.id) && (
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)}
/>
)}
);
}
const isRenaming = renamingItemKey === itemKey;
if (isRenaming) {
return (
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 = (
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);
}
}}
>
{item.name}
{
setActionItemKey(nextOpen ? itemKey : null);
if (nextOpen) setHoveredFileId(null);
}}
ariaLabel={t("assetLibrary.itemActions")}
onMove={() => handleStartMoveItem(item)}
onRename={() => handleStartRenameItem(item)}
onDelete={() => handleDeleteItem(item)}
/>
);
return (
{
if (actionItemKey !== null) return;
setHoveredFileId(nextOpen ? item.id : null);
}}
content={(
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",
},
}}
>
{row}
);
}, [
actionItemKey,
applying,
expandedKeys,
handleApplyToCanvas,
handleCancelRename,
handleCommitRename,
handleCommitNewFolder,
handleDeleteItem,
handleStartCreateFolder,
handleStartMoveItem,
handleStartRenameItem,
hoveredFileId,
loadingFolderIds,
renameValue,
renamingItemKey,
selectedFile,
newFolderName,
submittingRename,
submittingFolder,
t,
toggleFolderExpansion,
]);
return (
<>
{t("assetLibrary.folders")}
document.body}
styles={{
container: {
padding: 0,
},
content: {
padding: 0,
background: "transparent",
boxShadow: "none",
},
}}
>
}
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)]"
/>
{creatingFolderParentId === ROOT_PARENT_ID && (
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)]"
/>
)}
{loadingRoot ? (
) : error ? (
{error}
) : treeData.length === 0 ? (
{t("assetLibrary.empty")}}
/>
) : (
blockNode
showIcon={false}
showLine={false}
className={treeClassName}
treeData={treeData}
expandedKeys={expandedKeys}
selectedKeys={selectedFile ? [`file-${selectedFile.id}`] : []}
onExpand={handleExpand}
titleRender={titleRender}
/>
)}
setManagerModalOpen(false)}
onChanged={handleManagerChanged}
/>
{
if (!submittingMove) setMovingItem(null);
}}
onConfirm={handleConfirmMove}
/>
>
);
}
export function AssetLibraryDrawer({ open, onClose }: AssetLibraryDrawerProps) {
const { t } = useI18n();
return (
{t("assetLibrary.title")}
);
}