@dickpy/dsh-imagegen 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,687 @@
1
+ /**
2
+ * The AI 生图 studio: a three-column layout — left, a card-grouped
3
+ * configuration sidebar (mode tabs, prompt with counter, rounded parameter
4
+ * selectors, model dropdown + generate button); center, the result canvas;
5
+ * right, a persistent generation history column.
6
+ *
7
+ * Controls ride the system UI primitives (@deepseek-ai/dsh-client-ui-primitives,
8
+ * a platform module) so the studio matches the dsh shell look by construction.
9
+ */
10
+
11
+ import { useEffect, useRef, useState } from 'react'
12
+ import { createPortal } from 'react-dom'
13
+ import { Button, Pill } from '@deepseek-ai/dsh-client-ui-primitives'
14
+ import type { ImageGenApi } from './api.ts'
15
+ import { errorMessage, tt } from './helpers.ts'
16
+ import type { GeneratedImage, GenerateMode, GenerateRequest, HistoryEntry, HistoryEntryInput, HistoryImageRef } from '../protocol.ts'
17
+ import type { ImageGenConfig, ImageGenScope } from './settings-scope.ts'
18
+ import css from './panel.module.css'
19
+
20
+ /** The model dropdown offers exactly the plugin's namesake model. */
21
+ const MODELS = ['gpt-image-2'] as const
22
+
23
+ /** All size options for gpt-image-2. */
24
+ const SIZES = ['auto', '1024x1024', '1536x1024', '1024x1536', '512x512', '1792x1024', '1024x1792'] as const
25
+
26
+ /** Size option keys in the locale dictionary. */
27
+ const SIZE_KEYS: Record<string, 'size.auto' | 'size.square' | 'size.landscape' | 'size.portrait' | 'size.small' | 'size.wide' | 'size.tall'> = {
28
+ auto: 'size.auto',
29
+ '1024x1024': 'size.square',
30
+ '1536x1024': 'size.landscape',
31
+ '1024x1536': 'size.portrait',
32
+ '512x512': 'size.small',
33
+ '1792x1024': 'size.wide',
34
+ '1024x1792': 'size.tall',
35
+ }
36
+
37
+ /** Quality options. */
38
+ const QUALITIES = ['auto', 'low', 'medium', 'high'] as const
39
+
40
+ /** Detail options ('' = omit the passthrough). */
41
+ const DETAILS = ['', 'standard', 'high'] as const
42
+
43
+ const PROMPT_MAX = 2000
44
+ const REF_IMAGE_MAX_BYTES = 10 * 1024 * 1024
45
+
46
+ /** Read the current config from the settings scope snapshot. */
47
+ function useConfig(scope: ImageGenScope): ImageGenConfig | undefined {
48
+ const [value, setValue] = useState(scope.getSnapshot().value)
49
+ useEffect(() => scope.subscribe(() => { setValue(scope.getSnapshot().value) }), [scope])
50
+ return value
51
+ }
52
+
53
+ /** Tick a seconds counter while `running`. */
54
+ function useElapsed(running: boolean, startedAt: number | null): number {
55
+ const [elapsed, setElapsed] = useState(0)
56
+ useEffect(() => {
57
+ if (!running || startedAt === null) return
58
+ const timer = window.setInterval(() => {
59
+ setElapsed(Math.max(1, Math.round((Date.now() - startedAt) / 1000)))
60
+ }, 1000)
61
+ return () => window.clearInterval(timer)
62
+ }, [running, startedAt])
63
+ return elapsed
64
+ }
65
+
66
+ /** Data URL for a generated image. */
67
+ function srcOf(image: GeneratedImage): string {
68
+ return `data:${image.mime};base64,${image.b64}`
69
+ }
70
+
71
+ /** Fetch persisted history image refs and decode them back to in-memory
72
+ * GeneratedImage[] (base64), so the canvas/preview can reuse the same
73
+ * rendering path as a fresh generation. */
74
+ async function historyImagesToGenerated(refs: HistoryImageRef[]): Promise<GeneratedImage[]> {
75
+ return Promise.all(refs.map(async ref => {
76
+ const response = await fetch(ref.url)
77
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
78
+ const blob = await response.blob()
79
+ const dataUrl = await new Promise<string>((resolve, reject) => {
80
+ const reader = new FileReader()
81
+ reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : '')
82
+ reader.onerror = () => reject(new Error('image read failed'))
83
+ reader.readAsDataURL(blob)
84
+ })
85
+ const comma = dataUrl.indexOf(',')
86
+ return {
87
+ b64: comma >= 0 ? dataUrl.slice(comma + 1) : '',
88
+ mime: ref.mime,
89
+ ...ref.revisedPrompt === undefined ? {} : { revisedPrompt: ref.revisedPrompt },
90
+ }
91
+ }))
92
+ }
93
+
94
+ /** Compact, locale-independent timestamp for history entries. */
95
+ function formatTime(timestamp: number): string {
96
+ const d = new Date(timestamp)
97
+ const pad = (n: number): string => String(n).padStart(2, '0')
98
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
99
+ }
100
+
101
+ /** Render the studio. */
102
+ export function ImageGenPanel(props: {
103
+ api: ImageGenApi
104
+ scope: ImageGenScope
105
+ }) {
106
+ const { api, scope } = props
107
+ const config = useConfig(scope)
108
+ const enabled = config?.enabled ?? true
109
+ const apiUrl = config?.apiUrl ?? ''
110
+ const configured = apiUrl.trim() !== ''
111
+
112
+ const [mode, setMode] = useState<GenerateMode>('text')
113
+ const [prompt, setPrompt] = useState('')
114
+ const [size, setSize] = useState<string>('auto')
115
+ const [quality, setQuality] = useState<string>('auto')
116
+ const [count, setCount] = useState(1)
117
+ const [detail, setDetail] = useState('')
118
+ const [model, setModel] = useState<string>(MODELS[0])
119
+ const [refImage, setRefImage] = useState<{ dataUrl: string; name: string } | null>(null)
120
+ const [images, setImages] = useState<GeneratedImage[]>([])
121
+ const [error, setError] = useState<string | null>(null)
122
+ const [generating, setGenerating] = useState(false)
123
+ const [startedAt, setStartedAt] = useState<number | null>(null)
124
+ const [history, setHistory] = useState<HistoryEntry[]>([])
125
+ const [viewingHistoryId, setViewingHistoryId] = useState<string | null>(null)
126
+ const [preview, setPreview] = useState<{ images: GeneratedImage[]; index: number } | null>(null)
127
+ const fileInput = useRef<HTMLInputElement>(null)
128
+ const elapsed = useElapsed(generating, startedAt)
129
+
130
+ // Load the host-persisted history once on mount (it lives in ~/.dsh on the
131
+ // DSH host, so every browser/device sees the same list).
132
+ useEffect(() => {
133
+ let disposed = false
134
+ api.historyList()
135
+ .then(entries => { if (!disposed) setHistory(entries) })
136
+ .catch(() => { /* history unavailable — leave the list empty */ })
137
+ return () => { disposed = true }
138
+ }, [api])
139
+
140
+ /** Read an uploaded reference image into a data URL. */
141
+ const acceptFile = (file: File | undefined): void => {
142
+ if (file === undefined) return
143
+ if (!file.type.startsWith('image/')) {
144
+ setError(tt('edit.uploadHint'))
145
+ return
146
+ }
147
+ if (file.size > REF_IMAGE_MAX_BYTES) {
148
+ setError(tt('edit.uploadHint'))
149
+ return
150
+ }
151
+ const reader = new FileReader()
152
+ reader.onload = () => {
153
+ if (typeof reader.result === 'string') setRefImage({ dataUrl: reader.result, name: file.name })
154
+ }
155
+ reader.onerror = () => { setError(tt('edit.uploadHint')) }
156
+ reader.readAsDataURL(file)
157
+ }
158
+
159
+ /** Run one generation. */
160
+ const handleGenerate = async (): Promise<void> => {
161
+ if (generating) return
162
+ const promptText = prompt.trim()
163
+ if (promptText === '') {
164
+ setError(tt('prompt.required'))
165
+ return
166
+ }
167
+ if (mode === 'edit' && refImage === null) {
168
+ setError(tt('edit.required'))
169
+ return
170
+ }
171
+ const request: GenerateRequest = {
172
+ mode,
173
+ model,
174
+ prompt: promptText,
175
+ size,
176
+ quality,
177
+ n: count,
178
+ detail,
179
+ ...mode === 'edit' && refImage !== null ? { image: refImage.dataUrl } : {},
180
+ }
181
+ setGenerating(true)
182
+ setError(null)
183
+ setImages([])
184
+ setStartedAt(Date.now())
185
+ try {
186
+ const result = await api.generate(request)
187
+ setImages(result.images)
188
+ setViewingHistoryId(null)
189
+ const entry: HistoryEntryInput = {
190
+ id: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
191
+ createdAt: Date.now(),
192
+ mode,
193
+ model,
194
+ prompt: promptText,
195
+ size,
196
+ quality,
197
+ detail,
198
+ n: count,
199
+ images: result.images,
200
+ ...mode === 'edit' && refImage !== null ? { refName: refImage.name } : {},
201
+ }
202
+ try {
203
+ setHistory(await api.historyAppend(entry))
204
+ } catch {
205
+ // Persisting history is best-effort; the images stay on the canvas.
206
+ }
207
+ } catch (caught) {
208
+ setError(errorMessage(caught))
209
+ } finally {
210
+ setGenerating(false)
211
+ setStartedAt(null)
212
+ }
213
+ }
214
+
215
+ /** Open the full-screen image preview at a given index. */
216
+ const openPreview = (previewImages: GeneratedImage[], index: number): void => {
217
+ setPreview({ images: previewImages, index })
218
+ }
219
+
220
+ /** Step the preview by ±1, wrapping around. */
221
+ const stepPreview = (delta: number): void => {
222
+ setPreview(current => {
223
+ if (current === null) return null
224
+ const total = current.images.length
225
+ return { images: current.images, index: (current.index + delta + total) % total }
226
+ })
227
+ }
228
+
229
+ // Keyboard navigation for the preview overlay.
230
+ useEffect(() => {
231
+ if (preview === null) return
232
+ const onKey = (event: KeyboardEvent): void => {
233
+ if (event.key === 'Escape') setPreview(null)
234
+ else if (event.key === 'ArrowLeft') stepPreview(-1)
235
+ else if (event.key === 'ArrowRight') stepPreview(1)
236
+ }
237
+ window.addEventListener('keydown', onKey)
238
+ return () => window.removeEventListener('keydown', onKey)
239
+ }, [preview])
240
+
241
+ /** Load a past generation's images into the canvas. */
242
+ const viewHistoryEntry = async (entry: HistoryEntry): Promise<void> => {
243
+ try {
244
+ setImages(await historyImagesToGenerated(entry.images))
245
+ setError(null)
246
+ setViewingHistoryId(entry.id)
247
+ } catch (caught) {
248
+ setError(errorMessage(caught))
249
+ }
250
+ }
251
+
252
+ /** Restore a past generation's parameters (and its images) into the form. */
253
+ const restoreHistoryEntry = async (entry: HistoryEntry): Promise<void> => {
254
+ try {
255
+ const restored = await historyImagesToGenerated(entry.images)
256
+ setMode(entry.mode)
257
+ setPrompt(entry.prompt)
258
+ setSize((SIZES as readonly string[]).includes(entry.size) ? entry.size : 'auto')
259
+ setQuality((QUALITIES as readonly string[]).includes(entry.quality) ? entry.quality : 'auto')
260
+ setDetail((DETAILS as readonly string[]).includes(entry.detail) ? entry.detail : '')
261
+ setCount(entry.n >= 1 && entry.n <= 4 ? entry.n : 1)
262
+ setModel((MODELS as readonly string[]).includes(entry.model) ? entry.model : MODELS[0])
263
+ setRefImage(null)
264
+ setImages(restored)
265
+ setError(null)
266
+ setViewingHistoryId(entry.id)
267
+ } catch (caught) {
268
+ setError(errorMessage(caught))
269
+ }
270
+ }
271
+
272
+ /** Remove one history entry. */
273
+ const deleteHistoryEntry = async (id: string): Promise<void> => {
274
+ setHistory(history.filter(entry => entry.id !== id))
275
+ if (viewingHistoryId === id) setViewingHistoryId(null)
276
+ try {
277
+ setHistory(await api.historyRemove(id))
278
+ } catch {
279
+ // Keep the optimistic local removal.
280
+ }
281
+ }
282
+
283
+ /** Remove all history entries. */
284
+ const clearHistory = async (): Promise<void> => {
285
+ setHistory([])
286
+ setViewingHistoryId(null)
287
+ try {
288
+ setHistory(await api.historyClear())
289
+ } catch {
290
+ // Keep the cleared local state.
291
+ }
292
+ }
293
+
294
+ const generateDisabled = generating || !enabled || !configured
295
+ const viewingEntry = viewingHistoryId === null ? null : history.find(entry => entry.id === viewingHistoryId) ?? null
296
+ const previewImage = preview === null ? null : preview.images[preview.index] ?? null
297
+
298
+ return (
299
+ <div className={css.panel}>
300
+ <header className={css.panelHeader}>
301
+ <h2 className={css.panelTitle}>{tt('panel.title')}</h2>
302
+ <span className={css.panelSubtitle}>{tt('panel.subtitle')}</span>
303
+ </header>
304
+
305
+ {!enabled
306
+ ? <div className={css.banner} data-kind="warn">{tt('config.disabled')}</div>
307
+ : !configured
308
+ ? <div className={css.banner} data-kind="warn">{tt('config.missing')}</div>
309
+ : <div className={css.banner} data-kind="ok">{tt('config.configured', { url: apiUrl })}</div>}
310
+
311
+ <div className={css.studio}>
312
+ {/* ---------------------------------------------------- config sidebar */}
313
+ <aside className={css.config}>
314
+ <div className={css.configScroll}>
315
+ {/* mode tabs */}
316
+ <section className={css.card}>
317
+ <div className={css.modeRow} role="tablist" aria-label={tt('panel.title')}>
318
+ <Pill
319
+ active={mode === 'text'}
320
+ onClick={() => { setMode('text') }}
321
+ className={css.modePill}
322
+ >
323
+ {tt('mode.text')}
324
+ </Pill>
325
+ <Pill
326
+ active={mode === 'edit'}
327
+ onClick={() => { setMode('edit') }}
328
+ className={css.modePill}
329
+ >
330
+ {tt('mode.edit')}
331
+ </Pill>
332
+ </div>
333
+ </section>
334
+
335
+ {/* reference image (edit mode) */}
336
+ {mode === 'edit' ? (
337
+ <section className={css.card}>
338
+ {refImage === null
339
+ ? (
340
+ <button
341
+ type="button"
342
+ className={css.uploadBox}
343
+ onClick={() => { fileInput.current?.click() }}
344
+ onDragOver={(event) => { event.preventDefault() }}
345
+ onDrop={(event) => {
346
+ event.preventDefault()
347
+ acceptFile(event.dataTransfer.files?.[0])
348
+ }}
349
+ >
350
+ <span className={css.uploadIcon}>
351
+ <svg viewBox="0 0 16 16" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M8 10.5V3"/><path d="M5 5.5l3-3 3 3"/><path d="M2.5 9v3.5h11V9"/></svg>
352
+ </span>
353
+ <span>{tt('edit.upload')}</span>
354
+ <span className={css.uploadHint}>{tt('edit.uploadHint')}</span>
355
+ </button>
356
+ )
357
+ : (
358
+ <div className={css.reference}>
359
+ <img className={css.referenceImage} src={refImage.dataUrl} alt={refImage.name} />
360
+ <div className={css.referenceActions}>
361
+ <Button variant="outline" size="sm" onClick={() => { fileInput.current?.click() }}>
362
+ {tt('edit.change')}
363
+ </Button>
364
+ <Button variant="outline" size="sm" onClick={() => { setRefImage(null) }}>
365
+ {tt('edit.remove')}
366
+ </Button>
367
+ </div>
368
+ </div>
369
+ )}
370
+ <input
371
+ ref={fileInput}
372
+ type="file"
373
+ accept="image/png,image/jpeg,image/webp,image/gif"
374
+ className={css.hiddenFile}
375
+ onChange={(event) => {
376
+ acceptFile(event.target.files?.[0])
377
+ event.target.value = ''
378
+ }}
379
+ />
380
+ </section>
381
+ ) : null}
382
+
383
+ {/* prompt */}
384
+ <section className={css.card}>
385
+ <textarea
386
+ className={css.prompt}
387
+ value={prompt}
388
+ maxLength={PROMPT_MAX}
389
+ placeholder={tt('prompt.placeholder')}
390
+ onChange={(event) => { setPrompt(event.target.value) }}
391
+ />
392
+ <div className={css.promptFooter}>
393
+ <span className={css.promptCount}>{tt('prompt.count', { count: prompt.length })}</span>
394
+ </div>
395
+ </section>
396
+
397
+ {/* parameters */}
398
+ <section className={css.card}>
399
+ <div className={css.paramGroup}>
400
+ <span className={css.paramLabel}>{tt('params.size')}</span>
401
+ <div className={css.optionGrid}>
402
+ {SIZES.map(option => (
403
+ <Pill
404
+ key={option}
405
+ active={size === option}
406
+ onClick={() => { setSize(option) }}
407
+ className={css.optionPill}
408
+ >
409
+ {tt(SIZE_KEYS[option] ?? 'size.auto')}
410
+ </Pill>
411
+ ))}
412
+ </div>
413
+ </div>
414
+ <div className={css.paramGroup}>
415
+ <span className={css.paramLabel}>{tt('params.quality')}</span>
416
+ <div className={css.optionRow}>
417
+ {QUALITIES.map(option => (
418
+ <Pill
419
+ key={option}
420
+ active={quality === option}
421
+ onClick={() => { setQuality(option) }}
422
+ className={css.optionPill}
423
+ >
424
+ {tt(`quality.${option}` as const)}
425
+ </Pill>
426
+ ))}
427
+ </div>
428
+ </div>
429
+ <div className={css.paramGroup}>
430
+ <span className={css.paramLabel}>{tt('params.count')}</span>
431
+ <div className={css.optionRow}>
432
+ {[1, 2, 3, 4].map(option => (
433
+ <Pill
434
+ key={option}
435
+ active={count === option}
436
+ onClick={() => { setCount(option) }}
437
+ className={css.optionPill}
438
+ >
439
+ {tt(`count.${option === 1 ? 'one' : option === 2 ? 'two' : option === 3 ? 'three' : 'four'}` as const)}
440
+ </Pill>
441
+ ))}
442
+ </div>
443
+ </div>
444
+ <div className={css.paramGroup}>
445
+ <span className={css.paramLabel}>{tt('params.detail')}</span>
446
+ <div className={css.optionRow}>
447
+ {DETAILS.map(option => (
448
+ <Pill
449
+ key={option === '' ? 'auto' : option}
450
+ active={detail === option}
451
+ onClick={() => { setDetail(option) }}
452
+ className={css.optionPill}
453
+ >
454
+ {tt(option === '' ? 'detail.auto' : option === 'standard' ? 'detail.standard' : 'detail.high')}
455
+ </Pill>
456
+ ))}
457
+ </div>
458
+ <span className={css.paramHint}>{tt('detail.hint')}</span>
459
+ </div>
460
+ </section>
461
+ </div>
462
+
463
+ {/* footer: model + generate — a fixed sibling of the scroll area, so
464
+ it never overlaps the cards scrolling above it. */}
465
+ <section className={css.footer}>
466
+ <label className={css.modelWrap}>
467
+ <span className={css.modelLabel}>{tt('model.label')}</span>
468
+ <select
469
+ className={css.modelSelect}
470
+ value={model}
471
+ disabled={generating}
472
+ onChange={(event) => { setModel(event.target.value) }}
473
+ >
474
+ {MODELS.map(option => <option key={option} value={option}>{option}</option>)}
475
+ </select>
476
+ </label>
477
+ <Button
478
+ variant="primary"
479
+ size="md"
480
+ className={css.generateButton}
481
+ disabled={generateDisabled}
482
+ onClick={() => { void handleGenerate() }}
483
+ >
484
+ {generating ? (
485
+ <span className={css.generateInner}>
486
+ <span className={css.spinner} />
487
+ {tt('generating')}
488
+ </span>
489
+ ) : tt('generate')}
490
+ </Button>
491
+ </section>
492
+ </aside>
493
+
494
+ {/* --------------------------------------------------------- canvas */}
495
+ <section className={css.canvas}>
496
+ {generating ? (
497
+ <div className={css.canvasState} role="status">
498
+ <span className={css.bigSpinner} />
499
+ <span className={css.canvasStateTitle}>{tt('canvas.generating')}</span>
500
+ <span className={css.canvasStateHint}>{tt('canvas.elapsed', { seconds: elapsed })}</span>
501
+ </div>
502
+ ) : null}
503
+
504
+ {!generating && error !== null ? (
505
+ <div className={css.canvasError} role="alert">{tt('canvas.error', { error })}</div>
506
+ ) : null}
507
+
508
+ {!generating && !error && images.length === 0 ? (
509
+ <div className={css.canvasState}>
510
+ <span className={css.canvasEmptyIcon}>
511
+ <svg viewBox="0 0 24 24" width="34" height="34" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="3"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>
512
+ </span>
513
+ <span className={css.canvasStateTitle}>{tt('canvas.emptyTitle')}</span>
514
+ <span className={css.canvasStateHint}>{tt('canvas.emptyHint')}</span>
515
+ </div>
516
+ ) : null}
517
+
518
+ {!generating && images.length > 0 ? (
519
+ <div className={css.canvasBody}>
520
+ <div className={css.canvasMeta}>
521
+ <span>{tt('canvas.images', { count: images.length })}</span>
522
+ {viewingEntry !== null ? (
523
+ <span className={css.canvasHistoryTag}>{tt('history.viewing', { time: formatTime(viewingEntry.createdAt) })}</span>
524
+ ) : null}
525
+ </div>
526
+ <div className={css.grid}>
527
+ {images.map((image, index) => (
528
+ <figure
529
+ key={index}
530
+ className={css.imageCard}
531
+ role="button"
532
+ tabIndex={0}
533
+ title={tt('preview.open')}
534
+ onClick={() => { openPreview(images, index) }}
535
+ onKeyDown={(event) => {
536
+ if (event.key === 'Enter' || event.key === ' ') {
537
+ event.preventDefault()
538
+ openPreview(images, index)
539
+ }
540
+ }}
541
+ >
542
+ <img
543
+ className={css.image}
544
+ src={srcOf(image)}
545
+ alt={image.revisedPrompt ?? `${tt('panel.title')} ${index + 1}`}
546
+ />
547
+ {image.revisedPrompt !== undefined ? (
548
+ <figcaption className={css.imageCaption} title={image.revisedPrompt}>
549
+ {tt('revisedPrompt', { prompt: image.revisedPrompt })}
550
+ </figcaption>
551
+ ) : null}
552
+ <span className={css.zoomHint}>
553
+ <svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><circle cx="7" cy="7" r="4"/><path d="M13 13l-3.2-3.2"/><path d="M7 5.4v3.2M5.4 7h3.2"/></svg>
554
+ {tt('preview.open')}
555
+ </span>
556
+ <a
557
+ className={css.download}
558
+ href={srcOf(image)}
559
+ download={`dsh-image-${index + 1}.${extensionOf(image.mime)}`}
560
+ onClick={(event) => { event.stopPropagation() }}
561
+ >
562
+ {tt('download')}
563
+ </a>
564
+ </figure>
565
+ ))}
566
+ </div>
567
+ </div>
568
+ ) : null}
569
+ </section>
570
+
571
+ {/* -------------------------------------------------------- history */}
572
+ <aside className={css.history}>
573
+ <header className={css.historyHeader}>
574
+ <span className={css.historyTitle}>{tt('history.title')}</span>
575
+ {history.length > 0 ? (
576
+ <button type="button" className={css.historyClear} onClick={() => { void clearHistory() }}>
577
+ {tt('history.clear')}
578
+ </button>
579
+ ) : null}
580
+ </header>
581
+
582
+ {history.length === 0 ? (
583
+ <div className={css.historyEmpty}>{tt('history.empty')}</div>
584
+ ) : (
585
+ <div className={css.historyList}>
586
+ {history.map(entry => (
587
+ <div
588
+ key={entry.id}
589
+ className={css.historyItem}
590
+ data-active={entry.id === viewingHistoryId ? '' : undefined}
591
+ >
592
+ <button
593
+ type="button"
594
+ className={css.historyMain}
595
+ onClick={() => { void viewHistoryEntry(entry) }}
596
+ >
597
+ {entry.images.length > 0 ? (
598
+ <img className={css.historyThumb} src={entry.images[0]!.url} alt="" />
599
+ ) : (
600
+ <span className={css.historyThumbPlaceholder} />
601
+ )}
602
+ <span className={css.historyInfo}>
603
+ <span className={css.historyPrompt}>{entry.prompt}</span>
604
+ <span className={css.historyMeta}>
605
+ {tt(`mode.${entry.mode === 'edit' ? 'edit' : 'text'}` as const)}
606
+ {' · '}{formatTime(entry.createdAt)}
607
+ {' · '}{entry.images.length} {tt('history.images')}
608
+ </span>
609
+ </span>
610
+ </button>
611
+ <span className={css.historyActions}>
612
+ <button type="button" className={css.historyAction} onClick={() => { void restoreHistoryEntry(entry) }}>
613
+ {tt('history.restore')}
614
+ </button>
615
+ <button type="button" className={css.historyAction} data-danger onClick={() => { void deleteHistoryEntry(entry.id) }}>
616
+ {tt('history.delete')}
617
+ </button>
618
+ </span>
619
+ </div>
620
+ ))}
621
+ </div>
622
+ )}
623
+ </aside>
624
+ </div>
625
+
626
+ {/* -------------------------------------------------- preview overlay */}
627
+ {preview !== null && previewImage !== null
628
+ ? createPortal(
629
+ <div
630
+ className={css.lightbox}
631
+ role="dialog"
632
+ aria-modal="true"
633
+ aria-label={tt('preview.title')}
634
+ onClick={() => { setPreview(null) }}
635
+ >
636
+ <button type="button" className={css.lightboxClose} aria-label={tt('preview.close')} onClick={() => { setPreview(null) }}>
637
+ <svg viewBox="0 0 16 16" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"/></svg>
638
+ </button>
639
+ {preview.images.length > 1 ? (
640
+ <>
641
+ <button type="button" className={css.lightboxNav} data-dir="prev" aria-label={tt('preview.prev')} onClick={(event) => { event.stopPropagation(); stepPreview(-1) }}>
642
+ <svg viewBox="0 0 16 16" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M10 3l-5 5 5 5"/></svg>
643
+ </button>
644
+ <button type="button" className={css.lightboxNav} data-dir="next" aria-label={tt('preview.next')} onClick={(event) => { event.stopPropagation(); stepPreview(1) }}>
645
+ <svg viewBox="0 0 16 16" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M6 3l5 5-5 5"/></svg>
646
+ </button>
647
+ </>
648
+ ) : null}
649
+ <figure className={css.lightboxFigure} onClick={(event) => { event.stopPropagation() }}>
650
+ <img
651
+ className={css.lightboxImage}
652
+ src={srcOf(previewImage)}
653
+ alt={previewImage.revisedPrompt ?? tt('preview.title')}
654
+ />
655
+ {previewImage.revisedPrompt !== undefined ? (
656
+ <figcaption className={css.lightboxCaption} title={previewImage.revisedPrompt}>
657
+ {tt('revisedPrompt', { prompt: previewImage.revisedPrompt })}
658
+ </figcaption>
659
+ ) : null}
660
+ <div className={css.lightboxMeta}>
661
+ <span className={css.lightboxIndex}>{tt('preview.index', { index: preview.index + 1, total: preview.images.length })}</span>
662
+ <a
663
+ className={css.lightboxDownload}
664
+ href={srcOf(previewImage)}
665
+ download={`dsh-image-${preview.index + 1}.${extensionOf(previewImage.mime)}`}
666
+ >
667
+ {tt('download')}
668
+ </a>
669
+ </div>
670
+ </figure>
671
+ </div>,
672
+ document.body,
673
+ )
674
+ : null}
675
+ </div>
676
+ )
677
+ }
678
+
679
+ /** File extension for a MIME type (download filenames). */
680
+ function extensionOf(mime: string): string {
681
+ switch (mime.split(';')[0]!.trim()) {
682
+ case 'image/jpeg': return 'jpg'
683
+ case 'image/webp': return 'webp'
684
+ case 'image/gif': return 'gif'
685
+ default: return 'png'
686
+ }
687
+ }