visual-journal / src /components /image-output.tsx
Visual Journal deploy
Deploy 5554830 to Docker Space
e4e0afe
Raw
History Blame Contribute Delete
42.3 kB
'use client';
import { ImageCompareView, resolveCompareTargetIndex } from '@/components/image-compare-view';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog';
import { useI18n } from '@/lib/i18n';
import { getActivityLogLevelLabel } from '@/lib/image-display-labels';
import { buildLogScopeDiagnostics, filterLogsByScope, resolveLogClientRequestIds } from '@/lib/log-filter';
import { cn } from '@/lib/utils';
import {
Copy,
Download,
GitCompare,
Grid,
Loader2,
RefreshCcw,
Send,
Share2,
Activity,
Trash2,
ImageIcon
} from 'lucide-react';
import Image from 'next/image';
import * as React from 'react';
type ImageInfo = {
path: string;
filename: string;
clientRequestId?: string;
storageMode?: 'fs' | 'indexeddb';
};
type ImageActionTarget = {
filename: string;
storageMode?: ImageInfo['storageMode'];
};
type ImageOutputProps = {
imageBatch: ImageInfo[] | null;
viewMode: 'grid' | number;
onViewChange: (view: 'grid' | number) => void;
altText?: string;
isLoading: boolean;
onSendToEdit: (filename: string, storageMode?: ImageInfo['storageMode']) => void;
onDownloadImage: (filename: string, storageMode?: ImageInfo['storageMode']) => void;
onShareImage: (filename: string, storageMode?: ImageInfo['storageMode']) => void;
onCreateVariant: () => void;
onReusePrompt: () => void;
failureMessage?: string | null;
onRetry?: () => void;
compareImage?: ImageInfo | null;
compareImageLabel?: string;
canCreateVariant: boolean;
canReusePrompt: boolean;
currentMode: 'generate' | 'edit';
baseImagePreviewUrl: string | null;
streamingPreviewImages?: Map<number, string>;
isStreamingRequest?: boolean;
clientPasswordHash: string | null;
canOpenLogs: boolean;
openLogsSignal?: number;
logClientRequestIds?: string[];
logFilenames?: string[];
};
type LogEntry = {
id: number;
at: string;
level: 'debug' | 'info' | 'warn' | 'error';
message: string;
context?: string;
clientRequestId?: string;
filenames?: string[];
};
type ImageDimensions = {
width: number;
height: number;
};
export function buildImageActionTarget(image: ImageInfo | null): ImageActionTarget | null {
if (!image) return null;
return {
filename: image.filename,
...(image.storageMode ? { storageMode: image.storageMode } : {})
};
}
export function formatLogTime(value: string, locale: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleTimeString(locale);
}
function formatLogScopeValues(values: string[]): string {
return values.length > 0 ? values.join(', ') : '-';
}
function readImageDimensionsFromSource(source: string): Promise<ImageDimensions | null> {
return new Promise((resolve) => {
const image = new window.Image();
image.onload = () => {
if (!image.naturalWidth || !image.naturalHeight) {
resolve(null);
return;
}
resolve({
width: image.naturalWidth,
height: image.naturalHeight
});
};
image.onerror = () => resolve(null);
image.src = source;
});
}
const getGridColsClass = (count: number): string => {
if (count <= 1) return 'grid-cols-1';
if (count <= 4) return 'grid-cols-2';
if (count <= 9) return 'grid-cols-3';
return 'grid-cols-3';
};
type ResultActionButtonProps = {
icon: React.ReactNode;
label: string;
onClick?: () => void;
disabled?: boolean;
active?: boolean;
emphasized?: boolean;
iconOnly?: boolean;
};
function ResultActionButton({
icon,
label,
onClick,
disabled = false,
active = false,
emphasized = false,
iconOnly = false
}: ResultActionButtonProps) {
return (
<Button
variant='ghost'
size='sm'
onClick={onClick}
disabled={disabled}
className={cn(
'touch-target text-muted-foreground hover:text-foreground shrink-0 rounded-md px-3 text-xs transition-[background-color,color,box-shadow,transform] enabled:active:scale-[0.96] disabled:opacity-50 lg:px-2',
active && 'bg-accent text-accent-foreground hover:text-accent-foreground',
emphasized && 'border-border/70 bg-card/80 border'
)}
aria-label={iconOnly ? label : undefined}>
{icon}
{!iconOnly && <span>{label}</span>}
</Button>
);
}
export function ImageOutput({
imageBatch,
viewMode,
onViewChange,
altText,
isLoading,
onSendToEdit,
onDownloadImage,
onShareImage,
onCreateVariant,
onReusePrompt,
failureMessage = null,
onRetry,
compareImage = null,
compareImageLabel,
canCreateVariant,
canReusePrompt,
currentMode,
baseImagePreviewUrl,
streamingPreviewImages,
isStreamingRequest = false,
clientPasswordHash,
canOpenLogs,
openLogsSignal,
logClientRequestIds = [],
logFilenames = []
}: ImageOutputProps) {
const { locale, t } = useI18n();
const resolvedAltText = altText ?? t('output.alt');
const [isLogDialogOpen, setIsLogDialogOpen] = React.useState(false);
const [logs, setLogs] = React.useState<LogEntry[]>([]);
const [logConnectionState, setLogConnectionState] = React.useState<'idle' | 'connected' | 'error'>('idle');
const [copiedLogScopeText, setCopiedLogScopeText] = React.useState<string | null>(null);
const [compareSelection, setCompareSelection] = React.useState<{
imageBatch: ImageInfo[];
selectedImageIndex: number;
compareTargetFilename: string;
} | null>(null);
const [imageDimensions, setImageDimensions] = React.useState<Record<string, ImageDimensions>>({});
const logEndRef = React.useRef<HTMLDivElement | null>(null);
const resolvedLogClientRequestIds = React.useMemo(
() => resolveLogClientRequestIds({ logs, clientRequestIds: logClientRequestIds, filenames: logFilenames }),
[logClientRequestIds, logFilenames, logs]
);
const filteredLogs = React.useMemo(
() => filterLogsByScope({ logs, clientRequestIds: resolvedLogClientRequestIds, filenames: [] }) as LogEntry[],
[logs, resolvedLogClientRequestIds]
);
const logScopeDiagnostics = React.useMemo(
() =>
buildLogScopeDiagnostics({
clientRequestIds: logClientRequestIds,
filenames: logFilenames,
resolvedClientRequestIds: resolvedLogClientRequestIds
}),
[logClientRequestIds, logFilenames, resolvedLogClientRequestIds]
);
const hasSelectedImageBatch = !!imageBatch && imageBatch.length > 0;
const hasLogScope = resolvedLogClientRequestIds.length > 0;
const hasScopeCandidate = logClientRequestIds.length > 0 || logFilenames.length > 0;
const visibleLogs = React.useMemo(() => (hasLogScope ? filteredLogs : []), [filteredLogs, hasLogScope]);
const showCarousel = Boolean(imageBatch && imageBatch.length > 1);
const selectedImageIndex =
imageBatch && imageBatch.length > 0
? typeof viewMode === 'number' && imageBatch[viewMode]
? viewMode
: showCarousel
? null
: 0
: null;
const selectedImage = selectedImageIndex === null ? null : imageBatch?.[selectedImageIndex] || null;
const sameBatchCompareTargetIndex = imageBatch
? resolveCompareTargetIndex(imageBatch.length, selectedImageIndex)
: null;
const sameBatchCompareTargetImage =
sameBatchCompareTargetIndex === null ? null : imageBatch?.[sameBatchCompareTargetIndex] || null;
const compareTargetImage = sameBatchCompareTargetImage || compareImage || null;
const selectedImageDimensions = selectedImage ? imageDimensions[selectedImage.filename] : null;
const hasFailure = !isLoading && !hasSelectedImageBatch && Boolean(failureMessage);
const hasEditReferencePreview = currentMode === 'edit' && Boolean(baseImagePreviewUrl);
const isGenerateEmptyState = !isLoading && !hasFailure && !hasSelectedImageBatch && currentMode === 'generate';
const previewStateLabel = isLoading
? t('output.progressDeveloping')
: hasFailure
? t('output.failedTitle')
: hasEditReferencePreview
? t('output.editReferenceReady')
: currentMode === 'edit'
? t('output.editReferenceNeeded')
: imageBatch && imageBatch.length > 0
? t('output.previewReady')
: t('output.emptyTitle');
const previewMetaItems = [
!isLoading && imageBatch && imageBatch.length > 0
? selectedImageIndex === null
? t('output.selectImageForActions')
: t('output.selectedImageMeta', {
index: selectedImageIndex + 1,
count: imageBatch.length
})
: null,
!isLoading && selectedImageDimensions
? t('output.imageDimensions', {
width: selectedImageDimensions.width,
height: selectedImageDimensions.height
})
: null,
isLoading ? (isStreamingRequest ? t('output.streaming') : t('output.progressGenerating')) : null
].filter((item): item is string => Boolean(item));
const canUseSelectedImageActions = !isLoading && !!selectedImage;
const canCompareImages = !isLoading && !!selectedImage && !!compareTargetImage;
const isCompareView =
!!compareSelection &&
compareSelection.imageBatch === imageBatch &&
compareSelection.selectedImageIndex === selectedImageIndex &&
compareSelection.compareTargetFilename === compareTargetImage?.filename;
const compareReferenceLabel = sameBatchCompareTargetImage
? selectedImageIndex === 0
? t('output.compareOther')
: t('output.compareReference')
: (compareImageLabel ?? t('output.compareReference'));
const handleSendClick = () => {
const target = buildImageActionTarget(selectedImage);
if (!target) return;
onSendToEdit(target.filename, target.storageMode);
};
const handleDownloadClick = () => {
const target = buildImageActionTarget(selectedImage);
if (!target) return;
onDownloadImage(target.filename, target.storageMode);
};
const handleShareClick = () => {
const target = buildImageActionTarget(selectedImage);
if (!target) return;
onShareImage(target.filename, target.storageMode);
};
const handleCompareClick = () => {
if (canCompareImages && imageBatch && selectedImageIndex !== null && compareTargetImage) {
setCompareSelection({ imageBatch, selectedImageIndex, compareTargetFilename: compareTargetImage.filename });
}
};
const handleImageLoad = React.useCallback((filename: string, event: React.SyntheticEvent<HTMLImageElement>) => {
const source = event.currentTarget.currentSrc || event.currentTarget.src;
if (!source) return;
void readImageDimensionsFromSource(source).then((nextDimensions) => {
if (!nextDimensions) return;
setImageDimensions((current) => {
const currentDimensions = current[filename];
if (
currentDimensions?.width === nextDimensions.width &&
currentDimensions.height === nextDimensions.height
) {
return current;
}
return { ...current, [filename]: nextDimensions };
});
});
}, []);
const handleLogDialogOpenChange = (open: boolean) => {
setIsLogDialogOpen(open);
if (!open) {
setLogConnectionState('idle');
setCopiedLogScopeText(null);
}
};
const handleCopyLogScope = async () => {
try {
if (!navigator.clipboard?.writeText) {
throw new Error('Clipboard API unavailable.');
}
await navigator.clipboard.writeText(logScopeDiagnostics.copyText);
setCopiedLogScopeText(logScopeDiagnostics.copyText);
window.setTimeout(() => {
setCopiedLogScopeText((current) => (current === logScopeDiagnostics.copyText ? null : current));
}, 1500);
} catch (error) {
console.error('复制日志诊断范围失败。', error);
}
};
React.useEffect(() => {
if (!isLogDialogOpen || !canOpenLogs) return;
const abortController = new AbortController();
const readLogs = async () => {
try {
const response = await fetch('/api/logs', {
headers: clientPasswordHash ? { Authorization: `Bearer ${clientPasswordHash}` } : {},
signal: abortController.signal
});
if (!response.ok || !response.body) {
setLogConnectionState('error');
return;
}
setLogConnectionState('connected');
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
const processEvent = (rawEvent: string) => {
const dataLines = rawEvent
.split(/\r?\n/)
.filter((line) => line.startsWith('data: '))
.map((line) => line.slice(6));
if (dataLines.length === 0) return;
let entry: LogEntry;
try {
entry = JSON.parse(dataLines.join('\n')) as LogEntry;
} catch (error) {
console.error('解析日志事件失败。', error);
return;
}
setLogs((prevLogs) => {
const nextLogs = prevLogs.some((log) => log.id === entry.id)
? prevLogs
: [...prevLogs, entry].slice(-300);
return nextLogs;
});
};
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const events = buffer.split(/\n\n|\r\n\r\n/);
buffer = events.pop() || '';
events.forEach(processEvent);
}
const remainingEvent = buffer.trim();
if (remainingEvent) {
processEvent(remainingEvent);
}
setLogConnectionState('idle');
} catch (error) {
if (abortController.signal.aborted) return;
console.error('读取日志流失败。', error);
setLogConnectionState('error');
}
};
void readLogs();
return () => {
abortController.abort();
};
}, [canOpenLogs, clientPasswordHash, isLogDialogOpen]);
React.useEffect(() => {
if (!isLogDialogOpen) return;
logEndRef.current?.scrollIntoView({ block: 'end' });
}, [isLogDialogOpen, visibleLogs]);
React.useEffect(() => {
if (!openLogsSignal || !canOpenLogs) return;
queueMicrotask(() => setIsLogDialogOpen(true));
}, [canOpenLogs, openLogsSignal]);
return (
<div
className={cn(
'workbench-panel text-card-foreground border-border flex min-h-[300px] w-full flex-col overflow-hidden rounded-lg border',
isGenerateEmptyState ? 'h-fit xl:h-full' : 'h-full'
)}>
<div className='border-border/70 flex shrink-0 flex-wrap items-center justify-between gap-3 border-b px-4 py-3'>
<h2 className='editorial-title text-xl font-semibold'>{t('output.previewTitle')}</h2>
<div className='text-muted-foreground ui-stat flex flex-wrap items-center gap-3 text-xs'>
<span className='inline-flex items-center gap-1.5'>
{isLoading ? (
<Loader2 className='text-primary h-3.5 w-3.5 animate-spin' />
) : (
<span
className={cn(
'h-2 w-2 rounded-full',
imageBatch && imageBatch.length > 0 ? 'bg-emerald-500' : 'bg-muted-foreground/45'
)}
/>
)}
{previewStateLabel}
</span>
{previewMetaItems.map((item) => (
<span key={item}>{item}</span>
))}
</div>
</div>
<div
className={cn(
'preview-gallery-board relative flex items-center justify-center overflow-hidden px-3 py-5 sm:px-6',
isGenerateEmptyState
? 'min-h-[220px] shrink-0 sm:min-h-[248px] lg:min-h-[272px] xl:min-h-0 xl:flex-1'
: 'min-h-[300px] flex-1 sm:min-h-[420px] lg:min-h-[520px]'
)}>
{isLoading ? (
streamingPreviewImages && streamingPreviewImages.size > 0 ? (
// 展示流式预览图,单图时和最终视图一样居中。
<div className='photo-paper relative flex aspect-[4/3] w-full max-w-[780px] items-center justify-center p-3 2xl:max-w-[860px]'>
{/* 展示最新的预览图,也就是最大索引图片。 */}
{(() => {
const entries = Array.from(streamingPreviewImages.entries());
const latestEntry = entries[entries.length - 1];
if (!latestEntry) return null;
const [, dataUrl] = latestEntry;
return (
<Image
src={dataUrl}
alt={t('output.streaming')}
width={512}
height={512}
className='image-edge h-full w-full rounded-md object-contain'
unoptimized
/>
);
})()}
{/* 在底部居中叠加加载状态。 */}
<div className='bg-foreground/80 text-background absolute bottom-4 left-1/2 flex -translate-x-1/2 items-center gap-2 rounded-full px-3 py-1.5'>
<Loader2 className='h-4 w-4 animate-spin' />
<p className='text-sm'>{t('output.streaming')}</p>
</div>
</div>
) : currentMode === 'edit' && baseImagePreviewUrl ? (
<div className='photo-paper relative flex aspect-[4/3] w-full max-w-[780px] items-center justify-center p-3 2xl:max-w-[860px]'>
<Image
src={baseImagePreviewUrl}
alt={t('output.editing')}
fill
style={{ objectFit: 'contain' }}
className='image-edge rounded-md blur-md filter'
unoptimized
/>
<div className='absolute inset-0 flex flex-col items-center justify-center bg-black/50 text-white/80'>
<Loader2 className='mb-2 h-8 w-8 animate-spin' />
<p>{t('output.editing')}</p>
</div>
</div>
) : (
<div className='text-muted-foreground flex flex-col items-center justify-center'>
<Loader2 className='mb-2 h-8 w-8 animate-spin' />
<p>{isStreamingRequest ? t('output.keepalive') : t('output.generating')}</p>
</div>
)
) : hasFailure ? (
<div className='photo-paper relative flex aspect-[4/3] w-full max-w-[780px] flex-col justify-between p-5 sm:p-6 2xl:max-w-[860px]'>
<div className='space-y-3'>
<div className='text-muted-foreground text-xs'>{t('output.failedKicker')}</div>
<div className='space-y-2'>
<h3 className='editorial-title text-2xl font-semibold'>{t('output.failedTitle')}</h3>
<p className='text-muted-foreground max-w-[46rem] text-sm leading-6'>
{failureMessage}
</p>
</div>
</div>
<div className='flex flex-wrap items-center gap-2'>
{onRetry ? (
<Button type='button' onClick={onRetry} className='min-h-11'>
<RefreshCcw className='mr-2 h-4 w-4' />
{t('output.retry')}
</Button>
) : null}
{canOpenLogs ? (
<Button
type='button'
variant='outline'
onClick={() => setIsLogDialogOpen(true)}
className='bg-background/76 min-h-11'>
<Activity className='mr-2 h-4 w-4' />
{t('logs.open')}
</Button>
) : null}
</div>
</div>
) : isCompareView && selectedImage && compareTargetImage ? (
<ImageCompareView
leftImage={compareTargetImage}
leftLabel={compareReferenceLabel}
rightImage={selectedImage}
rightLabel={t('output.compareCurrent')}
/>
) : imageBatch && imageBatch.length > 0 ? (
viewMode === 'grid' ? (
<div
className={`grid ${getGridColsClass(imageBatch.length)} w-full max-w-[780px] gap-2 2xl:max-w-[860px]`}>
{imageBatch.map((img, index) => (
<button
type='button'
key={img.filename}
onClick={() => onViewChange(index)}
className={cn(
'photo-paper relative aspect-square overflow-hidden p-2 text-left transition-[box-shadow,transform] enabled:motion-safe:hover:-translate-y-0.5',
selectedImageIndex === index
? 'ring-ring ring-offset-background ring-2 ring-offset-2'
: 'focus-visible:ring-ring focus-visible:ring-offset-background focus-visible:ring-2 focus-visible:ring-offset-2'
)}
aria-label={t('output.selectImage', { index: index + 1 })}>
<Image
src={img.path}
alt={t('output.generatedImage', { index: index + 1 })}
fill
style={{ objectFit: 'contain' }}
className='image-edge rounded-md'
sizes='(max-width: 768px) 50vw, (max-width: 1024px) 33vw, 25vw'
onLoad={(event) => handleImageLoad(img.filename, event)}
unoptimized
/>
</button>
))}
</div>
) : imageBatch[viewMode] ? (
<div className='photo-paper relative aspect-[4/3] w-full max-w-[780px] p-3 2xl:max-w-[860px]'>
<Image
src={imageBatch[viewMode].path}
alt={resolvedAltText}
fill
sizes='(max-width: 768px) 92vw, (max-width: 1536px) 44vw, 860px'
className='image-edge rounded-md object-contain'
onLoad={(event) => handleImageLoad(imageBatch[viewMode].filename, event)}
unoptimized
/>
</div>
) : (
<div className='text-muted-foreground text-center'>
<p>{t('output.error')}</p>
</div>
)
) : currentMode === 'edit' ? (
baseImagePreviewUrl ? (
<div className='photo-paper relative flex aspect-[4/3] w-full max-w-[780px] items-center justify-center p-3 2xl:max-w-[860px]'>
<Image
src={baseImagePreviewUrl}
alt={t('output.editReferenceAlt')}
fill
sizes='(max-width: 768px) 92vw, (max-width: 1536px) 44vw, 860px'
className='image-edge rounded-md object-contain p-3'
unoptimized
/>
<div className='border-border/70 bg-background/86 text-muted-foreground absolute bottom-5 left-6 rounded-md border px-3 py-1 text-xs shadow-sm'>
{t('output.editReferenceReadyLabel')}
</div>
</div>
) : (
<div className='relative mx-auto flex w-full max-w-[30rem] flex-col items-center justify-center gap-3 px-6 py-7 text-center'>
<div className='border-border/70 bg-background/70 text-muted-foreground flex h-11 w-11 items-center justify-center rounded-md border shadow-sm'>
<ImageIcon className='h-5 w-5' />
</div>
<div className='space-y-2'>
<h3 className='editorial-title text-lg font-semibold'>
{t('output.editReferenceNeeded')}
</h3>
<p className='text-muted-foreground max-w-[36rem] text-sm leading-6'>
{t('output.editReferenceDescription')}
</p>
</div>
<span className='border-border bg-card text-muted-foreground rounded-md border px-3 py-1 text-xs'>
{t('output.editReferenceWaitingLabel')}
</span>
</div>
)
) : (
<div className='relative mx-auto flex w-full max-w-[30rem] flex-col items-center justify-center gap-3 px-6 py-7 text-center'>
<div className='border-border/70 bg-background/70 text-muted-foreground flex h-11 w-11 items-center justify-center rounded-md border shadow-sm'>
<ImageIcon className='h-5 w-5' />
</div>
<div className='space-y-2'>
<h3 className='editorial-title text-lg font-semibold'>{t('output.emptyTitle')}</h3>
<p className='text-muted-foreground text-sm leading-6'>{t('output.emptyDescription')}</p>
</div>
</div>
)}
</div>
<Dialog open={isLogDialogOpen} onOpenChange={handleLogDialogOpenChange}>
<DialogContent className='sm:max-w-[760px]'>
<DialogHeader>
<DialogTitle>{t('logs.title')}</DialogTitle>
<DialogDescription>{t('logs.description')}</DialogDescription>
</DialogHeader>
<div className='text-muted-foreground bg-muted/40 border-border flex items-center justify-between rounded-md border px-3 py-2 text-xs'>
<span>{t(`logs.status.${logConnectionState}`)}</span>
<span>{t('logs.count', { count: visibleLogs.length })}</span>
</div>
{hasLogScope ? (
<div className='text-muted-foreground border-border bg-muted/20 rounded-md border px-3 py-2 text-xs'>
{t('logs.scopeSelected')}
</div>
) : hasSelectedImageBatch ? (
<div className='text-muted-foreground border-border rounded-md border border-dashed px-3 py-2 text-xs'>
{t('logs.scopeMissing')}
</div>
) : (
<div className='text-muted-foreground border-border rounded-md border border-dashed px-3 py-2 text-xs'>
{t('logs.scopeNone')}
</div>
)}
<div className='border-border bg-card/70 flex flex-col gap-2 rounded-md border px-3 py-2 text-xs sm:flex-row sm:items-start sm:justify-between'>
<div className='text-muted-foreground min-w-0 space-y-1'>
<p className='break-all'>
{t('logs.scopeRequestIds', {
value: formatLogScopeValues(logScopeDiagnostics.requestIds)
})}
</p>
<p className='break-all'>
{t('logs.scopeFilenames', {
value: formatLogScopeValues(logScopeDiagnostics.filenames)
})}
</p>
<p className='break-all'>
{t('logs.scopeResolvedRequestIds', {
value: formatLogScopeValues(logScopeDiagnostics.filenameMatchedRequestIds)
})}
</p>
</div>
<Button
type='button'
variant='outline'
size='sm'
className='shrink-0'
onClick={handleCopyLogScope}>
<Copy className='mr-2 h-4 w-4' />
{copiedLogScopeText === logScopeDiagnostics.copyText
? t('common.copied')
: t('logs.copyScope')}
</Button>
</div>
<div className='literary-scrollbar bg-muted/30 border-border text-foreground/80 h-[420px] overflow-y-auto rounded-md border p-3 font-mono text-xs leading-5'>
{visibleLogs.length === 0 ? (
<p className='text-muted-foreground'>
{hasLogScope
? t('logs.emptyForSelection')
: hasSelectedImageBatch && !hasScopeCandidate
? t('logs.historyWithoutScope')
: t('logs.selectImage')}
</p>
) : (
visibleLogs.map((entry) => (
<div key={entry.id} className='border-border/50 border-b py-2 last:border-b-0'>
<div className='flex flex-wrap items-center gap-2'>
<span className='text-muted-foreground'>{formatLogTime(entry.at, locale)}</span>
<span
className={cn(
'rounded border px-1.5 py-0.5 uppercase',
entry.level === 'error' &&
'border-red-500/40 text-red-700 dark:border-red-400/40 dark:text-red-200',
entry.level === 'warn' &&
'border-yellow-500/40 text-yellow-700 dark:border-yellow-400/40 dark:text-yellow-200',
entry.level === 'info' &&
'border-blue-500/40 text-blue-700 dark:border-blue-300/30 dark:text-blue-100',
entry.level === 'debug' && 'border-border text-muted-foreground'
)}>
{getActivityLogLevelLabel(entry.level, t)}
</span>
<span className='text-foreground break-all'>{entry.message}</span>
</div>
{entry.context ? (
<pre className='text-muted-foreground mt-1 break-words whitespace-pre-wrap'>
{entry.context}
</pre>
) : null}
</div>
))
)}
<div ref={logEndRef} />
</div>
<DialogFooter>
<Button type='button' variant='outline' size='sm' onClick={() => setLogs([])}>
<Trash2 className='mr-2 h-4 w-4' />
{t('logs.clear')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<div
className={cn(
'border-border/40 flex w-full shrink-0 flex-wrap items-center justify-center gap-1.5 border-t bg-transparent px-3 py-2',
!hasSelectedImageBatch && 'bg-background/36'
)}>
{hasSelectedImageBatch ? (
<>
{showCarousel && (
<div className='bg-card/80 border-border literary-scrollbar flex max-w-full items-center gap-1.5 overflow-x-auto rounded-md border p-1'>
<Button
variant='ghost'
size='icon'
className={cn(
'touch-target rounded p-1',
viewMode === 'grid'
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground'
)}
onClick={() => onViewChange('grid')}
aria-label={t('output.showGrid')}>
<Grid className='h-4 w-4' />
</Button>
{imageBatch.map((img, index) => (
<Button
key={img.filename}
variant='ghost'
size='icon'
className={cn(
'touch-target overflow-hidden rounded p-0.5',
viewMode === index
? 'ring-ring ring-offset-background ring-2 ring-offset-1'
: 'opacity-60 hover:opacity-100'
)}
onClick={() => onViewChange(index)}
aria-label={t('output.selectImage', { index: index + 1 })}>
<Image
src={img.path}
alt={t('output.thumbnail', { index: index + 1 })}
width={28}
height={28}
className='image-edge h-full w-full rounded object-cover'
unoptimized
/>
</Button>
))}
</div>
)}
{canOpenLogs && (
<ResultActionButton
icon={<Activity className='mr-2 h-4 w-4' />}
label={t('logs.open')}
onClick={() => setIsLogDialogOpen(true)}
/>
)}
<ResultActionButton
icon={<Download className='mr-2 h-4 w-4' />}
label={t('output.download')}
onClick={handleDownloadClick}
disabled={!canUseSelectedImageActions}
emphasized={showCarousel && viewMode === 'grid'}
/>
<ResultActionButton
icon={<Send className='mr-2 h-4 w-4' />}
label={t('output.continueEdit')}
onClick={handleSendClick}
disabled={!canUseSelectedImageActions}
emphasized={showCarousel && viewMode === 'grid'}
/>
<ResultActionButton
icon={<RefreshCcw className='mr-2 h-4 w-4' />}
label={t('output.createVariant')}
onClick={onCreateVariant}
disabled={isLoading || !canCreateVariant}
/>
<ResultActionButton
icon={<Copy className='mr-2 h-4 w-4' />}
label={t('output.reusePrompt')}
onClick={onReusePrompt}
disabled={isLoading || !canReusePrompt}
/>
<ResultActionButton
icon={<GitCompare className='mr-2 h-4 w-4' />}
label={t('output.compare')}
onClick={handleCompareClick}
disabled={!canCompareImages}
active={isCompareView}
/>
<ResultActionButton
icon={<Share2 className='mr-2 h-4 w-4' />}
label={t('output.share')}
onClick={handleShareClick}
disabled={!canUseSelectedImageActions}
emphasized={showCarousel && viewMode === 'grid'}
/>
</>
) : (
<>
<ResultActionButton
icon={<Download className='mr-2 h-4 w-4' />}
label={t('output.download')}
disabled
/>
<ResultActionButton
icon={<Send className='mr-2 h-4 w-4' />}
label={t('output.continueEdit')}
disabled
/>
<ResultActionButton
icon={<RefreshCcw className='mr-2 h-4 w-4' />}
label={t('output.createVariant')}
disabled
/>
<ResultActionButton
icon={<Copy className='mr-2 h-4 w-4' />}
label={t('output.reusePrompt')}
disabled
/>
<ResultActionButton
icon={<GitCompare className='mr-2 h-4 w-4' />}
label={t('output.compare')}
disabled
/>
</>
)}
</div>
</div>
);
}