@dsh-plus/image-studio 0.1.0 → 0.1.1

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.
@@ -0,0 +1,217 @@
1
+ /**
2
+ * 画廊视图:条目网格(首图缩略)+ 详情模态(全部图片、提示词/改写、
3
+ * 请求参数回放、衍生链源图、下载/删除/二次编辑)。
4
+ * @module image-studio/client/panel/gallery
5
+ */
6
+ import { IconDownload, IconRefreshOutline16, IconTrashOutline16 } from '@dsh-plus/shared/client'
7
+ import { type ReactElement, useState } from 'react'
8
+ import type { GalleryItem } from '../../gallery/store.ts'
9
+ import { deleteGalleryItem, imageUrl } from '../api.ts'
10
+ import type { Translate } from '../i18n.ts'
11
+
12
+ interface GalleryViewProps {
13
+ t: Translate
14
+ items: GalleryItem[]
15
+ loading: boolean
16
+ failed: boolean
17
+ onRefresh(): void
18
+ onEdit(item: GalleryItem): void
19
+ /** 外部请求打开的条目(任务条「查看」);打开后由 onDetailOpened 消费。 */
20
+ detailId: string | null
21
+ onDetailChange(id: string | null): void
22
+ hidden: boolean
23
+ }
24
+
25
+ function fmtTime(iso: string): string {
26
+ const date = new Date(iso)
27
+ return Number.isNaN(date.getTime()) ? iso : date.toLocaleString()
28
+ }
29
+
30
+ /** 详情模态。 */
31
+ function GalleryDetail(props: {
32
+ t: Translate
33
+ item: GalleryItem
34
+ onClose(): void
35
+ onEdit(item: GalleryItem): void
36
+ onDeleted(id: string): void
37
+ }): ReactElement {
38
+ const { t, item, onClose, onEdit, onDeleted } = props
39
+ const [confirming, setConfirming] = useState(false)
40
+ const [deleting, setDeleting] = useState(false)
41
+ const [error, setError] = useState<string | null>(null)
42
+
43
+ const doDelete = (): void => {
44
+ setDeleting(true)
45
+ deleteGalleryItem(item.id)
46
+ .then(() => onDeleted(item.id))
47
+ .catch((err: unknown) => {
48
+ setError(err instanceof Error ? err.message : String(err))
49
+ setDeleting(false)
50
+ setConfirming(false)
51
+ })
52
+ }
53
+
54
+ const paramRows = Object.entries(item.params)
55
+ return (
56
+ // biome-ignore lint/a11y/noStaticElementInteractions: 背板点击关闭是指针便利交互,键盘用户走关闭/取消按钮(同 web-terminal 既有约定)
57
+ <div className="ims-detailBackdrop" role="presentation" onClick={onClose}>
58
+ {/* biome-ignore lint/a11y/useKeyWithClickEvents: 阻断冒泡仅防误触背板关闭,无实际点击行为 */}
59
+ <div
60
+ className="ims-detail"
61
+ role="dialog"
62
+ aria-modal="true"
63
+ aria-label={item.prompt.slice(0, 40)}
64
+ onClick={(event) => event.stopPropagation()}
65
+ >
66
+ <div className="ims-detailHead">
67
+ <span className="ims-detailMeta">
68
+ {t('gallery.detail.model')}:{item.model} · {t('gallery.detail.endpoint')}:
69
+ {item.endpoint} · {t('gallery.detail.time')}:{fmtTime(item.createdAt)}
70
+ </span>
71
+ <button type="button" className="ims-btn ims-btnGhost" onClick={onClose}>
72
+ {t('common.close')}
73
+ </button>
74
+ </div>
75
+ <div className="ims-detailBody">
76
+ <div className="ims-detailImages">
77
+ {item.imageIds.map((imageId, index) => (
78
+ <figure key={imageId} className="ims-detailFigure">
79
+ <img src={imageUrl(imageId)} alt={item.prompt} />
80
+ <figcaption>
81
+ {item.revisedPrompts[index] !== null &&
82
+ item.revisedPrompts[index] !== undefined ? (
83
+ <span className="ims-revised" title={item.revisedPrompts[index] ?? ''}>
84
+ {t('gallery.detail.revised')}:{item.revisedPrompts[index]}
85
+ </span>
86
+ ) : null}
87
+ <a
88
+ className="ims-btn ims-btnGhost ims-btnSmall"
89
+ href={imageUrl(imageId)}
90
+ download
91
+ >
92
+ <IconDownload size={14} /> {t('common.download')}
93
+ </a>
94
+ </figcaption>
95
+ </figure>
96
+ ))}
97
+ </div>
98
+ <div className="ims-detailInfo">
99
+ <h4 className="ims-detailLabel">{t('gallery.detail.prompt')}</h4>
100
+ <p className="ims-detailPrompt">{item.prompt}</p>
101
+ {item.sourceIds.length > 0 ? (
102
+ <>
103
+ <h4 className="ims-detailLabel">{t('gallery.detail.sources')}</h4>
104
+ <div className="ims-sources">
105
+ {item.sourceIds.map((imageId) => (
106
+ <span key={imageId} className="ims-thumb">
107
+ <img src={imageUrl(imageId)} alt="" />
108
+ </span>
109
+ ))}
110
+ </div>
111
+ </>
112
+ ) : null}
113
+ <h4 className="ims-detailLabel">{t('gallery.detail.params')}</h4>
114
+ {paramRows.length === 0 ? (
115
+ <p className="ims-hint">—</p>
116
+ ) : (
117
+ <table className="ims-paramTable">
118
+ <tbody>
119
+ {paramRows.map(([key, value]) => (
120
+ <tr key={key}>
121
+ <td className="ims-paramTableKey">{key}</td>
122
+ <td>{JSON.stringify(value)}</td>
123
+ </tr>
124
+ ))}
125
+ </tbody>
126
+ </table>
127
+ )}
128
+ </div>
129
+ </div>
130
+ <div className="ims-detailFoot">
131
+ {error !== null ? (
132
+ <p className="ims-status ims-statusError" role="alert">
133
+ {error}
134
+ </p>
135
+ ) : null}
136
+ <button type="button" className="ims-btn ims-btnPrimary" onClick={() => onEdit(item)}>
137
+ {t('gallery.edit')}
138
+ </button>
139
+ {confirming ? (
140
+ <button
141
+ type="button"
142
+ className="ims-btn ims-btnDanger"
143
+ disabled={deleting}
144
+ onClick={doDelete}
145
+ >
146
+ {t('common.confirmDelete')}
147
+ </button>
148
+ ) : (
149
+ <button
150
+ type="button"
151
+ className="ims-btn ims-btnGhost"
152
+ onClick={() => setConfirming(true)}
153
+ >
154
+ <IconTrashOutline16 size={14} /> {t('common.delete')}
155
+ </button>
156
+ )}
157
+ </div>
158
+ </div>
159
+ </div>
160
+ )
161
+ }
162
+
163
+ export function GalleryView(props: GalleryViewProps): ReactElement {
164
+ const { t, items, loading, failed, onRefresh, onEdit, detailId, onDetailChange } = props
165
+ const detail = items.find((item) => item.id === detailId) ?? null
166
+ return (
167
+ <div className="ims-gallery" hidden={props.hidden}>
168
+ <div className="ims-galleryBar">
169
+ <span className="ims-hint">{t('gallery.total').replace('{n}', String(items.length))}</span>
170
+ <button type="button" className="ims-btn ims-btnGhost ims-btnSmall" onClick={onRefresh}>
171
+ <IconRefreshOutline16 size={14} /> {t('common.refresh')}
172
+ </button>
173
+ </div>
174
+ {failed ? (
175
+ <p className="ims-empty">
176
+ {t('common.loadFailed')}
177
+ <button type="button" className="ims-btn ims-btnGhost ims-btnSmall" onClick={onRefresh}>
178
+ {t('common.retry')}
179
+ </button>
180
+ </p>
181
+ ) : loading ? (
182
+ <p className="ims-empty">{t('common.loading')}</p>
183
+ ) : items.length === 0 ? (
184
+ <p className="ims-empty">{t('gallery.empty')}</p>
185
+ ) : (
186
+ <div className="ims-grid">
187
+ {[...items].reverse().map((item) => (
188
+ <button
189
+ key={item.id}
190
+ type="button"
191
+ className="ims-cell"
192
+ onClick={() => onDetailChange(item.id)}
193
+ >
194
+ <img src={imageUrl(item.imageIds[0] ?? '')} alt={item.prompt} loading="lazy" />
195
+ <span className="ims-cellPrompt">{item.prompt}</span>
196
+ </button>
197
+ ))}
198
+ </div>
199
+ )}
200
+ {detail !== null ? (
201
+ <GalleryDetail
202
+ t={t}
203
+ item={detail}
204
+ onClose={() => onDetailChange(null)}
205
+ onEdit={(item) => {
206
+ onDetailChange(null)
207
+ onEdit(item)
208
+ }}
209
+ onDeleted={() => {
210
+ onDetailChange(null)
211
+ onRefresh()
212
+ }}
213
+ />
214
+ ) : null}
215
+ </div>
216
+ )
217
+ }