'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; 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([]); const [loading, setLoading] = useState(true); const [syncStatus, setSyncStatus] = useState<'idle' | 'syncing' | 'done'>('idle'); const [category, setCategory] = useState('all'); const [likedIds, setLikedIds] = useState>(new Set()); const [selectedWork, setSelectedWork] = useState(null); const [fullscreenSrc, setFullscreenSrc] = useState(null); const [sortBy, setSortBy] = useState<'newest' | 'popular'>('newest'); const [searchQuery, setSearchQuery] = useState(''); const [masonryColumnCount, setMasonryColumnCount] = useState(4); const [measuredMediaSizes, setMeasuredMediaSizes] = useState>({}); const [selectedGalleryIds, setSelectedGalleryIds] = useState>(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) => { 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((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 (
{/* Header */}

作品画廊

{syncStatus === 'syncing' && ( 同步本地数据... )}

探索社区创作,发现灵感之美

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 && ( )}
{/* Filters */}
{CATEGORIES.map((cat) => { const Icon = cat.icon; return ( ); })}
{isAdmin && selectedGalleryIds.size > 0 && ( )}
{/* Gallery Grid */} {loading ? (

加载中...

) : filteredWorks.length === 0 ? (

暂无作品

创作并发布你的作品,让大家一起欣赏

) : (
{masonryColumns.map((columnWorks, columnIndex) => (
{columnWorks.map((work) => ( setSelectedWork(work)} >
{(work.thumbnailUrl || (work.url && !work.url.startsWith('data:'))) ? ( {(work.prompt handleCardImageLoad(work.id, e)} onDoubleClick={(e) => { e.stopPropagation(); setFullscreenSrc(work.url); }} /> ) : (
)} {isAdmin && apiWorkIds.has(work.id) && ( )} {(work.type === 'video' || work.type === 'text2video' || work.type === 'img2video') && ( 视频 )} {getCategoryLabel(work)} {/* Hover overlay */}

{work.prompt}

{isAdmin && apiWorkIds.has(work.id) && ( )}
{work.publisherAvatarUrl ? ( {work.publisherNickname} ) : ( getAvatarText(work.publisherNickname) )}
{work.publisherNickname}
{work.likes + (likedIds.has(work.id) ? 1 : 0)}

{work.prompt}

))}
))}
)}
{/* Detail - Fullscreen Overlay */} {selectedWork && (
{ if (e.target === e.currentTarget) setSelectedWork(null); }} >
{selectedWork.url && !selectedWork.url.startsWith('data:') && ( <>
)} {/* Left: Image/Video */}
{selectedWork.type === 'video' || selectedWork.type === 'text2video' || selectedWork.type === 'img2video' ? (
{/* Right: Info Panel */}
{/* Close header */}

作品详情

{/* Publisher info */}
{selectedWork.publisherAvatarUrl ? ( {selectedWork.publisherNickname} ) : ( getAvatarText(selectedWork.publisherNickname) )}

{selectedWork.publisherNickname}

{formatDate(selectedWork.publishedAt)}

{/* Prompt */} {(selectedWork.prompt || selectedWork.negativePrompt) && (
{selectedWork.prompt && (

提示词

{selectedWork.prompt}

)} {selectedWork.negativePrompt && (

负面提示词

{selectedWork.negativePrompt}

)}
)} {/* Reference Image */} {selectedReferenceImages.length > 0 && (

参考图

{selectedReferenceImages.length} 张
{selectedReferenceImages.map((url, index) => (
{`参考图 setFullscreenSrc(url)} />
))}
)}
{/* Model & Params */} {selectedWork.params && Object.keys(selectedWork.params).length > 0 && (

模型与参数

{(!!selectedWork.params.modelLabel || !!selectedWork.params.model) && (

模型

{String(selectedWork.params.modelLabel || selectedWork.params.model || '')}

)}

类型

{getCategoryLabel(selectedWork)}
{!!selectedWork.params.size && (

尺寸

{String(selectedWork.params.size)}

)} {!!selectedWork.params.steps && (

步数

{String(selectedWork.params.steps)}

)} {!!selectedWork.params.cfg_scale && (

引导系数

{String(selectedWork.params.cfg_scale)}

)} {!!selectedWork.params.seed && (

种子

{String(selectedWork.params.seed)}

)}
)}
{isAdmin && apiWorkIds.has(selectedWork.id) && ( )}
)} {/* Fullscreen image preview overlay */} setFullscreenSrc(null)} />
); }