@dickpy/dsh-imagegen 1.0.9 → 1.0.20

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.
@@ -18,25 +18,33 @@ import type { GeneratedImage, GenerateMode, GenerateRequest, HistoryEntry, Histo
18
18
  import type { ImageGenConfig, ImageGenScope } from './settings-scope.ts'
19
19
  import css from './panel.module.css'
20
20
 
21
- /** The model dropdown offers exactly the plugin's namesake model. */
22
- const MODELS = ['gpt-image-2'] as const
21
+ /** Models offered by the dropdown. Anything OpenAI-compatible that answers
22
+ * /images/generations (+ /images/edits) works; grok-imagine-image is handled
23
+ * specially host-side (JSON /images/edits, aspect_ratio, b64_json). */
24
+ const MODELS = ['gpt-image-2', 'grok-imagine-image'] as const
23
25
 
24
- /** All size options for gpt-image-2. */
25
- const SIZES = ['auto', '1024x1024', '1536x1024', '1024x1536', '512x512', '1792x1024', '1024x1792'] as const
26
+ /** Size options, presented as aspect ratios (auto = let the model decide).
27
+ * The host maps each ratio onto the model's own vocabulary: aspect_ratio for
28
+ * Grok Imagine, the closest pixel size for OpenAI-compatible endpoints. */
29
+ const SIZES = ['auto', '1:1', '3:4', '4:3', '9:16', '2:3', '3:2', '16:9', '21:9'] as const
26
30
 
27
31
  /** Size option keys in the locale dictionary. */
28
- const SIZE_KEYS: Record<string, 'size.auto' | 'size.square' | 'size.landscape' | 'size.portrait' | 'size.small' | 'size.wide' | 'size.tall'> = {
32
+ const SIZE_KEYS: Record<string, 'size.auto' | 'size.square' | 'size.portrait34' | 'size.landscape43' | 'size.portrait916' | 'size.portrait23' | 'size.landscape32' | 'size.wide169' | 'size.ultrawide21'> = {
29
33
  auto: 'size.auto',
30
- '1024x1024': 'size.square',
31
- '1536x1024': 'size.landscape',
32
- '1024x1536': 'size.portrait',
33
- '512x512': 'size.small',
34
- '1792x1024': 'size.wide',
35
- '1024x1792': 'size.tall',
34
+ '1:1': 'size.square',
35
+ '3:4': 'size.portrait34',
36
+ '4:3': 'size.landscape43',
37
+ '9:16': 'size.portrait916',
38
+ '2:3': 'size.portrait23',
39
+ '3:2': 'size.landscape32',
40
+ '16:9': 'size.wide169',
41
+ '21:9': 'size.ultrawide21',
36
42
  }
37
43
 
38
- /** Quality options. */
39
- const QUALITIES = ['auto', 'low', 'medium', 'high'] as const
44
+ /** Quality options, shown as output-resolution tiers (auto = let the model
45
+ * decide). The host maps them: resolution for Grok, quality level for
46
+ * OpenAI-compatible endpoints (1k→low, 2k→medium, 4k→high). */
47
+ const QUALITIES = ['auto', '1k', '2k', '4k'] as const
40
48
 
41
49
  /** Detail options ('' = omit the passthrough). */
42
50
  const DETAILS = ['', 'standard', 'high'] as const
@@ -47,6 +55,36 @@ const PREVIEW_SCALE_MIN = 0.5
47
55
  const PREVIEW_SCALE_MAX = 3
48
56
  const PREVIEW_SCALE_STEP = 0.25
49
57
 
58
+ /** Legacy pixel sizes saved by older versions, mapped onto the current
59
+ * aspect-ratio vocabulary so restoring old history entries still works. */
60
+ const LEGACY_SIZE_TO_RATIO: Record<string, string> = {
61
+ '512x512': '1:1',
62
+ '1024x1024': '1:1',
63
+ '1536x1024': '3:2',
64
+ '1024x1536': '2:3',
65
+ '1792x1024': '16:9',
66
+ '1024x1792': '9:16',
67
+ }
68
+
69
+ /** Legacy quality levels saved by older versions, mapped onto resolution. */
70
+ const LEGACY_QUALITY_TO_RES: Record<string, string> = {
71
+ low: '1k',
72
+ medium: '2k',
73
+ high: '4k',
74
+ }
75
+
76
+ /** Normalize a saved size value into a current dropdown option. */
77
+ function normalizeSize(value: string): string {
78
+ if ((SIZES as readonly string[]).includes(value)) return value
79
+ return LEGACY_SIZE_TO_RATIO[value] ?? 'auto'
80
+ }
81
+
82
+ /** Normalize a saved quality value into a current dropdown option. */
83
+ function normalizeQuality(value: string): string {
84
+ if ((QUALITIES as readonly string[]).includes(value)) return value
85
+ return LEGACY_QUALITY_TO_RES[value] ?? 'auto'
86
+ }
87
+
50
88
  function clampPreviewScale(scale: number): number {
51
89
  return Math.min(PREVIEW_SCALE_MAX, Math.max(PREVIEW_SCALE_MIN, scale))
52
90
  }
@@ -113,6 +151,11 @@ function formatTime(timestamp: number): string {
113
151
  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
114
152
  }
115
153
 
154
+ /** Studio tabs: the two generation modes plus the gallery view. */
155
+ type PanelTab = GenerateMode | 'gallery'
156
+
157
+ type GalleryFilter = 'all' | 'text' | 'edit' | 'gpt-image-2' | 'grok-imagine-image'
158
+
116
159
  /** Render the studio. */
117
160
  export function ImageGenPanel(props: {
118
161
  api: ImageGenApi
@@ -126,13 +169,14 @@ export function ImageGenPanel(props: {
126
169
  const keySet = useKeySet(scope)
127
170
  const connected = enabled && configured && keySet
128
171
 
129
- const [mode, setMode] = useState<GenerateMode>('text')
172
+ const [tab, setTab] = useState<PanelTab>('text')
130
173
  const [prompt, setPrompt] = useState('')
131
174
  const [size, setSize] = useState<string>('auto')
132
175
  const [quality, setQuality] = useState<string>('auto')
133
176
  const [count, setCount] = useState(1)
134
177
  const [detail, setDetail] = useState('')
135
178
  const [model, setModel] = useState<string>(MODELS[0])
179
+ const [modelOpen, setModelOpen] = useState(false)
136
180
  const [refImage, setRefImage] = useState<{ dataUrl: string; name: string } | null>(null)
137
181
  const [images, setImages] = useState<GeneratedImage[]>([])
138
182
  const [error, setError] = useState<string | null>(null)
@@ -140,6 +184,14 @@ export function ImageGenPanel(props: {
140
184
  const [startedAt, setStartedAt] = useState<number | null>(null)
141
185
  const [history, setHistory] = useState<HistoryEntry[]>([])
142
186
  const [viewingHistoryId, setViewingHistoryId] = useState<string | null>(null)
187
+ const [gallery, setGallery] = useState<HistoryEntry[]>([])
188
+ const [galleryViewingId, setGalleryViewingId] = useState<string | null>(null)
189
+ const [galleryAdding, setGalleryAdding] = useState(false)
190
+ const [galleryMessage, setGalleryMessage] = useState<string | null>(null)
191
+ const [galleryFilter, setGalleryFilter] = useState<GalleryFilter>('all')
192
+ const [galleryRatio, setGalleryRatio] = useState('all')
193
+ const [galleryView, setGalleryView] = useState<'masonry' | 'grid'>('masonry')
194
+ const [gallerySort, setGallerySort] = useState<'newest' | 'oldest'>('newest')
143
195
  const [preview, setPreview] = useState<{ images: GeneratedImage[]; index: number } | null>(null)
144
196
  const [previewScale, setPreviewScale] = useState(1)
145
197
  const [promptCopied, setPromptCopied] = useState(false)
@@ -152,16 +204,46 @@ export function ImageGenPanel(props: {
152
204
  const previewStage = useRef<HTMLDivElement>(null)
153
205
  const elapsed = useElapsed(generating, startedAt)
154
206
 
155
- // Load the host-persisted history once on mount (it lives in ~/.dsh on the
156
- // DSH host, so every browser/device sees the same list).
207
+ const filteredGallery = gallery
208
+ .filter(entry => {
209
+ if (galleryFilter === 'all') return true
210
+ if (galleryFilter === 'text' || galleryFilter === 'edit') return entry.mode === galleryFilter
211
+ return entry.model === galleryFilter
212
+ })
213
+ .filter(entry => galleryRatio === 'all' || normalizeSize(entry.size) === galleryRatio)
214
+ .slice()
215
+ .sort((a, b) => gallerySort === 'newest' ? b.createdAt - a.createdAt : a.createdAt - b.createdAt)
216
+
217
+ // Load the host-persisted history and gallery once on mount (they live in
218
+ // ~/.dsh on the DSH host, so every browser/device sees the same lists).
157
219
  useEffect(() => {
158
220
  let disposed = false
159
221
  api.historyList()
160
222
  .then(entries => { if (!disposed) setHistory(entries) })
161
223
  .catch(() => { /* history unavailable — leave the list empty */ })
224
+ api.galleryList()
225
+ .then(entries => { if (!disposed) setGallery(entries) })
226
+ .catch(() => { /* gallery unavailable — leave the list empty */ })
162
227
  return () => { disposed = true }
163
228
  }, [api])
164
229
 
230
+ // Close the model dropdown when clicking anywhere outside it.
231
+ const modelMenuRef = useRef<HTMLDivElement>(null)
232
+ useEffect(() => {
233
+ if (!modelOpen) return
234
+ const onPointer = (event: MouseEvent | FocusEvent): void => {
235
+ const target = event.target
236
+ if (target instanceof Node && modelMenuRef.current?.contains(target)) return
237
+ setModelOpen(false)
238
+ }
239
+ document.addEventListener('mousedown', onPointer)
240
+ document.addEventListener('focusin', onPointer)
241
+ return () => {
242
+ document.removeEventListener('mousedown', onPointer)
243
+ document.removeEventListener('focusin', onPointer)
244
+ }
245
+ }, [modelOpen])
246
+
165
247
  // Release checks are host-mediated and intentionally best-effort: a GitHub
166
248
  // outage must never make the image-generation studio unavailable.
167
249
  useEffect(() => {
@@ -218,20 +300,20 @@ export function ImageGenPanel(props: {
218
300
  setError(tt('prompt.required'))
219
301
  return
220
302
  }
221
- if (mode === 'edit' && refImage === null) {
303
+ if (tab === 'edit' && refImage === null) {
222
304
  setError(tt('edit.required'))
223
305
  return
224
306
  }
225
307
  const request: GenerateRequest = {
226
- mode,
308
+ mode: tab === 'gallery' ? 'text' : tab,
227
309
  model,
228
310
  prompt: promptText,
229
311
  size,
230
312
  quality,
231
313
  n: count,
232
314
  detail,
233
- ...mode === 'edit' && refImage !== null ? { image: refImage.dataUrl } : {},
234
- ...mode === 'edit' && refImage !== null ? { refName: refImage.name } : {},
315
+ ...tab === 'edit' && refImage !== null ? { image: refImage.dataUrl } : {},
316
+ ...tab === 'edit' && refImage !== null ? { refName: refImage.name } : {},
235
317
  }
236
318
  setGenerating(true)
237
319
  setError(null)
@@ -241,6 +323,7 @@ export function ImageGenPanel(props: {
241
323
  const result = await api.generate(request)
242
324
  setImages(result.images)
243
325
  setViewingHistoryId(null)
326
+ setGalleryViewingId(null)
244
327
  if (result.history !== undefined) setHistory(result.history)
245
328
  if (result.historyError !== undefined) setError(result.historyError)
246
329
  } catch (caught) {
@@ -309,6 +392,7 @@ export function ImageGenPanel(props: {
309
392
  setImages(await historyImagesToGenerated(entry.images))
310
393
  setError(null)
311
394
  setViewingHistoryId(entry.id)
395
+ setGalleryViewingId(null)
312
396
  } catch (caught) {
313
397
  setError(errorMessage(caught))
314
398
  }
@@ -318,10 +402,10 @@ export function ImageGenPanel(props: {
318
402
  const restoreHistoryEntry = async (entry: HistoryEntry): Promise<void> => {
319
403
  try {
320
404
  const restored = await historyImagesToGenerated(entry.images)
321
- setMode(entry.mode)
405
+ setTab(entry.mode)
322
406
  setPrompt(entry.prompt)
323
- setSize((SIZES as readonly string[]).includes(entry.size) ? entry.size : 'auto')
324
- setQuality((QUALITIES as readonly string[]).includes(entry.quality) ? entry.quality : 'auto')
407
+ setSize(normalizeSize(entry.size))
408
+ setQuality(normalizeQuality(entry.quality))
325
409
  setDetail((DETAILS as readonly string[]).includes(entry.detail) ? entry.detail : '')
326
410
  setCount(entry.n >= 1 && entry.n <= 4 ? entry.n : 1)
327
411
  setModel((MODELS as readonly string[]).includes(entry.model) ? entry.model : MODELS[0])
@@ -329,6 +413,7 @@ export function ImageGenPanel(props: {
329
413
  setImages(restored)
330
414
  setError(null)
331
415
  setViewingHistoryId(entry.id)
416
+ setGalleryViewingId(null)
332
417
  } catch (caught) {
333
418
  setError(errorMessage(caught))
334
419
  }
@@ -356,8 +441,119 @@ export function ImageGenPanel(props: {
356
441
  }
357
442
  }
358
443
 
444
+ /** Add one generated image to the gallery (host deduplicates by content).
445
+ * `entry` makes the action available from a history/gallery list item (its
446
+ * metadata + first image are saved); otherwise the current form state is
447
+ * used. */
448
+ const addToGallery = async (image: GeneratedImage, entry?: HistoryEntry): Promise<void> => {
449
+ if (galleryAdding || tab === 'gallery') return
450
+ const source = entry ?? viewingEntry ?? {
451
+ mode: tab === 'edit' ? 'edit' as GenerateMode : 'text' as GenerateMode,
452
+ model,
453
+ prompt: prompt.trim(),
454
+ size,
455
+ quality,
456
+ detail,
457
+ ...refImage !== null ? { refName: refImage.name } : {},
458
+ }
459
+ setGalleryAdding(true)
460
+ try {
461
+ const result = await api.galleryAppend({
462
+ id: '', // the host assigns a fresh id
463
+ createdAt: Date.now(),
464
+ mode: source.mode,
465
+ model: source.model,
466
+ prompt: source.prompt,
467
+ size: source.size,
468
+ quality: source.quality,
469
+ detail: source.detail,
470
+ n: 1,
471
+ images: [image],
472
+ ...source.refName === undefined ? {} : { refName: source.refName },
473
+ })
474
+ setGallery(result.entries)
475
+ setGalleryMessage(result.added ? tt('gallery.added') : tt('gallery.already'))
476
+ window.setTimeout(() => { setGalleryMessage(null) }, 2200)
477
+ } catch (caught) {
478
+ setError(errorMessage(caught))
479
+ } finally {
480
+ setGalleryAdding(false)
481
+ }
482
+ }
483
+
484
+ /** Add one history entry's first image to the gallery (fetches it from the
485
+ * history image route, then delegates to addToGallery). */
486
+ const addHistoryEntryToGallery = async (entry: HistoryEntry): Promise<void> => {
487
+ if (galleryAdding || entry.images.length === 0) return
488
+ try {
489
+ const [image] = await historyImagesToGenerated(entry.images.slice(0, 1))
490
+ if (image === undefined) return
491
+ await addToGallery(image, entry)
492
+ } catch (caught) {
493
+ setError(errorMessage(caught))
494
+ }
495
+ }
496
+
497
+ /** View a gallery image in the canvas. */
498
+ const viewGalleryEntry = async (entry: HistoryEntry): Promise<void> => {
499
+ try {
500
+ const restored = await historyImagesToGenerated(entry.images)
501
+ setImages(restored)
502
+ setError(null)
503
+ setViewingHistoryId(null)
504
+ setGalleryViewingId(entry.id)
505
+ if (restored.length > 0) openPreview(restored, 0)
506
+ } catch (caught) {
507
+ setError(errorMessage(caught))
508
+ }
509
+ }
510
+
511
+ /** Restore a gallery entry's parameters (and its images) into the form. */
512
+ const restoreGalleryEntry = async (entry: HistoryEntry): Promise<void> => {
513
+ try {
514
+ const restored = await historyImagesToGenerated(entry.images)
515
+ setTab(entry.mode)
516
+ setPrompt(entry.prompt)
517
+ setSize(normalizeSize(entry.size))
518
+ setQuality(normalizeQuality(entry.quality))
519
+ setDetail((DETAILS as readonly string[]).includes(entry.detail) ? entry.detail : '')
520
+ setCount(entry.n >= 1 && entry.n <= 4 ? entry.n : 1)
521
+ setModel((MODELS as readonly string[]).includes(entry.model) ? entry.model : MODELS[0])
522
+ setRefImage(null)
523
+ setImages(restored)
524
+ setError(null)
525
+ setViewingHistoryId(null)
526
+ setGalleryViewingId(null)
527
+ } catch (caught) {
528
+ setError(errorMessage(caught))
529
+ }
530
+ }
531
+
532
+ /** Remove one gallery entry. */
533
+ const deleteGalleryEntry = async (id: string): Promise<void> => {
534
+ setGallery(gallery.filter(entry => entry.id !== id))
535
+ if (galleryViewingId === id) setGalleryViewingId(null)
536
+ try {
537
+ setGallery(await api.galleryRemove(id))
538
+ } catch {
539
+ // Keep the optimistic local removal.
540
+ }
541
+ }
542
+
543
+ /** Remove every gallery entry. */
544
+ const clearGalleryAll = async (): Promise<void> => {
545
+ setGallery([])
546
+ setGalleryViewingId(null)
547
+ try {
548
+ setGallery(await api.galleryClear())
549
+ } catch {
550
+ // Keep the cleared local state.
551
+ }
552
+ }
553
+
359
554
  const generateDisabled = generating || !enabled || !configured
360
555
  const viewingEntry = viewingHistoryId === null ? null : history.find(entry => entry.id === viewingHistoryId) ?? null
556
+ const viewingGalleryEntry = galleryViewingId === null ? null : gallery.find(entry => entry.id === galleryViewingId) ?? null
361
557
  const previewImage = preview === null ? null : preview.images[preview.index] ?? null
362
558
  const previewFrameScale = Math.max(1, previewScale)
363
559
  const previewImageScale = previewScale / previewFrameScale
@@ -386,7 +582,7 @@ export function ImageGenPanel(props: {
386
582
 
387
583
  const addPreviewToEdit = (): void => {
388
584
  if (previewImage === null || preview === null) return
389
- setMode('edit')
585
+ setTab('edit')
390
586
  setRefImage({
391
587
  dataUrl: srcOf(previewImage),
392
588
  name: `dsh-image-${preview.index + 1}.${extensionOf(previewImage.mime)}`,
@@ -401,7 +597,16 @@ export function ImageGenPanel(props: {
401
597
  <header className={css.panelHeader}>
402
598
  <span className={css.panelHeading}>
403
599
  <h2 className={css.panelTitle}>{tt('panel.title')}</h2>
404
- <span className={css.panelSubtitle}>{tt('panel.subtitle')}</span>
600
+ <a
601
+ className={css.githubLink}
602
+ href="https://github.com/dickpy/dsh-imagegen"
603
+ target="_blank"
604
+ rel="noreferrer"
605
+ title={tt('panel.githubTip')}
606
+ aria-label={tt('panel.githubTip')}
607
+ >
608
+ <svg viewBox="0 0 16 16" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>
609
+ </a>
405
610
  </span>
406
611
  <button
407
612
  type="button"
@@ -430,30 +635,52 @@ export function ImageGenPanel(props: {
430
635
 
431
636
  <div className={css.studio}>
432
637
  {/* ---------------------------------------------------- config sidebar */}
433
- <aside className={css.config}>
638
+ <aside className={css.config} data-gallery={tab === 'gallery' ? 'true' : undefined}>
639
+ {tab === 'gallery' ? (
640
+ <div className={css.galleryFilters}>
641
+ <div className={css.galleryFilterHeading}>{tt('gallery.categories')}</div>
642
+ {([
643
+ ['all', 'gallery.all'],
644
+ ['text', 'mode.text'],
645
+ ['edit', 'mode.edit'],
646
+ ['gpt-image-2', 'gallery.gpt'],
647
+ ['grok-imagine-image', 'gallery.grok'],
648
+ ] as const).map(([value, label]) => (
649
+ <button
650
+ key={value}
651
+ type="button"
652
+ className={css.galleryFilter}
653
+ data-active={galleryFilter === value ? '' : undefined}
654
+ onClick={() => { setGalleryFilter(value) }}
655
+ >
656
+ <span>{tt(label as never)}</span>
657
+ <span className={css.galleryFilterCount}>{gallery.filter(entry => value === 'all' || value === 'text' || value === 'edit' ? (value === 'all' ? true : entry.mode === value) : entry.model === value).length}</span>
658
+ </button>
659
+ ))}
660
+ <div className={css.galleryFilterDivider} />
661
+ <div className={css.galleryFilterHeading}>{tt('gallery.ratio')}</div>
662
+ <div className={css.galleryRatioList}>
663
+ {(['all', '1:1', '3:4', '4:3', '16:9'] as const).map(ratio => (
664
+ <button key={ratio} type="button" className={css.galleryRatio} data-active={galleryRatio === ratio ? '' : undefined} onClick={() => { setGalleryRatio(ratio) }}>
665
+ {ratio === 'all' ? tt('gallery.all') : ratio}
666
+ </button>
667
+ ))}
668
+ </div>
669
+ <div className={css.galleryFilterNote}>{tt('gallery.filterHint')}</div>
670
+ </div>
671
+ ) : null}
434
672
  <div className={css.configScroll}>
435
- {/* mode tabs */}
673
+ {/* mode / gallery tabs */}
436
674
  <section className={css.card}>
437
675
  <div className={css.modeRow} role="tablist" aria-label={tt('panel.title')}>
438
- <Pill
439
- active={mode === 'text'}
440
- onClick={() => { setMode('text') }}
441
- className={css.modePill}
442
- >
443
- {tt('mode.text')}
444
- </Pill>
445
- <Pill
446
- active={mode === 'edit'}
447
- onClick={() => { setMode('edit') }}
448
- className={css.modePill}
449
- >
450
- {tt('mode.edit')}
451
- </Pill>
676
+ <Pill active={tab === 'text'} onClick={() => { setTab('text') }} className={css.modePill}>{tt('mode.text')}</Pill>
677
+ <Pill active={tab === 'edit'} onClick={() => { setTab('edit') }} className={css.modePill}>{tt('mode.edit')}</Pill>
678
+ <Pill active={tab === 'gallery'} onClick={() => { setTab('gallery') }} className={css.modePill}>{tt('gallery.title')}</Pill>
452
679
  </div>
453
680
  </section>
454
681
 
455
682
  {/* reference image (edit mode) */}
456
- {mode === 'edit' ? (
683
+ {tab === 'edit' ? (
457
684
  <section className={css.card}>
458
685
  {refImage === null
459
686
  ? (
@@ -500,8 +727,8 @@ export function ImageGenPanel(props: {
500
727
  </section>
501
728
  ) : null}
502
729
 
503
- {/* prompt */}
504
- <section className={css.card}>
730
+ {/* prompt */}
731
+ <section className={css.card}>
505
732
  <textarea
506
733
  className={css.prompt}
507
734
  value={prompt}
@@ -591,17 +818,39 @@ export function ImageGenPanel(props: {
591
818
 
592
819
  {/* footer: model + generate — a fixed sibling of the scroll area, so
593
820
  it never overlaps the cards scrolling above it. */}
594
- <section className={css.footer}>
821
+ <section className={css.footer}>
595
822
  <label className={css.modelWrap}>
596
823
  <span className={css.modelLabel}>{tt('model.label')}</span>
597
- <select
598
- className={css.modelSelect}
599
- value={model}
600
- disabled={generating}
601
- onChange={(event) => { setModel(event.target.value) }}
602
- >
603
- {MODELS.map(option => <option key={option} value={option}>{option}</option>)}
604
- </select>
824
+ <span ref={modelMenuRef} className={css.modelMenu} data-open={modelOpen ? 'true' : 'false'}>
825
+ <button
826
+ type="button"
827
+ className={css.modelSelect}
828
+ disabled={generating}
829
+ aria-haspopup="listbox"
830
+ aria-expanded={modelOpen}
831
+ onClick={() => { setModelOpen(open => !open) }}
832
+ >
833
+ <span>{model}</span>
834
+ <svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M8 10.5L4 6h8z"/></svg>
835
+ </button>
836
+ {modelOpen ? (
837
+ <div className={css.modelMenuList} role="listbox" aria-label={tt('model.label')}>
838
+ {MODELS.map(option => (
839
+ <button
840
+ key={option}
841
+ type="button"
842
+ role="option"
843
+ aria-selected={model === option}
844
+ className={css.modelMenuItem}
845
+ data-selected={model === option ? '' : undefined}
846
+ onClick={() => { setModel(option); setModelOpen(false) }}
847
+ >
848
+ {option}
849
+ </button>
850
+ ))}
851
+ </div>
852
+ ) : null}
853
+ </span>
605
854
  </label>
606
855
  <Button
607
856
  variant="primary"
@@ -616,12 +865,63 @@ export function ImageGenPanel(props: {
616
865
  {tt('generating')}
617
866
  </span>
618
867
  ) : tt('generate')}
619
- </Button>
620
- </section>
868
+ </Button>
869
+ </section>
621
870
  </aside>
622
871
 
623
872
  {/* --------------------------------------------------------- canvas */}
624
- <section className={css.canvas}>
873
+ <section className={css.canvas} data-gallery={tab === 'gallery' ? 'true' : undefined}>
874
+ {tab === 'gallery' ? (
875
+ <div className={css.galleryWorkspace}>
876
+ <header className={css.galleryToolbar}>
877
+ <div>
878
+ <h3 className={css.galleryHeading}>{tt('gallery.all')}</h3>
879
+ <span className={css.galleryCount}>{tt('gallery.count', { count: filteredGallery.length })}</span>
880
+ </div>
881
+ <div className={css.galleryToolbarActions}>
882
+ <div className={css.galleryViewToggle} role="group" aria-label={tt('gallery.viewMode')}>
883
+ <button type="button" data-active={galleryView === 'masonry' ? '' : undefined} onClick={() => { setGalleryView('masonry') }} title={tt('gallery.masonry')}>
884
+ <span aria-hidden="true">▦</span> {tt('gallery.masonry')}
885
+ </button>
886
+ <button type="button" data-active={galleryView === 'grid' ? '' : undefined} onClick={() => { setGalleryView('grid') }} title={tt('gallery.grid')}>
887
+ <span aria-hidden="true">▤</span> {tt('gallery.grid')}
888
+ </button>
889
+ </div>
890
+ <select className={css.gallerySort} value={gallerySort} onChange={event => { setGallerySort(event.target.value as 'newest' | 'oldest') }} aria-label={tt('gallery.sort')}>
891
+ <option value="newest">{tt('gallery.newest')}</option>
892
+ <option value="oldest">{tt('gallery.oldest')}</option>
893
+ </select>
894
+ {gallery.length > 0 ? <button type="button" className={css.galleryClear} onClick={() => { void clearGalleryAll() }}>{tt('gallery.clear')}</button> : null}
895
+ </div>
896
+ </header>
897
+ {filteredGallery.length === 0 ? (
898
+ <div className={css.historyEmpty}>{tt('gallery.empty')}</div>
899
+ ) : (
900
+ <div className={css.galleryMasonry} data-view={galleryView}>
901
+ {filteredGallery.map(entry => {
902
+ const image = entry.images[0]
903
+ if (image === undefined) return null
904
+ return (
905
+ <article key={entry.id} className={css.galleryCard}>
906
+ <button type="button" className={css.galleryImageButton} onClick={() => { void viewGalleryEntry(entry) }} title={tt('preview.open')}>
907
+ <img className={css.galleryImage} src={image.url} alt={entry.prompt} />
908
+ <span className={css.galleryBadge}>{entry.mode === 'edit' ? tt('mode.edit') : tt('mode.text')}</span>
909
+ </button>
910
+ <div className={css.galleryCardFooter}>
911
+ <span className={css.galleryAvatar}>{entry.model.startsWith('grok') ? 'G' : 'D'}</span>
912
+ <span className={css.galleryCardInfo}>
913
+ <strong>{entry.prompt || tt('gallery.untitled')}</strong>
914
+ <small>{entry.model} · {normalizeSize(entry.size)} · {formatTime(entry.createdAt)}</small>
915
+ </span>
916
+ <button type="button" className={css.galleryRemove} onClick={() => { void deleteGalleryEntry(entry.id) }} title={tt('gallery.delete')}>×</button>
917
+ </div>
918
+ </article>
919
+ )
920
+ })}
921
+ </div>
922
+ )}
923
+ </div>
924
+ ) : null}
625
925
  {generating ? (
626
926
  <div className={css.canvasState} role="status">
627
927
  <span className={css.bigSpinner} />
@@ -648,8 +948,12 @@ export function ImageGenPanel(props: {
648
948
  <div className={css.canvasBody}>
649
949
  <div className={css.canvasMeta}>
650
950
  <span>{tt('canvas.images', { count: images.length })}</span>
651
- {viewingEntry !== null ? (
652
- <span className={css.canvasHistoryTag}>{tt('history.viewing', { time: formatTime(viewingEntry.createdAt) })}</span>
951
+ {viewingEntry !== null || viewingGalleryEntry !== null ? (
952
+ <span className={css.canvasHistoryTag}>
953
+ {viewingEntry !== null
954
+ ? tt('history.viewing', { time: formatTime(viewingEntry.createdAt) })
955
+ : tt('gallery.viewing', { time: formatTime(viewingGalleryEntry!.createdAt) })}
956
+ </span>
653
957
  ) : null}
654
958
  </div>
655
959
  <div className={css.grid} data-count={images.length}>
@@ -682,6 +986,16 @@ export function ImageGenPanel(props: {
682
986
  <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>
683
987
  {tt('preview.open')}
684
988
  </span>
989
+ <button
990
+ type="button"
991
+ className={css.galleryAdd}
992
+ title={tt('gallery.add')}
993
+ disabled={galleryAdding}
994
+ onClick={(event) => { event.stopPropagation(); void addToGallery(image) }}
995
+ >
996
+ <svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="2.5" y="3" width="11" height="10" rx="1.5"/><path d="M8 5.8v4.4M5.8 8h4.4"/></svg>
997
+ {tt('gallery.add')}
998
+ </button>
685
999
  <a
686
1000
  className={css.download}
687
1001
  href={srcOf(image)}
@@ -697,8 +1011,61 @@ export function ImageGenPanel(props: {
697
1011
  ) : null}
698
1012
  </section>
699
1013
 
700
- {/* -------------------------------------------------------- history */}
701
- <aside className={css.history}>
1014
+ {/* ------------------------ right column: gallery (tab) or history */}
1015
+ {tab === 'gallery' ? (
1016
+ <aside className={css.history}>
1017
+ <header className={css.historyHeader}>
1018
+ <span className={css.historyTitle}>{tt('gallery.title')}</span>
1019
+ {gallery.length > 0 ? (
1020
+ <button type="button" className={css.historyClear} onClick={() => { void clearGalleryAll() }}>
1021
+ {tt('gallery.clear')}
1022
+ </button>
1023
+ ) : null}
1024
+ </header>
1025
+
1026
+ {gallery.length === 0 ? (
1027
+ <div className={css.historyEmpty}>{tt('gallery.empty')}</div>
1028
+ ) : (
1029
+ <div className={css.historyList}>
1030
+ {gallery.map(entry => (
1031
+ <div
1032
+ key={entry.id}
1033
+ className={css.historyItem}
1034
+ data-active={entry.id === galleryViewingId ? '' : undefined}
1035
+ >
1036
+ <button
1037
+ type="button"
1038
+ className={css.historyMain}
1039
+ onClick={() => { void viewGalleryEntry(entry) }}
1040
+ >
1041
+ {entry.images.length > 0 ? (
1042
+ <img className={css.historyThumb} src={entry.images[0]!.url} alt="" />
1043
+ ) : (
1044
+ <span className={css.historyThumbPlaceholder} />
1045
+ )}
1046
+ <span className={css.historyInfo}>
1047
+ <span className={css.historyPrompt}>{entry.prompt}</span>
1048
+ <span className={css.historyMeta}>
1049
+ {tt(`mode.${entry.mode === 'edit' ? 'edit' : 'text'}` as const)}
1050
+ {' · '}{formatTime(entry.createdAt)}
1051
+ </span>
1052
+ </span>
1053
+ </button>
1054
+ <span className={css.historyActions}>
1055
+ <button type="button" className={css.historyAction} onClick={() => { void restoreGalleryEntry(entry) }}>
1056
+ {tt('history.restore')}
1057
+ </button>
1058
+ <button type="button" className={css.historyAction} data-danger onClick={() => { void deleteGalleryEntry(entry.id) }}>
1059
+ {tt('gallery.delete')}
1060
+ </button>
1061
+ </span>
1062
+ </div>
1063
+ ))}
1064
+ </div>
1065
+ )}
1066
+ </aside>
1067
+ ) : (
1068
+ <aside className={css.history}>
702
1069
  <header className={css.historyHeader}>
703
1070
  <span className={css.historyTitle}>{tt('history.title')}</span>
704
1071
  {history.length > 0 ? (
@@ -738,6 +1105,17 @@ export function ImageGenPanel(props: {
738
1105
  </span>
739
1106
  </button>
740
1107
  <span className={css.historyActions}>
1108
+ {entry.images.length > 0 ? (
1109
+ <button
1110
+ type="button"
1111
+ className={css.historyAction}
1112
+ disabled={galleryAdding}
1113
+ title={tt('gallery.add')}
1114
+ onClick={() => { void addHistoryEntryToGallery(entry) }}
1115
+ >
1116
+ {tt('gallery.add')}
1117
+ </button>
1118
+ ) : null}
741
1119
  <button type="button" className={css.historyAction} onClick={() => { void restoreHistoryEntry(entry) }}>
742
1120
  {tt('history.restore')}
743
1121
  </button>
@@ -749,7 +1127,8 @@ export function ImageGenPanel(props: {
749
1127
  ))}
750
1128
  </div>
751
1129
  )}
752
- </aside>
1130
+ </aside>
1131
+ )}
753
1132
  </div>
754
1133
 
755
1134
  {/* ------------------------------------------------ template library */}
@@ -758,7 +1137,7 @@ export function ImageGenPanel(props: {
758
1137
  api={api}
759
1138
  onClose={() => { setLibraryOpen(false) }}
760
1139
  onUse={(text) => {
761
- setMode('text')
1140
+ setTab('text')
762
1141
  setPrompt(text)
763
1142
  setError(null)
764
1143
  setLibraryOpen(false)
@@ -839,6 +1218,9 @@ export function ImageGenPanel(props: {
839
1218
  <div className={css.lightboxMeta}>
840
1219
  <span className={css.lightboxIndex}>{tt('preview.index', { index: preview.index + 1, total: preview.images.length })}</span>
841
1220
  <span className={css.lightboxActions}>
1221
+ <button type="button" className={css.lightboxEdit} disabled={galleryAdding} onClick={() => { void addToGallery(previewImage) }}>
1222
+ {tt('gallery.add')}
1223
+ </button>
842
1224
  <button type="button" className={css.lightboxEdit} onClick={addPreviewToEdit}>
843
1225
  {tt('preview.addToEdit')}
844
1226
  </button>
@@ -856,6 +1238,14 @@ export function ImageGenPanel(props: {
856
1238
  document.body,
857
1239
  )
858
1240
  : null}
1241
+
1242
+ {/* ------------------------------------------------- gallery toast */}
1243
+ {galleryMessage !== null ? (
1244
+ <div className={css.galleryToast} role="status">
1245
+ <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="2.5" y="3" width="11" height="10" rx="1.5"/><path d="M8 5.8v4.4M5.8 8h4.4"/></svg>
1246
+ {galleryMessage}
1247
+ </div>
1248
+ ) : null}
859
1249
  </div>
860
1250
  )
861
1251
  }