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

"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>
);
}