@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.
- package/README.md +28 -6
- package/docs/images/gallery-workspace.png +0 -0
- package/docs/images/prompt-template-library.png +0 -0
- package/lib/client.js +1153 -466
- package/lib/client.js.map +1 -1
- package/lib/index.js +441 -36
- package/package.json +3 -3
- package/src/client/ImageGenPanel.tsx +452 -62
- package/src/client/api.ts +38 -1
- package/src/client/locales.ts +71 -22
- package/src/client/mount.tsx +11 -6
- package/src/client/panel.module.css +209 -7
- package/src/engine.ts +78 -11
- package/src/gallery-store.ts +266 -0
- package/src/index.ts +2 -1
- package/src/protocol.ts +21 -5
- package/src/routes.ts +119 -1
|
@@ -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
|
-
/**
|
|
22
|
-
|
|
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
|
-
/**
|
|
25
|
-
|
|
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.
|
|
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
|
-
'
|
|
31
|
-
'
|
|
32
|
-
'
|
|
33
|
-
'
|
|
34
|
-
'
|
|
35
|
-
'
|
|
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
|
-
|
|
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 [
|
|
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
|
-
|
|
156
|
-
|
|
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 (
|
|
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
|
-
...
|
|
234
|
-
...
|
|
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
|
-
|
|
405
|
+
setTab(entry.mode)
|
|
322
406
|
setPrompt(entry.prompt)
|
|
323
|
-
setSize((
|
|
324
|
-
setQuality((
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
440
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
504
|
-
|
|
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
|
-
|
|
821
|
+
<section className={css.footer}>
|
|
595
822
|
<label className={css.modelWrap}>
|
|
596
823
|
<span className={css.modelLabel}>{tt('model.label')}</span>
|
|
597
|
-
<
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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
|
-
|
|
620
|
-
|
|
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}>
|
|
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
|
-
{/*
|
|
701
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|