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.
258 lines
9.5 KiB
258 lines
9.5 KiB
"use client";
|
|
|
|
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
|
import { CheckOutlined, CloseOutlined } from "@ant-design/icons";
|
|
import { Button, Empty, Modal, TreeSelect, message } from "antd";
|
|
|
|
import { useI18n } from "@/i18n";
|
|
import { authFetch } from "@/utils/authFetch";
|
|
import {
|
|
getUserFileList,
|
|
ROOT_PARENT_ID,
|
|
USER_FILE_PAGE_SIZE,
|
|
type UserFileItem,
|
|
type UserFileListResponse,
|
|
type UserFileMutationResponse,
|
|
} from "@/utils/userFileLibrary";
|
|
|
|
interface SaveToAssetLibraryModalProps {
|
|
open: boolean;
|
|
mediaUrl: string | null;
|
|
onClose: () => void;
|
|
onSaved?: (folderId: number) => void | Promise<void>;
|
|
}
|
|
|
|
interface FolderTreeNode {
|
|
title: ReactNode;
|
|
value: number;
|
|
key: number;
|
|
isLeaf: boolean;
|
|
children?: FolderTreeNode[];
|
|
}
|
|
|
|
function SaveToAssetLibraryModalHeader({
|
|
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>
|
|
);
|
|
}
|
|
|
|
export function SaveToAssetLibraryModal({
|
|
open,
|
|
mediaUrl,
|
|
onClose,
|
|
onSaved,
|
|
}: SaveToAssetLibraryModalProps) {
|
|
const { t } = useI18n();
|
|
const [foldersByParentId, setFoldersByParentId] = useState<Record<number, UserFileItem[]>>({});
|
|
const [loadedFolderIds, setLoadedFolderIds] = useState<Set<number>>(new Set());
|
|
const [loadingFolderIds, setLoadingFolderIds] = useState<Set<number>>(new Set());
|
|
const [selectedFolderId, setSelectedFolderId] = useState(0);
|
|
const [expandedFolderIds, setExpandedFolderIds] = useState<(string | number)[]>([]);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
|
|
const rootFolders = foldersByParentId[ROOT_PARENT_ID] || [];
|
|
|
|
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]);
|
|
|
|
const loadFolderChildren = useCallback(async (folderId: number) => {
|
|
setLoadingFolderIds((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");
|
|
setFoldersByParentId((current) => ({ ...current, [folderId]: folders }));
|
|
setLoadedFolderIds((current) => new Set(current).add(folderId));
|
|
} catch (err) {
|
|
const messageText = err instanceof Error ? err.message : t("assetLibrary.loadFailed");
|
|
void message.error(messageText);
|
|
} finally {
|
|
setLoadingFolderIds((current) => {
|
|
const next = new Set(current);
|
|
next.delete(folderId);
|
|
return next;
|
|
});
|
|
}
|
|
}, [t]);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
setFoldersByParentId({});
|
|
setLoadedFolderIds(new Set());
|
|
setLoadingFolderIds(new Set());
|
|
setSelectedFolderId(0);
|
|
setExpandedFolderIds([]);
|
|
setSubmitting(false);
|
|
void loadFolderChildren(ROOT_PARENT_ID);
|
|
}, [loadFolderChildren, open]);
|
|
|
|
useEffect(() => {
|
|
if (!open || selectedFolderId !== 0 || rootFolders.length === 0) return;
|
|
setSelectedFolderId(rootFolders[0].id);
|
|
}, [open, rootFolders, selectedFolderId]);
|
|
|
|
const treeData = useMemo(() => {
|
|
const buildNodes = (parentId: number): FolderTreeNode[] => {
|
|
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 handleTreeExpand = useCallback((expandedKeys: (string | number)[]) => {
|
|
setExpandedFolderIds(expandedKeys);
|
|
expandedKeys.forEach((key) => {
|
|
const folderId = Number(key);
|
|
if (Number.isInteger(folderId) && folderId > 0) {
|
|
void loadFolderChildren(folderId);
|
|
}
|
|
});
|
|
}, [loadFolderChildren]);
|
|
|
|
const handleConfirm = useCallback(async () => {
|
|
if (submitting || !mediaUrl || !folderIdExists(selectedFolderId)) 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: [mediaUrl],
|
|
}),
|
|
});
|
|
const payload = (await response.json().catch(() => null)) as UserFileMutationResponse | null;
|
|
if (!response.ok || !payload?.success) {
|
|
throw new Error(payload?.error || t("assetLibrary.saveToLibraryFailed"));
|
|
}
|
|
|
|
await onSaved?.(selectedFolderId);
|
|
void message.success(t("assetLibrary.saveToLibrarySuccess"));
|
|
onClose();
|
|
} catch (err) {
|
|
const messageText = err instanceof Error ? err.message : t("assetLibrary.saveToLibraryFailed");
|
|
void message.error(messageText);
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
}, [folderIdExists, mediaUrl, onClose, onSaved, selectedFolderId, submitting, t]);
|
|
|
|
const canConfirm = Boolean(mediaUrl) && folderIdExists(selectedFolderId) && !submitting;
|
|
|
|
return (
|
|
<Modal
|
|
open={open}
|
|
title={null}
|
|
closable={false}
|
|
centered
|
|
width={420}
|
|
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)]">
|
|
<SaveToAssetLibraryModalHeader
|
|
title={t("assetLibrary.saveToLibrary")}
|
|
onClose={() => {
|
|
if (!submitting) onClose();
|
|
}}
|
|
/>
|
|
<div className="px-4 pb-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(loadFolderChildren(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>
|
|
<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>
|
|
);
|
|
}
|
|
|