Files
miaojingAI/src/app/gallery/page.tsx

911 lines
41 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.
'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>
);
}