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.
470 lines
17 KiB
470 lines
17 KiB
"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<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;
|
|
}
|
|
|
|
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 (
|
|
<div className="flex h-12 shrink-0 items-center justify-between px-4">
|
|
<h2 className="text-sm font-semibold text-[var(--text-primary)]">{title}</h2>
|
|
<Button
|
|
type="text"
|
|
aria-label={t("common.close")}
|
|
icon={<CloseOutlined className="text-[14px]" />}
|
|
onClick={onClose}
|
|
className="!flex !h-8 !w-8 !items-center !justify-center !rounded-lg !p-0 !text-[var(--text-muted)] hover:!bg-[var(--surface-hover)] hover:!text-[var(--text-primary)]"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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<HTMLDivElement>) {
|
|
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"
|
|
? <FileImageOutlined className={iconClassName} />
|
|
: item.kind === "video"
|
|
? <PlayCircleOutlined className={iconClassName} />
|
|
: <AudioOutlined className={iconClassName} />;
|
|
const previewUrl = item.previewUrl || item.url;
|
|
|
|
return (
|
|
<div className="group/card min-w-0" onClick={(event) => event.stopPropagation()}>
|
|
<div className="relative flex h-[120px] w-[120px] flex-col items-center justify-center rounded-lg border border-[var(--border-subtle)] bg-[var(--surface-2)]">
|
|
<Button
|
|
type="text"
|
|
aria-label={t("assetLibrary.delete")}
|
|
icon={<CloseOutlined className="text-[12px]" />}
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
onRemove(item);
|
|
}}
|
|
className="!absolute !top-1 !right-1 !z-10 !flex !h-6 !w-6 !items-center !justify-center !rounded-md !bg-black/60 !p-0 !text-white opacity-0 transition-opacity group-hover/card:opacity-100 hover:!bg-black/80 hover:!text-white"
|
|
/>
|
|
{item.status === "uploading" ? (
|
|
<>
|
|
<span className="text-xs font-medium text-[var(--text-muted)]">{t("assetLibrary.uploading")}</span>
|
|
<div className="mt-3 h-1 w-16 overflow-hidden rounded-full bg-[var(--surface-3)]">
|
|
<span className="block h-full rounded-full bg-[var(--text-muted)]" style={{ width: `${item.progress}%` }} />
|
|
</div>
|
|
</>
|
|
) : item.status === "error" ? (
|
|
<span className="px-2 text-center text-xs font-medium text-red-300">{item.error || t("assetLibrary.uploadFailed")}</span>
|
|
) : previewUrl && item.kind === "image" ? (
|
|
<img src={previewUrl} alt="" className="h-full w-full rounded-lg object-cover" />
|
|
) : (
|
|
icon
|
|
)}
|
|
</div>
|
|
<div className="mt-2 w-[120px] truncate text-xs font-medium text-[var(--text-primary)]">{item.name}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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<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 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;
|
|
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<HTMLInputElement>) => {
|
|
const files = event.target.files;
|
|
if (files) handleFiles(files);
|
|
event.target.value = "";
|
|
}, [handleFiles]);
|
|
|
|
const handleDragEnter = useCallback((event: DragEvent<HTMLDivElement>) => {
|
|
event.preventDefault();
|
|
if (!hasDraggedFiles(event)) return;
|
|
dragDepthRef.current += 1;
|
|
setDragActive(true);
|
|
}, []);
|
|
|
|
const handleDragOver = useCallback((event: DragEvent<HTMLDivElement>) => {
|
|
event.preventDefault();
|
|
if (!hasDraggedFiles(event)) return;
|
|
event.dataTransfer.dropEffect = "copy";
|
|
}, []);
|
|
|
|
const handleDragLeave = useCallback((event: DragEvent<HTMLDivElement>) => {
|
|
event.preventDefault();
|
|
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
|
|
if (dragDepthRef.current === 0) setDragActive(false);
|
|
}, []);
|
|
|
|
const handleDrop = useCallback((event: DragEvent<HTMLDivElement>) => {
|
|
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 (
|
|
<Modal
|
|
open={open}
|
|
title={null}
|
|
closable={false}
|
|
centered
|
|
width={640}
|
|
footer={null}
|
|
rootClassName="[&_.ant-modal-container]:!p-0"
|
|
onCancel={submitting ? undefined : onClose}
|
|
className={[
|
|
"nodrag nopan nowheel",
|
|
"[&_.ant-modal-content]:!overflow-hidden [&_.ant-modal-content]:!rounded-lg [&_.ant-modal-content]:!border-0 [&_.ant-modal-content]:!bg-[var(--surface-1)] [&_.ant-modal-content]:!p-0 [&_.ant-modal-content]:!shadow-[var(--shadow-panel)]",
|
|
"[&_.ant-modal-body]:!p-0",
|
|
].join(" ")}
|
|
>
|
|
<div className="text-[var(--text-primary)]">
|
|
<AssetLibraryUploadModalHeader
|
|
title={t("assetLibrary.uploadAsset")}
|
|
onClose={() => {
|
|
if (!submitting) onClose();
|
|
}}
|
|
/>
|
|
<div className="px-4 pb-4">
|
|
<div className="mb-4">
|
|
<div className="mb-2 text-xs text-[var(--text-muted)]">{t("assetLibrary.uploadLocation")}</div>
|
|
{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
|
|
ref={fileInputRef}
|
|
type="file"
|
|
multiple
|
|
accept={uploadAccept}
|
|
className="hidden"
|
|
onChange={handleFileInputChange}
|
|
/>
|
|
<div
|
|
onDragEnter={handleDragEnter}
|
|
onDragOver={handleDragOver}
|
|
onDragLeave={handleDragLeave}
|
|
onDrop={handleDrop}
|
|
className={[
|
|
"h-[360px] overflow-y-auto rounded-lg border border-dashed bg-[var(--surface-2)] p-3 text-left [scrollbar-color:var(--text-muted)_transparent] [scrollbar-width:thin]",
|
|
dragActive ? "border-white/80" : "border-[var(--border-subtle)]",
|
|
"transition-colors",
|
|
submitting ? "cursor-wait opacity-80" : "",
|
|
].join(" ")}
|
|
>
|
|
<div className="grid grid-cols-4 content-start items-start gap-3">
|
|
<button
|
|
type="button"
|
|
disabled={submitting}
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
fileInputRef.current?.click();
|
|
}}
|
|
className="flex h-[120px] w-[120px] items-center justify-center rounded-lg border border-dashed border-[var(--border-subtle)] bg-[var(--surface-2)] text-[var(--text-muted)] transition-colors hover:border-[var(--text-muted)] hover:text-[var(--text-primary)] disabled:cursor-wait disabled:opacity-60"
|
|
>
|
|
<PlusOutlined className="text-[28px]" />
|
|
</button>
|
|
{uploadItems.map((item) => (
|
|
<UploadAssetCard key={item.id} item={item} onRemove={handleRemoveUploadItem} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="mt-3 text-xs text-[var(--text-muted)]">
|
|
{t("assetLibrary.uploadDropHint")}
|
|
</div>
|
|
</div>
|
|
<div className="flex justify-end gap-2 px-4 pb-4">
|
|
<Button
|
|
disabled={submitting}
|
|
onClick={onClose}
|
|
className="!h-8 !rounded-lg !border-0 !bg-[var(--surface-3)] !px-4 !text-sm !font-medium !text-[var(--text-secondary)] hover:!bg-[var(--surface-hover)] hover:!text-[var(--text-primary)]"
|
|
>
|
|
{t("common.cancel")}
|
|
</Button>
|
|
<Button
|
|
type="primary"
|
|
loading={submitting}
|
|
disabled={!canConfirm}
|
|
onClick={() => void handleConfirm()}
|
|
className="!h-8 !rounded-lg !border-0 !bg-[#3b82f6] !px-4 !text-sm !font-medium !text-white hover:!bg-[#60a5fa]"
|
|
>
|
|
{t("common.confirm")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
);
|
|
}
|
|
|