diff --git a/src/app/api/popi/user-file/update-cover/route.ts b/src/app/api/popi/user-file/update-cover/route.ts new file mode 100644 index 00000000..21bf60da --- /dev/null +++ b/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 = { + 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 : {}; + 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 | 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 } + ); + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 2f5d85d5..88c9d8ed 100644 --- a/src/app/layout.tsx +++ b/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"; diff --git a/src/components/AssetLibraryManagerModal.tsx b/src/components/AssetLibraryManagerModal.tsx index 81f59349..8b4df20e 100644 --- a/src/components/AssetLibraryManagerModal.tsx +++ b/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>>({}); const [actionMenuOpenId, setActionMenuOpenId] = useState(null); const [movePanelOpenId, setMovePanelOpenId] = useState(null); + const [activeActionKey, setActiveActionKey] = useState(null); const [batchMode, setBatchMode] = useState(false); const [selectedItemIds, setSelectedItemIds] = useState>(new Set()); const [batchDeleting, setBatchDeleting] = useState(false); + const [updatingCoverId, setUpdatingCoverId] = useState(null); + const [uploadFoldersByParentId, setUploadFoldersByParentId] = useState>({}); + const [loadedUploadFolderIds, setLoadedUploadFolderIds] = useState>(new Set()); + const [loadingUploadFolderIds, setLoadingUploadFolderIds] = useState>(new Set()); + const coverInputRef = useRef(null); + const coverTargetRef = useRef(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) => { + 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(() => { - 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(() => { - if (parentId === ROOT_PARENT_ID) { - return [{ + return [ + { key: "folder", icon: , label: t("assetLibrary.newFolder"), onClick: () => setCreateFolderOpen(true), - }]; - } - - return [ - { - key: "folder", - icon: , - label: t("assetLibrary.newFolder"), - onClick: () => setCreateFolderOpen(true), }, { - key: "upload", - icon: , - label: t("assetLibrary.uploadAsset"), - onClick: () => setUploadOpen(true), + key: "upload", + icon: , + 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 && ( - {breadcrumbs.map((item, index) => ( - - / - {index === breadcrumbs.length - 1 ? ( - {item.name} - ) : ( - - )} - - ))} + {t("assetLibrary.title")} + ) : ( + + ), + }, + ...breadcrumbs.map((item, index) => ({ + title: index === breadcrumbs.length - 1 ? ( + {item.name} + ) : ( + + ), + })), + ]} + /> @@ -1138,8 +1241,11 @@ export function AssetLibraryManagerModal({ open, onClose, onChanged }: AssetLibr setUploadOpen(false)} onError={setError} onUploaded={async (folderId) => { @@ -1149,6 +1255,13 @@ export function AssetLibraryManagerModal({ open, onClose, onChanged }: AssetLibr await onChanged?.(folderId); }} /> + ); } diff --git a/src/components/AssetLibraryUploadModal.tsx b/src/components/AssetLibraryUploadModal.tsx index 4a3b4a9e..3d9087ae 100644 --- a/src/components/AssetLibraryUploadModal.tsx +++ b/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; + loadedFolderIds: Set; + loadingFolderIds: Set; initialFolderId: number; + onLoadFolderChildren: (folderId: number) => void | Promise; onClose: () => void; onUploaded: (folderId: number) => void | Promise; 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([]); const [submitting, setSubmitting] = useState(false); const [dragActive, setDragActive] = useState(false); + const [expandedFolderIds, setExpandedFolderIds] = useState<(string | number)[]>([]); const uploadSequenceRef = useRef(0); const uploadKeySetRef = useRef>(new Set()); const dragDepthRef = useRef(0); const fileInputRef = useRef(null); - const selectOptions = useMemo(() => { - const seen = new Set(); - 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: ( + + {folder.name} + {selectedFolderId === folder.id ? : null} + + ), + 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 (
{t("assetLibrary.uploadLocation")}
-