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
360 lines
14 KiB
JavaScript
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" })] })] }));
|
|
}
|