Files
ai-toy-patent-workflow/src/components/ProjectGalleryDrawer.tsx
2026-05-21 02:09:03 +08:00

298 lines
12 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 { 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}
</>
);
}