Files
ttstd-infinite-canvas/web/src/components/assets/asset-library-picker-modal.tsx

231 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Modal } from "antd";
import { Check, FileText, FolderOpen, Image as ImageIcon, LoaderCircle, Music2, Search, Upload, UserRound, Video } from "lucide-react";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { AssetMediaPreview } from "@/components/asset-media-preview";
import { cn } from "@/lib/utils";
import type { Asset } from "@/stores/use-asset-store";
export type AssetLibraryPickerItem = {
id: string;
title: string;
category: string;
kindLabel: string;
asset?: Asset;
imageUrl?: string;
imageFit?: "cover" | "contain";
description?: string;
searchText?: string;
disabledReason?: string;
};
type Props = {
open: boolean;
items: AssetLibraryPickerItem[];
categoryLabels: Record<string, string>;
initialCategory?: string;
initialSelectedIds?: Iterable<string>;
multiple?: boolean;
title?: string;
eyebrow?: string;
confirmLabel?: (count: number) => string;
emptyTitle?: string;
emptyDescription?: string;
footerNote?: string;
upload?: {
accept: string;
description: string;
onUpload: (files: FileList) => Promise<string[]>;
};
onClose: () => void;
onConfirm: (ids: string[]) => Promise<void> | void;
};
export function AssetLibraryPickerModal({
open,
items,
categoryLabels,
initialCategory = "all",
initialSelectedIds,
multiple = true,
title = "素材库",
eyebrow = "参考内容",
confirmLabel = (count) => `使用已选素材${count ? `(${count})` : ""}`,
emptyTitle = "这个分类还没有素材",
emptyDescription = "换个分类后再试。",
footerNote,
upload,
onClose,
onConfirm,
}: Props) {
const [category, setCategory] = useState(initialCategory);
const [keyword, setKeyword] = useState("");
const [selected, setSelected] = useState<Set<string>>(new Set());
const [working, setWorking] = useState(false);
const [uploadingCount, setUploadingCount] = useState(0);
const [error, setError] = useState("");
const uploadInputRef = useRef<HTMLInputElement>(null);
const initialSelectedIdsRef = useRef(initialSelectedIds);
const itemsRef = useRef(items);
initialSelectedIdsRef.current = initialSelectedIds;
itemsRef.current = items;
const categories = useMemo(() => ["all", ...Array.from(new Set(items.map((item) => item.category || "other")))], [items]);
const visibleItems = useMemo(() => {
const query = keyword.trim().toLowerCase();
return items.filter((item) => {
if (category !== "all" && item.category !== category) return false;
return !query || [item.title, item.searchText || "", item.description || ""].join(" ").toLowerCase().includes(query);
});
}, [category, items, keyword]);
const selectedIds = useMemo(
() => items.filter((item) => !item.disabledReason && selected.has(item.id)).map((item) => item.id),
[items, selected],
);
useEffect(() => {
if (!open) return;
setCategory(initialCategory);
setKeyword("");
const selectableIds = new Set(itemsRef.current.filter((item) => !item.disabledReason).map((item) => item.id));
setSelected(new Set(Array.from(initialSelectedIdsRef.current || []).filter((id) => selectableIds.has(id))));
setWorking(false);
setUploadingCount(0);
setError("");
}, [initialCategory, open]);
useEffect(() => {
if (category === "all" || categories.includes(category)) return;
setCategory("all");
}, [categories, category]);
const toggle = (item: AssetLibraryPickerItem) => {
if (item.disabledReason || working) return;
setError("");
setSelected((current) => {
if (!multiple) return current.has(item.id) ? new Set() : new Set([item.id]);
const next = new Set(current);
if (next.has(item.id)) next.delete(item.id);
else next.add(item.id);
return next;
});
};
const confirm = async () => {
if (!selectedIds.length || working) return;
setWorking(true);
setError("");
try {
await onConfirm(selectedIds);
} catch (reason) {
setError(reason instanceof Error ? reason.message : "素材操作失败,请重试");
} finally {
setWorking(false);
}
};
const handleUpload = async (files: FileList | null) => {
if (!upload || !files?.length || working) return;
setWorking(true);
setError("");
setUploadingCount(files.length);
try {
const ids = await upload.onUpload(files);
if (ids.length) setSelected((current) => new Set(multiple ? [...current, ...ids] : ids.slice(-1)));
} catch (reason) {
setError(reason instanceof Error ? reason.message : "素材上传失败,请重试");
} finally {
if (uploadInputRef.current) uploadInputRef.current.value = "";
setWorking(false);
setUploadingCount(0);
}
};
const countFor = (value: string) => value === "all" ? items.length : items.filter((item) => item.category === value).length;
const uploading = uploadingCount > 0;
return (
<Modal
open={open}
footer={null}
title={null}
destroyOnHidden
closable={!working}
maskClosable={!working}
keyboard={!working}
onCancel={() => {
if (!working) onClose();
}}
width="min(1040px, calc(100vw - 24px))"
className="asset-library-picker-modal"
styles={{ container: { padding: 0 }, body: { padding: 0 } }}
>
<div className="asset-picker-shell">
<header className="asset-picker-toolbar">
<div className="asset-picker-heading"><span>{eyebrow}</span><strong>{title}</strong></div>
<label className="asset-picker-search"><Search aria-hidden /><input value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="搜索素材名称或标签" aria-label="搜索素材" /></label>
<span className="asset-picker-count">已选 {selectedIds.length} · {visibleItems.length} 个素材</span>
</header>
<div className="asset-picker-body">
<nav className="asset-picker-categories" aria-label="素材分类">
{categories.map((value) => (
<button key={value} type="button" className={category === value ? "is-active" : ""} onClick={() => setCategory(value)}>
<span>{categoryLabels[value] || "其他"}</span><em>{countFor(value)}</em>
</button>
))}
</nav>
<div className="asset-picker-grid-wrap">
<div className="asset-picker-grid">
{visibleItems.length ? visibleItems.map((item) => (
<PickerCard key={item.id} item={item} selected={selected.has(item.id)} onToggle={() => toggle(item)} />
)) : (
<div className="asset-picker-empty"><FolderOpen /><strong>{emptyTitle}</strong><span>{upload ? "换个分类,或从底部上传一份新素材。" : emptyDescription}</span></div>
)}
</div>
</div>
</div>
<footer className={cn("asset-picker-footer", !upload && "is-compact")}>
{upload ? (
<>
<input ref={uploadInputRef} type="file" hidden accept={upload.accept} multiple={multiple} onChange={(event) => void handleUpload(event.target.files)} />
<button type="button" className="asset-picker-upload" onClick={() => uploadInputRef.current?.click()} disabled={working} aria-busy={uploading}>
{uploading ? <LoaderCircle className="animate-spin" /> : <Upload />}
<span><strong>{uploading ? `正在上传 ${uploadingCount} 个素材` : "上传新素材"}</strong><small>{uploading ? "保存完成后会自动选中" : upload.description}</small></span>
</button>
</>
) : footerNote ? <span className="asset-picker-footer-note">{footerNote}</span> : <span />}
{error ? <span className="asset-picker-footer-error" role="alert">{error}</span> : null}
<div className="asset-picker-actions">
<button type="button" onClick={onClose} disabled={working}>取消</button>
<button type="button" className="is-primary" disabled={working || !selectedIds.length} onClick={() => void confirm()}>
{working && !uploading ? <LoaderCircle className="animate-spin" /> : <Check />}{confirmLabel(selectedIds.length)}
</button>
</div>
</footer>
</div>
</Modal>
);
}
function PickerCard({ item, selected, onToggle }: { item: AssetLibraryPickerItem; selected: boolean; onToggle: () => void }) {
const disabled = Boolean(item.disabledReason);
return (
<button type="button" className={cn("asset-picker-card", selected && "is-selected", disabled && "is-disabled")} onClick={onToggle} disabled={disabled} aria-pressed={selected} title={item.disabledReason || item.title}>
<div className="asset-picker-card-media">
{item.imageUrl ? <img src={item.imageUrl} alt={item.title} loading="lazy" decoding="async" className={item.imageFit === "contain" ? "is-contain" : undefined} /> : <AssetMediaPreview asset={item.asset} alt={item.title} fallback={<div className="asset-picker-card-fallback">{kindIcon(item.kindLabel)}</div>} />}
<span className="asset-picker-card-check"><Check /></span>
<span className="asset-picker-card-kind">{item.kindLabel}</span>
{item.disabledReason ? <span className="asset-picker-card-lock">{item.disabledReason}</span> : null}
</div>
<div className="asset-picker-card-copy"><strong>{item.title || "未命名素材"}</strong>{item.description ? <span>{item.description}</span> : null}</div>
</button>
);
}
function kindIcon(label: string): ReactNode {
if (label.includes("角色")) return <UserRound />;
if (label.includes("视频")) return <Video />;
if (label.includes("音频")) return <Music2 />;
if (label.includes("文本")) return <FileText />;
return <ImageIcon />;
}