911 lines
41 KiB
TypeScript
911 lines
41 KiB
TypeScript
'use client';
|
||
|
||
import { useState, useMemo, useEffect, useCallback } from 'react';
|
||
import { Card, CardContent } from '@/components/ui/card';
|
||
import { Badge } from '@/components/ui/badge';
|
||
import { Button } from '@/components/ui/button';
|
||
import {
|
||
LayoutGrid,
|
||
Heart,
|
||
Download,
|
||
Brush,
|
||
ImagePlus,
|
||
Video,
|
||
Film,
|
||
X,
|
||
Clock,
|
||
Cpu,
|
||
Sparkles,
|
||
Image as ImageIcon,
|
||
MessageSquare,
|
||
Copy,
|
||
Maximize2,
|
||
ArrowLeft,
|
||
Trash2,
|
||
Search,
|
||
} from 'lucide-react';
|
||
import { copyTextToClipboard, downloadFile } from '@/lib/utils';
|
||
import { usePublishedWorks, useCreationHistory, syncPublishedToSupabase, type PublishedWork } from '@/lib/creation-history-store';
|
||
import { useAuth } from '@/lib/auth-store';
|
||
import { FullscreenPreview } from '@/components/fullscreen-preview';
|
||
import { toast } from 'sonner';
|
||
|
||
const CATEGORIES = [
|
||
{ value: 'all', label: '全部', icon: LayoutGrid },
|
||
{ value: 'text2img', label: '文生图', icon: Brush },
|
||
{ value: 'img2img', label: '图生图', icon: ImagePlus },
|
||
{ value: 'text2video', label: '文生视频', icon: Video },
|
||
{ value: 'img2video', label: '图生视频', icon: Film },
|
||
];
|
||
|
||
/* ---------- Gallery Work (from API) ---------- */
|
||
interface GalleryWork {
|
||
id: string;
|
||
type: string;
|
||
title?: string | null;
|
||
prompt?: string | null;
|
||
negativePrompt?: string | null;
|
||
url: string;
|
||
thumbnailUrl?: string | null;
|
||
width?: number | null;
|
||
height?: number | null;
|
||
duration?: number | null;
|
||
likes: number;
|
||
creditsCost?: number | null;
|
||
params: Record<string, unknown>;
|
||
referenceImage?: string | null;
|
||
referenceImages?: string[];
|
||
publisherId: string;
|
||
publisherNickname: string;
|
||
publisherAvatarUrl?: string | null;
|
||
publishedAt: string;
|
||
}
|
||
|
||
function getCategoryFromWork(work: GalleryWork): string {
|
||
const mode = work.params?.creationMode || work.params?.workType || work.params?.mode;
|
||
if (
|
||
mode === 'text2img' ||
|
||
mode === 'img2img' ||
|
||
mode === 'text2video' ||
|
||
mode === 'img2video'
|
||
) {
|
||
return mode;
|
||
}
|
||
if (work.type === 'text2video' || work.type === 'img2video') {
|
||
return work.type;
|
||
}
|
||
if (work.type === 'img2img') return work.type;
|
||
const hasReference =
|
||
Boolean(work.referenceImage) ||
|
||
(Array.isArray(work.referenceImages) && work.referenceImages.length > 0) ||
|
||
Boolean(work.params?.referenceImage) ||
|
||
(Array.isArray(work.params?.referenceImages) && work.params.referenceImages.length > 0);
|
||
// Fallback: infer from type + referenceImage
|
||
if (work.type === 'video' || work.duration) {
|
||
return hasReference ? 'img2video' : 'text2video';
|
||
}
|
||
return hasReference ? 'img2img' : 'text2img';
|
||
}
|
||
|
||
function getCategoryLabel(work: GalleryWork): string {
|
||
const cat = CATEGORIES.find(c => c.value === getCategoryFromWork(work));
|
||
return cat?.label ?? work.type;
|
||
}
|
||
|
||
function formatDate(iso: string): string {
|
||
try {
|
||
return new Date(iso).toLocaleDateString('zh-CN', {
|
||
year: 'numeric',
|
||
month: 'short',
|
||
day: 'numeric',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
});
|
||
} catch {
|
||
return iso;
|
||
}
|
||
}
|
||
|
||
function getAvatarText(nickname: string): string {
|
||
const trimmed = nickname.trim();
|
||
return trimmed ? trimmed.slice(0, 1).toUpperCase() : '匿';
|
||
}
|
||
|
||
function getWorkReferenceImages(work: GalleryWork): string[] {
|
||
const fromArray = Array.isArray(work.referenceImages) ? work.referenceImages : [];
|
||
const fromParams = Array.isArray(work.params?.referenceImages)
|
||
? (work.params.referenceImages as unknown[]).filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
|
||
: [];
|
||
const single = typeof work.referenceImage === 'string' && work.referenceImage.trim()
|
||
? [work.referenceImage]
|
||
: typeof work.params?.referenceImage === 'string' && work.params.referenceImage.trim()
|
||
? [work.params.referenceImage]
|
||
: [];
|
||
return [...new Set([...single, ...fromArray, ...fromParams].filter(url => url && !url.startsWith('data:') && !url.startsWith('[')))];
|
||
}
|
||
|
||
async function copyGalleryText(text: string, successMessage: string) {
|
||
const copied = await copyTextToClipboard(text);
|
||
if (copied) {
|
||
toast.success(successMessage);
|
||
} else {
|
||
toast.error('复制失败,请手动选择文本复制');
|
||
}
|
||
}
|
||
|
||
function getCreateUrlForCategory(category: string): string {
|
||
const type = category === 'img2img' || category === 'text2video' || category === 'img2video'
|
||
? category
|
||
: 'text2img';
|
||
return `/create?type=${type}`;
|
||
}
|
||
|
||
type MediaSize = { width: number; height: number };
|
||
|
||
function getEstimatedWorkHeight(work: GalleryWork, measuredSize?: MediaSize): number {
|
||
const width = Number(measuredSize?.width || work.width || 0);
|
||
const height = Number(measuredSize?.height || work.height || 0);
|
||
const imageHeight = width > 0 && height > 0 ? Math.max(120, (height / width) * 320) : 320;
|
||
return imageHeight + 152 + 16;
|
||
}
|
||
|
||
const galleryGlassPanel =
|
||
'liquid-glass';
|
||
const galleryGlassCard =
|
||
'liquid-surface';
|
||
const galleryGlassBlock =
|
||
'rounded-xl border border-border bg-card/40';
|
||
const detailGlassBlock =
|
||
'rounded-xl border border-white/[0.08] bg-[#12161d]/82 shadow-[inset_0_1px_0_rgba(255,255,255,0.045),0_16px_36px_rgba(0,0,0,0.18)] backdrop-blur-xl light:border-amber-900/18 light:bg-white/36 light:text-foreground light:shadow-[inset_0_1px_0_rgba(255,255,255,0.70),0_16px_40px_rgba(83,61,27,0.12)]';
|
||
const detailGlassInner =
|
||
'rounded-md border border-white/[0.07] bg-[#0d1219]/80 light:border-amber-900/16 light:bg-white/32';
|
||
const galleryMenuItemClass =
|
||
'inline-flex h-10 cursor-pointer items-center gap-2.5 rounded-xl border border-transparent px-5 text-base font-semibold leading-none text-foreground/75 transition-colors hover:bg-white/[0.035]';
|
||
const galleryMenuItemActiveClass =
|
||
'border-transparent bg-white/[0.075] text-primary shadow-[inset_0_1px_0_rgba(255,255,255,0.12),0_0_18px_rgba(244,166,36,0.18),0_6px_18px_rgba(0,0,0,0.18)] [&_svg]:text-primary';
|
||
|
||
export default function GalleryPage() {
|
||
const [apiWorks, setApiWorks] = useState<GalleryWork[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [syncStatus, setSyncStatus] = useState<'idle' | 'syncing' | 'done'>('idle');
|
||
const [category, setCategory] = useState('all');
|
||
const [likedIds, setLikedIds] = useState<Set<string>>(new Set());
|
||
const [selectedWork, setSelectedWork] = useState<GalleryWork | null>(null);
|
||
const [fullscreenSrc, setFullscreenSrc] = useState<string | null>(null);
|
||
const [sortBy, setSortBy] = useState<'newest' | 'popular'>('newest');
|
||
const [searchQuery, setSearchQuery] = useState('');
|
||
const [masonryColumnCount, setMasonryColumnCount] = useState(4);
|
||
const [measuredMediaSizes, setMeasuredMediaSizes] = useState<Record<string, MediaSize>>({});
|
||
const [selectedGalleryIds, setSelectedGalleryIds] = useState<Set<string>>(new Set());
|
||
|
||
useEffect(() => {
|
||
const updateColumnCount = () => {
|
||
const width = window.innerWidth;
|
||
if (width >= 1280) setMasonryColumnCount(4);
|
||
else if (width >= 1024) setMasonryColumnCount(3);
|
||
else if (width >= 640) setMasonryColumnCount(2);
|
||
else setMasonryColumnCount(1);
|
||
};
|
||
|
||
updateColumnCount();
|
||
window.addEventListener('resize', updateColumnCount);
|
||
return () => window.removeEventListener('resize', updateColumnCount);
|
||
}, []);
|
||
|
||
// ESC to close detail overlay
|
||
useEffect(() => {
|
||
if (!selectedWork) return;
|
||
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') setSelectedWork(null); };
|
||
window.addEventListener('keydown', handler);
|
||
return () => window.removeEventListener('keydown', handler);
|
||
}, [selectedWork]);
|
||
|
||
// Prevent body scroll when detail is open
|
||
useEffect(() => {
|
||
if (selectedWork) {
|
||
document.body.style.overflow = 'hidden';
|
||
} else {
|
||
document.body.style.overflow = '';
|
||
}
|
||
return () => { document.body.style.overflow = ''; };
|
||
}, [selectedWork]);
|
||
const { works: localPublished } = usePublishedWorks();
|
||
const { records: creationHistory } = useCreationHistory();
|
||
const { user, accessToken, isAdmin } = useAuth();
|
||
|
||
// Fetch works from API, after syncing localStorage to Supabase
|
||
const fetchWorks = useCallback(async () => {
|
||
setLoading(true);
|
||
try {
|
||
const params = new URLSearchParams({ sort: sortBy, limit: '300' });
|
||
if (searchQuery.trim()) params.set('q', searchQuery.trim());
|
||
const res = await fetch(`/api/gallery?${params.toString()}`);
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
setApiWorks(data.works || []);
|
||
}
|
||
} catch { /* ignore */ }
|
||
setLoading(false);
|
||
}, [sortBy, searchQuery]);
|
||
|
||
// Sync localStorage to Supabase on first mount only
|
||
useEffect(() => {
|
||
setSyncStatus('syncing');
|
||
syncPublishedToSupabase().then(synced => {
|
||
setSyncStatus('done');
|
||
if (synced > 0) {
|
||
// Re-fetch after sync to show newly synced works
|
||
fetchWorks();
|
||
}
|
||
}).catch(() => {
|
||
setSyncStatus('done');
|
||
});
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
fetchWorks();
|
||
}, [fetchWorks]);
|
||
|
||
// Merge API works with localStorage published works + published creation history
|
||
// This ensures previously shared works are visible even if not yet in Supabase
|
||
const works = useMemo(() => {
|
||
const apiUrls = new Set(apiWorks.map(w => w.url));
|
||
|
||
// From localStorage published gallery
|
||
const localAsGallery: GalleryWork[] = localPublished
|
||
.filter(w => !apiUrls.has(w.url))
|
||
.map(w => ({
|
||
id: w.id,
|
||
type: w.type === 'video' ? (w.referenceImage ? 'img2video' : 'text2video') : (w.referenceImage ? 'img2img' : 'text2img'),
|
||
title: null,
|
||
prompt: w.prompt,
|
||
negativePrompt: w.negativePrompt,
|
||
url: w.url,
|
||
thumbnailUrl: null,
|
||
width: null,
|
||
height: null,
|
||
duration: null,
|
||
likes: w.likes || 0,
|
||
creditsCost: null,
|
||
params: { model: w.model, modelLabel: w.modelLabel, ...w.params },
|
||
referenceImage: w.referenceImage,
|
||
referenceImages: w.referenceImages,
|
||
publisherId: w.publisherId,
|
||
publisherNickname: w.publisherNickname,
|
||
publisherAvatarUrl: null,
|
||
publishedAt: w.publishedAt,
|
||
}));
|
||
|
||
// From creation history records marked as published
|
||
const existingUrls = new Set([...apiUrls, ...localAsGallery.map(w => w.url)]);
|
||
const historyPublished: GalleryWork[] = creationHistory
|
||
.filter(r => r.published && r.url && !existingUrls.has(r.url) && !r.url.startsWith('data:') && !r.url.startsWith('['))
|
||
.map(r => ({
|
||
id: r.id,
|
||
type: r.type === 'video' ? (r.referenceImage ? 'img2video' : 'text2video') : (r.referenceImage ? 'img2img' : 'text2img'),
|
||
title: null,
|
||
prompt: r.prompt,
|
||
negativePrompt: r.negativePrompt,
|
||
url: r.url,
|
||
thumbnailUrl: null,
|
||
width: null,
|
||
height: null,
|
||
duration: null,
|
||
likes: 0,
|
||
creditsCost: null,
|
||
params: { model: r.model, modelLabel: r.modelLabel, ...r.params },
|
||
referenceImage: r.referenceImage,
|
||
referenceImages: r.referenceImages,
|
||
publisherId: user?.id || 'anonymous',
|
||
publisherNickname: user?.nickname || user?.email?.split('@')[0] || '匿名用户',
|
||
publisherAvatarUrl: user?.avatarUrl || null,
|
||
publishedAt: r.createdAt,
|
||
}));
|
||
|
||
return [...apiWorks, ...localAsGallery, ...historyPublished];
|
||
}, [apiWorks, localPublished, creationHistory, user]);
|
||
|
||
const filteredWorks = useMemo(() => {
|
||
const query = searchQuery.trim().toLowerCase();
|
||
return works.filter(work => {
|
||
if (category !== 'all' && getCategoryFromWork(work) !== category) return false;
|
||
if (!query) return true;
|
||
const haystack = [
|
||
work.title,
|
||
work.prompt,
|
||
work.negativePrompt,
|
||
work.publisherNickname,
|
||
work.params?.model,
|
||
work.params?.modelLabel,
|
||
work.type,
|
||
].map(value => String(value || '').toLowerCase()).join('\n');
|
||
return haystack.includes(query);
|
||
});
|
||
}, [works, category, searchQuery]);
|
||
|
||
const apiWorkIds = useMemo(() => new Set(apiWorks.map(work => work.id)), [apiWorks]);
|
||
|
||
const handleCardImageLoad = useCallback((workId: string, e: React.SyntheticEvent<HTMLImageElement>) => {
|
||
const img = e.currentTarget;
|
||
if (img.naturalWidth <= 0 || img.naturalHeight <= 0) return;
|
||
|
||
setMeasuredMediaSizes(prev => {
|
||
const current = prev[workId];
|
||
if (current?.width === img.naturalWidth && current?.height === img.naturalHeight) {
|
||
return prev;
|
||
}
|
||
return {
|
||
...prev,
|
||
[workId]: { width: img.naturalWidth, height: img.naturalHeight },
|
||
};
|
||
});
|
||
}, []);
|
||
|
||
const masonryColumns = useMemo(() => {
|
||
const columns = Array.from({ length: masonryColumnCount }, () => [] as GalleryWork[]);
|
||
const columnHeights = Array.from({ length: masonryColumnCount }, () => 0);
|
||
filteredWorks.forEach((work) => {
|
||
const targetIndex = columnHeights.indexOf(Math.min(...columnHeights));
|
||
columns[targetIndex].push(work);
|
||
columnHeights[targetIndex] += getEstimatedWorkHeight(work, measuredMediaSizes[work.id]);
|
||
});
|
||
return columns;
|
||
}, [filteredWorks, masonryColumnCount, measuredMediaSizes]);
|
||
|
||
const selectedReferenceImages = useMemo(
|
||
() => selectedWork ? getWorkReferenceImages(selectedWork) : [],
|
||
[selectedWork],
|
||
);
|
||
|
||
const toggleLike = (id: string, e?: React.MouseEvent) => {
|
||
e?.stopPropagation();
|
||
if (likedIds.has(id)) return;
|
||
setLikedIds(prev => new Set(prev).add(id));
|
||
};
|
||
|
||
const handleDownload = async (url: string, filename: string, e?: React.MouseEvent) => {
|
||
e?.stopPropagation();
|
||
const result = await downloadFile(url, filename);
|
||
if (!result.ok) {
|
||
window.open(url, '_blank');
|
||
}
|
||
};
|
||
|
||
const toggleSelectGalleryWork = (id: string, e?: React.MouseEvent) => {
|
||
e?.stopPropagation();
|
||
setSelectedGalleryIds(prev => {
|
||
const next = new Set(prev);
|
||
if (next.has(id)) next.delete(id);
|
||
else next.add(id);
|
||
return next;
|
||
});
|
||
};
|
||
|
||
const handleDeleteGalleryWorks = async (ids: string[], e?: React.MouseEvent) => {
|
||
e?.stopPropagation();
|
||
const targetIds = ids.filter(id => apiWorkIds.has(id));
|
||
if (targetIds.length === 0) {
|
||
toast.error('没有可删除的服务器画廊作品');
|
||
return;
|
||
}
|
||
const confirmed = window.confirm(targetIds.length === 1 ? '确认从画廊移除这个作品?' : `确认从画廊批量移除 ${targetIds.length} 个作品?`);
|
||
if (!confirmed) return;
|
||
|
||
try {
|
||
const res = await fetch('/api/gallery', {
|
||
method: 'DELETE',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
|
||
},
|
||
body: JSON.stringify({ ids: targetIds }),
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) {
|
||
throw new Error(data.error || '删除失败');
|
||
}
|
||
const removedIds = new Set<string>((data.ids || targetIds) as string[]);
|
||
setApiWorks(prev => prev.filter(work => !removedIds.has(work.id)));
|
||
setSelectedGalleryIds(prev => new Set([...prev].filter(id => !removedIds.has(id))));
|
||
if (selectedWork && removedIds.has(selectedWork.id)) {
|
||
setSelectedWork(null);
|
||
}
|
||
toast.success(`已从画廊移除 ${data.removed ?? removedIds.size} 个作品`);
|
||
} catch (err) {
|
||
toast.error(err instanceof Error ? err.message : '删除失败');
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="min-h-screen bg-background">
|
||
<div className="mx-auto max-w-7xl px-4 sm:px-6 py-8">
|
||
{/* Header */}
|
||
<div className="mb-8">
|
||
<div className="flex items-center gap-3">
|
||
<h1 className="font-serif text-3xl font-bold">作品画廊</h1>
|
||
{syncStatus === 'syncing' && (
|
||
<span className="text-xs text-muted-foreground animate-pulse">同步本地数据...</span>
|
||
)}
|
||
</div>
|
||
<p className="mt-2 text-muted-foreground">探索社区创作,发现灵感之美</p>
|
||
</div>
|
||
|
||
<div className={`${galleryGlassPanel} mb-4 flex items-center gap-2.5 rounded-2xl px-4 py-2`}>
|
||
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||
<input
|
||
value={searchQuery}
|
||
onChange={(event) => setSearchQuery(event.target.value)}
|
||
placeholder="搜索作品、用户、提示词、模型"
|
||
className="h-8 min-w-0 flex-1 bg-transparent text-sm font-medium outline-none placeholder:text-muted-foreground/70"
|
||
/>
|
||
{searchQuery && (
|
||
<button
|
||
onClick={() => setSearchQuery('')}
|
||
className="flex h-7 w-7 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-white/10 hover:text-foreground"
|
||
>
|
||
<X className="h-3.5 w-3.5" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Filters */}
|
||
<div className={`${galleryGlassPanel} mb-8 flex min-h-12 flex-col items-start justify-between gap-4 rounded-2xl p-1 sm:flex-row sm:items-center`}>
|
||
<div className="flex flex-wrap gap-2">
|
||
{CATEGORIES.map((cat) => {
|
||
const Icon = cat.icon;
|
||
return (
|
||
<button
|
||
key={cat.value}
|
||
className={`${galleryMenuItemClass} ${category === cat.value ? galleryMenuItemActiveClass : ''}`}
|
||
onClick={() => setCategory(cat.value)}
|
||
>
|
||
<Icon className="h-3.5 w-3.5" />
|
||
{cat.label}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
<button
|
||
className={`${galleryMenuItemClass} ${sortBy === 'newest' ? galleryMenuItemActiveClass : ''}`}
|
||
onClick={() => setSortBy('newest')}
|
||
>
|
||
最新发布
|
||
</button>
|
||
<button
|
||
className={`${galleryMenuItemClass} ${sortBy === 'popular' ? galleryMenuItemActiveClass : ''}`}
|
||
onClick={() => setSortBy('popular')}
|
||
>
|
||
最受欢迎
|
||
</button>
|
||
{isAdmin && selectedGalleryIds.size > 0 && (
|
||
<Button
|
||
size="sm"
|
||
variant="destructive"
|
||
className="h-10 rounded-xl px-4 text-sm font-semibold"
|
||
onClick={(e) => handleDeleteGalleryWorks([...selectedGalleryIds], e)}
|
||
>
|
||
<Trash2 className="mr-2 h-4 w-4" />
|
||
批量删除 {selectedGalleryIds.size}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Gallery Grid */}
|
||
{loading ? (
|
||
<div className="flex flex-col items-center justify-center py-24 text-muted-foreground">
|
||
<Sparkles className="h-12 w-12 mb-4 animate-pulse opacity-30" />
|
||
<p className="text-lg font-serif">加载中...</p>
|
||
</div>
|
||
) : filteredWorks.length === 0 ? (
|
||
<div className="flex flex-col items-center justify-center py-24 text-muted-foreground">
|
||
<LayoutGrid className="h-16 w-16 mb-4 opacity-30" />
|
||
<p className="text-lg font-serif">暂无作品</p>
|
||
<p className="text-sm mt-1">创作并发布你的作品,让大家一起欣赏</p>
|
||
<Button
|
||
className="mt-4"
|
||
variant="outline"
|
||
onClick={() => window.location.href = getCreateUrlForCategory(category)}
|
||
>
|
||
<Sparkles className="h-4 w-4 mr-2" />
|
||
前往创作
|
||
</Button>
|
||
</div>
|
||
) : (
|
||
<div
|
||
className="grid gap-4"
|
||
style={{ gridTemplateColumns: `repeat(${masonryColumnCount}, minmax(0, 1fr))` }}
|
||
>
|
||
{masonryColumns.map((columnWorks, columnIndex) => (
|
||
<div key={columnIndex} className="flex min-w-0 flex-col gap-4">
|
||
{columnWorks.map((work) => (
|
||
<Card
|
||
key={work.id}
|
||
className={`${galleryGlassCard} group w-full overflow-hidden cursor-pointer !rounded-2xl !py-0 transition-all duration-300 hover:border-primary/25 hover:bg-slate-900/[0.52] hover:shadow-[0_18px_52px_rgba(0,0,0,0.34),inset_0_1px_0_rgba(255,255,255,0.075)]`}
|
||
onClick={() => setSelectedWork(work)}
|
||
>
|
||
<div className="relative overflow-hidden bg-black/25">
|
||
{(work.thumbnailUrl || (work.url && !work.url.startsWith('data:'))) ? (
|
||
<img
|
||
src={work.thumbnailUrl || work.url}
|
||
alt={(work.prompt || '').slice(0, 30)}
|
||
className="block h-auto w-full object-contain"
|
||
loading="lazy"
|
||
onLoad={(e) => handleCardImageLoad(work.id, e)}
|
||
onDoubleClick={(e) => { e.stopPropagation(); setFullscreenSrc(work.url); }}
|
||
/>
|
||
) : (
|
||
<div className="flex aspect-square w-full flex-col items-center justify-center bg-gradient-to-br from-muted to-muted/50">
|
||
<Sparkles className="h-8 w-8 text-muted-foreground/20" />
|
||
</div>
|
||
)}
|
||
{isAdmin && apiWorkIds.has(work.id) && (
|
||
<button
|
||
className={`absolute left-2 top-2 z-20 flex h-7 w-7 items-center justify-center rounded-lg border text-xs font-semibold backdrop-blur-md transition-colors ${
|
||
selectedGalleryIds.has(work.id)
|
||
? 'border-primary/60 bg-primary text-primary-foreground'
|
||
: 'border-white/20 bg-black/45 text-white hover:bg-black/65'
|
||
}`}
|
||
onClick={(e) => toggleSelectGalleryWork(work.id, e)}
|
||
title={selectedGalleryIds.has(work.id) ? '取消选择' : '选择作品'}
|
||
>
|
||
{selectedGalleryIds.has(work.id) ? '✓' : ''}
|
||
</button>
|
||
)}
|
||
{(work.type === 'video' || work.type === 'text2video' || work.type === 'img2video') && (
|
||
<Badge className={`absolute left-2 ${isAdmin && apiWorkIds.has(work.id) ? 'top-11' : 'top-2'}`} variant="secondary">
|
||
<Film className="h-3 w-3 mr-1" />视频
|
||
</Badge>
|
||
)}
|
||
<Badge className="absolute top-2 right-2" variant="secondary">
|
||
{getCategoryLabel(work)}
|
||
</Badge>
|
||
{/* Hover overlay */}
|
||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/50 transition-colors flex flex-col items-center justify-center gap-2 opacity-0 group-hover:opacity-100">
|
||
<p className="text-white text-sm font-serif line-clamp-2 px-4 text-center">
|
||
{work.prompt}
|
||
</p>
|
||
<div className="flex gap-2">
|
||
<Button
|
||
size="sm"
|
||
variant="secondary"
|
||
className="h-8 w-8 p-0"
|
||
onClick={(e) => toggleLike(work.id, e)}
|
||
>
|
||
<Heart className={`h-4 w-4 ${likedIds.has(work.id) ? 'fill-rose-500 text-rose-500' : ''}`} />
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="secondary"
|
||
className="h-8 w-8 p-0"
|
||
onClick={(e) => handleDownload(work.url, `miaojing-${work.id}.png`, e)}
|
||
>
|
||
<Download className="h-4 w-4" />
|
||
</Button>
|
||
{isAdmin && apiWorkIds.has(work.id) && (
|
||
<Button
|
||
size="sm"
|
||
variant="destructive"
|
||
className="h-8 w-8 p-0"
|
||
onClick={(e) => handleDeleteGalleryWorks([work.id], e)}
|
||
title="从画廊删除"
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<CardContent className="flex h-[152px] flex-col p-3">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex min-w-0 items-center gap-2">
|
||
<div className="flex h-6 w-6 shrink-0 items-center justify-center overflow-hidden rounded-full bg-primary/15 text-xs font-semibold text-primary ring-1 ring-primary/25">
|
||
{work.publisherAvatarUrl ? (
|
||
<img
|
||
src={work.publisherAvatarUrl}
|
||
alt={work.publisherNickname}
|
||
className="h-full w-full object-cover"
|
||
loading="lazy"
|
||
/>
|
||
) : (
|
||
getAvatarText(work.publisherNickname)
|
||
)}
|
||
</div>
|
||
<span className="truncate text-sm font-medium">
|
||
{work.publisherNickname}
|
||
</span>
|
||
</div>
|
||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||
<Heart className={`h-3 w-3 ${likedIds.has(work.id) ? 'fill-rose-500 text-rose-500' : ''}`} />
|
||
{work.likes + (likedIds.has(work.id) ? 1 : 0)}
|
||
</div>
|
||
</div>
|
||
<p className="mt-2 h-[100px] overflow-hidden whitespace-pre-wrap break-words text-xs leading-5 text-muted-foreground line-clamp-5">
|
||
{work.prompt}
|
||
</p>
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Detail - Fullscreen Overlay */}
|
||
{selectedWork && (
|
||
<div
|
||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md animate-in fade-in duration-200 light:bg-white/58 light:backdrop-blur-xl"
|
||
onClick={(e) => { if (e.target === e.currentTarget) setSelectedWork(null); }}
|
||
>
|
||
<div className="relative flex h-[96vh] w-[98vw] overflow-hidden rounded-2xl border border-white/[0.08] bg-[#07090d] shadow-[0_28px_80px_rgba(0,0,0,0.55)] light:border-amber-900/18 light:bg-white/30 light:shadow-[0_28px_80px_rgba(83,61,27,0.16),inset_0_1px_0_rgba(255,255,255,0.70)]">
|
||
{selectedWork.url && !selectedWork.url.startsWith('data:') && (
|
||
<>
|
||
<img
|
||
src={selectedWork.thumbnailUrl || selectedWork.url}
|
||
alt=""
|
||
aria-hidden="true"
|
||
className="pointer-events-none absolute inset-0 h-full w-full scale-125 object-cover opacity-48 blur-[5px]"
|
||
/>
|
||
<div className="pointer-events-none absolute inset-0 bg-black/42 light:bg-white/38" />
|
||
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(255,255,255,0.015),rgba(0,0,0,0.62))] light:bg-[radial-gradient(circle_at_center,rgba(255,255,255,0.16),rgba(255,248,235,0.54))]" />
|
||
</>
|
||
)}
|
||
{/* Left: Image/Video */}
|
||
<div className="relative z-10 flex min-w-0 flex-1 items-center justify-center overflow-hidden bg-black/22 light:bg-white/12">
|
||
{selectedWork.type === 'video' || selectedWork.type === 'text2video' || selectedWork.type === 'img2video' ? (
|
||
<video
|
||
src={selectedWork.url}
|
||
controls
|
||
className="relative z-10 h-full w-full object-contain"
|
||
/>
|
||
) : (
|
||
<img
|
||
src={selectedWork.url}
|
||
alt={(selectedWork.prompt || '').slice(0, 30)}
|
||
className="relative z-10 h-full w-full cursor-zoom-in object-contain"
|
||
onDoubleClick={() => setFullscreenSrc(selectedWork.url)}
|
||
/>
|
||
)}
|
||
{/* Fullscreen button overlay */}
|
||
{selectedWork.type !== 'video' && selectedWork.type !== 'text2video' && selectedWork.type !== 'img2video' && (
|
||
<button
|
||
onClick={() => setFullscreenSrc(selectedWork.url)}
|
||
className="absolute bottom-4 right-4 z-20 flex h-10 w-10 items-center justify-center rounded-full bg-black/50 text-white shadow-lg backdrop-blur-md transition-colors hover:bg-black/70"
|
||
>
|
||
<Maximize2 className="h-5 w-5 text-white" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Right: Info Panel */}
|
||
<div className="relative z-10 flex w-[410px] shrink-0 flex-col overflow-hidden border-l border-white/[0.07] bg-[#0a0d12]/74 backdrop-blur-2xl light:border-amber-900/14 light:bg-white/28">
|
||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-r from-[#0a0d12]/46 via-[#0a0d12]/72 to-[#0a0d12]/86 light:from-white/8 light:via-white/24 light:to-white/42" />
|
||
{/* Close header */}
|
||
<div className={`${detailGlassBlock} relative z-10 m-4 mb-0 flex items-center gap-2 px-4 py-3`}>
|
||
<button
|
||
onClick={() => setSelectedWork(null)}
|
||
className="flex h-8 w-8 items-center justify-center rounded-full transition-colors hover:bg-white/[0.07]"
|
||
>
|
||
<ArrowLeft className="h-4 w-4" />
|
||
</button>
|
||
<h2 className="font-serif text-lg font-semibold">作品详情</h2>
|
||
<button
|
||
onClick={() => setSelectedWork(null)}
|
||
className="ml-auto flex h-8 w-8 items-center justify-center rounded-full transition-colors hover:bg-white/[0.07]"
|
||
>
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="relative z-10 flex min-h-0 flex-1 flex-col gap-4 overflow-hidden p-4">
|
||
{/* Publisher info */}
|
||
<div className={`${detailGlassBlock} flex shrink-0 items-center gap-3 p-4`}>
|
||
<div className="flex h-11 w-11 shrink-0 items-center justify-center overflow-hidden rounded-full bg-primary/10 text-sm font-semibold text-primary ring-1 ring-primary/25">
|
||
{selectedWork.publisherAvatarUrl ? (
|
||
<img
|
||
src={selectedWork.publisherAvatarUrl}
|
||
alt={selectedWork.publisherNickname}
|
||
className="h-full w-full object-cover"
|
||
/>
|
||
) : (
|
||
getAvatarText(selectedWork.publisherNickname)
|
||
)}
|
||
</div>
|
||
<div className="min-w-0">
|
||
<p className="truncate text-base font-semibold">{selectedWork.publisherNickname}</p>
|
||
<p className="flex items-center gap-1 text-xs text-slate-400 light:text-muted-foreground">
|
||
<Clock className="h-3 w-3" />
|
||
{formatDate(selectedWork.publishedAt)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Prompt */}
|
||
{(selectedWork.prompt || selectedWork.negativePrompt) && (
|
||
<div className={`${detailGlassBlock} flex min-h-0 flex-1 flex-col space-y-4 p-4`}>
|
||
{selectedWork.prompt && (
|
||
<div className="flex min-h-0 flex-1 flex-col space-y-2">
|
||
<div className="flex items-center justify-between">
|
||
<p className="flex items-center gap-2 text-sm font-medium text-slate-400 light:text-muted-foreground">
|
||
<MessageSquare className="h-4 w-4 text-primary" />
|
||
提示词
|
||
</p>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="h-6 gap-1 px-2 text-xs"
|
||
onClick={() => copyGalleryText(selectedWork.prompt || '', '提示词已复制')}
|
||
>
|
||
<Copy className="h-3 w-3" />复制
|
||
</Button>
|
||
</div>
|
||
<div className={`${detailGlassInner} min-h-0 flex-1 overflow-y-auto p-3`}>
|
||
<p className="whitespace-pre-wrap break-words text-sm leading-6 text-slate-100 light:text-foreground">{selectedWork.prompt}</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{selectedWork.negativePrompt && (
|
||
<div className="shrink-0 space-y-2">
|
||
<div className="flex items-center justify-between gap-2">
|
||
<p className="flex items-center gap-2 text-sm font-medium text-slate-400 light:text-muted-foreground">
|
||
<X className="h-4 w-4 text-destructive" />
|
||
负面提示词
|
||
</p>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="h-6 gap-1 px-2 text-xs"
|
||
onClick={() => copyGalleryText(selectedWork.negativePrompt || '', '负面提示词已复制')}
|
||
>
|
||
<Copy className="h-3 w-3" />复制
|
||
</Button>
|
||
</div>
|
||
<div className={`${detailGlassInner} max-h-28 overflow-y-auto p-3`}>
|
||
<p className="whitespace-pre-wrap break-words text-sm leading-6 text-slate-300 light:text-foreground/75">
|
||
{selectedWork.negativePrompt}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Reference Image */}
|
||
{selectedReferenceImages.length > 0 && (
|
||
<div className={`${detailGlassBlock} shrink-0 p-4`}>
|
||
<div className="mb-2 flex items-center justify-between gap-2">
|
||
<div className="flex items-center gap-2">
|
||
<ImageIcon className="h-4 w-4 text-primary" />
|
||
<p className="text-sm font-medium text-slate-100 light:text-foreground">参考图</p>
|
||
</div>
|
||
<span className="text-xs text-slate-400 light:text-muted-foreground">{selectedReferenceImages.length} 张</span>
|
||
</div>
|
||
<div className="grid max-h-[240px] grid-cols-2 gap-2 overflow-y-auto pr-1">
|
||
{selectedReferenceImages.map((url, index) => (
|
||
<div key={`${url}-${index}`} className={`${detailGlassInner} group relative overflow-hidden`}>
|
||
<img
|
||
src={url}
|
||
alt={`参考图 ${index + 1}`}
|
||
className="aspect-square w-full cursor-zoom-in object-cover"
|
||
onDoubleClick={() => setFullscreenSrc(url)}
|
||
/>
|
||
<div className="absolute inset-x-0 bottom-0 flex justify-end gap-1 bg-black/35 p-1 opacity-0 backdrop-blur-sm transition-opacity group-hover:opacity-100">
|
||
<button
|
||
className="flex h-7 w-7 items-center justify-center rounded-full bg-white/90 text-black"
|
||
onClick={() => setFullscreenSrc(url)}
|
||
>
|
||
<Maximize2 className="h-3.5 w-3.5" />
|
||
</button>
|
||
<button
|
||
className="flex h-7 w-7 items-center justify-center rounded-full bg-white/90 text-black"
|
||
onClick={(event) => handleDownload(url, `miaojing-reference-${selectedWork.id}-${index + 1}.png`, event)}
|
||
>
|
||
<Download className="h-3.5 w-3.5" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className={`${detailGlassBlock} mt-auto shrink-0 space-y-4 p-4`}>
|
||
{/* Model & Params */}
|
||
{selectedWork.params && Object.keys(selectedWork.params).length > 0 && (
|
||
<div>
|
||
<div className="mb-3 flex items-center gap-2">
|
||
<Cpu className="h-4 w-4 text-primary" />
|
||
<p className="text-sm font-medium text-slate-100 light:text-foreground">模型与参数</p>
|
||
</div>
|
||
<div className="grid max-h-36 grid-cols-2 gap-3 overflow-y-auto text-sm">
|
||
{(!!selectedWork.params.modelLabel || !!selectedWork.params.model) && (
|
||
<div>
|
||
<p className="text-xs text-slate-500 light:text-muted-foreground/80">模型</p>
|
||
<p className="font-medium text-slate-100 light:text-foreground">{String(selectedWork.params.modelLabel || selectedWork.params.model || '')}</p>
|
||
</div>
|
||
)}
|
||
<div>
|
||
<p className="text-xs text-slate-500 light:text-muted-foreground/80">类型</p>
|
||
<Badge variant="secondary">{getCategoryLabel(selectedWork)}</Badge>
|
||
</div>
|
||
{!!selectedWork.params.size && (
|
||
<div>
|
||
<p className="text-xs text-slate-500 light:text-muted-foreground/80">尺寸</p>
|
||
<p className="text-slate-100 light:text-foreground">{String(selectedWork.params.size)}</p>
|
||
</div>
|
||
)}
|
||
{!!selectedWork.params.steps && (
|
||
<div>
|
||
<p className="text-xs text-slate-500 light:text-muted-foreground/80">步数</p>
|
||
<p className="text-slate-100 light:text-foreground">{String(selectedWork.params.steps)}</p>
|
||
</div>
|
||
)}
|
||
{!!selectedWork.params.cfg_scale && (
|
||
<div>
|
||
<p className="text-xs text-slate-500 light:text-muted-foreground/80">引导系数</p>
|
||
<p className="text-slate-100 light:text-foreground">{String(selectedWork.params.cfg_scale)}</p>
|
||
</div>
|
||
)}
|
||
{!!selectedWork.params.seed && (
|
||
<div>
|
||
<p className="text-xs text-slate-500 light:text-muted-foreground/80">种子</p>
|
||
<p className="text-slate-100 light:text-foreground">{String(selectedWork.params.seed)}</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex items-center justify-between gap-3 border-t border-white/[0.07] light:border-amber-900/14 pt-4">
|
||
<Button
|
||
size="sm"
|
||
variant={likedIds.has(selectedWork.id) ? 'default' : 'outline'}
|
||
className="h-9 min-w-[92px] gap-1.5 px-3 text-sm font-semibold"
|
||
onClick={() => toggleLike(selectedWork.id)}
|
||
>
|
||
<Heart className={`h-3.5 w-3.5 ${likedIds.has(selectedWork.id) ? 'fill-current' : ''}`} />
|
||
{selectedWork.likes + (likedIds.has(selectedWork.id) ? 1 : 0)}
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
className="h-9 min-w-[112px] gap-1.5 px-3 text-sm font-semibold"
|
||
onClick={() => handleDownload(selectedWork.url, `miaojing-${selectedWork.id}.png`)}
|
||
>
|
||
<Download className="h-3.5 w-3.5" />
|
||
下载图片
|
||
</Button>
|
||
{isAdmin && apiWorkIds.has(selectedWork.id) && (
|
||
<Button
|
||
size="sm"
|
||
variant="destructive"
|
||
className="h-9 min-w-[92px] gap-1.5 px-3 text-sm font-semibold"
|
||
onClick={(e) => handleDeleteGalleryWorks([selectedWork.id], e)}
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
删除
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Fullscreen image preview overlay */}
|
||
<FullscreenPreview
|
||
src={fullscreenSrc || ''}
|
||
alt="全屏预览"
|
||
open={!!fullscreenSrc}
|
||
onClose={() => setFullscreenSrc(null)}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
|