mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-05 17:04:27 +08:00
219 lines
11 KiB
TypeScript
219 lines
11 KiB
TypeScript
"use client";
|
||
|
||
import { Copy, FolderPlus, Search } from "lucide-react";
|
||
import { useEffect, useState } from "react";
|
||
import { useQuery } from "@tanstack/react-query";
|
||
import { App, Button, Card, Drawer, Empty, Image, Input, Pagination, Spin, Tag, Typography } from "antd";
|
||
import axios from "axios";
|
||
import copy from "copy-to-clipboard";
|
||
|
||
import { cn } from "@/lib/utils";
|
||
import { useAssetStore } from "@/stores/use-asset-store";
|
||
import { fetchAssetLibrary, type AssetLibraryItem } from "@/services/api/assets";
|
||
import { uploadImage } from "@/services/image-storage";
|
||
|
||
const PAGE_SIZE = 12;
|
||
|
||
export default function AssetLibraryPage() {
|
||
const { message } = App.useApp();
|
||
const [keyword, setKeyword] = useState("");
|
||
const [selectedType, setSelectedType] = useState("");
|
||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||
const [page, setPage] = useState(1);
|
||
const [selectedAsset, setSelectedAsset] = useState<AssetLibraryItem | null>(null);
|
||
const addAsset = useAssetStore((state) => state.addAsset);
|
||
|
||
const query = useQuery({
|
||
queryKey: ["asset-library", keyword, selectedType, selectedTags, page],
|
||
queryFn: () => fetchAssetLibrary({ keyword, type: selectedType, tag: selectedTags, page, pageSize: PAGE_SIZE }),
|
||
retry: false,
|
||
});
|
||
|
||
useEffect(() => {
|
||
if (query.isError) {
|
||
message.error(query.error instanceof Error ? query.error.message : "获取素材库失败");
|
||
}
|
||
}, [message, query.error, query.isError]);
|
||
|
||
const isReady = query.isFetched || query.isError;
|
||
const items = query.data?.items || [];
|
||
const availableTags = query.data?.tags || [];
|
||
const total = query.data?.total || 0;
|
||
|
||
const toggleTag = (tag: string) => {
|
||
setSelectedTags((items) => items.includes(tag) ? items.filter((item) => item !== tag) : [...items, tag]);
|
||
};
|
||
|
||
const saveToMyAssets = async (asset: AssetLibraryItem) => {
|
||
try {
|
||
if (asset.type === "image") {
|
||
const dataUrl = await remoteImageToDataUrl(asset.url);
|
||
const image = await uploadImage(dataUrl);
|
||
addAsset({
|
||
kind: "image",
|
||
title: asset.title,
|
||
coverUrl: asset.coverUrl,
|
||
tags: asset.tags,
|
||
source: asset.category,
|
||
note: asset.description,
|
||
data: { dataUrl: image.url, storageKey: image.storageKey, width: image.width, height: image.height, bytes: image.bytes, mimeType: image.mimeType },
|
||
metadata: { source: "asset-library", assetId: asset.id },
|
||
});
|
||
} else {
|
||
addAsset({
|
||
kind: "text",
|
||
title: asset.title,
|
||
coverUrl: asset.coverUrl,
|
||
tags: asset.tags,
|
||
source: asset.category,
|
||
note: asset.description,
|
||
data: { content: asset.content },
|
||
metadata: { source: "asset-library", assetId: asset.id },
|
||
});
|
||
}
|
||
message.success("已加入我的素材");
|
||
} catch {
|
||
message.error("加入失败");
|
||
}
|
||
};
|
||
|
||
const copyText = async (value: string) => {
|
||
copy(value);
|
||
message.success("已复制");
|
||
};
|
||
|
||
if (!isReady) {
|
||
return (
|
||
<div className="flex h-full items-center justify-center">
|
||
<Spin />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="flex h-full flex-col overflow-hidden bg-background text-stone-800 dark:text-stone-100">
|
||
<main className="min-h-0 flex-1 overflow-y-auto bg-background bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] px-6 py-8 [background-size:16px_16px] dark:bg-[radial-gradient(rgba(245,245,244,.16)_1px,transparent_1px)]">
|
||
<div className="pb-8">
|
||
<div className="mx-auto max-w-5xl text-center">
|
||
<h1 className="text-4xl font-semibold tracking-tight text-stone-950 dark:text-stone-100">素材库</h1>
|
||
<p className="mt-3 text-sm text-stone-500 dark:text-stone-400">挑选团队素材,加入我的素材后继续编辑和使用。</p>
|
||
</div>
|
||
<div className="mx-auto mt-8 w-full max-w-2xl">
|
||
<Input size="large" className="w-full" prefix={<Search className="size-4 text-stone-400" />} value={keyword} placeholder="按标题查询" onChange={(event) => { setPage(1); setKeyword(event.target.value); }} />
|
||
</div>
|
||
<div className="mx-auto mt-6 max-w-6xl space-y-3">
|
||
<div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-start">
|
||
<div className="pt-2 text-xs font-medium text-stone-500 dark:text-stone-400">类型</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
{[
|
||
{ label: "全部", value: "" },
|
||
{ label: "文本", value: "text" },
|
||
{ label: "图片", value: "image" },
|
||
].map((item) => (
|
||
<Tag.CheckableTag key={item.value || "all"} checked={selectedType === item.value} className={cn("prompt-filter-tag", selectedType === item.value && "is-active")} onChange={() => { setPage(1); setSelectedType(item.value); }}>
|
||
{item.label}
|
||
</Tag.CheckableTag>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-start">
|
||
<div className="pt-2 text-xs font-medium text-stone-500 dark:text-stone-400">标签</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
<Tag.CheckableTag checked={selectedTags.length === 0} className={cn("prompt-filter-tag", selectedTags.length === 0 && "is-active")} onChange={() => { setPage(1); setSelectedTags([]); }}>
|
||
全部
|
||
</Tag.CheckableTag>
|
||
{availableTags.map((tag) => (
|
||
<Tag.CheckableTag key={tag} checked={selectedTags.includes(tag)} className={cn("prompt-filter-tag", selectedTags.includes(tag) && "is-active")} onChange={() => { setPage(1); toggleTag(tag); }}>
|
||
{tag}
|
||
</Tag.CheckableTag>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mx-auto flex max-w-7xl flex-col gap-5">
|
||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4 2xl:grid-cols-5">
|
||
{items.map((asset) => (
|
||
<LibraryCard key={asset.id} asset={asset} onOpen={() => setSelectedAsset(asset)} onAdd={() => void saveToMyAssets(asset)} />
|
||
))}
|
||
</div>
|
||
|
||
{!items.length ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有找到素材" className="py-20" /> : null}
|
||
|
||
<div className="flex justify-center">
|
||
<Pagination current={page} pageSize={PAGE_SIZE} total={total} showSizeChanger={false} onChange={(nextPage) => setPage(nextPage)} />
|
||
</div>
|
||
</div>
|
||
</main>
|
||
|
||
<Drawer title="素材详情" open={Boolean(selectedAsset)} size="large" onClose={() => setSelectedAsset(null)}>
|
||
{selectedAsset ? (
|
||
<div className="space-y-5">
|
||
{selectedAsset.coverUrl ? <Image src={selectedAsset.coverUrl} alt={selectedAsset.title} className="rounded-lg" /> : <div className="rounded-lg border border-stone-200 bg-stone-50 p-5 text-sm leading-6 text-stone-600 dark:border-stone-800 dark:bg-stone-900 dark:text-stone-300">{selectedAsset.content || "暂无封面"}</div>}
|
||
<div>
|
||
<Typography.Title level={4} className="!mb-2">{selectedAsset.title}</Typography.Title>
|
||
<div className="flex flex-wrap gap-1.5">
|
||
<Tag>{selectedAsset.type === "image" ? "图片" : "文本"}</Tag>
|
||
{selectedAsset.tags.map((tag) => <Tag key={tag}>{tag}</Tag>)}
|
||
</div>
|
||
</div>
|
||
<div className="rounded-lg border border-stone-200 p-4 dark:border-stone-800">
|
||
<Typography.Text type="secondary" className="block text-xs">内容</Typography.Text>
|
||
{selectedAsset.type === "text" ? <Typography.Paragraph className="mt-2 whitespace-pre-wrap">{selectedAsset.content}</Typography.Paragraph> : <Typography.Text className="mt-2 block">{selectedAsset.url}</Typography.Text>}
|
||
</div>
|
||
{selectedAsset.description ? <Typography.Paragraph type="secondary">{selectedAsset.description}</Typography.Paragraph> : null}
|
||
<div className="flex flex-wrap gap-2">
|
||
{selectedAsset.type === "text" ? <Button type="primary" icon={<Copy className="size-4" />} onClick={() => void copyText(selectedAsset.content)}>复制文本</Button> : null}
|
||
{selectedAsset.type === "image" ? <Button type="primary" icon={<Copy className="size-4" />} onClick={() => void copyText(selectedAsset.url)}>复制链接</Button> : null}
|
||
<Button icon={<FolderPlus className="size-4" />} onClick={() => void saveToMyAssets(selectedAsset)}>加入我的素材</Button>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</Drawer>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function LibraryCard({ asset, onOpen, onAdd }: { asset: AssetLibraryItem; onOpen: () => void; onAdd: () => void }) {
|
||
const cover = asset.coverUrl;
|
||
return (
|
||
<Card hoverable className="overflow-hidden" styles={{ body: { padding: 0 } }} cover={<button type="button" className="block w-full text-left" onClick={onOpen}>{cover ? <img src={cover} alt={asset.title} className="aspect-[4/3] w-full object-cover" /> : <div className="flex aspect-[4/3] items-center justify-center bg-stone-100 p-5 text-center text-sm leading-6 text-stone-600 dark:bg-stone-900 dark:text-stone-300">{asset.content || "暂无封面"}</div>}</button>}>
|
||
<button type="button" className="block w-full text-left" onClick={onOpen}>
|
||
<div className="p-4">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<h2 className="line-clamp-1 text-sm font-semibold text-stone-950 dark:text-stone-100">{asset.title}</h2>
|
||
<Tag className="m-0 shrink-0 text-[11px]">{asset.type === "image" ? "图片" : "文本"}</Tag>
|
||
</div>
|
||
<Typography.Paragraph type="secondary" ellipsis={{ rows: 3 }} className="!mb-0 !mt-2 !text-xs !leading-5">
|
||
{asset.type === "text" ? asset.content : asset.url}
|
||
</Typography.Paragraph>
|
||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||
{asset.tags.slice(0, 3).map((tag) => <Tag key={tag} className="m-0 text-[11px]">{tag}</Tag>)}
|
||
{!asset.tags.length ? <Tag className="m-0 text-[11px]">无标签</Tag> : null}
|
||
</div>
|
||
</div>
|
||
</button>
|
||
<div className="flex items-center gap-2 px-4 pb-4">
|
||
<Button size="small" onClick={onOpen}>查看</Button>
|
||
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={onAdd}>加入我的素材</Button>
|
||
</div>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
async function remoteImageToDataUrl(url: string) {
|
||
const response = await axios.get(url, { responseType: "blob" });
|
||
const blob = response.data as Blob;
|
||
return await blobToDataUrl(blob);
|
||
}
|
||
|
||
function blobToDataUrl(blob: Blob) {
|
||
return new Promise<string>((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.onload = () => resolve(String(reader.result || ""));
|
||
reader.onerror = () => reject(new Error("读取图片失败"));
|
||
reader.readAsDataURL(blob);
|
||
});
|
||
}
|