@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.
- package/README.md +28 -9
- package/docs/images/prompt-template-library.png +0 -0
- package/lib/client.js +1739 -408
- package/lib/client.js.map +1 -1
- package/lib/index.js +797 -38
- package/package.json +5 -4
- package/src/client/ImageGenPanel.tsx +476 -61
- package/src/client/TemplateLibrary.tsx +336 -0
- package/src/client/api.ts +58 -1
- package/src/client/locales.ts +127 -22
- package/src/client/mount.tsx +11 -6
- package/src/client/panel.module.css +245 -8
- package/src/client/templates.module.css +453 -0
- package/src/engine.ts +78 -11
- package/src/gallery-store.ts +266 -0
- package/src/index.ts +3 -1
- package/src/protocol.ts +79 -5
- package/src/routes.ts +191 -1
- package/src/templates/cases.json +10196 -0
- package/src/templates-store.ts +278 -0
|
@@ -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
|
-
/**
|
|
21
|
-
|
|
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
|
-
/**
|
|
24
|
-
|
|
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.
|
|
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
|
-
'
|
|
30
|
-
'
|
|
31
|
-
'
|
|
32
|
-
'
|
|
33
|
-
'
|
|
34
|
-
'
|
|
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
|
-
|
|
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 [
|
|
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
|
-
|
|
154
|
-
|
|
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 (
|
|
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
|
-
...
|
|
232
|
-
...
|
|
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
|
-
|
|
405
|
+
setTab(entry.mode)
|
|
320
406
|
setPrompt(entry.prompt)
|
|
321
|
-
setSize((
|
|
322
|
-
setQuality((
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
438
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
502
|
-
|
|
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
|
-
|
|
821
|
+
<section className={css.footer}>
|
|
584
822
|
<label className={css.modelWrap}>
|
|
585
823
|
<span className={css.modelLabel}>{tt('model.label')}</span>
|
|
586
|
-
<
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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
|
-
|
|
609
|
-
|
|
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}>
|
|
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
|
-
{/*
|
|
690
|
-
|
|
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
|
-
|
|
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
|
}
|