Initial miaojingAI project with image resolution guard

This commit is contained in:
FengLee
2026-05-09 11:32:34 +08:00
commit d499020d4e
264 changed files with 54160 additions and 0 deletions

910
src/app/gallery/page.tsx Normal file
View File

@@ -0,0 +1,910 @@
'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>
);
}