"use client"; 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, Empty, Modal, TreeSelect, message } from "antd"; import { useI18n } from "@/i18n"; import { authFetch } from "@/utils/authFetch"; import { uploadFileToGatewayMediaWithProgress, type GatewayMediaKind } from "@/utils/gatewayMediaUpload"; 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; foldersByParentId: Record; loadedFolderIds: Set; loadingFolderIds: Set; initialFolderId: number; onLoadFolderChildren: (folderId: number) => void | Promise; onClose: () => void; onUploaded: (folderId: number) => void | Promise; onError?: (message: string) => void; } const uploadAccept = "image/*,video/*,audio/*"; type UploadItemStatus = "uploading" | "done" | "error"; interface UploadAssetItem { id: string; key: string; file: File; name: string; kind: GatewayMediaKind; status: UploadItemStatus; progress: number; url?: string; previewUrl?: string; error?: string; } function AssetLibraryUploadModalHeader({ title, onClose, }: { title: string; onClose: () => void; }) { const { t } = useI18n(); return (

{title}

); } function getUploadMediaKind(file: File): GatewayMediaKind | null { if (file.type.startsWith("image/")) return "image"; if (file.type.startsWith("video/")) return "video"; if (file.type.startsWith("audio/")) return "audio"; return null; } function hasDraggedFiles(event: DragEvent) { return Array.from(event.dataTransfer.types).includes("Files"); } function UploadAssetCard({ item, onRemove, }: { item: UploadAssetItem; onRemove: (item: UploadAssetItem) => void; }) { const { t } = useI18n(); const iconClassName = "text-[28px] text-[var(--text-muted)]"; const icon = item.kind === "image" ? : item.kind === "video" ? : ; const previewUrl = item.previewUrl || item.url; return (
event.stopPropagation()}>
{item.name}
); } export function AssetLibraryUploadModal({ open, foldersByParentId, loadedFolderIds, loadingFolderIds, initialFolderId, onLoadFolderChildren, onClose, onUploaded, onError, }: AssetLibraryUploadModalProps) { const { t } = useI18n(); const [selectedFolderId, setSelectedFolderId] = useState(initialFolderId); 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 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; void onLoadFolderChildren(ROOT_PARENT_ID); setSelectedFolderId(initialFolderId !== ROOT_PARENT_ID ? initialFolderId : 0); setUploadItems([]); setDragActive(false); uploadKeySetRef.current.clear(); dragDepthRef.current = 0; }, [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); if (!kind) { void message.error(t("assetLibrary.selectMediaFile")); return; } const id = `${Date.now()}-${uploadSequenceRef.current++}`; const item: UploadAssetItem = { id, key: `${file.name}-${file.size}-${file.lastModified}`, file, name: file.name || `${t("assetLibrary.asset")} ${uploadSequenceRef.current}`, kind, status: "uploading", progress: 0, }; setUploadItems((current) => [...current, item]); void uploadFileToGatewayMediaWithProgress(file, kind, (progress) => { setUploadItems((current) => current.map((currentItem) => ( currentItem.id === id ? { ...currentItem, progress } : currentItem ))); }) .then((uploaded) => { setUploadItems((current) => current.map((currentItem) => ( currentItem.id === id ? { ...currentItem, status: "done", progress: 100, url: uploaded.url, previewUrl: uploaded.previewUrl, } : currentItem ))); }) .catch((err: unknown) => { const messageText = err instanceof Error ? err.message : t("assetLibrary.uploadFailed"); setUploadItems((current) => current.map((currentItem) => ( currentItem.id === id ? { ...currentItem, status: "error", error: messageText, progress: 0 } : currentItem ))); }); }, [t]); const handleFiles = useCallback((files: FileList | File[]) => { Array.from(files).forEach((file) => { const key = `${file.name}-${file.size}-${file.lastModified}`; if (uploadKeySetRef.current.has(key)) return; uploadKeySetRef.current.add(key); startUpload(file); }); }, [startUpload]); const handleRemoveUploadItem = useCallback((item: UploadAssetItem) => { uploadKeySetRef.current.delete(item.key); setUploadItems((current) => current.filter((currentItem) => currentItem.id !== item.id)); }, []); const handleFileInputChange = useCallback((event: ChangeEvent) => { const files = event.target.files; if (files) handleFiles(files); event.target.value = ""; }, [handleFiles]); const handleDragEnter = useCallback((event: DragEvent) => { event.preventDefault(); if (!hasDraggedFiles(event)) return; dragDepthRef.current += 1; setDragActive(true); }, []); const handleDragOver = useCallback((event: DragEvent) => { event.preventDefault(); if (!hasDraggedFiles(event)) return; event.dataTransfer.dropEffect = "copy"; }, []); const handleDragLeave = useCallback((event: DragEvent) => { event.preventDefault(); dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); if (dragDepthRef.current === 0) setDragActive(false); }, []); const handleDrop = useCallback((event: DragEvent) => { event.preventDefault(); dragDepthRef.current = 0; setDragActive(false); if (event.dataTransfer.files.length > 0) { handleFiles(event.dataTransfer.files); } }, [handleFiles]); const handleConfirm = useCallback(async () => { if (submitting) return; const uploadedUrls = uploadItems .filter((item): item is UploadAssetItem & { url: string } => item.status === "done" && Boolean(item.url)) .map((item) => item.url); if (uploadedUrls.length === 0) return; setSubmitting(true); try { const response = await authFetch("/api/popi/user-file/upload", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ parentId: selectedFolderId, urlList: uploadedUrls, }), }); const payload = (await response.json().catch(() => null)) as UserFileMutationResponse | null; if (!response.ok || !payload?.success) { throw new Error(payload?.error || t("assetLibrary.uploadFailed")); } await onUploaded(selectedFolderId); void message.success(t("assetLibrary.uploadSuccess")); onClose(); } catch (err) { const messageText = err instanceof Error ? err.message : t("assetLibrary.uploadFailed"); onError?.(messageText); void message.error(messageText); } finally { setSubmitting(false); } }, [onClose, onError, onUploaded, selectedFolderId, t, submitting, uploadItems]); const hasUploading = uploadItems.some((item) => item.status === "uploading"); const uploadedCount = uploadItems.filter((item) => item.status === "done" && item.url).length; const canConfirm = uploadedCount > 0 && !hasUploading && !submitting && 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 (
{ if (!submitting) onClose(); }} />
{t("assetLibrary.uploadLocation")}
{rootFolders.length === 0 && loadedFolderIds.has(ROOT_PARENT_ID) ? (
{t("assetLibrary.noFolders")}} />
) : ( 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" /> )}
{uploadItems.map((item) => ( ))}
{t("assetLibrary.uploadDropHint")}
); }