298 lines
12 KiB
TypeScript
298 lines
12 KiB
TypeScript
'use client';
|
||
|
||
import { useEffect, useState } from 'react';
|
||
import { createPortal } from 'react-dom';
|
||
import { PACK_LABELS, PACK_ORDER, PACK_TEMPLATES } from '@/lib/templates';
|
||
import type { GenSession, PackKind } from '@/lib/types';
|
||
import ResultGrid from './ResultGrid';
|
||
|
||
export type ProjectGalleryDrawerProps = {
|
||
session: GenSession;
|
||
activeNav: string;
|
||
onAction: (imageId: string, action: 'select' | 'reject' | 'reset') => void;
|
||
};
|
||
|
||
type GalleryItem = {
|
||
id: string;
|
||
url: string;
|
||
title: string;
|
||
description?: string;
|
||
status?: string;
|
||
aspectRatio?: string;
|
||
mediaType?: 'image' | 'video';
|
||
downloadName?: string;
|
||
};
|
||
|
||
type GalleryPanel = {
|
||
mode: 'pack' | 'empty' | 'project' | 'video';
|
||
kind?: PackKind;
|
||
label: string;
|
||
description: string;
|
||
total: number;
|
||
images: GalleryItem[];
|
||
};
|
||
|
||
function statusClass(status?: string) {
|
||
if (status === 'selected') return 'ring-[#e6f578]/80 shadow-[0_0_24px_-12px_rgba(230,245,120,0.9)]';
|
||
if (status === 'rejected') return 'opacity-45 grayscale ring-white/10';
|
||
return 'ring-white/12 hover:ring-white/28';
|
||
}
|
||
|
||
function aspectCss(aspectRatio?: string) {
|
||
if (!aspectRatio) return '1 / 1';
|
||
if (aspectRatio === 'long') return '1 / 3';
|
||
return aspectRatio.replace(':', ' / ');
|
||
}
|
||
|
||
function activeKindFromNav(activeNav: string): PackKind | null {
|
||
const kind = PACK_ORDER.find(item => activeNav === `pack-${item}`);
|
||
return kind ?? null;
|
||
}
|
||
|
||
function galleryForPanel(session: GenSession, activeNav: string): GalleryPanel {
|
||
const kind = activeKindFromNav(activeNav);
|
||
const selectedImages = session.images.filter(image => image.status === 'selected');
|
||
const primaryImage = selectedImages[0] ?? session.images[0] ?? null;
|
||
|
||
if (kind) {
|
||
const pack = session.packs?.find(item => item.kind === kind && (!primaryImage || item.sourceImageId === primaryImage.id))
|
||
?? session.packs?.find(item => item.kind === kind);
|
||
const images: GalleryItem[] = pack?.assets.map(asset => ({
|
||
id: asset.id,
|
||
url: asset.url,
|
||
title: asset.title,
|
||
description: asset.description,
|
||
status: asset.status,
|
||
aspectRatio: asset.aspectRatio,
|
||
})) ?? [];
|
||
return {
|
||
mode: 'pack' as const,
|
||
kind,
|
||
label: PACK_LABELS[kind],
|
||
description: `${PACK_LABELS[kind]}当前区块图片`,
|
||
total: PACK_TEMPLATES[kind].length,
|
||
images,
|
||
};
|
||
}
|
||
|
||
if (activeNav === 'pack-text') {
|
||
return { mode: 'empty' as const, label: '文字', description: '文字区块暂无图片素材', total: 0, images: [] as GalleryItem[] };
|
||
}
|
||
if (activeNav === 'pack-video') {
|
||
const videos: GalleryItem[] = (session.videoTasks ?? [])
|
||
.filter(task => !/_part[12]$/.test(task.templateId))
|
||
.filter(task => task.videoUrl)
|
||
.map(task => ({
|
||
id: task.templateId,
|
||
url: task.videoUrl!,
|
||
title: task.title,
|
||
description: `${task.ratio} · ${task.duration}s · ${task.status}`,
|
||
status: task.status,
|
||
aspectRatio: task.ratio,
|
||
mediaType: 'video' as const,
|
||
downloadName: `${session.id}_${task.templateId}.mp4`,
|
||
}));
|
||
return {
|
||
mode: 'video' as const,
|
||
label: '视频',
|
||
description: '当前项目所有视频成片',
|
||
total: videos.length,
|
||
images: videos,
|
||
};
|
||
}
|
||
|
||
return {
|
||
mode: 'project',
|
||
label: '项目',
|
||
description: session.characterSpec?.name || session.prompt || '当前项目',
|
||
total: session.images.length,
|
||
images: session.images.map((image, index) => ({
|
||
id: image.id,
|
||
url: image.url,
|
||
title: `主图库 ${index + 1}`,
|
||
description: undefined,
|
||
status: image.status,
|
||
aspectRatio: '1:1',
|
||
})),
|
||
};
|
||
}
|
||
|
||
export default function ProjectGalleryDrawer({ session, activeNav, onAction }: ProjectGalleryDrawerProps) {
|
||
const [open, setOpen] = useState(false);
|
||
const [mounted, setMounted] = useState(false);
|
||
const [preview, setPreview] = useState<GalleryItem | null>(null);
|
||
const gallery = galleryForPanel(session, activeNav);
|
||
const downloadHref = gallery.kind && gallery.images.length
|
||
? `/api/packs/download?sessionId=${encodeURIComponent(session.id)}&kind=${encodeURIComponent(gallery.kind)}`
|
||
: null;
|
||
|
||
useEffect(() => {
|
||
setMounted(true);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
function handleKey(event: KeyboardEvent) {
|
||
if (event.key === 'Escape') setOpen(false);
|
||
}
|
||
window.addEventListener('keydown', handleKey);
|
||
return () => window.removeEventListener('keydown', handleKey);
|
||
}, [open]);
|
||
|
||
const drawer = (
|
||
<>
|
||
<div className={`gallery-backdrop ${open ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0'}`} onClick={() => setOpen(false)} />
|
||
<aside className={`gallery-drawer ${open ? 'is-open' : ''}`} aria-hidden={!open}>
|
||
<div className="flex shrink-0 items-start justify-between gap-4 border-b border-white/10 p-5">
|
||
<div className="min-w-0">
|
||
<span className="section-eyebrow">Project Gallery</span>
|
||
<h2 className="mt-2 text-lg font-semibold text-white">{gallery.label}图库</h2>
|
||
<p className="mt-1 line-clamp-2 text-xs leading-relaxed text-white/42">
|
||
{gallery.description}
|
||
</p>
|
||
</div>
|
||
<button
|
||
onClick={() => setOpen(false)}
|
||
tabIndex={open ? 0 : -1}
|
||
className="grid h-10 w-10 shrink-0 cursor-pointer place-items-center rounded-[8px] bg-white/[0.06] text-white/60 ring-1 ring-white/10 transition-colors hover:bg-white/[0.10] hover:text-white"
|
||
title="关闭图库"
|
||
aria-label="关闭图库"
|
||
>
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" aria-hidden="true">
|
||
<path d="M6 6l12 12M18 6 6 18" strokeLinecap="round" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<div className="min-h-0 flex-1 overflow-y-auto p-5">
|
||
{open && gallery.mode === 'project' ? (
|
||
<ResultGrid images={session.images} onAction={onAction} />
|
||
) : (
|
||
<div className="gallery-drawer-grid">
|
||
{gallery.images.map((image, index) => (
|
||
<div key={image.id} className="gallery-drawer-card">
|
||
<div className="gallery-drawer-card__image" style={{ aspectRatio: aspectCss(image.aspectRatio) }}>
|
||
{image.mediaType === 'video' ? (
|
||
<video src={image.url} controls muted playsInline preload="metadata" />
|
||
) : (
|
||
<img src={image.url} alt={image.title} />
|
||
)}
|
||
</div>
|
||
<div className="mt-3 min-w-0">
|
||
<div className="flex min-w-0 items-center gap-2">
|
||
<span className="truncate text-[12px] font-semibold text-white">{image.title}</span>
|
||
<span className="shrink-0 font-mono text-[10px] text-[#dff5a8]">{index + 1}</span>
|
||
</div>
|
||
{image.description && (
|
||
<p className="mt-1 line-clamp-2 text-[10px] leading-relaxed text-white/42">{image.description}</p>
|
||
)}
|
||
{image.mediaType === 'video' && (
|
||
<a href={image.url} download={image.downloadName} className="mt-2 inline-flex text-[10px] font-semibold text-[#e6f578] hover:text-white">
|
||
下载视频
|
||
</a>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
{!gallery.images.length && (
|
||
<div className="gallery-empty-state">当前区块还没有可显示的图片。</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</aside>
|
||
</>
|
||
);
|
||
|
||
const centerPreview = preview ? (
|
||
<div className="gallery-center-preview" aria-hidden="true">
|
||
{preview.mediaType === 'video' ? (
|
||
<video src={preview.url} muted autoPlay loop playsInline />
|
||
) : (
|
||
<img src={preview.url} alt="" />
|
||
)}
|
||
</div>
|
||
) : null;
|
||
|
||
return (
|
||
<>
|
||
<aside className="gallery-rail flex h-full min-h-0 flex-col items-center gap-3 overflow-hidden rounded-[8px] border border-white/10 bg-white/[0.045] p-2 backdrop-blur-xl">
|
||
<button
|
||
onClick={() => setOpen(true)}
|
||
className="flex h-12 w-full cursor-pointer flex-col items-center justify-center rounded-[8px] bg-[#e6f578] text-[#081006] transition-colors hover:bg-[#f0fb82]"
|
||
title={`打开${gallery.label}图库`}
|
||
aria-label={`打开${gallery.label}图库`}
|
||
>
|
||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" aria-hidden="true">
|
||
<rect x="3" y="5" width="18" height="14" rx="2" />
|
||
<path d="m7 14 3-3 3 3 2-2 2 2" strokeLinecap="round" strokeLinejoin="round" />
|
||
<circle cx="8" cy="9" r="1.2" fill="currentColor" stroke="none" />
|
||
</svg>
|
||
<span className="mt-0.5 text-[9px] font-semibold">图库</span>
|
||
</button>
|
||
|
||
{downloadHref && (
|
||
<a
|
||
href={downloadHref}
|
||
className="gallery-download-button"
|
||
title={`打包下载${gallery.label}图片`}
|
||
aria-label={`打包下载${gallery.label}图片`}
|
||
>
|
||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" aria-hidden="true">
|
||
<path d="M12 4v10" strokeLinecap="round" />
|
||
<path d="m8 10 4 4 4-4" strokeLinecap="round" strokeLinejoin="round" />
|
||
<path d="M5 19h14" strokeLinecap="round" />
|
||
</svg>
|
||
<span>下载</span>
|
||
</a>
|
||
)}
|
||
|
||
<div className="w-full rounded-[8px] bg-black/22 px-1.5 py-2 text-center text-[10px] text-white/42">
|
||
<b className="block text-[13px] text-[#e6f578]">{gallery.images.length}</b>
|
||
<span>/ {gallery.total}</span>
|
||
</div>
|
||
|
||
<div className="gallery-thumb-list flex min-h-0 w-full flex-1 flex-col gap-2 overflow-y-auto pr-0.5">
|
||
{gallery.images.map((image, index) => (
|
||
<button
|
||
key={image.id}
|
||
onClick={() => setOpen(true)}
|
||
onMouseEnter={() => setPreview(image)}
|
||
onMouseLeave={() => setPreview(null)}
|
||
onFocus={() => setPreview(image)}
|
||
onBlur={() => setPreview(null)}
|
||
className={`gallery-rail-thumb relative w-full cursor-pointer overflow-hidden rounded-[8px] bg-white ring-1 transition-all ${statusClass(image.status)}`}
|
||
style={{ aspectRatio: aspectCss(image.aspectRatio) }}
|
||
title={`查看${image.title}`}
|
||
aria-label={`查看${image.title}`}
|
||
>
|
||
{image.mediaType === 'video' ? (
|
||
<video src={image.url} muted playsInline preload="metadata" className="h-full w-full object-contain" />
|
||
) : (
|
||
<img src={image.url} alt="" className="h-full w-full object-contain" />
|
||
)}
|
||
<span className="absolute left-1 top-1 rounded-[6px] bg-black/60 px-1.5 py-0.5 text-[9px] font-semibold text-white">
|
||
{index + 1}
|
||
</span>
|
||
{image.mediaType === 'video' && (
|
||
<span className="absolute right-1 top-1 rounded-[6px] bg-black/70 px-1.5 py-0.5 text-[8px] font-semibold text-[#e6f578]">▶</span>
|
||
)}
|
||
{image.status === 'selected' && (
|
||
<span className="absolute right-1 top-1 grid h-5 w-5 place-items-center rounded-full bg-[#e6f578] text-[10px] font-bold text-[#081006]">✓</span>
|
||
)}
|
||
{image.status === 'rejected' && (
|
||
<span className="absolute right-1 top-1 grid h-5 w-5 place-items-center rounded-full bg-black/65 text-[10px] font-bold text-white">×</span>
|
||
)}
|
||
</button>
|
||
))}
|
||
{!gallery.images.length && (
|
||
<div className="gallery-rail-empty">暂无图片</div>
|
||
)}
|
||
</div>
|
||
</aside>
|
||
|
||
{mounted ? createPortal(drawer, document.body) : null}
|
||
{mounted && centerPreview ? createPortal(centerPreview, document.body) : null}
|
||
</>
|
||
);
|
||
}
|