ui-dev-content/dist/components/ImageSessionBrowser.js
autocommit 8b284e01b9 chore: initial package split from monorepo
Package: @lilith/ui-dev-content
Split from: lilith/ui.git or lilith/build.git
Publish workflow: calls lilith/workflows/.forgejo/workflows/publish-npm.yml@main
2026-04-20 01:11:45 -07:00

360 lines
14 KiB
JavaScript

import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
/**
* ImageSessionBrowser - Browse and manage image generation sessions
*
* Provides:
* - Gallery view of all sessions for a content+layout
* - Previous/Next navigation between sessions
* - Thumbnail strip showing all variants
* - Regenerate button to create new session
* - Activate button to deploy a variant
*
* Integration:
* - Calls feature-owned session API (e.g., marketplace)
* - Uses ImageRegenerationTransformer for API communication
*/
import { useState, useEffect, useCallback } from 'react';
import { Modal, ModalActions, useToast } from '@lilith/ui-feedback';
import { Button, Spinner } from '@lilith/ui-primitives';
import { ChevronLeftIcon, ChevronRightIcon, RefreshCwIcon, CheckIcon, ImageIcon, } from '@lilith/ui-icons';
import { ZINDEX_LAYERS } from '@lilith/ui-zname';
import styled from '@lilith/ui-styled-components';
// ============================================================================
// Styled Components
// ============================================================================
const BrowserContainer = styled.div `
display: flex;
flex-direction: column;
gap: 16px;
min-height: 400px;
`;
const MainImageContainer = styled.div `
position: relative;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.3);
border-radius: 8px;
min-height: 300px;
overflow: hidden;
`;
const MainImage = styled.img `
max-width: 100%;
max-height: 400px;
object-fit: contain;
border-radius: 4px;
`;
const NavigationButton = styled.button `
position: absolute;
${props => props.$position}: 8px;
top: 50%;
transform: translateY(-50%);
width: 40px;
height: 40px;
border-radius: 50%;
border: none;
background: rgba(0, 0, 0, 0.7);
color: #fff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
&:hover:not(:disabled) {
background: #0af;
color: #000;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`;
const SessionInfo = styled.div `
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background: rgba(0, 0, 0, 0.5);
border-radius: 4px;
font-size: 0.875rem;
`;
const SessionCounter = styled.span `
color: rgba(255, 255, 255, 0.7);
`;
const ActiveBadge = styled.span `
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background: #22c55e;
color: #000;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
`;
const QualityScore = styled.span `
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background: ${props => {
if (props.$score >= 80)
return '#22c55e';
if (props.$score >= 60)
return '#f59e0b';
return '#ef4444';
}};
color: #fff;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
`;
const ThumbnailStrip = styled.div `
display: flex;
gap: 8px;
padding: 8px;
overflow-x: auto;
background: rgba(0, 0, 0, 0.3);
border-radius: 4px;
&::-webkit-scrollbar {
height: 6px;
}
&::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
}
&::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 3px;
}
`;
const Thumbnail = styled.button `
flex-shrink: 0;
width: 80px;
height: 60px;
border: 2px solid ${props => {
if (props.$isSelected)
return '#0af';
if (props.$isActive)
return '#22c55e';
return 'transparent';
}};
border-radius: 4px;
overflow: hidden;
cursor: pointer;
padding: 0;
background: rgba(0, 0, 0, 0.5);
position: relative;
transition: border-color 0.2s ease;
&:hover {
border-color: #0af;
}
img {
width: 100%;
height: 100%;
object-fit: cover;
}
`;
const ThumbnailActiveBadge = styled.div `
position: absolute;
top: 2px;
right: 2px;
width: 16px;
height: 16px;
background: #22c55e;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
svg {
width: 10px;
height: 10px;
}
`;
const LoadingOverlay = styled.div `
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
background: rgba(0, 0, 0, 0.8);
border-radius: 8px;
`;
const LoadingText = styled.span `
color: rgba(255, 255, 255, 0.7);
font-size: 0.875rem;
`;
const EmptyState = styled.div `
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
padding: 32px;
color: rgba(255, 255, 255, 0.5);
`;
const SessionDetails = styled.div `
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px;
background: rgba(0, 0, 0, 0.5);
border-radius: 4px;
font-size: 0.8125rem;
`;
const DetailRow = styled.div `
display: flex;
justify-content: space-between;
align-items: center;
span:first-child {
color: rgba(255, 255, 255, 0.5);
}
span:last-child {
color: #fff;
}
`;
// ============================================================================
// Component
// ============================================================================
export function ImageSessionBrowser({ isOpen, onClose, handle, transformer, onImageChange, }) {
const { showToast } = useToast();
const [sessions, setSessions] = useState([]);
const [selectedIndex, setSelectedIndex] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [isRegenerating, setIsRegenerating] = useState(false);
const [isActivating, setIsActivating] = useState(false);
// Parse content identifier
const parseIdentifier = useCallback(() => {
const parts = handle.identifier.split(':');
if (parts.length < 3) {
return null;
}
return { contentId: parts[1], layout: parts[2] };
}, [handle.identifier]);
// Load sessions
const loadSessions = useCallback(async () => {
const parsed = parseIdentifier();
if (!parsed) {
showToast('Invalid image identifier format', 'error');
return;
}
setIsLoading(true);
try {
const fetchedSessions = await transformer.getSessions(parsed.contentId, parsed.layout);
setSessions(fetchedSessions);
// Select the active session by default
const activeIndex = fetchedSessions.findIndex(s => s.isActive);
if (activeIndex >= 0) {
setSelectedIndex(activeIndex);
}
else if (fetchedSessions.length > 0) {
setSelectedIndex(0);
}
}
catch (error) {
showToast(`Failed to load sessions: ${error.message}`, 'error');
}
finally {
setIsLoading(false);
}
}, [parseIdentifier, transformer, showToast]);
// Load sessions when modal opens
useEffect(() => {
if (isOpen) {
loadSessions();
}
}, [isOpen, loadSessions]);
// Navigation
const goToPrevious = useCallback(() => {
setSelectedIndex(prev => Math.max(0, prev - 1));
}, []);
const goToNext = useCallback(() => {
setSelectedIndex(prev => Math.min(sessions.length - 1, prev + 1));
}, [sessions.length]);
// Regenerate new session
const handleRegenerate = useCallback(async () => {
const parsed = parseIdentifier();
if (!parsed) {
showToast('Invalid image identifier format', 'error');
return;
}
setIsRegenerating(true);
showToast('Generating new image...', 'loading');
try {
// Call the transformer's transform method which creates a new session
const result = await transformer.transform(JSON.stringify({ contentId: parsed.contentId, layout: parsed.layout }), { handle, metadata: {} });
if (result.success && result.metadata) {
showToast('New image generated!', 'success');
// Reload sessions and select the new one
await loadSessions();
// New session is typically at index 0 (most recent)
setSelectedIndex(0);
}
else {
showToast(result.error || 'Generation failed', 'error');
}
}
catch (error) {
showToast(`Generation error: ${error.message}`, 'error');
}
finally {
setIsRegenerating(false);
}
}, [parseIdentifier, transformer, handle, loadSessions, showToast]);
// Activate session
const handleActivate = useCallback(async () => {
const currentSession = sessions[selectedIndex];
if (!currentSession)
return;
setIsActivating(true);
showToast('Activating image...', 'loading');
try {
await transformer.activateSession(currentSession.id);
showToast('Image activated!', 'success');
// Update local state
setSessions(prev => prev.map(s => ({
...s,
isActive: s.id === currentSession.id,
})));
// Notify parent to update the displayed image
await onImageChange(currentSession.imageUrl);
}
catch (error) {
showToast(`Activation failed: ${error.message}`, 'error');
}
finally {
setIsActivating(false);
}
}, [sessions, selectedIndex, transformer, onImageChange, showToast]);
// Apply selected image (hot swap without activation)
const handleApply = useCallback(async () => {
const currentSession = sessions[selectedIndex];
if (!currentSession)
return;
try {
await onImageChange(currentSession.imageUrl);
showToast('Image applied to preview', 'success');
onClose();
}
catch (error) {
showToast(`Failed to apply image: ${error.message}`, 'error');
}
}, [sessions, selectedIndex, onImageChange, onClose, showToast]);
const currentSession = sessions[selectedIndex];
const isCurrentActive = currentSession?.isActive ?? false;
return (_jsxs(Modal, { "data-testid": "image-session-browser", isOpen: isOpen, onClose: onClose, title: "Image Session Browser", maxWidth: "900px", zIndex: ZINDEX_LAYERS['high-priority'], children: [_jsxs(BrowserContainer, { children: [_jsx(MainImageContainer, { children: isLoading ? (_jsxs(LoadingOverlay, { children: [_jsx(Spinner, { size: "lg" }), _jsx(LoadingText, { children: "Loading sessions..." })] })) : isRegenerating ? (_jsxs(LoadingOverlay, { children: [_jsx(Spinner, { size: "lg" }), _jsx(LoadingText, { children: "Generating new image..." })] })) : sessions.length === 0 ? (_jsxs(EmptyState, { children: [_jsx(ImageIcon, { size: 48 }), _jsx("p", { children: "No sessions yet. Click \"Regenerate\" to create one." })] })) : currentSession ? (_jsxs(_Fragment, { children: [_jsx(NavigationButton, { "$position": "left", onClick: goToPrevious, disabled: selectedIndex === 0, "aria-label": "Previous image", children: _jsx(ChevronLeftIcon, { size: 24 }) }), _jsx(MainImage, { src: currentSession.imageUrl, alt: `Session ${currentSession.id}` }, currentSession.id), _jsx(NavigationButton, { "$position": "right", onClick: goToNext, disabled: selectedIndex === sessions.length - 1, "aria-label": "Next image", children: _jsx(ChevronRightIcon, { size: 24 }) })] })) : null }), currentSession && (_jsxs(SessionInfo, { children: [_jsxs(SessionCounter, { children: [selectedIndex + 1, " of ", sessions.length] }), _jsxs("div", { style: { display: 'flex', gap: '8px', alignItems: 'center' }, children: [currentSession.qualityScore != null && (_jsxs(QualityScore, { "$score": currentSession.qualityScore, children: ["Score: ", currentSession.qualityScore, "%"] })), isCurrentActive && (_jsxs(ActiveBadge, { children: [_jsx(CheckIcon, { size: 12 }), "Active"] }))] })] })), currentSession && (_jsxs(SessionDetails, { children: [_jsxs(DetailRow, { children: [_jsx("span", { children: "Created" }), _jsx("span", { children: new Date(currentSession.createdAt).toLocaleString() })] }), _jsxs(DetailRow, { children: [_jsx("span", { children: "Dimensions" }), _jsxs("span", { children: [currentSession.width, " \u00D7 ", currentSession.height] })] }), currentSession.prompt && (_jsxs(DetailRow, { children: [_jsx("span", { children: "Prompt" }), _jsx("span", { style: { maxWidth: '300px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, children: currentSession.prompt })] }))] })), sessions.length > 1 && (_jsx(ThumbnailStrip, { children: sessions.map((session, index) => (_jsxs(Thumbnail, { "$isSelected": index === selectedIndex, "$isActive": session.isActive, onClick: () => setSelectedIndex(index), "aria-label": `Select session ${index + 1}${session.isActive ? ' (active)' : ''}`, children: [_jsx("img", { src: session.imageUrl, alt: `Thumbnail ${index + 1}` }), session.isActive && (_jsx(ThumbnailActiveBadge, { children: _jsx(CheckIcon, {}) }))] }, session.id))) }))] }), _jsxs(ModalActions, { children: [_jsx(Button, { "data-testid": "close-browser", variant: "secondary", onClick: onClose, disabled: isRegenerating || isActivating, children: "Close" }), _jsxs(Button, { "data-testid": "regenerate-image", variant: "secondary", onClick: handleRegenerate, disabled: isRegenerating || isActivating, children: [_jsx(RefreshCwIcon, { size: 16, style: { marginRight: '4px' } }), isRegenerating ? 'Generating...' : 'Regenerate'] }), currentSession && !isCurrentActive && (_jsxs(Button, { "data-testid": "activate-session", variant: "secondary", onClick: handleActivate, disabled: isRegenerating || isActivating, children: [_jsx(CheckIcon, { size: 16, style: { marginRight: '4px' } }), isActivating ? 'Activating...' : 'Set as Active'] })), _jsx(Button, { "data-testid": "apply-image", variant: "primary", onClick: handleApply, disabled: !currentSession || isRegenerating || isActivating, children: "Apply to Preview" })] })] }));
}