@dickpy/dsh-imagegen 1.5.9 → 1.5.10
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/lib/client.js +1319 -804
- package/lib/client.js.map +1 -1
- package/lib/index.js +1 -1
- package/package.json +82 -82
- package/src/client/CanvasBackgrounds.tsx +1 -19
- package/src/client/CanvasWorkspace.tsx +141 -53
- package/src/client/GooeyNav.module.css +224 -0
- package/src/client/GooeyNav.tsx +185 -0
- package/src/client/ImageGenPanel.tsx +203 -100
- package/src/client/canvas-workspace.module.css +63 -24
- package/src/client/locales.ts +1578 -1542
- package/src/client/panel.module.css +86 -11
- package/src/client/surface-theme.ts +20 -0
- package/src/protocol.ts +583 -580
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/** reactbits.dev "GooeyNav" port (controlled, dependency-free): the active
|
|
2
|
+
* pill merges with a radial burst of gooey particles through a
|
|
3
|
+
* blur+contrast filter. The original renders white ink on dark surfaces,
|
|
4
|
+
* so the palette flips per detected surface to stay readable in the host
|
|
5
|
+
* theme. */
|
|
6
|
+
|
|
7
|
+
import { useEffect, useRef, type ReactNode } from 'react'
|
|
8
|
+
import { detectLightSurface } from './surface-theme.ts'
|
|
9
|
+
import css from './GooeyNav.module.css'
|
|
10
|
+
|
|
11
|
+
export interface GooeyNavItem {
|
|
12
|
+
key: string
|
|
13
|
+
label: ReactNode
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const ANIMATION_TIME = 600
|
|
17
|
+
const PARTICLE_COUNT = 15
|
|
18
|
+
const PARTICLE_DISTANCES = [90, 10]
|
|
19
|
+
const PARTICLE_R = 100
|
|
20
|
+
const TIME_VARIANCE = 300
|
|
21
|
+
const COLORS = [1, 2, 3, 1, 2, 3, 1, 4]
|
|
22
|
+
|
|
23
|
+
export function GooeyNav(props: {
|
|
24
|
+
items: GooeyNavItem[]
|
|
25
|
+
activeIndex: number
|
|
26
|
+
onSelect: (index: number) => void
|
|
27
|
+
ariaLabel?: string
|
|
28
|
+
}): React.JSX.Element {
|
|
29
|
+
const { items, activeIndex, onSelect, ariaLabel } = props
|
|
30
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
31
|
+
const navRef = useRef<HTMLUListElement>(null)
|
|
32
|
+
const filterRef = useRef<HTMLSpanElement>(null)
|
|
33
|
+
const textRef = useRef<HTMLSpanElement>(null)
|
|
34
|
+
const burstRef = useRef<(li: HTMLElement) => void>(() => {})
|
|
35
|
+
const timersRef = useRef<number[]>([])
|
|
36
|
+
const activeIndexRef = useRef(activeIndex)
|
|
37
|
+
activeIndexRef.current = activeIndex
|
|
38
|
+
|
|
39
|
+
const updateEffectPosition = (element: HTMLElement): void => {
|
|
40
|
+
const container = containerRef.current
|
|
41
|
+
const filter = filterRef.current
|
|
42
|
+
const text = textRef.current
|
|
43
|
+
if (container === null || filter === null || text === null) return
|
|
44
|
+
const containerRect = container.getBoundingClientRect()
|
|
45
|
+
const pos = element.getBoundingClientRect()
|
|
46
|
+
const styles = {
|
|
47
|
+
left: `${pos.x - containerRect.x}px`,
|
|
48
|
+
top: `${pos.y - containerRect.y}px`,
|
|
49
|
+
width: `${pos.width}px`,
|
|
50
|
+
height: `${pos.height}px`,
|
|
51
|
+
}
|
|
52
|
+
Object.assign(filter.style, styles)
|
|
53
|
+
Object.assign(text.style, styles)
|
|
54
|
+
text.textContent = element.textContent
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// One-time setup: the particle burst timers must survive selection changes,
|
|
58
|
+
// so they live outside the activeIndex-driven effect below.
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
const container = containerRef.current
|
|
61
|
+
const filter = filterRef.current
|
|
62
|
+
const text = textRef.current
|
|
63
|
+
if (container === null || filter === null || text === null) return
|
|
64
|
+
container.dataset.theme = detectLightSurface(container) ? 'light' : 'dark'
|
|
65
|
+
let disposed = false
|
|
66
|
+
const noise = (amount: number): number => amount / 2 - Math.random() * amount
|
|
67
|
+
const getXY = (distance: number, pointIndex: number, totalPoints: number): [number, number] => {
|
|
68
|
+
const angle = ((360 + noise(8)) / totalPoints) * pointIndex * (Math.PI / 180)
|
|
69
|
+
return [distance * Math.cos(angle), distance * Math.sin(angle)]
|
|
70
|
+
}
|
|
71
|
+
const makeParticles = (element: HTMLElement): void => {
|
|
72
|
+
const bubbleTime = ANIMATION_TIME * 2 + TIME_VARIANCE
|
|
73
|
+
element.style.setProperty('--time', `${bubbleTime}ms`)
|
|
74
|
+
for (let i = 0; i < PARTICLE_COUNT; i++) {
|
|
75
|
+
const time = ANIMATION_TIME * 2 + noise(TIME_VARIANCE * 2)
|
|
76
|
+
const rotateSeed = noise(PARTICLE_R / 10)
|
|
77
|
+
const start = getXY(PARTICLE_DISTANCES[0] as number, PARTICLE_COUNT - i, PARTICLE_COUNT)
|
|
78
|
+
const end = getXY((PARTICLE_DISTANCES[1] as number) + noise(7), PARTICLE_COUNT - i, PARTICLE_COUNT)
|
|
79
|
+
const scale = 1 + noise(0.2)
|
|
80
|
+
const color = COLORS[Math.floor(Math.random() * COLORS.length)] ?? 1
|
|
81
|
+
const rotate = rotateSeed > 0 ? (rotateSeed + PARTICLE_R / 20) * 10 : (rotateSeed - PARTICLE_R / 20) * 10
|
|
82
|
+
const timer = window.setTimeout(() => {
|
|
83
|
+
if (disposed) return
|
|
84
|
+
const particle = document.createElement('span')
|
|
85
|
+
const point = document.createElement('span')
|
|
86
|
+
particle.className = css.particle
|
|
87
|
+
point.className = css.point
|
|
88
|
+
particle.style.setProperty('--start-x', `${start[0] as number}px`)
|
|
89
|
+
particle.style.setProperty('--start-y', `${start[1] as number}px`)
|
|
90
|
+
particle.style.setProperty('--end-x', `${end[0] as number}px`)
|
|
91
|
+
particle.style.setProperty('--end-y', `${end[1] as number}px`)
|
|
92
|
+
particle.style.setProperty('--time', `${time}ms`)
|
|
93
|
+
particle.style.setProperty('--scale', `${scale}`)
|
|
94
|
+
particle.style.setProperty('--color', `var(--gooey-color-${color}, currentColor)`)
|
|
95
|
+
particle.style.setProperty('--rotate', `${rotate}deg`)
|
|
96
|
+
particle.appendChild(point)
|
|
97
|
+
element.appendChild(particle)
|
|
98
|
+
if (typeof window.requestAnimationFrame === 'function') {
|
|
99
|
+
window.requestAnimationFrame(() => { element.classList.add(css.active) })
|
|
100
|
+
} else {
|
|
101
|
+
element.classList.add(css.active)
|
|
102
|
+
}
|
|
103
|
+
const removeTimer = window.setTimeout(() => { particle.remove() }, time)
|
|
104
|
+
timersRef.current.push(removeTimer)
|
|
105
|
+
}, 30)
|
|
106
|
+
timersRef.current.push(timer)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
burstRef.current = (li: HTMLElement): void => {
|
|
110
|
+
updateEffectPosition(li)
|
|
111
|
+
for (const particle of [...filter.querySelectorAll(`.${css.particle}`)]) particle.remove()
|
|
112
|
+
text.classList.remove(css.active)
|
|
113
|
+
void text.offsetWidth
|
|
114
|
+
text.classList.add(css.active)
|
|
115
|
+
makeParticles(filter)
|
|
116
|
+
}
|
|
117
|
+
const positionActive = (): void => {
|
|
118
|
+
const nav = navRef.current
|
|
119
|
+
const activeLi = nav?.querySelectorAll('li')[activeIndexRef.current]
|
|
120
|
+
if (activeLi) {
|
|
121
|
+
updateEffectPosition(activeLi)
|
|
122
|
+
text.classList.add(css.active)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
positionActive()
|
|
126
|
+
const observer = new ResizeObserver(positionActive)
|
|
127
|
+
observer.observe(container)
|
|
128
|
+
return () => {
|
|
129
|
+
disposed = true
|
|
130
|
+
for (const timer of timersRef.current) window.clearTimeout(timer)
|
|
131
|
+
timersRef.current = []
|
|
132
|
+
observer.disconnect()
|
|
133
|
+
}
|
|
134
|
+
}, [])
|
|
135
|
+
|
|
136
|
+
// External selection changes only reposition the pill (no burst); clicks
|
|
137
|
+
// animate through burstRef above.
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
const nav = navRef.current
|
|
140
|
+
const text = textRef.current
|
|
141
|
+
const activeLi = nav?.querySelectorAll('li')[activeIndex]
|
|
142
|
+
if (activeLi) {
|
|
143
|
+
updateEffectPosition(activeLi)
|
|
144
|
+
text?.classList.add(css.active)
|
|
145
|
+
}
|
|
146
|
+
}, [activeIndex])
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<div className={css.container} ref={containerRef}>
|
|
150
|
+
<svg className={css.gooSvg} aria-hidden="true" focusable="false">
|
|
151
|
+
<defs>
|
|
152
|
+
{/* Alpha-contrast goo: merges the pill and particles without
|
|
153
|
+
crushing their colors the way CSS contrast() would. */}
|
|
154
|
+
<filter id="dsh-gooey-nav-goo" x="-150%" y="-150%" width="400%" height="400%">
|
|
155
|
+
<feGaussianBlur in="SourceGraphic" stdDeviation="6" result="blur" />
|
|
156
|
+
<feColorMatrix in="blur" mode="matrix" values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 19 -9" />
|
|
157
|
+
</filter>
|
|
158
|
+
</defs>
|
|
159
|
+
</svg>
|
|
160
|
+
<nav aria-label={ariaLabel} role="tablist">
|
|
161
|
+
<ul ref={navRef}>
|
|
162
|
+
{items.map((item, index) => (
|
|
163
|
+
<li key={item.key} className={activeIndex === index ? css.active : undefined}>
|
|
164
|
+
<button
|
|
165
|
+
type="button"
|
|
166
|
+
role="tab"
|
|
167
|
+
aria-selected={activeIndex === index}
|
|
168
|
+
onClick={event => {
|
|
169
|
+
if (index === activeIndex) return
|
|
170
|
+
const li = event.currentTarget.closest('li')
|
|
171
|
+
if (li !== null) burstRef.current(li)
|
|
172
|
+
onSelect(index)
|
|
173
|
+
}}
|
|
174
|
+
>
|
|
175
|
+
{item.label}
|
|
176
|
+
</button>
|
|
177
|
+
</li>
|
|
178
|
+
))}
|
|
179
|
+
</ul>
|
|
180
|
+
</nav>
|
|
181
|
+
<span className={`${css.effect} ${css.effectFilter}`} ref={filterRef} />
|
|
182
|
+
<span className={`${css.effect} ${css.effectText}`} ref={textRef} />
|
|
183
|
+
</div>
|
|
184
|
+
)
|
|
185
|
+
}
|
|
@@ -17,6 +17,7 @@ import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
|
|
17
17
|
import type { ImageGenApi } from './api.ts'
|
|
18
18
|
import { errorMessage, tt } from './helpers.ts'
|
|
19
19
|
import { TemplateLibrary } from './TemplateLibrary.tsx'
|
|
20
|
+
import { GooeyNav } from './GooeyNav.tsx'
|
|
20
21
|
import { InspirationGallery } from './InspirationGallery.tsx'
|
|
21
22
|
import { CanvasWorkspace } from './CanvasWorkspace.tsx'
|
|
22
23
|
import { useImageGenLanguageTick } from './use-language.ts'
|
|
@@ -353,7 +354,7 @@ function formatTime(timestamp: number): string {
|
|
|
353
354
|
function defaultEcommerceDraft(): ProductSetDraft {
|
|
354
355
|
return {
|
|
355
356
|
projectId: '', projectName: '', category: '通用商品', platform: '通用', language: '中文', customLanguage: '', size: '1:1',
|
|
356
|
-
productName: '',
|
|
357
|
+
productName: '', promptInfo: '',
|
|
357
358
|
slots: PRODUCT_SET_SLOTS.map(slot => ({ ...slot })),
|
|
358
359
|
}
|
|
359
360
|
}
|
|
@@ -377,13 +378,12 @@ function effectiveEcommerceLanguage(draft: ProductSetDraft): string {
|
|
|
377
378
|
}
|
|
378
379
|
|
|
379
380
|
function ecommercePrompt(draft: ProductSetDraft, slot: ProductSetSlot): string {
|
|
380
|
-
const
|
|
381
|
-
const protectedFeatures = draft.protectedFeatures.trim() || '保持商品颜色、形状、Logo、包装文字和结构真实,不添加不存在的配件'
|
|
381
|
+
const info = draft.promptInfo.trim() || '突出商品真实材质、结构和核心价值;保持商品颜色、形状、Logo、包装文字和结构真实,不添加不存在的配件'
|
|
382
382
|
const language = effectiveEcommerceLanguage(draft) || '中文'
|
|
383
383
|
const refClause = slot.refRole !== undefined && slot.refRole !== 'none'
|
|
384
384
|
? `本图以上传的${ECOMMERCE_ROLE_PROMPT_LABELS[slot.refRole]}图片为参考,商品与风格必须与参考图保持一致;`
|
|
385
385
|
: ''
|
|
386
|
-
return `电商${slot.label}:为${draft.productName.trim() || '该商品'}制作${slot.description}。商品品类:${draft.category};平台:${draft.platform};语言:${language}
|
|
386
|
+
return `电商${slot.label}:为${draft.productName.trim() || '该商品'}制作${slot.description}。商品品类:${draft.category};平台:${draft.platform};语言:${language}。${refClause}商品信息与要求:${info}。整体要求:商品主体清晰、比例真实、光线自然、画面干净、适合电商发布。`
|
|
387
387
|
}
|
|
388
388
|
|
|
389
389
|
/** Consistency prefix for slots generated after the main image exists. */
|
|
@@ -561,6 +561,14 @@ export function ImageGenPanel(props: {
|
|
|
561
561
|
if (Array.isArray(merged.slots)) {
|
|
562
562
|
merged.slots = merged.slots.map(slot => ({ ...slot, refRole: slot.refRole ?? 'product' }))
|
|
563
563
|
}
|
|
564
|
+
// Drafts from the three-field era (卖点/保护要素/风格) fold into 参数信息.
|
|
565
|
+
merged.promptInfo = typeof merged.promptInfo === 'string' ? merged.promptInfo : ''
|
|
566
|
+
if (merged.promptInfo.trim() === '') {
|
|
567
|
+
const legacy = [merged.sellingPoints ?? '', merged.protectedFeatures ?? '', merged.styleHint ?? '']
|
|
568
|
+
.map(part => part.trim())
|
|
569
|
+
.filter(part => part !== '')
|
|
570
|
+
if (legacy.length > 0) merged.promptInfo = legacy.join('\n')
|
|
571
|
+
}
|
|
564
572
|
return merged
|
|
565
573
|
}
|
|
566
574
|
} catch { /* ignore malformed or unavailable storage */ }
|
|
@@ -568,6 +576,7 @@ export function ImageGenPanel(props: {
|
|
|
568
576
|
})
|
|
569
577
|
const [ecommercePreview, setEcommercePreview] = useState(false)
|
|
570
578
|
const [ecommerceGenerating, setEcommerceGenerating] = useState(false)
|
|
579
|
+
const [ecommerceEnhancing, setEcommerceEnhancing] = useState(false)
|
|
571
580
|
const [ecommerceProjectId, setEcommerceProjectId] = useState<string | null>(null)
|
|
572
581
|
const [ecommerceAssets, setEcommerceAssets] = useState<ProductAsset[]>([])
|
|
573
582
|
/** History-restored product set currently shown in the results canvas. */
|
|
@@ -827,6 +836,34 @@ export function ImageGenPanel(props: {
|
|
|
827
836
|
}
|
|
828
837
|
}
|
|
829
838
|
|
|
839
|
+
/** AI 帮写:整理/补全电商参数信息(提示词),走提示词增强通道。 */
|
|
840
|
+
const ecommerceEnhanceInfo = async (): Promise<void> => {
|
|
841
|
+
if (ecommerceEnhancing) return
|
|
842
|
+
const promptEndpointConfigured = (config?.promptApiUrl ?? '').trim() !== '' || configured
|
|
843
|
+
if ((config?.promptModel ?? '').trim() === '' || !promptEndpointConfigured || (!promptKeySet && !apiKeySet)) {
|
|
844
|
+
openSettingsGuide('enhancement')
|
|
845
|
+
return
|
|
846
|
+
}
|
|
847
|
+
setEcommerceEnhancing(true)
|
|
848
|
+
setError(null)
|
|
849
|
+
try {
|
|
850
|
+
const instruction = [
|
|
851
|
+
'你是电商图文策划。请把下面的商品信息整理成一段可直接用于 AI 生图提示词的中文参数描述(120 字以内),',
|
|
852
|
+
'涵盖商品主体、规格材质、核心卖点与必须保留的特征;信息缺失处按商品类目合理补全,不要解释,只输出整理结果。',
|
|
853
|
+
`商品名称:${ecommerce.productName.trim() || '未提供'}`,
|
|
854
|
+
`商品类目:${ecommerce.category}`,
|
|
855
|
+
`商品平台:${ecommerce.platform}`,
|
|
856
|
+
`已有信息:${ecommerce.promptInfo.trim() !== '' ? ecommerce.promptInfo : '无,请根据商品名称与类目合理补全'}`,
|
|
857
|
+
].join('\n')
|
|
858
|
+
const result = await api.enhancePrompt(instruction)
|
|
859
|
+
setEcommerce(previous => ({ ...previous, promptInfo: result }))
|
|
860
|
+
} catch (caught) {
|
|
861
|
+
setError(errorMessage(caught))
|
|
862
|
+
} finally {
|
|
863
|
+
setEcommerceEnhancing(false)
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
830
867
|
/** Read an uploaded reference image into a data URL. */
|
|
831
868
|
const acceptFile = (file: File | undefined): void => {
|
|
832
869
|
if (file === undefined) return
|
|
@@ -846,9 +883,10 @@ export function ImageGenPanel(props: {
|
|
|
846
883
|
reader.readAsDataURL(file)
|
|
847
884
|
}
|
|
848
885
|
|
|
849
|
-
/** Read uploaded product assets into session-only data-URL chips
|
|
850
|
-
*
|
|
851
|
-
|
|
886
|
+
/** Read uploaded product assets into session-only data-URL chips. Product
|
|
887
|
+
* refs cap at MAX_ECOMMERCE_ASSETS; the style group holds a single image
|
|
888
|
+
* that gets replaced on re-upload. */
|
|
889
|
+
const acceptEcommerceFiles = (files: FileList | undefined, role: 'product' | 'style' = 'product'): void => {
|
|
852
890
|
if (files === undefined) return
|
|
853
891
|
const incoming = Array.from(files).filter(file => file.type.startsWith('image/') && file.size <= REF_IMAGE_MAX_BYTES)
|
|
854
892
|
if (incoming.length === 0) {
|
|
@@ -861,7 +899,11 @@ export function ImageGenPanel(props: {
|
|
|
861
899
|
if (typeof reader.result !== 'string') return
|
|
862
900
|
const dataUrl = reader.result
|
|
863
901
|
setEcommerceAssets(previous => {
|
|
864
|
-
if (
|
|
902
|
+
if (role === 'style') {
|
|
903
|
+
const styleAsset = { id: newComparisonId(), dataUrl, name: file.name, role: 'style' as const }
|
|
904
|
+
return [...previous.filter(item => item.role !== 'style'), styleAsset]
|
|
905
|
+
}
|
|
906
|
+
if (previous.filter(item => item.role !== 'style').length >= MAX_ECOMMERCE_ASSETS) {
|
|
865
907
|
setError(tt('ecommerce.assetsFull'))
|
|
866
908
|
return previous
|
|
867
909
|
}
|
|
@@ -1059,9 +1101,7 @@ export function ImageGenPanel(props: {
|
|
|
1059
1101
|
platform: ecommerce.platform,
|
|
1060
1102
|
language: ecommerce.language,
|
|
1061
1103
|
size: ecommerce.size,
|
|
1062
|
-
|
|
1063
|
-
protectedFeatures: ecommerce.protectedFeatures,
|
|
1064
|
-
styleHint: ecommerce.styleHint,
|
|
1104
|
+
promptInfo: ecommerce.promptInfo,
|
|
1065
1105
|
},
|
|
1066
1106
|
generatedAt: new Date().toISOString(),
|
|
1067
1107
|
images: ecommerceMergedItems.map(item => ({
|
|
@@ -1494,6 +1534,8 @@ export function ImageGenPanel(props: {
|
|
|
1494
1534
|
const ecommerceTotal = ecommerceSlots.reduce((total, slot) => total + slot.count, 0)
|
|
1495
1535
|
const ecommerceGenerateDisabled = submitting || ecommerceGenerating || ecommerceSlots.length === 0 || ecommerce.productName.trim() === '' || (ecommerce.language === 'custom' && effectiveEcommerceLanguage(ecommerce) === '')
|
|
1496
1536
|
const ecommerceFileInput = useRef<HTMLInputElement>(null)
|
|
1537
|
+
/** Which group (主图/风格) the shared file input uploads into. */
|
|
1538
|
+
const ecommerceUploadRoleRef = useRef<'product' | 'style'>('product')
|
|
1497
1539
|
// The results canvas merges live tasks of the active project with restored
|
|
1498
1540
|
// history entries of the same project; restored slots that were regenerated
|
|
1499
1541
|
// this session are covered by their live counterparts (same slotKey).
|
|
@@ -1522,6 +1564,49 @@ export function ImageGenPanel(props: {
|
|
|
1522
1564
|
const ecommerceResultGroups = [...new Set(ecommerceMergedItems.map(item => item.label))]
|
|
1523
1565
|
.filter(label => label !== '')
|
|
1524
1566
|
.map(label => ({ label, items: ecommerceMergedItems.filter(item => item.label === label) }))
|
|
1567
|
+
// 套图结果左右布局:主图组独占左侧,其余分组在右侧纵排。live 任务的
|
|
1568
|
+
// slotKey 带序号后缀(main-1),这里按 main 前缀识别主图组。
|
|
1569
|
+
const ecommerceMainGroup = ecommerceResultGroups.find(group => group.items.some(item => item.slotKey === 'main' || item.slotKey.startsWith('main-'))) ?? null
|
|
1570
|
+
const ecommerceSideGroups = ecommerceResultGroups.filter(group => group !== ecommerceMainGroup)
|
|
1571
|
+
const renderEcommerceGroup = (group: { label: string, items: EcommerceResultItem[] }, main = false): React.JSX.Element => (
|
|
1572
|
+
<section key={group.label} className={css.ecommerceGroup} data-ecommerce-group={group.label} data-main={main ? '' : undefined}>
|
|
1573
|
+
<header>
|
|
1574
|
+
<strong>{group.label}</strong>
|
|
1575
|
+
<span>{group.items.filter(item => item.status === 'completed').length}/{group.items.length}</span>
|
|
1576
|
+
<button type="button" className={css.galleryBulkButton} disabled={ecommerceGenerating} onClick={() => { void regenerateEcommerceSlot(group.label) }}>{tt('ecommerce.results.regenerate')}</button>
|
|
1577
|
+
</header>
|
|
1578
|
+
<div className={css.ecommerceGroupGrid}>
|
|
1579
|
+
{group.items.map(item => (
|
|
1580
|
+
<div key={item.id} className={css.ecommerceTaskCard} data-status={item.status}>
|
|
1581
|
+
{item.status === 'completed' && item.images.length > 0 ? item.images.map((image, imageIndex) => (
|
|
1582
|
+
<figure
|
|
1583
|
+
key={imageIndex}
|
|
1584
|
+
className={css.imageCard}
|
|
1585
|
+
role="button"
|
|
1586
|
+
tabIndex={0}
|
|
1587
|
+
title={tt('preview.open')}
|
|
1588
|
+
onClick={() => { openPreview(item.images, imageIndex) }}
|
|
1589
|
+
>
|
|
1590
|
+
<img className={css.image} src={srcOf(image)} alt={`${group.label} ${imageIndex + 1}`} />
|
|
1591
|
+
<span className={css.ecommerceResultBadge}>{group.label}</span>
|
|
1592
|
+
<span className={css.ecommerceTaskActions} onClick={event => event.stopPropagation()}>
|
|
1593
|
+
<a className={css.ecommerceActionChip} href={srcOf(image)} download={`product-${item.slotKey || item.id}-${imageIndex + 1}.${extensionOf(image.mime)}`}>{tt('download')}</a>
|
|
1594
|
+
<button type="button" className={css.ecommerceActionChip} disabled={galleryAdding} onClick={() => { void addToGallery(image) }}>{tt('gallery.add')}</button>
|
|
1595
|
+
<button type="button" className={css.ecommerceActionChip} disabled={conversationBusy} onClick={() => { void addImageToConversation(image, imageIndex, `${item.id}:${imageIndex}`) }}>{addingToConversation === `${item.id}:${imageIndex}` ? tt('conversation.adding') : tt('conversation.add')}</button>
|
|
1596
|
+
</span>
|
|
1597
|
+
</figure>
|
|
1598
|
+
)) : (
|
|
1599
|
+
<span className={css.ecommerceTaskState}>
|
|
1600
|
+
<b>{group.label}</b>
|
|
1601
|
+
{tt(`tasks.${item.status}` as never)}
|
|
1602
|
+
{item.error !== undefined ? ` · ${item.error}` : ''}
|
|
1603
|
+
</span>
|
|
1604
|
+
)}
|
|
1605
|
+
</div>
|
|
1606
|
+
))}
|
|
1607
|
+
</div>
|
|
1608
|
+
</section>
|
|
1609
|
+
)
|
|
1525
1610
|
const conversationBusy = addingToConversation !== null || galleryConversationAddingId !== null || historyConversationAddingId !== null
|
|
1526
1611
|
const viewingEntry = viewingHistoryId === null ? null : history.find(entry => entry.id === viewingHistoryId) ?? null
|
|
1527
1612
|
const viewingGalleryEntry = galleryViewingId === null ? null : gallery.find(entry => entry.id === galleryViewingId) ?? null
|
|
@@ -1752,13 +1837,22 @@ export function ImageGenPanel(props: {
|
|
|
1752
1837
|
<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>
|
|
1753
1838
|
</a>
|
|
1754
1839
|
</span>
|
|
1755
|
-
<
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1840
|
+
<GooeyNav
|
|
1841
|
+
ariaLabel={tt('workspace.label')}
|
|
1842
|
+
activeIndex={workspace === 'normal' ? (tab === 'gallery' ? 1 : 0) : workspace === 'canvas' ? 2 : 3}
|
|
1843
|
+
onSelect={index => {
|
|
1844
|
+
if (index === 0) openTab('text')
|
|
1845
|
+
else if (index === 1) openTab('gallery')
|
|
1846
|
+
else if (index === 2) setWorkspace('canvas')
|
|
1847
|
+
else setWorkspace('ecommerce')
|
|
1848
|
+
}}
|
|
1849
|
+
items={[
|
|
1850
|
+
{ key: 'normal', label: tt('workspace.normal') },
|
|
1851
|
+
{ key: 'gallery', label: tt('gallery.title') },
|
|
1852
|
+
{ key: 'canvas', label: tt('workspace.canvas') },
|
|
1853
|
+
{ key: 'ecommerce', label: <>{tt('workspace.ecommerce')}<span className={css.previewBadge}>{tt('ecommerce.badge')}</span></> },
|
|
1854
|
+
]}
|
|
1855
|
+
/>
|
|
1762
1856
|
<span className={css.panelHeaderActions}>
|
|
1763
1857
|
<button
|
|
1764
1858
|
type="button"
|
|
@@ -1883,30 +1977,13 @@ export function ImageGenPanel(props: {
|
|
|
1883
1977
|
{workspace === 'ecommerce' ? (
|
|
1884
1978
|
<section className={css.ecommerceWorkspace} data-ecommerce-workspace="">
|
|
1885
1979
|
<div className={css.ecommerceSection}>
|
|
1886
|
-
<
|
|
1887
|
-
|
|
1888
|
-
<span className={css.
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
{ecommerceAssets.length === 0 ? (
|
|
1892
|
-
<button
|
|
1893
|
-
type="button"
|
|
1894
|
-
className={css.ecommerceUploadHero}
|
|
1895
|
-
data-ecommerce-upload=""
|
|
1896
|
-
onClick={() => { ecommerceFileInput.current?.click() }}
|
|
1897
|
-
onDragOver={(event) => { event.preventDefault() }}
|
|
1898
|
-
onDrop={(event) => {
|
|
1899
|
-
event.preventDefault()
|
|
1900
|
-
acceptEcommerceFiles(event.dataTransfer.files ?? undefined)
|
|
1901
|
-
}}
|
|
1902
|
-
>
|
|
1903
|
-
<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M8 10V3.5"/><path d="M5.5 5.5L8 3l2.5 2.5"/><path d="M3 9.5V12a1.5 1.5 0 001.5 1.5h7A1.5 1.5 0 0013 12V9.5"/></svg>
|
|
1904
|
-
<span>{tt('ecommerce.uploadRef')}</span>
|
|
1905
|
-
<small>{tt('edit.uploadHint')}</small>
|
|
1906
|
-
</button>
|
|
1907
|
-
) : (
|
|
1980
|
+
<header className={css.ecommerceCardHead}>
|
|
1981
|
+
<h3>{tt('ecommerce.productRefTitle')}<small className={css.ecommerceSectionHint}>{tt('ecommerce.productRefHint')}</small></h3>
|
|
1982
|
+
<span className={css.ecommerceCardCount}>{ecommerceAssets.filter(asset => asset.role !== 'style').length}/{MAX_ECOMMERCE_ASSETS}</span>
|
|
1983
|
+
</header>
|
|
1984
|
+
{ecommerceAssets.some(asset => asset.role !== 'style') ? (
|
|
1908
1985
|
<div className={css.ecommerceAssets}>
|
|
1909
|
-
{ecommerceAssets.map(asset => (
|
|
1986
|
+
{ecommerceAssets.filter(asset => asset.role !== 'style').map(asset => (
|
|
1910
1987
|
<div key={asset.id} className={css.ecommerceAsset} data-ecommerce-asset="">
|
|
1911
1988
|
<img src={asset.dataUrl} alt={asset.name} />
|
|
1912
1989
|
<select
|
|
@@ -1915,24 +1992,24 @@ export function ImageGenPanel(props: {
|
|
|
1915
1992
|
aria-label={tt('ecommerce.refSelect')}
|
|
1916
1993
|
onChange={event => setEcommerceAssets(previous => previous.map(item => item.id === asset.id ? { ...item, role: event.target.value as EcommerceAssetRole } : item))}
|
|
1917
1994
|
>
|
|
1918
|
-
{ECOMMERCE_ASSET_ROLES.map(role => (
|
|
1995
|
+
{ECOMMERCE_ASSET_ROLES.filter(role => role !== 'style').map(role => (
|
|
1919
1996
|
<option key={role} value={role}>{tt(`ecommerce.role.${role}` as never)}</option>
|
|
1920
1997
|
))}
|
|
1921
1998
|
</select>
|
|
1922
1999
|
<button type="button" aria-label={tt('edit.remove')} onClick={() => { setEcommerceAssets(previous => previous.filter(item => item.id !== asset.id)) }}>×</button>
|
|
1923
2000
|
</div>
|
|
1924
2001
|
))}
|
|
1925
|
-
{ecommerceAssets.length < MAX_ECOMMERCE_ASSETS ? (
|
|
2002
|
+
{ecommerceAssets.filter(asset => asset.role !== 'style').length < MAX_ECOMMERCE_ASSETS ? (
|
|
1926
2003
|
<button
|
|
1927
2004
|
type="button"
|
|
1928
2005
|
className={css.ecommerceAssetAdd}
|
|
1929
2006
|
data-ecommerce-upload=""
|
|
1930
2007
|
title={tt('ecommerce.uploadRef')}
|
|
1931
|
-
onClick={() => { ecommerceFileInput.current?.click() }}
|
|
2008
|
+
onClick={() => { ecommerceUploadRoleRef.current = 'product'; ecommerceFileInput.current?.click() }}
|
|
1932
2009
|
onDragOver={(event) => { event.preventDefault() }}
|
|
1933
2010
|
onDrop={(event) => {
|
|
1934
2011
|
event.preventDefault()
|
|
1935
|
-
acceptEcommerceFiles(event.dataTransfer.files ?? undefined)
|
|
2012
|
+
acceptEcommerceFiles(event.dataTransfer.files ?? undefined, 'product')
|
|
1936
2013
|
}}
|
|
1937
2014
|
>
|
|
1938
2015
|
<span aria-hidden="true">+</span>
|
|
@@ -1940,8 +2017,79 @@ export function ImageGenPanel(props: {
|
|
|
1940
2017
|
</button>
|
|
1941
2018
|
) : null}
|
|
1942
2019
|
</div>
|
|
2020
|
+
) : (
|
|
2021
|
+
<button
|
|
2022
|
+
type="button"
|
|
2023
|
+
className={css.ecommerceUploadHero}
|
|
2024
|
+
data-ecommerce-upload=""
|
|
2025
|
+
onClick={() => { ecommerceUploadRoleRef.current = 'product'; ecommerceFileInput.current?.click() }}
|
|
2026
|
+
onDragOver={(event) => { event.preventDefault() }}
|
|
2027
|
+
onDrop={(event) => {
|
|
2028
|
+
event.preventDefault()
|
|
2029
|
+
acceptEcommerceFiles(event.dataTransfer.files ?? undefined, 'product')
|
|
2030
|
+
}}
|
|
2031
|
+
>
|
|
2032
|
+
<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M8 10V3.5"/><path d="M5.5 5.5L8 3l2.5 2.5"/><path d="M3 9.5V12a1.5 1.5 0 001.5 1.5h7A1.5 1.5 0 0013 12V9.5"/></svg>
|
|
2033
|
+
<span>{tt('ecommerce.uploadRef')}</span>
|
|
2034
|
+
<small>{tt('edit.uploadHint')}</small>
|
|
2035
|
+
</button>
|
|
2036
|
+
)}
|
|
2037
|
+
</div>
|
|
2038
|
+
<div className={css.ecommerceSection}>
|
|
2039
|
+
<header className={css.ecommerceCardHead}>
|
|
2040
|
+
<h3>{tt('ecommerce.styleRefTitle')}<small className={css.ecommerceSectionHint}>({tt('ecommerce.styleRefBadge')})</small></h3>
|
|
2041
|
+
<span className={css.ecommerceCardCount}>{ecommerceAssets.some(asset => asset.role === 'style') ? 1 : 0}/1</span>
|
|
2042
|
+
</header>
|
|
2043
|
+
<p className={css.ecommerceCardHint}>{tt('ecommerce.styleRefHint')}</p>
|
|
2044
|
+
{ecommerceAssets.some(asset => asset.role === 'style') ? (
|
|
2045
|
+
<div className={css.ecommerceAssets}>
|
|
2046
|
+
{ecommerceAssets.filter(asset => asset.role === 'style').map(asset => (
|
|
2047
|
+
<div key={asset.id} className={css.ecommerceAsset} data-ecommerce-asset="">
|
|
2048
|
+
<img src={asset.dataUrl} alt={asset.name} />
|
|
2049
|
+
<button type="button" aria-label={tt('edit.remove')} onClick={() => { setEcommerceAssets(previous => previous.filter(item => item.id !== asset.id)) }}>×</button>
|
|
2050
|
+
</div>
|
|
2051
|
+
))}
|
|
2052
|
+
</div>
|
|
2053
|
+
) : (
|
|
2054
|
+
<button
|
|
2055
|
+
type="button"
|
|
2056
|
+
className={css.ecommerceAssetAdd}
|
|
2057
|
+
data-ecommerce-upload=""
|
|
2058
|
+
title={tt('ecommerce.styleRefUpload')}
|
|
2059
|
+
onClick={() => { ecommerceUploadRoleRef.current = 'style'; ecommerceFileInput.current?.click() }}
|
|
2060
|
+
onDragOver={(event) => { event.preventDefault() }}
|
|
2061
|
+
onDrop={(event) => {
|
|
2062
|
+
event.preventDefault()
|
|
2063
|
+
acceptEcommerceFiles(event.dataTransfer.files ?? undefined, 'style')
|
|
2064
|
+
}}
|
|
2065
|
+
>
|
|
2066
|
+
<span aria-hidden="true">+</span>
|
|
2067
|
+
<small>{tt('ecommerce.styleRefUpload')}</small>
|
|
2068
|
+
</button>
|
|
1943
2069
|
)}
|
|
1944
2070
|
</div>
|
|
2071
|
+
<div className={css.ecommerceSection}>
|
|
2072
|
+
<label className={css.ecommerceField}>
|
|
2073
|
+
<span className={css.ecommerceFieldLabel}>{tt('ecommerce.productName')}</span>
|
|
2074
|
+
<input value={ecommerce.productName} placeholder={tt('ecommerce.productName')} onChange={event => setEcommerce(previous => ({ ...previous, productName: event.target.value }))} />
|
|
2075
|
+
</label>
|
|
2076
|
+
</div>
|
|
2077
|
+
<div className={css.ecommerceSection}>
|
|
2078
|
+
<header className={css.ecommerceCardHead}>
|
|
2079
|
+
<h3>{tt('ecommerce.refInfoTitle')}<small className={css.ecommerceSectionHint}>({tt('ecommerce.optional')})</small></h3>
|
|
2080
|
+
<button
|
|
2081
|
+
type="button"
|
|
2082
|
+
className={css.ecommerceAiButton}
|
|
2083
|
+
disabled={ecommerceEnhancing}
|
|
2084
|
+
title={tt('ecommerce.aiWriteHint')}
|
|
2085
|
+
onClick={() => { void ecommerceEnhanceInfo() }}
|
|
2086
|
+
>
|
|
2087
|
+
<svg viewBox="0 0 16 16" width="11" height="11" fill="currentColor" aria-hidden="true"><path d="M8 1.5l1.4 3.6L13 6.5l-3.6 1.4L8 11.5 6.6 7.9 3 6.5l3.6-1.4z"/></svg>
|
|
2088
|
+
{ecommerceEnhancing ? tt('ecommerce.aiWriting') : tt('ecommerce.aiWrite')}
|
|
2089
|
+
</button>
|
|
2090
|
+
</header>
|
|
2091
|
+
<textarea value={ecommerce.promptInfo} placeholder={tt('ecommerce.promptInfoPlaceholder')} onChange={event => setEcommerce(previous => ({ ...previous, promptInfo: event.target.value }))} />
|
|
2092
|
+
</div>
|
|
1945
2093
|
<div className={css.ecommerceSection}>
|
|
1946
2094
|
<h3>{tt('ecommerce.params')}</h3>
|
|
1947
2095
|
<div className={css.ecommerceParamGrid}>
|
|
@@ -1964,20 +2112,16 @@ export function ImageGenPanel(props: {
|
|
|
1964
2112
|
/>
|
|
1965
2113
|
) : null}
|
|
1966
2114
|
</label>
|
|
1967
|
-
<label className={css.ecommerceField}>
|
|
1968
|
-
<span className={css.ecommerceFieldLabel}>{tt('ecommerce.ratioLabel')}</span>
|
|
1969
|
-
<select value={ecommerce.size} onChange={event => setEcommerce(previous => ({ ...previous, size: event.target.value }))}>{SIZES.filter(size => size !== 'auto').map(size => <option key={size}>{size}</option>)}</select>
|
|
1970
|
-
</label>
|
|
1971
2115
|
<label className={css.ecommerceField}>
|
|
1972
2116
|
<span className={css.ecommerceFieldLabel}>{tt('ecommerce.categoryLabel')}</span>
|
|
1973
2117
|
<select value={ecommerce.category} onChange={event => setEcommerce(previous => ({ ...previous, category: event.target.value }))}><option>通用商品</option><option>食品饮料</option><option>美妆个护</option><option>服装配饰</option><option>家居用品</option><option>3C 数码</option></select>
|
|
1974
2118
|
</label>
|
|
2119
|
+
<label className={css.ecommerceField}>
|
|
2120
|
+
<span className={css.ecommerceFieldLabel}>{tt('ecommerce.ratioLabel')}</span>
|
|
2121
|
+
<select value={ecommerce.size} onChange={event => setEcommerce(previous => ({ ...previous, size: event.target.value }))}>{SIZES.filter(size => size !== 'auto').map(size => <option key={size}>{size}</option>)}</select>
|
|
2122
|
+
</label>
|
|
1975
2123
|
</div>
|
|
1976
2124
|
</div>
|
|
1977
|
-
<div className={css.ecommerceSection}>
|
|
1978
|
-
<h3>{tt('ecommerce.sellingTitle')}</h3>
|
|
1979
|
-
<textarea value={ecommerce.sellingPoints} placeholder={tt('ecommerce.sellingPoints')} onChange={event => setEcommerce(previous => ({ ...previous, sellingPoints: event.target.value }))} />
|
|
1980
|
-
</div>
|
|
1981
2125
|
<div className={css.ecommerceSection}>
|
|
1982
2126
|
<h3>{tt('ecommerce.setStructure')}<small className={css.ecommerceSectionHint}>{tt('ecommerce.multiSelect')}</small></h3>
|
|
1983
2127
|
<div className={css.ecommerceStructureGrid}>
|
|
@@ -2033,12 +2177,6 @@ export function ImageGenPanel(props: {
|
|
|
2033
2177
|
<select value={modeModels.includes(model) ? model : modeModels[0] ?? ''} aria-label={tt('model.label')} onChange={event => setModel(event.target.value)}>{modeModels.map(option => <option key={option} value={option}>{option}</option>)}</select>
|
|
2034
2178
|
<div className={css.optionRow}>{QUALITIES.map(option => <Pill key={option} active={quality === option} onClick={() => { setQuality(option) }} className={css.optionPill}>{tt(`quality.${option}` as const)}</Pill>)}</div>
|
|
2035
2179
|
</div>
|
|
2036
|
-
<div className={css.ecommerceSection}>
|
|
2037
|
-
<h3>{tt('ecommerce.styleTitle')}</h3>
|
|
2038
|
-
<textarea value={ecommerce.styleHint} placeholder={tt('ecommerce.styleHint')} onChange={event => setEcommerce(previous => ({ ...previous, styleHint: event.target.value }))} />
|
|
2039
|
-
<span className={css.ecommerceFieldLabel}>{tt('ecommerce.protectedLabel')}</span>
|
|
2040
|
-
<textarea value={ecommerce.protectedFeatures} placeholder={tt('ecommerce.protectedFeatures')} onChange={event => setEcommerce(previous => ({ ...previous, protectedFeatures: event.target.value }))} />
|
|
2041
|
-
</div>
|
|
2042
2180
|
<input
|
|
2043
2181
|
ref={ecommerceFileInput}
|
|
2044
2182
|
type="file"
|
|
@@ -2046,7 +2184,7 @@ export function ImageGenPanel(props: {
|
|
|
2046
2184
|
accept="image/png,image/jpeg,image/webp,image/gif"
|
|
2047
2185
|
className={css.hiddenFile}
|
|
2048
2186
|
onChange={(event) => {
|
|
2049
|
-
acceptEcommerceFiles(event.target.files ?? undefined)
|
|
2187
|
+
acceptEcommerceFiles(event.target.files ?? undefined, ecommerceUploadRoleRef.current)
|
|
2050
2188
|
event.target.value = ''
|
|
2051
2189
|
}}
|
|
2052
2190
|
/>
|
|
@@ -2456,46 +2594,11 @@ export function ImageGenPanel(props: {
|
|
|
2456
2594
|
{ecommerceMergedItems.length === 0 ? (
|
|
2457
2595
|
<div className={css.ecommerceResultsEmpty}>{tt('ecommerce.results.empty')}</div>
|
|
2458
2596
|
) : (
|
|
2459
|
-
<div className={css.ecommerceGroups}>
|
|
2460
|
-
{
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
<span>{group.items.filter(item => item.status === 'completed').length}/{group.items.length}</span>
|
|
2465
|
-
<button type="button" className={css.galleryBulkButton} disabled={ecommerceGenerating} onClick={() => { void regenerateEcommerceSlot(group.label) }}>{tt('ecommerce.results.regenerate')}</button>
|
|
2466
|
-
</header>
|
|
2467
|
-
<div className={css.ecommerceGroupGrid}>
|
|
2468
|
-
{group.items.map(item => (
|
|
2469
|
-
<div key={item.id} className={css.ecommerceTaskCard} data-status={item.status}>
|
|
2470
|
-
{item.status === 'completed' && item.images.length > 0 ? item.images.map((image, imageIndex) => (
|
|
2471
|
-
<figure
|
|
2472
|
-
key={imageIndex}
|
|
2473
|
-
className={css.imageCard}
|
|
2474
|
-
role="button"
|
|
2475
|
-
tabIndex={0}
|
|
2476
|
-
title={tt('preview.open')}
|
|
2477
|
-
onClick={() => { openPreview(item.images, imageIndex) }}
|
|
2478
|
-
>
|
|
2479
|
-
<img className={css.image} src={srcOf(image)} alt={`${group.label} ${imageIndex + 1}`} />
|
|
2480
|
-
<span className={css.ecommerceResultBadge}>{group.label}</span>
|
|
2481
|
-
<span className={css.ecommerceTaskActions} onClick={event => event.stopPropagation()}>
|
|
2482
|
-
<a className={css.ecommerceActionChip} href={srcOf(image)} download={`product-${item.slotKey || item.id}-${imageIndex + 1}.${extensionOf(image.mime)}`}>{tt('download')}</a>
|
|
2483
|
-
<button type="button" className={css.ecommerceActionChip} disabled={galleryAdding} onClick={() => { void addToGallery(image) }}>{tt('gallery.add')}</button>
|
|
2484
|
-
<button type="button" className={css.ecommerceActionChip} disabled={conversationBusy} onClick={() => { void addImageToConversation(image, imageIndex, `${item.id}:${imageIndex}`) }}>{addingToConversation === `${item.id}:${imageIndex}` ? tt('conversation.adding') : tt('conversation.add')}</button>
|
|
2485
|
-
</span>
|
|
2486
|
-
</figure>
|
|
2487
|
-
)) : (
|
|
2488
|
-
<span className={css.ecommerceTaskState}>
|
|
2489
|
-
<b>{group.label}</b>
|
|
2490
|
-
{tt(`tasks.${item.status}` as never)}
|
|
2491
|
-
{item.error !== undefined ? ` · ${item.error}` : ''}
|
|
2492
|
-
</span>
|
|
2493
|
-
)}
|
|
2494
|
-
</div>
|
|
2495
|
-
))}
|
|
2496
|
-
</div>
|
|
2497
|
-
</section>
|
|
2498
|
-
))}
|
|
2597
|
+
<div className={css.ecommerceGroups} data-split={ecommerceMainGroup !== null ? 'true' : undefined}>
|
|
2598
|
+
{ecommerceMainGroup !== null ? renderEcommerceGroup(ecommerceMainGroup, true) : null}
|
|
2599
|
+
<div className={css.ecommerceGroupsSide}>
|
|
2600
|
+
{ecommerceSideGroups.map(group => renderEcommerceGroup(group))}
|
|
2601
|
+
</div>
|
|
2499
2602
|
</div>
|
|
2500
2603
|
)}
|
|
2501
2604
|
</div>
|