Browse Source

素材库完善

feature/composer
TianYun 3 weeks ago
parent
commit
d4a82f00f7
  1. 73
      src/app/api/popi/user-file/update-cover/route.ts
  2. 1
      src/app/layout.tsx
  3. 259
      src/components/AssetLibraryManagerModal.tsx
  4. 116
      src/components/AssetLibraryUploadModal.tsx
  5. 8
      src/i18n/index.tsx
  6. 88
      src/styles/antd-overrides.css

73
src/app/api/popi/user-file/update-cover/route.ts

@ -0,0 +1,73 @@
import { NextRequest, NextResponse } from "next/server";
import { requireLogin } from "@/app/api/_auth";
import { ApiError } from "@/app/api/_errors";
const POPI_USER_FILE_UPDATE_COVER_PATH = "/api_client/users/userFile/updateCover";
type PopiserverResponse<T> = {
status?: string;
message?: string;
data?: T;
};
function getPopiBaseUrl(request: NextRequest): string {
const baseUrl =
process.env.POPIART_API_BASE_URL ||
process.env.NEXT_PUBLIC_APP_URL ||
request.nextUrl.origin;
return baseUrl.replace(/\/+$/, "");
}
function readUpdateCoverBody(body: unknown): { id: number; cover: string } {
const value = body && typeof body === "object" ? body as Record<string, unknown> : {};
const id = Number(value.id);
const cover = typeof value.cover === "string" ? value.cover.trim() : "";
if (!Number.isInteger(id) || id <= 0) {
throw ApiError.badRequest("User file id is invalid.");
}
if (!cover) {
throw ApiError.badRequest("Cover is required.");
}
return { id, cover };
}
export async function POST(request: NextRequest) {
try {
const login = requireLogin(request);
const body = readUpdateCoverBody(await request.json().catch(() => null));
const upstream = await fetch(getPopiBaseUrl(request) + POPI_USER_FILE_UPDATE_COVER_PATH, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${login.token}`,
token: login.token,
},
body: JSON.stringify(body),
cache: "no-store",
});
const payload = (await upstream.json().catch(() => null)) as PopiserverResponse<unknown> | null;
if (!upstream.ok) {
throw ApiError.provider(payload?.message || "Popiserver update user file cover failed.", upstream.status);
}
if (!payload || payload.status !== "0000") {
throw ApiError.provider(payload?.message || "Popiserver update user file cover returned an error.", 502);
}
return NextResponse.json({
success: true,
data: payload.data,
});
} catch (error) {
if (error instanceof ApiError) {
return NextResponse.json({ success: false, error: error.message }, { status: error.status });
}
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : "Update user file cover failed." },
{ status: 500 }
);
}
}

1
src/app/layout.tsx

@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { AntdRegistry } from "@ant-design/nextjs-registry";
import { Suspense } from "react";
import "./globals.css";
import "@/styles/antd-overrides.css";
import { Toast } from "@/components/Toast";
import { I18nProvider } from "@/i18n";
import { AntdThemeProvider } from "@/components/AntdThemeProvider";

259
src/components/AssetLibraryManagerModal.tsx

@ -1,8 +1,8 @@
"use client";
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type ReactNode } from "react";
import { useReactFlow } from "@xyflow/react";
import { Button, Dropdown, Empty, Input, Modal, Spin, message } from "antd";
import { Breadcrumb, Button, Dropdown, Empty, Input, Modal, Spin, message } from "antd";
import type { MenuProps } from "antd";
import {
AudioOutlined,
@ -27,6 +27,7 @@ import {
import { useI18n } from "@/i18n";
import { useWorkflowStore } from "@/store/workflowStore";
import { authFetch } from "@/utils/authFetch";
import { uploadFileToGatewayMedia } from "@/utils/gatewayMediaUpload";
import { createSmartMediaNode, detectSmartMediaKind } from "@/utils/smartMediaNode";
import {
getUserFileList,
@ -36,9 +37,10 @@ import {
type UserFileListResponse,
type UserFileMutationResponse,
} from "@/utils/userFileLibrary";
import { AssetLibraryUploadModal, type AssetLibraryUploadFolderOption } from "./AssetLibraryUploadModal";
import { AssetLibraryUploadModal } from "./AssetLibraryUploadModal";
type AssetLibraryFileKind = "image" | "video" | "audio" | "other";
type AssetLibraryActionKey = "move" | "cover" | "rename" | "delete";
interface AssetLibraryManagerModalProps {
open: boolean;
@ -158,9 +160,16 @@ export function AssetLibraryManagerModal({ open, onClose, onChanged }: AssetLibr
const [moveFolderAncestorIds, setMoveFolderAncestorIds] = useState<Record<number, Set<number>>>({});
const [actionMenuOpenId, setActionMenuOpenId] = useState<number | null>(null);
const [movePanelOpenId, setMovePanelOpenId] = useState<number | null>(null);
const [activeActionKey, setActiveActionKey] = useState<AssetLibraryActionKey | null>(null);
const [batchMode, setBatchMode] = useState(false);
const [selectedItemIds, setSelectedItemIds] = useState<Set<number>>(new Set());
const [batchDeleting, setBatchDeleting] = useState(false);
const [updatingCoverId, setUpdatingCoverId] = useState<number | null>(null);
const [uploadFoldersByParentId, setUploadFoldersByParentId] = useState<Record<number, UserFileItem[]>>({});
const [loadedUploadFolderIds, setLoadedUploadFolderIds] = useState<Set<number>>(new Set());
const [loadingUploadFolderIds, setLoadingUploadFolderIds] = useState<Set<number>>(new Set());
const coverInputRef = useRef<HTMLInputElement>(null);
const coverTargetRef = useRef<UserFileItem | null>(null);
const loadItems = useCallback(async (signal?: AbortSignal) => {
setLoading(true);
@ -213,9 +222,15 @@ export function AssetLibraryManagerModal({ open, onClose, onChanged }: AssetLibr
setMoveFolderAncestorIds({});
setActionMenuOpenId(null);
setMovePanelOpenId(null);
setActiveActionKey(null);
setBatchMode(false);
setSelectedItemIds(new Set());
setBatchDeleting(false);
setUpdatingCoverId(null);
setUploadFoldersByParentId({});
setLoadedUploadFolderIds(new Set());
setLoadingUploadFolderIds(new Set());
coverTargetRef.current = null;
}, [open]);
const handleEnterFolder = useCallback((folder: UserFileItem) => {
@ -331,6 +346,7 @@ export function AssetLibraryManagerModal({ open, onClose, onChanged }: AssetLibr
await onChanged?.(parentId);
setActionMenuOpenId(null);
setMovePanelOpenId(null);
setActiveActionKey(null);
void message.success(t("assetLibrary.moveSuccess"));
} catch (err) {
const messageText = err instanceof Error ? err.message : t("assetLibrary.moveFailed");
@ -381,6 +397,37 @@ export function AssetLibraryManagerModal({ open, onClose, onChanged }: AssetLibr
}
}, [loadedMoveFolderIds, loadingMoveFolderIds, t]);
const loadUploadFolderChildren = useCallback(async (folderId: number) => {
if (loadedUploadFolderIds.has(folderId) || loadingUploadFolderIds.has(folderId)) return;
setLoadingUploadFolderIds((current) => new Set(current).add(folderId));
try {
const params = new URLSearchParams({
parentId: String(folderId),
keyword: "",
page: "1",
pageSize: String(USER_FILE_PAGE_SIZE),
});
const response = await authFetch(`/api/popi/user-file/list?${params.toString()}`);
const payload = (await response.json().catch(() => null)) as UserFileListResponse | null;
if (!response.ok || !payload?.success) {
throw new Error(payload?.error || t("assetLibrary.loadFailed"));
}
const folders = getUserFileList(payload.data).filter((item) => item.nodeType === "folder");
setUploadFoldersByParentId((current) => ({ ...current, [folderId]: folders }));
setLoadedUploadFolderIds((current) => new Set(current).add(folderId));
} catch (err) {
const messageText = err instanceof Error ? err.message : t("assetLibrary.loadFailed");
void message.error(messageText);
} finally {
setLoadingUploadFolderIds((current) => {
const next = new Set(current);
next.delete(folderId);
return next;
});
}
}, [loadedUploadFolderIds, loadingUploadFolderIds, t]);
const handleDeleteItem = useCallback((item: UserFileItem) => {
const isFolder = item.nodeType === "folder";
Modal.confirm({
@ -468,6 +515,49 @@ export function AssetLibraryManagerModal({ open, onClose, onChanged }: AssetLibr
});
}, [batchDeleting, loadItems, onChanged, parentId, selectedItemIds, t]);
const handleStartChangeCover = useCallback((item: UserFileItem) => {
if (item.nodeType !== "folder" || updatingCoverId !== null) return;
coverTargetRef.current = item;
setActionMenuOpenId(null);
setMovePanelOpenId(null);
coverInputRef.current?.click();
}, [updatingCoverId]);
const handleCoverFileChange = useCallback(async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = "";
const target = coverTargetRef.current;
coverTargetRef.current = null;
if (!file || !target || updatingCoverId !== null) return;
if (!file.type.startsWith("image/")) {
void message.error(t("assetLibrary.selectImageFile"));
return;
}
setUpdatingCoverId(target.id);
try {
const uploaded = await uploadFileToGatewayMedia(file, "image");
const response = await authFetch("/api/popi/user-file/update-cover", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: target.id, cover: uploaded.url }),
});
const payload = (await response.json().catch(() => null)) as UserFileMutationResponse | null;
if (!response.ok || !payload?.success) {
throw new Error(payload?.error || t("assetLibrary.updateCoverFailed"));
}
await loadItems();
await onChanged?.(parentId);
void message.success(t("assetLibrary.updateCoverSuccess"));
} catch (err) {
const messageText = err instanceof Error ? err.message : t("assetLibrary.updateCoverFailed");
void message.error(messageText);
} finally {
setUpdatingCoverId(null);
}
}, [loadItems, onChanged, parentId, t, updatingCoverId]);
const handleApplyToCanvas = useCallback(async (file: UserFileItem) => {
if (!file.url || applyingId !== null) return;
const kind = getFileKind(file);
@ -509,50 +599,25 @@ export function AssetLibraryManagerModal({ open, onClose, onChanged }: AssetLibr
}
}, [addNode, applyingId, screenToFlowPosition, selectSingleNode, t, updateNodeData]);
const uploadFolderOptions = useMemo<AssetLibraryUploadFolderOption[]>(() => {
const currentPathLabel = breadcrumbs.map((crumb) => crumb.name).join(" / ");
const options: AssetLibraryUploadFolderOption[] = [
...breadcrumbs.map((item, index) => ({
id: item.id,
label: breadcrumbs.slice(0, index + 1).map((crumb) => crumb.name).join(" / "),
})),
...items
.filter((item) => item.nodeType === "folder")
.map((item) => ({
id: item.id,
label: parentId === ROOT_PARENT_ID
? item.name
: `${currentPathLabel} / ${item.name}`,
})),
];
return options;
}, [breadcrumbs, items, parentId]);
const newMenuItems = useMemo<MenuProps["items"]>(() => {
if (parentId === ROOT_PARENT_ID) {
return [{
return [
{
key: "folder",
icon: <FolderAddOutlined />,
label: t("assetLibrary.newFolder"),
onClick: () => setCreateFolderOpen(true),
}];
}
return [
{
key: "folder",
icon: <FolderAddOutlined />,
label: t("assetLibrary.newFolder"),
onClick: () => setCreateFolderOpen(true),
},
{
key: "upload",
icon: <UploadOutlined />,
label: t("assetLibrary.uploadAsset"),
onClick: () => setUploadOpen(true),
key: "upload",
icon: <UploadOutlined />,
label: t("assetLibrary.uploadAsset"),
onClick: () => {
setUploadOpen(true);
void loadUploadFolderChildren(ROOT_PARENT_ID);
},
},
];
}, [parentId, t]);
}, [loadUploadFolderChildren, t]);
const handleToggleMoveFolder = useCallback((folderId: number) => {
setExpandedMoveFolderIds((current) => {
@ -669,10 +734,16 @@ export function AssetLibraryManagerModal({ open, onClose, onChanged }: AssetLibr
}, [handleMoveItem, loadingMoveFolderIds, moveFoldersByParentId, renderMoveTreeRows, t]);
const handleOpenMovePanel = useCallback((item: UserFileItem) => {
setActiveActionKey("move");
setMovePanelOpenId(item.id);
void loadMoveFolderChildren(ROOT_PARENT_ID);
}, [loadMoveFolderChildren]);
const handleActivateAction = useCallback((key: AssetLibraryActionKey) => {
setActiveActionKey(key);
if (key !== "move") setMovePanelOpenId(null);
}, []);
const renderItemActions = useCallback((item: UserFileItem) => {
const isFolder = item.nodeType === "folder";
const movePanelOpen = movePanelOpenId === item.id;
@ -687,7 +758,7 @@ export function AssetLibraryManagerModal({ open, onClose, onChanged }: AssetLibr
type="button"
className={[
"flex h-8 w-full items-center gap-2 rounded-md px-3 text-left transition-colors hover:bg-[var(--surface-hover)] hover:text-[var(--text-primary)]",
movePanelOpen ? "bg-[var(--surface-hover)] text-[var(--text-primary)]" : "",
activeActionKey === "move" ? "bg-[var(--surface-hover)] text-[var(--text-primary)]" : "",
].join(" ")}
onMouseEnter={() => handleOpenMovePanel(item)}
onClick={(event) => {
@ -702,10 +773,15 @@ export function AssetLibraryManagerModal({ open, onClose, onChanged }: AssetLibr
{isFolder && (
<button
type="button"
className="flex h-8 items-center gap-2 rounded-md px-3 text-left transition-colors hover:bg-[var(--surface-hover)] hover:text-[var(--text-primary)]"
className={[
"flex h-8 items-center gap-2 rounded-md px-3 text-left transition-colors hover:bg-[var(--surface-hover)] hover:text-[var(--text-primary)]",
activeActionKey === "cover" ? "bg-[var(--surface-hover)] text-[var(--text-primary)]" : "",
].join(" ")}
onMouseEnter={() => handleActivateAction("cover")}
onClick={(event) => {
event.stopPropagation();
void message.info(t("assetLibrary.changeCoverUnsupported"));
handleActivateAction("cover");
handleStartChangeCover(item);
}}
>
<PictureOutlined className="text-[14px]" />
@ -714,11 +790,17 @@ export function AssetLibraryManagerModal({ open, onClose, onChanged }: AssetLibr
)}
<button
type="button"
className="flex h-8 items-center gap-2 rounded-md px-3 text-left transition-colors hover:bg-[var(--surface-hover)] hover:text-[var(--text-primary)]"
className={[
"flex h-8 items-center gap-2 rounded-md px-3 text-left transition-colors hover:bg-[var(--surface-hover)] hover:text-[var(--text-primary)]",
activeActionKey === "rename" ? "bg-[var(--surface-hover)] text-[var(--text-primary)]" : "",
].join(" ")}
onMouseEnter={() => handleActivateAction("rename")}
onClick={(event) => {
event.stopPropagation();
handleActivateAction("rename");
setActionMenuOpenId(null);
setMovePanelOpenId(null);
setActiveActionKey(null);
openRenameModal(item);
}}
>
@ -727,11 +809,17 @@ export function AssetLibraryManagerModal({ open, onClose, onChanged }: AssetLibr
</button>
<button
type="button"
className="flex h-8 items-center gap-2 rounded-md px-3 text-left text-red-400 transition-colors hover:bg-red-500/10 hover:text-red-300"
className={[
"flex h-8 items-center gap-2 rounded-md px-3 text-left text-red-400 transition-colors hover:bg-red-500/10 hover:text-red-300",
activeActionKey === "delete" ? "bg-red-500/10 text-red-300" : "",
].join(" ")}
onMouseEnter={() => handleActivateAction("delete")}
onClick={(event) => {
event.stopPropagation();
handleActivateAction("delete");
setActionMenuOpenId(null);
setMovePanelOpenId(null);
setActiveActionKey(null);
handleDeleteItem(item);
}}
>
@ -746,7 +834,7 @@ export function AssetLibraryManagerModal({ open, onClose, onChanged }: AssetLibr
)}
</div>
);
}, [handleDeleteItem, handleOpenMovePanel, movePanelOpenId, openRenameModal, renderMovePanel, t]);
}, [activeActionKey, handleActivateAction, handleDeleteItem, handleOpenMovePanel, handleStartChangeCover, movePanelOpenId, openRenameModal, renderMovePanel, t]);
const renderItem = (item: UserFileItem) => {
const isFolder = item.nodeType === "folder";
@ -755,6 +843,7 @@ export function AssetLibraryManagerModal({ open, onClose, onChanged }: AssetLibr
const canApply = !isFolder && kind !== "other" && Boolean(item.url);
const canSelect = batchMode && !isFolder;
const selected = selectedItemIds.has(item.id);
const folderCoverUrl = isFolder ? item.coverUrl : null;
return (
<div
@ -784,7 +873,9 @@ export function AssetLibraryManagerModal({ open, onClose, onChanged }: AssetLibr
}}
>
<div className="relative flex aspect-[4/3] w-full items-center justify-center overflow-hidden rounded-md bg-[var(--surface-1)] transition-opacity group-hover:opacity-90">
{isFolder ? (
{isFolder && folderCoverUrl ? (
<img src={folderCoverUrl} alt="" className="absolute inset-0 h-full w-full object-cover" />
) : isFolder ? (
<FolderOutlined className="text-[34px] text-[var(--text-muted)]" />
) : previewUrl ? (
kind === "video" ? (
@ -836,7 +927,10 @@ export function AssetLibraryManagerModal({ open, onClose, onChanged }: AssetLibr
open={actionMenuOpenId === item.id}
onOpenChange={(nextOpen) => {
setActionMenuOpenId(nextOpen ? item.id : null);
if (!nextOpen) setMovePanelOpenId(null);
if (!nextOpen) {
setMovePanelOpenId(null);
setActiveActionKey(null);
}
}}
trigger={["click"]}
placement="bottomRight"
@ -873,16 +967,16 @@ export function AssetLibraryManagerModal({ open, onClose, onChanged }: AssetLibr
rootClassName="[&_.ant-modal-container]:!p-0"
onCancel={onClose}
className={[
"nodrag nopan nowheel",
"nodrag nopan nowheel !overflow-hidden !rounded-xl",
"[&_.ant-modal-content]:!overflow-hidden [&_.ant-modal-content]:!rounded-xl [&_.ant-modal-content]:!border-0 [&_.ant-modal-content]:!bg-[var(--surface-2)] [&_.ant-modal-content]:!p-0 [&_.ant-modal-content]:!shadow-[var(--shadow-panel)]",
"[&_.ant-modal-body]:!p-0",
].join(" ")}
>
<div className="flex min-h-0 flex-col bg-[var(--surface-2)] text-[var(--text-primary)]">
<div className="flex min-h-0 flex-col overflow-hidden rounded-xl bg-[var(--surface-2)] text-[var(--text-primary)]">
<AssetLibraryModalHeader title={t("assetLibrary.manageTitle")} onClose={onClose} />
<div className="px-4 pt-4">
<div className="flex min-w-0 items-center gap-2 text-sm font-semibold text-[var(--text-primary)]">
<div className="flex min-w-0 items-center gap-2 text-sm font-semibold text-[var(--text-primary)] [&_.ant-breadcrumb-link]:!text-[var(--text-secondary)] [&_.ant-breadcrumb-link:hover]:!text-[var(--text-primary)] [&_.ant-breadcrumb-separator]:!text-[var(--text-muted)]">
{breadcrumbs.length > 0 && (
<Button
type="text"
@ -892,29 +986,38 @@ export function AssetLibraryManagerModal({ open, onClose, onChanged }: AssetLibr
className="!h-7 !w-7 !rounded-md !text-[var(--text-secondary)] hover:!bg-[var(--surface-hover)] hover:!text-[var(--text-primary)]"
/>
)}
<button
type="button"
className="shrink-0 text-[var(--text-secondary)] transition-colors hover:text-[var(--text-primary)]"
onClick={() => handleBreadcrumbClick(-1)}
>
{t("assetLibrary.title")}
</button>
{breadcrumbs.map((item, index) => (
<span key={item.id} className="min-w-0">
<span className="mx-1 text-[var(--text-muted)]">/</span>
{index === breadcrumbs.length - 1 ? (
<span className="text-[var(--text-primary)]">{item.name}</span>
) : (
<button
type="button"
className="text-[var(--text-secondary)] transition-colors hover:text-[var(--text-primary)]"
onClick={() => handleBreadcrumbClick(index)}
>
{item.name}
</button>
)}
</span>
))}
<Breadcrumb
separator="/"
className="min-w-0"
items={[
{
title: breadcrumbs.length === 0 ? (
<span className="text-[var(--text-primary)]">{t("assetLibrary.title")}</span>
) : (
<button
type="button"
className="text-[var(--text-secondary)] transition-colors hover:text-[var(--text-primary)]"
onClick={() => handleBreadcrumbClick(-1)}
>
{t("assetLibrary.title")}
</button>
),
},
...breadcrumbs.map((item, index) => ({
title: index === breadcrumbs.length - 1 ? (
<span className="text-[var(--text-primary)]">{item.name}</span>
) : (
<button
type="button"
className="max-w-[160px] truncate text-[var(--text-secondary)] transition-colors hover:text-[var(--text-primary)]"
onClick={() => handleBreadcrumbClick(index)}
>
{item.name}
</button>
),
})),
]}
/>
</div>
</div>
@ -1138,8 +1241,11 @@ export function AssetLibraryManagerModal({ open, onClose, onChanged }: AssetLibr
<AssetLibraryUploadModal
open={uploadOpen}
folderOptions={uploadFolderOptions}
foldersByParentId={uploadFoldersByParentId}
loadedFolderIds={loadedUploadFolderIds}
loadingFolderIds={loadingUploadFolderIds}
initialFolderId={parentId}
onLoadFolderChildren={loadUploadFolderChildren}
onClose={() => setUploadOpen(false)}
onError={setError}
onUploaded={async (folderId) => {
@ -1149,6 +1255,13 @@ export function AssetLibraryManagerModal({ open, onClose, onChanged }: AssetLibr
await onChanged?.(folderId);
}}
/>
<input
ref={coverInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleCoverFileChange}
/>
</>
);
}

116
src/components/AssetLibraryUploadModal.tsx

@ -3,27 +3,39 @@
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type ChangeEvent } from "react";
import {
AudioOutlined,
CheckOutlined,
CloseOutlined,
FileImageOutlined,
PlusOutlined,
PlayCircleOutlined,
} from "@ant-design/icons";
import { Button, Modal, Select, message } from "antd";
import { Button, Empty, Modal, TreeSelect, message } from "antd";
import { useI18n } from "@/i18n";
import { authFetch } from "@/utils/authFetch";
import { uploadFileToGatewayMediaWithProgress, type GatewayMediaKind } from "@/utils/gatewayMediaUpload";
import { type UserFileMutationResponse } from "@/utils/userFileLibrary";
import { ROOT_PARENT_ID, type UserFileItem, type UserFileMutationResponse } from "@/utils/userFileLibrary";
export interface AssetLibraryUploadFolderOption {
id: number;
label: string;
}
interface UploadFolderTreeNode {
title: React.ReactNode;
value: number;
key: number;
isLeaf: boolean;
children?: UploadFolderTreeNode[];
}
interface AssetLibraryUploadModalProps {
open: boolean;
folderOptions: AssetLibraryUploadFolderOption[];
foldersByParentId: Record<number, UserFileItem[]>;
loadedFolderIds: Set<number>;
loadingFolderIds: Set<number>;
initialFolderId: number;
onLoadFolderChildren: (folderId: number) => void | Promise<void>;
onClose: () => void;
onUploaded: (folderId: number) => void | Promise<void>;
onError?: (message: string) => void;
@ -131,8 +143,11 @@ function UploadAssetCard({
export function AssetLibraryUploadModal({
open,
folderOptions,
foldersByParentId,
loadedFolderIds,
loadingFolderIds,
initialFolderId,
onLoadFolderChildren,
onClose,
onUploaded,
onError,
@ -142,30 +157,56 @@ export function AssetLibraryUploadModal({
const [uploadItems, setUploadItems] = useState<UploadAssetItem[]>([]);
const [submitting, setSubmitting] = useState(false);
const [dragActive, setDragActive] = useState(false);
const [expandedFolderIds, setExpandedFolderIds] = useState<(string | number)[]>([]);
const uploadSequenceRef = useRef(0);
const uploadKeySetRef = useRef<Set<string>>(new Set());
const dragDepthRef = useRef(0);
const fileInputRef = useRef<HTMLInputElement>(null);
const selectOptions = useMemo(() => {
const seen = new Set<number>();
const options = folderOptions.filter((option) => {
if (option.id === 0 || seen.has(option.id)) return false;
seen.add(option.id);
return true;
});
return options;
}, [folderOptions]);
const rootFolders = foldersByParentId[ROOT_PARENT_ID] || [];
const treeData = useMemo(() => {
const buildNodes = (parentId: number): UploadFolderTreeNode[] => {
const folders = foldersByParentId[parentId] || [];
return folders.map((folder) => {
const loaded = loadedFolderIds.has(folder.id);
const children = foldersByParentId[folder.id] || [];
return {
title: (
<span className="flex min-w-0 flex-1 items-center justify-between gap-3">
<span className="min-w-0 truncate">{folder.name}</span>
{selectedFolderId === folder.id ? <CheckOutlined className="shrink-0 text-[13px]" /> : null}
</span>
),
value: folder.id,
key: folder.id,
isLeaf: loaded && children.length === 0,
children: loaded && children.length > 0 ? buildNodes(folder.id) : undefined,
};
});
};
return buildNodes(ROOT_PARENT_ID);
}, [foldersByParentId, loadedFolderIds, selectedFolderId]);
const folderIdExists = useCallback((folderId: number) => {
if (folderId === ROOT_PARENT_ID) return false;
return Object.values(foldersByParentId).some((folders) => folders.some((folder) => folder.id === folderId));
}, [foldersByParentId]);
useEffect(() => {
if (!open) return;
const initialOption = selectOptions.find((option) => option.id === initialFolderId) ?? selectOptions[0];
setSelectedFolderId(initialOption?.id ?? initialFolderId);
void onLoadFolderChildren(ROOT_PARENT_ID);
setSelectedFolderId(initialFolderId !== ROOT_PARENT_ID ? initialFolderId : 0);
setUploadItems([]);
setDragActive(false);
uploadKeySetRef.current.clear();
dragDepthRef.current = 0;
}, [initialFolderId, open, selectOptions]);
}, [initialFolderId, onLoadFolderChildren, open]);
useEffect(() => {
if (!open || selectedFolderId !== 0 || initialFolderId !== ROOT_PARENT_ID || rootFolders.length === 0) return;
setSelectedFolderId(rootFolders[0].id);
}, [initialFolderId, open, rootFolders, selectedFolderId]);
const startUpload = useCallback((file: File) => {
const kind = getUploadMediaKind(file);
@ -301,7 +342,17 @@ export function AssetLibraryUploadModal({
const canConfirm = uploadedCount > 0
&& !hasUploading
&& !submitting
&& selectOptions.some((option) => option.id === selectedFolderId);
&& folderIdExists(selectedFolderId);
const handleTreeExpand = useCallback((expandedKeys: (string | number)[]) => {
setExpandedFolderIds(expandedKeys);
expandedKeys.forEach((key) => {
const folderId = Number(key);
if (Number.isInteger(folderId) && folderId > 0) {
void onLoadFolderChildren(folderId);
}
});
}, [onLoadFolderChildren]);
return (
<Modal
@ -329,14 +380,29 @@ export function AssetLibraryUploadModal({
<div className="px-4 pb-4">
<div className="mb-4">
<div className="mb-2 text-xs text-[var(--text-muted)]">{t("assetLibrary.uploadLocation")}</div>
<Select
value={selectOptions.some((option) => option.id === selectedFolderId) ? selectedFolderId : undefined}
options={selectOptions.map((option) => ({ value: option.id, label: option.label }))}
onChange={setSelectedFolderId}
placeholder={t("assetLibrary.selectFolder")}
className="w-full [&_.ant-select-selector]:!border-[var(--border-subtle)] [&_.ant-select-selector]:!bg-[var(--surface-2)] [&_.ant-select-selection-item]:!text-[var(--text-primary)] [&_.ant-select-selection-placeholder]:!text-[var(--text-muted)]"
popupClassName="nodrag nopan nowheel"
/>
{rootFolders.length === 0 && loadedFolderIds.has(ROOT_PARENT_ID) ? (
<div className="rounded-lg border border-[var(--border-subtle)] bg-[var(--surface-2)] py-4">
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={<span className="text-xs text-[var(--text-disabled)]">{t("assetLibrary.noFolders")}</span>}
/>
</div>
) : (
<TreeSelect
value={folderIdExists(selectedFolderId) ? selectedFolderId : undefined}
treeData={treeData}
loadData={(node) => Promise.resolve(onLoadFolderChildren(Number(node.value)))}
loading={loadingFolderIds.size > 0}
disabled={submitting}
onChange={setSelectedFolderId}
placeholder={t("assetLibrary.selectFolder")}
treeDefaultExpandAll={false}
treeExpandedKeys={expandedFolderIds}
onTreeExpand={handleTreeExpand}
className="w-full [&_.ant-select-selector]:!border-[var(--border-subtle)] [&_.ant-select-selector]:!bg-[var(--surface-2)] [&_.ant-select-selection-item]:!text-[var(--text-primary)] [&_.ant-select-selection-placeholder]:!text-[var(--text-muted)]"
popupClassName="nodrag nopan nowheel"
/>
)}
</div>
<input

8
src/i18n/index.tsx

@ -90,6 +90,8 @@ const en = {
"assetLibrary.sendToCanvas": "Send to canvas",
"assetLibrary.changeCover": "Change cover",
"assetLibrary.changeCoverUnsupported": "Changing cover is not available yet",
"assetLibrary.updateCoverFailed": "Failed to update cover",
"assetLibrary.updateCoverSuccess": "Cover updated",
"assetLibrary.expandFolder": "Expand folder",
"assetLibrary.collapseFolder": "Collapse folder",
"assetLibrary.itemActions": "Item actions",
@ -1271,6 +1273,8 @@ const zhCN: Dictionary = {
"assetLibrary.sendToCanvas": "发送到画布",
"assetLibrary.changeCover": "修改封面",
"assetLibrary.changeCoverUnsupported": "暂不支持修改封面",
"assetLibrary.updateCoverFailed": "修改封面失败",
"assetLibrary.updateCoverSuccess": "修改封面成功",
"assetLibrary.expandFolder": "展开文件夹",
"assetLibrary.collapseFolder": "收起文件夹",
"assetLibrary.itemActions": "素材操作",
@ -2212,6 +2216,8 @@ const zhTW: Dictionary = {
"assetLibrary.sendToCanvas": "傳送到畫布",
"assetLibrary.changeCover": "修改封面",
"assetLibrary.changeCoverUnsupported": "暫不支援修改封面",
"assetLibrary.updateCoverFailed": "修改封面失敗",
"assetLibrary.updateCoverSuccess": "修改封面成功",
"assetLibrary.expandFolder": "展開資料夾",
"assetLibrary.collapseFolder": "收合資料夾",
"assetLibrary.itemActions": "素材操作",
@ -2929,6 +2935,8 @@ const ja: Dictionary = {
"assetLibrary.sendToCanvas": "キャンバスへ送信",
"assetLibrary.changeCover": "カバーを変更",
"assetLibrary.changeCoverUnsupported": "カバー変更はまだ利用できません",
"assetLibrary.updateCoverFailed": "カバーの更新に失敗しました",
"assetLibrary.updateCoverSuccess": "カバーを更新しました",
"assetLibrary.expandFolder": "フォルダを展開",
"assetLibrary.collapseFolder": "フォルダを折りたたむ",
"assetLibrary.itemActions": "アイテム操作",

88
src/styles/antd-overrides.css

@ -0,0 +1,88 @@
/* Ant Design dropdown tree dark theme */
.ant-select-dropdown {
background: var(--surface-2) !important;
border: 1px solid var(--border-subtle) !important;
border-radius: 8px !important;
}
.ant-select-dropdown .ant-select-tree {
background: var(--surface-2) !important;
color: var(--text-secondary) !important;
padding: 4px !important;
}
.ant-select-dropdown .ant-select-tree .ant-select-tree-treenode {
align-items: center !important;
border-radius: 6px !important;
color: var(--text-secondary) !important;
display: flex !important;
min-height: 32px !important;
padding: 0 !important;
transition: background-color 120ms ease, color 120ms ease;
}
.ant-select-dropdown .ant-select-tree .ant-select-tree-treenode:hover,
.ant-select-dropdown .ant-select-tree .ant-select-tree-treenode-selected,
.ant-select-dropdown .ant-select-tree .ant-select-tree-treenode:has(.ant-select-tree-node-selected) {
background: var(--surface-hover) !important;
color: var(--text-primary) !important;
}
.ant-select-dropdown .ant-select-tree .ant-select-tree-indent {
align-self: stretch !important;
display: flex !important;
}
.ant-select-dropdown .ant-select-tree .ant-select-tree-indent-unit {
width: 20px !important;
}
.ant-select-dropdown .ant-select-tree .ant-select-tree-indent-unit::before {
border-color: var(--border-subtle) !important;
}
.ant-select-dropdown .ant-select-tree .ant-select-tree-switcher {
align-items: center !important;
align-self: stretch !important;
background: transparent !important;
color: var(--text-muted) !important;
display: inline-flex !important;
flex: 0 0 24px !important;
justify-content: center !important;
line-height: 1 !important;
width: 24px !important;
}
.ant-select-dropdown .ant-select-tree .ant-select-tree-switcher-icon,
.ant-select-dropdown .ant-select-tree .ant-select-tree-switcher svg {
color: inherit !important;
fill: currentColor !important;
}
.ant-select-dropdown .ant-select-tree .ant-select-tree-node-content-wrapper {
align-items: center !important;
background: transparent !important;
color: var(--text-secondary) !important;
display: flex !important;
flex: 1 1 auto !important;
height: 32px !important;
line-height: 32px !important;
min-width: 0 !important;
padding: 0 8px !important;
transition: color 120ms ease;
}
.ant-select-dropdown .ant-select-tree .ant-select-tree-node-content-wrapper:hover,
.ant-select-dropdown .ant-select-tree .ant-select-tree-node-content-wrapper.ant-select-tree-node-selected {
background: transparent !important;
color: var(--text-primary) !important;
}
.ant-select-dropdown .ant-select-tree .ant-select-tree-treenode:hover .ant-select-tree-switcher,
.ant-select-dropdown .ant-select-tree .ant-select-tree-treenode:hover .ant-select-tree-node-content-wrapper,
.ant-select-dropdown .ant-select-tree .ant-select-tree-treenode-selected .ant-select-tree-switcher,
.ant-select-dropdown .ant-select-tree .ant-select-tree-treenode-selected .ant-select-tree-node-content-wrapper,
.ant-select-dropdown .ant-select-tree .ant-select-tree-treenode:has(.ant-select-tree-node-selected) .ant-select-tree-switcher,
.ant-select-dropdown .ant-select-tree .ant-select-tree-treenode:has(.ant-select-tree-node-selected) .ant-select-tree-node-content-wrapper {
color: var(--text-primary) !important;
}
Loading…
Cancel
Save