@dickpy/dsh-imagegen 1.0.7 → 1.0.19

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.
@@ -13,29 +13,38 @@ import { createPortal } from 'react-dom'
13
13
  import { Button, Pill } from '@deepseek-ai/dsh-client-ui-primitives'
14
14
  import type { ImageGenApi } from './api.ts'
15
15
  import { errorMessage, tt } from './helpers.ts'
16
+ import { TemplateLibrary } from './TemplateLibrary.tsx'
16
17
  import type { GeneratedImage, GenerateMode, GenerateRequest, HistoryEntry, HistoryImageRef, UpdateInfo } from '../protocol.ts'
17
18
  import type { ImageGenConfig, ImageGenScope } from './settings-scope.ts'
18
19
  import css from './panel.module.css'
19
20
 
20
- /** The model dropdown offers exactly the plugin's namesake model. */
21
- 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
22
25
 
23
- /** All size options for gpt-image-2. */
24
- 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
25
30
 
26
31
  /** 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'> = {
32
+ const SIZE_KEYS: Record<string, 'size.auto' | 'size.square' | 'size.portrait34' | 'size.landscape43' | 'size.portrait916' | 'size.portrait23' | 'size.landscape32' | 'size.wide169' | 'size.ultrawide21'> = {
28
33
  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',
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',
35
42
  }
36
43
 
37
- /** Quality options. */
38
- 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
39
48
 
40
49
  /** Detail options ('' = omit the passthrough). */
41
50
  const DETAILS = ['', 'standard', 'high'] as const
@@ -46,6 +55,36 @@ const PREVIEW_SCALE_MIN = 0.5
46
55
  const PREVIEW_SCALE_MAX = 3
47
56
  const PREVIEW_SCALE_STEP = 0.25
48
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
+
49
88
  function clampPreviewScale(scale: number): number {
50
89
  return Math.min(PREVIEW_SCALE_MAX, Math.max(PREVIEW_SCALE_MIN, scale))
51
90
  }
@@ -112,6 +151,11 @@ function formatTime(timestamp: number): string {
112
151
  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
113
152
  }
114
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
+
115
159
  /** Render the studio. */
116
160
  export function ImageGenPanel(props: {
117
161
  api: ImageGenApi
@@ -125,13 +169,14 @@ export function ImageGenPanel(props: {
125
169
  const keySet = useKeySet(scope)
126
170
  const connected = enabled && configured && keySet
127
171
 
128
- const [mode, setMode] = useState<GenerateMode>('text')
172
+ const [tab, setTab] = useState<PanelTab>('text')
129
173
  const [prompt, setPrompt] = useState('')
130
174
  const [size, setSize] = useState<string>('auto')
131
175
  const [quality, setQuality] = useState<string>('auto')
132
176
  const [count, setCount] = useState(1)
133
177
  const [detail, setDetail] = useState('')
134
178
  const [model, setModel] = useState<string>(MODELS[0])
179
+ const [modelOpen, setModelOpen] = useState(false)
135
180
  const [refImage, setRefImage] = useState<{ dataUrl: string; name: string } | null>(null)
136
181
  const [images, setImages] = useState<GeneratedImage[]>([])
137
182
  const [error, setError] = useState<string | null>(null)
@@ -139,6 +184,14 @@ export function ImageGenPanel(props: {
139
184
  const [startedAt, setStartedAt] = useState<number | null>(null)
140
185
  const [history, setHistory] = useState<HistoryEntry[]>([])
141
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')
142
195
  const [preview, setPreview] = useState<{ images: GeneratedImage[]; index: number } | null>(null)
143
196
  const [previewScale, setPreviewScale] = useState(1)
144
197
  const [promptCopied, setPromptCopied] = useState(false)
@@ -146,20 +199,51 @@ export function ImageGenPanel(props: {
146
199
  const [updating, setUpdating] = useState(false)
147
200
  const [updateMessage, setUpdateMessage] = useState<string | null>(null)
148
201
  const [updateResult, setUpdateResult] = useState<'success' | 'failed' | null>(null)
202
+ const [libraryOpen, setLibraryOpen] = useState(false)
149
203
  const fileInput = useRef<HTMLInputElement>(null)
150
204
  const previewStage = useRef<HTMLDivElement>(null)
151
205
  const elapsed = useElapsed(generating, startedAt)
152
206
 
153
- // Load the host-persisted history once on mount (it lives in ~/.dsh on the
154
- // 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).
155
219
  useEffect(() => {
156
220
  let disposed = false
157
221
  api.historyList()
158
222
  .then(entries => { if (!disposed) setHistory(entries) })
159
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 */ })
160
227
  return () => { disposed = true }
161
228
  }, [api])
162
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
+
163
247
  // Release checks are host-mediated and intentionally best-effort: a GitHub
164
248
  // outage must never make the image-generation studio unavailable.
165
249
  useEffect(() => {
@@ -216,20 +300,20 @@ export function ImageGenPanel(props: {
216
300
  setError(tt('prompt.required'))
217
301
  return
218
302
  }
219
- if (mode === 'edit' && refImage === null) {
303
+ if (tab === 'edit' && refImage === null) {
220
304
  setError(tt('edit.required'))
221
305
  return
222
306
  }
223
307
  const request: GenerateRequest = {
224
- mode,
308
+ mode: tab === 'gallery' ? 'text' : tab,
225
309
  model,
226
310
  prompt: promptText,
227
311
  size,
228
312
  quality,
229
313
  n: count,
230
314
  detail,
231
- ...mode === 'edit' && refImage !== null ? { image: refImage.dataUrl } : {},
232
- ...mode === 'edit' && refImage !== null ? { refName: refImage.name } : {},
315
+ ...tab === 'edit' && refImage !== null ? { image: refImage.dataUrl } : {},
316
+ ...tab === 'edit' && refImage !== null ? { refName: refImage.name } : {},
233
317
  }
234
318
  setGenerating(true)
235
319
  setError(null)
@@ -239,6 +323,7 @@ export function ImageGenPanel(props: {
239
323
  const result = await api.generate(request)
240
324
  setImages(result.images)
241
325
  setViewingHistoryId(null)
326
+ setGalleryViewingId(null)
242
327
  if (result.history !== undefined) setHistory(result.history)
243
328
  if (result.historyError !== undefined) setError(result.historyError)
244
329
  } catch (caught) {
@@ -307,6 +392,7 @@ export function ImageGenPanel(props: {
307
392
  setImages(await historyImagesToGenerated(entry.images))
308
393
  setError(null)
309
394
  setViewingHistoryId(entry.id)
395
+ setGalleryViewingId(null)
310
396
  } catch (caught) {
311
397
  setError(errorMessage(caught))
312
398
  }
@@ -316,10 +402,10 @@ export function ImageGenPanel(props: {
316
402
  const restoreHistoryEntry = async (entry: HistoryEntry): Promise<void> => {
317
403
  try {
318
404
  const restored = await historyImagesToGenerated(entry.images)
319
- setMode(entry.mode)
405
+ setTab(entry.mode)
320
406
  setPrompt(entry.prompt)
321
- setSize((SIZES as readonly string[]).includes(entry.size) ? entry.size : 'auto')
322
- setQuality((QUALITIES as readonly string[]).includes(entry.quality) ? entry.quality : 'auto')
407
+ setSize(normalizeSize(entry.size))
408
+ setQuality(normalizeQuality(entry.quality))
323
409
  setDetail((DETAILS as readonly string[]).includes(entry.detail) ? entry.detail : '')
324
410
  setCount(entry.n >= 1 && entry.n <= 4 ? entry.n : 1)
325
411
  setModel((MODELS as readonly string[]).includes(entry.model) ? entry.model : MODELS[0])
@@ -327,6 +413,7 @@ export function ImageGenPanel(props: {
327
413
  setImages(restored)
328
414
  setError(null)
329
415
  setViewingHistoryId(entry.id)
416
+ setGalleryViewingId(null)
330
417
  } catch (caught) {
331
418
  setError(errorMessage(caught))
332
419
  }
@@ -354,8 +441,119 @@ export function ImageGenPanel(props: {
354
441
  }
355
442
  }
356
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
+
357
554
  const generateDisabled = generating || !enabled || !configured
358
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
359
557
  const previewImage = preview === null ? null : preview.images[preview.index] ?? null
360
558
  const previewFrameScale = Math.max(1, previewScale)
361
559
  const previewImageScale = previewScale / previewFrameScale
@@ -384,7 +582,7 @@ export function ImageGenPanel(props: {
384
582
 
385
583
  const addPreviewToEdit = (): void => {
386
584
  if (previewImage === null || preview === null) return
387
- setMode('edit')
585
+ setTab('edit')
388
586
  setRefImage({
389
587
  dataUrl: srcOf(previewImage),
390
588
  name: `dsh-image-${preview.index + 1}.${extensionOf(previewImage.mime)}`,
@@ -399,7 +597,16 @@ export function ImageGenPanel(props: {
399
597
  <header className={css.panelHeader}>
400
598
  <span className={css.panelHeading}>
401
599
  <h2 className={css.panelTitle}>{tt('panel.title')}</h2>
402
- <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>
403
610
  </span>
404
611
  <button
405
612
  type="button"
@@ -428,30 +635,52 @@ export function ImageGenPanel(props: {
428
635
 
429
636
  <div className={css.studio}>
430
637
  {/* ---------------------------------------------------- config sidebar */}
431
- <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}
432
672
  <div className={css.configScroll}>
433
- {/* mode tabs */}
673
+ {/* mode / gallery tabs */}
434
674
  <section className={css.card}>
435
675
  <div className={css.modeRow} role="tablist" aria-label={tt('panel.title')}>
436
- <Pill
437
- active={mode === 'text'}
438
- onClick={() => { setMode('text') }}
439
- className={css.modePill}
440
- >
441
- {tt('mode.text')}
442
- </Pill>
443
- <Pill
444
- active={mode === 'edit'}
445
- onClick={() => { setMode('edit') }}
446
- className={css.modePill}
447
- >
448
- {tt('mode.edit')}
449
- </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>
450
679
  </div>
451
680
  </section>
452
681
 
453
682
  {/* reference image (edit mode) */}
454
- {mode === 'edit' ? (
683
+ {tab === 'edit' ? (
455
684
  <section className={css.card}>
456
685
  {refImage === null
457
686
  ? (
@@ -498,8 +727,8 @@ export function ImageGenPanel(props: {
498
727
  </section>
499
728
  ) : null}
500
729
 
501
- {/* prompt */}
502
- <section className={css.card}>
730
+ {/* prompt */}
731
+ <section className={css.card}>
503
732
  <textarea
504
733
  className={css.prompt}
505
734
  value={prompt}
@@ -508,6 +737,15 @@ export function ImageGenPanel(props: {
508
737
  onChange={(event) => { setPrompt(event.target.value) }}
509
738
  />
510
739
  <div className={css.promptFooter}>
740
+ <button
741
+ type="button"
742
+ className={css.templatesButton}
743
+ title={tt('templates.title')}
744
+ onClick={() => { setLibraryOpen(true) }}
745
+ >
746
+ <svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M2.5 3.5h11M2.5 8h11M2.5 12.5h7"/></svg>
747
+ {tt('templates.open')}
748
+ </button>
511
749
  <span className={css.promptCount}>{tt('prompt.count', { count: prompt.length })}</span>
512
750
  </div>
513
751
  </section>
@@ -580,17 +818,39 @@ export function ImageGenPanel(props: {
580
818
 
581
819
  {/* footer: model + generate — a fixed sibling of the scroll area, so
582
820
  it never overlaps the cards scrolling above it. */}
583
- <section className={css.footer}>
821
+ <section className={css.footer}>
584
822
  <label className={css.modelWrap}>
585
823
  <span className={css.modelLabel}>{tt('model.label')}</span>
586
- <select
587
- className={css.modelSelect}
588
- value={model}
589
- disabled={generating}
590
- onChange={(event) => { setModel(event.target.value) }}
591
- >
592
- {MODELS.map(option => <option key={option} value={option}>{option}</option>)}
593
- </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>
594
854
  </label>
595
855
  <Button
596
856
  variant="primary"
@@ -605,12 +865,63 @@ export function ImageGenPanel(props: {
605
865
  {tt('generating')}
606
866
  </span>
607
867
  ) : tt('generate')}
608
- </Button>
609
- </section>
868
+ </Button>
869
+ </section>
610
870
  </aside>
611
871
 
612
872
  {/* --------------------------------------------------------- canvas */}
613
- <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}
614
925
  {generating ? (
615
926
  <div className={css.canvasState} role="status">
616
927
  <span className={css.bigSpinner} />
@@ -637,8 +948,12 @@ export function ImageGenPanel(props: {
637
948
  <div className={css.canvasBody}>
638
949
  <div className={css.canvasMeta}>
639
950
  <span>{tt('canvas.images', { count: images.length })}</span>
640
- {viewingEntry !== null ? (
641
- <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>
642
957
  ) : null}
643
958
  </div>
644
959
  <div className={css.grid} data-count={images.length}>
@@ -671,6 +986,16 @@ export function ImageGenPanel(props: {
671
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>
672
987
  {tt('preview.open')}
673
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>
674
999
  <a
675
1000
  className={css.download}
676
1001
  href={srcOf(image)}
@@ -686,8 +1011,61 @@ export function ImageGenPanel(props: {
686
1011
  ) : null}
687
1012
  </section>
688
1013
 
689
- {/* -------------------------------------------------------- history */}
690
- <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}>
691
1069
  <header className={css.historyHeader}>
692
1070
  <span className={css.historyTitle}>{tt('history.title')}</span>
693
1071
  {history.length > 0 ? (
@@ -727,6 +1105,17 @@ export function ImageGenPanel(props: {
727
1105
  </span>
728
1106
  </button>
729
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}
730
1119
  <button type="button" className={css.historyAction} onClick={() => { void restoreHistoryEntry(entry) }}>
731
1120
  {tt('history.restore')}
732
1121
  </button>
@@ -738,9 +1127,24 @@ export function ImageGenPanel(props: {
738
1127
  ))}
739
1128
  </div>
740
1129
  )}
741
- </aside>
1130
+ </aside>
1131
+ )}
742
1132
  </div>
743
1133
 
1134
+ {/* ------------------------------------------------ template library */}
1135
+ {libraryOpen ? (
1136
+ <TemplateLibrary
1137
+ api={api}
1138
+ onClose={() => { setLibraryOpen(false) }}
1139
+ onUse={(text) => {
1140
+ setTab('text')
1141
+ setPrompt(text)
1142
+ setError(null)
1143
+ setLibraryOpen(false)
1144
+ }}
1145
+ />
1146
+ ) : null}
1147
+
744
1148
  {/* -------------------------------------------------- preview overlay */}
745
1149
  {preview !== null && previewImage !== null
746
1150
  ? createPortal(
@@ -814,6 +1218,9 @@ export function ImageGenPanel(props: {
814
1218
  <div className={css.lightboxMeta}>
815
1219
  <span className={css.lightboxIndex}>{tt('preview.index', { index: preview.index + 1, total: preview.images.length })}</span>
816
1220
  <span className={css.lightboxActions}>
1221
+ <button type="button" className={css.lightboxEdit} disabled={galleryAdding} onClick={() => { void addToGallery(previewImage) }}>
1222
+ {tt('gallery.add')}
1223
+ </button>
817
1224
  <button type="button" className={css.lightboxEdit} onClick={addPreviewToEdit}>
818
1225
  {tt('preview.addToEdit')}
819
1226
  </button>
@@ -831,6 +1238,14 @@ export function ImageGenPanel(props: {
831
1238
  document.body,
832
1239
  )
833
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}
834
1249
  </div>
835
1250
  )
836
1251
  }