@dickpy/dsh-imagegen 1.5.0 → 1.5.2
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 +11 -8
- package/docs/images/ecommerce-mode.png +0 -0
- package/docs/images/gallery-workspace.png +0 -0
- package/docs/images/image-generation-studio-three-column.png +0 -0
- package/docs/images/imagegen-overview.png +0 -0
- package/docs/images/plugin-settings.png +0 -0
- package/docs/images/prompt-template-library.png +0 -0
- package/lib/client.js +824 -449
- package/lib/client.js.map +1 -1
- package/lib/index.js +496 -80
- package/package.json +23 -17
- package/src/client/ImageGenPanel.tsx +10 -8
- package/src/client/InspirationGallery.tsx +106 -0
- package/src/client/SettingsCard.tsx +1 -1
- package/src/client/TemplateLibrary.tsx +159 -48
- package/src/client/api.ts +41 -8
- package/src/client/channels-form.ts +1 -1
- package/src/client/conversation-sync.ts +1 -1
- package/src/client/image-toolview.tsx +16 -10
- package/src/client/index.ts +5 -4
- package/src/client/inspiration.module.css +148 -0
- package/src/client/locales.ts +34 -8
- package/src/client/mount.tsx +1 -1
- package/src/client/settings-form.ts +2 -1
- package/src/client/settings-scope.ts +9 -5
- package/src/client/templates.module.css +95 -0
- package/src/index.ts +26 -6
- package/src/protocol.ts +81 -7
- package/src/routes.ts +128 -12
- package/src/settings-compat.ts +60 -0
- package/src/template-favorites.ts +108 -0
- package/src/templates/canghe-cases.json +11126 -0
- package/src/templates-store.ts +179 -68
- package/docs/images/image-generation-studio-single.png +0 -0
- package/docs/images/image-generation-studio.png +0 -0
- package/docs/images/poster-features-16x9.png +0 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dickpy/dsh-imagegen",
|
|
3
3
|
"description": "AI image generation plugin for the dsh web GUI: text-to-image and image-to-image through configurable provider channels (gpt-image-2 / grok-imagine-image / nanobanana series / seedream-5.0-pro / dall-e-3, with native xAI Grok Imagine, Google Nano Banana and ByteDance Seedream request shaping), with per-channel model catalogs and a New Session / Image Generation tab entry opening a three-column studio beside the native conversation.",
|
|
4
|
-
"version": "1.5.
|
|
4
|
+
"version": "1.5.2",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|
|
@@ -15,9 +15,12 @@
|
|
|
15
15
|
},
|
|
16
16
|
"client": {
|
|
17
17
|
"inject": [
|
|
18
|
-
"@deepseek-ai/dsh-client-
|
|
18
|
+
"@deepseek-ai/dsh-client-store",
|
|
19
|
+
"@deepseek-ai/dsh-api-remotes",
|
|
20
|
+
"@deepseek-ai/dsh-api-session-controller",
|
|
19
21
|
"@deepseek-ai/dsh-client-connection",
|
|
20
22
|
"@deepseek-ai/dsh-client-ui-conversation",
|
|
23
|
+
"@deepseek-ai/dsh-client-ui-renderer",
|
|
21
24
|
"@deepseek-ai/dsh-client-ui-settings",
|
|
22
25
|
"@deepseek-ai/dsh-client-ui-tool"
|
|
23
26
|
],
|
|
@@ -28,21 +31,24 @@
|
|
|
28
31
|
"schemastery": "^3.18.0"
|
|
29
32
|
},
|
|
30
33
|
"devDependencies": {
|
|
31
|
-
"@deepseek-ai/cordis": "^4.0.
|
|
32
|
-
"@deepseek-ai/dsh-
|
|
33
|
-
"@deepseek-ai/dsh-
|
|
34
|
-
"@deepseek-ai/dsh-
|
|
35
|
-
"@deepseek-ai/dsh-client-
|
|
36
|
-
"@deepseek-ai/dsh-client-
|
|
37
|
-
"@deepseek-ai/dsh-client-
|
|
38
|
-
"@deepseek-ai/dsh-client-ui-
|
|
39
|
-
"@deepseek-ai/dsh-client-ui-
|
|
40
|
-
"@deepseek-ai/dsh-
|
|
41
|
-
"@deepseek-ai/dsh-
|
|
42
|
-
"@deepseek-ai/dsh-
|
|
43
|
-
"@deepseek-ai/dsh-
|
|
44
|
-
"@deepseek-ai/dsh-
|
|
45
|
-
"@deepseek-ai/dsh-
|
|
34
|
+
"@deepseek-ai/cordis": "^4.0.2",
|
|
35
|
+
"@deepseek-ai/dsh-api-remotes": "0.1.2-alpha.2",
|
|
36
|
+
"@deepseek-ai/dsh-api-session-controller": "0.1.2-alpha.2",
|
|
37
|
+
"@deepseek-ai/dsh-attachment": "0.1.2-alpha.2",
|
|
38
|
+
"@deepseek-ai/dsh-client-connection": "0.1.2-alpha.2",
|
|
39
|
+
"@deepseek-ai/dsh-client-locale": "0.1.2-alpha.2",
|
|
40
|
+
"@deepseek-ai/dsh-client-store": "0.1.2-alpha.2",
|
|
41
|
+
"@deepseek-ai/dsh-client-ui-conversation": "0.1.2-alpha.2",
|
|
42
|
+
"@deepseek-ai/dsh-client-ui-primitives": "0.1.2-alpha.2",
|
|
43
|
+
"@deepseek-ai/dsh-client-ui-renderer": "0.1.2-alpha.2",
|
|
44
|
+
"@deepseek-ai/dsh-client-ui-settings": "0.1.2-alpha.2",
|
|
45
|
+
"@deepseek-ai/dsh-client-ui-slots": "0.1.2-alpha.2",
|
|
46
|
+
"@deepseek-ai/dsh-commands": "0.1.2-alpha.2",
|
|
47
|
+
"@deepseek-ai/dsh-host-webserver": "0.1.2-alpha.2",
|
|
48
|
+
"@deepseek-ai/dsh-llm": "0.1.2-alpha.2",
|
|
49
|
+
"@deepseek-ai/dsh-settings": "0.1.2-alpha.2",
|
|
50
|
+
"@deepseek-ai/dsh-system-prompt": "0.1.2-alpha.2",
|
|
51
|
+
"@deepseek-ai/dsh-tools": "0.1.2-alpha.2",
|
|
46
52
|
"@types/node": "^22.20.0",
|
|
47
53
|
"@types/react": "~18.3.1",
|
|
48
54
|
"@types/react-dom": "^18.3.5",
|
|
@@ -11,11 +11,13 @@
|
|
|
11
11
|
import { useEffect, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent } from 'react'
|
|
12
12
|
import { createPortal } from 'react-dom'
|
|
13
13
|
import { Button, Pill } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
14
|
-
import type {
|
|
14
|
+
import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
|
|
15
|
+
import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client'
|
|
15
16
|
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
|
16
17
|
import type { ImageGenApi } from './api.ts'
|
|
17
18
|
import { errorMessage, tt } from './helpers.ts'
|
|
18
19
|
import { TemplateLibrary } from './TemplateLibrary.tsx'
|
|
20
|
+
import { InspirationGallery } from './InspirationGallery.tsx'
|
|
19
21
|
import type { EcommerceRefRole, GeneratedImage, GenerateMode, GenerateRequest, GenerationTask, GenerationTaskStatus, HistoryEntry, HistoryImageRef, ProductSetDraft, ProductSetSlot, UpdateInfo } from '../protocol.ts'
|
|
20
22
|
import { AGENT_IMAGE_API } from '../protocol.ts'
|
|
21
23
|
import type { ImageGenConfig, ImageGenScope } from './settings-scope.ts'
|
|
@@ -2427,13 +2429,13 @@ export function ImageGenPanel(props: {
|
|
|
2427
2429
|
) : null}
|
|
2428
2430
|
|
|
2429
2431
|
{!generating && !error && images.length === 0 && workspace !== 'ecommerce' ? (
|
|
2430
|
-
<
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2432
|
+
<InspirationGallery
|
|
2433
|
+
api={api}
|
|
2434
|
+
onUse={(text) => {
|
|
2435
|
+
setPrompt(text)
|
|
2436
|
+
setError(null)
|
|
2437
|
+
}}
|
|
2438
|
+
/>
|
|
2437
2439
|
) : null}
|
|
2438
2440
|
|
|
2439
2441
|
{!generating && images.length > 0 && workspace !== 'ecommerce' ? (
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inspiration wall for the studio's empty canvas: a small grid of random
|
|
3
|
+
* template cases sampled host-side across every library source. Clicking a
|
|
4
|
+
* card hands its prompt to the form; the 随机 button re-rolls the pick. On
|
|
5
|
+
* failure the wall collapses to nothing (the panel falls back to the plain
|
|
6
|
+
* empty-state hint).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useEffect, useState } from 'react'
|
|
10
|
+
import { Button } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
11
|
+
import type { ImageGenApi } from './api.ts'
|
|
12
|
+
import { tt } from './helpers.ts'
|
|
13
|
+
import { TEMPLATES_API, type TemplateSample } from '../protocol.ts'
|
|
14
|
+
import css from './inspiration.module.css'
|
|
15
|
+
|
|
16
|
+
/** Card count per deal — a 4×3 wall that uses the canvas's spare width. */
|
|
17
|
+
const SAMPLE_COUNT = 12
|
|
18
|
+
|
|
19
|
+
/** Same-origin proxy URL of one sampled case's reference image. */
|
|
20
|
+
function imageUrlOf(sample: TemplateSample): string {
|
|
21
|
+
return `${TEMPLATES_API.image}/${encodeURIComponent(sample.sourceId)}/${encodeURIComponent(sample.case.image)}`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** One thumbnail that degrades to a text tile when the image 404s. */
|
|
25
|
+
function SampleThumb(props: { sample: TemplateSample }) {
|
|
26
|
+
const [failed, setFailed] = useState(false)
|
|
27
|
+
return (
|
|
28
|
+
<span className={css.thumbWrap}>
|
|
29
|
+
{props.sample.case.image !== '' && !failed ? (
|
|
30
|
+
<img className={css.thumb} src={imageUrlOf(props.sample)} alt={props.sample.case.title} loading="lazy" onError={() => { setFailed(true) }} />
|
|
31
|
+
) : (
|
|
32
|
+
<span className={css.thumbFallback} aria-hidden="true">{props.sample.case.title}</span>
|
|
33
|
+
)}
|
|
34
|
+
<span className={css.thumbTitle}>{props.sample.case.title}</span>
|
|
35
|
+
</span>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The random-case wall shown while the canvas has no results. */
|
|
40
|
+
export function InspirationGallery(props: {
|
|
41
|
+
api: ImageGenApi
|
|
42
|
+
/** Hand the picked prompt back to the studio form. */
|
|
43
|
+
onUse: (prompt: string) => void
|
|
44
|
+
}) {
|
|
45
|
+
const { api, onUse } = props
|
|
46
|
+
const [samples, setSamples] = useState<TemplateSample[] | null>(null)
|
|
47
|
+
const [loading, setLoading] = useState(false)
|
|
48
|
+
|
|
49
|
+
const deal = (): void => {
|
|
50
|
+
if (loading) return
|
|
51
|
+
setLoading(true)
|
|
52
|
+
api.templatesSample(SAMPLE_COUNT)
|
|
53
|
+
.then(setSamples)
|
|
54
|
+
.catch(() => { setSamples(current => current ?? []) })
|
|
55
|
+
.finally(() => { setLoading(false) })
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Deal once on mount; shuffles re-use the same handler.
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
deal()
|
|
61
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
62
|
+
}, [])
|
|
63
|
+
|
|
64
|
+
// Sample fetch failed or the libraries are empty: collapse to the plain
|
|
65
|
+
// empty-state hint so the canvas never looks broken.
|
|
66
|
+
if (samples !== null && samples.length === 0) {
|
|
67
|
+
return (
|
|
68
|
+
<div className={css.wrap} aria-label={tt('inspiration.title')}>
|
|
69
|
+
<span className={css.emptyIcon}>
|
|
70
|
+
<svg viewBox="0 0 24 24" width="34" height="34" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="3"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>
|
|
71
|
+
</span>
|
|
72
|
+
<span className={css.emptyTitle}>{tt('canvas.emptyTitle')}</span>
|
|
73
|
+
<span className={css.emptyHint}>{tt('canvas.emptyHint')}</span>
|
|
74
|
+
</div>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div className={css.wrap} aria-label={tt('inspiration.title')}>
|
|
80
|
+
<span className={css.title}>{tt('inspiration.title')}</span>
|
|
81
|
+
{samples === null ? (
|
|
82
|
+
<span className={css.spinner} aria-hidden="true" />
|
|
83
|
+
) : (
|
|
84
|
+
<>
|
|
85
|
+
<div className={css.grid}>
|
|
86
|
+
{samples.map(sample => (
|
|
87
|
+
<button
|
|
88
|
+
key={`${sample.sourceId}:${sample.case.id}`}
|
|
89
|
+
type="button"
|
|
90
|
+
className={css.tile}
|
|
91
|
+
title={tt('inspiration.useHint')}
|
|
92
|
+
onClick={() => { onUse(sample.case.prompt) }}
|
|
93
|
+
>
|
|
94
|
+
<SampleThumb sample={sample} />
|
|
95
|
+
</button>
|
|
96
|
+
))}
|
|
97
|
+
</div>
|
|
98
|
+
<Button variant="outline" size="sm" disabled={loading} onClick={deal}>
|
|
99
|
+
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M2 18h1.4c1.3 0 2.5-.6 3.3-1.7l6.1-8.6c.8-1.1 2-1.7 3.3-1.7H22"/><path d="m18 2 4 4-4 4"/><path d="M2 6h1.9c1.5 0 2.9.9 3.6 2.2"/><path d="M22 18h-5.9c-1.3 0-2.6-.7-3.3-1.8l-.5-.8"/><path d="m18 14 4 4-4 4"/></svg>
|
|
100
|
+
{loading ? tt('inspiration.shuffling') : tt('inspiration.shuffle')}
|
|
101
|
+
</Button>
|
|
102
|
+
</>
|
|
103
|
+
)}
|
|
104
|
+
</div>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import { useEffect, useRef, useState } from 'react'
|
|
16
16
|
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
|
17
|
-
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-
|
|
17
|
+
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
|
|
18
18
|
import { CardForm, booleanField, secretField, textField, type CardActions, type CardShell, type FieldState as CardFieldState } from './settings-form.ts'
|
|
19
19
|
import { ChannelsForm, type ChannelDraft, type ChannelsFormActions, type ChannelsFormState } from './channels-form.ts'
|
|
20
20
|
import type { ImageGenScope } from './settings-scope.ts'
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Prompt-template library overlay: a searchable, category-
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
2
|
+
* Prompt-template library overlay: a multi-source, searchable, category-
|
|
3
|
+
* filtered gallery. Each registered source (TEMPLATE_SOURCES) renders as its
|
|
4
|
+
* own tab with an independent list, refresh state, and image pool; case lists
|
|
5
|
+
* are served by the host (bundled snapshot, optionally refreshed online or
|
|
6
|
+
* auto-synced in the background) and reference images load lazily through the
|
|
7
|
+
* host's caching proxy, so browsing progressively mirrors the gallery onto the
|
|
8
|
+
* local disk. Templates can be starred; favorites persist host-side as full
|
|
9
|
+
* case snapshots and are reachable through the ★ filter pill per tab. Picking
|
|
10
|
+
* a template hands its prompt back to the studio form.
|
|
8
11
|
*/
|
|
9
12
|
|
|
10
13
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
@@ -12,19 +15,24 @@ import { createPortal } from 'react-dom'
|
|
|
12
15
|
import { Button } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
13
16
|
import type { ImageGenApi } from './api.ts'
|
|
14
17
|
import { errorMessage, tt } from './helpers.ts'
|
|
15
|
-
import { TEMPLATES_API, type TemplateCase, type TemplateListResult } from '../protocol.ts'
|
|
18
|
+
import { TEMPLATE_SOURCES, TEMPLATES_API, type TemplateCase, type TemplateFavorite, type TemplateListResult } from '../protocol.ts'
|
|
16
19
|
import css from './templates.module.css'
|
|
17
20
|
|
|
18
21
|
/** Concurrent image downloads while caching the whole gallery offline. */
|
|
19
22
|
const CACHE_ALL_CONCURRENCY = 4
|
|
20
23
|
|
|
24
|
+
/** Stable favorites key of one case within a source. */
|
|
25
|
+
function favoriteKeyOf(sourceId: string, item: TemplateCase): string {
|
|
26
|
+
return `${sourceId}:${item.id}`
|
|
27
|
+
}
|
|
28
|
+
|
|
21
29
|
/** Same-origin URL of one case's reference image (host caching proxy). */
|
|
22
|
-
function imageUrlOf(item: TemplateCase): string {
|
|
23
|
-
return `${TEMPLATES_API.image}/${encodeURIComponent(item.image)}`
|
|
30
|
+
function imageUrlOf(sourceId: string, item: TemplateCase): string {
|
|
31
|
+
return `${TEMPLATES_API.image}/${encodeURIComponent(sourceId)}/${encodeURIComponent(item.image)}`
|
|
24
32
|
}
|
|
25
33
|
|
|
26
34
|
/** A card thumbnail that falls back to a placeholder when the proxy 404s. */
|
|
27
|
-
function TemplateThumb(props: { item: TemplateCase }) {
|
|
35
|
+
function TemplateThumb(props: { sourceId: string; item: TemplateCase }) {
|
|
28
36
|
const [failed, setFailed] = useState(false)
|
|
29
37
|
if (props.item.image === '' || failed) {
|
|
30
38
|
return (
|
|
@@ -36,7 +44,7 @@ function TemplateThumb(props: { item: TemplateCase }) {
|
|
|
36
44
|
return (
|
|
37
45
|
<img
|
|
38
46
|
className={css.thumb}
|
|
39
|
-
src={imageUrlOf(props.item)}
|
|
47
|
+
src={imageUrlOf(props.sourceId, props.item)}
|
|
40
48
|
alt={props.item.title}
|
|
41
49
|
loading="lazy"
|
|
42
50
|
onError={() => { setFailed(true) }}
|
|
@@ -44,6 +52,30 @@ function TemplateThumb(props: { item: TemplateCase }) {
|
|
|
44
52
|
)
|
|
45
53
|
}
|
|
46
54
|
|
|
55
|
+
/** Card-corner star toggle; the click must not open the detail view. Rendered
|
|
56
|
+
* as a span (a button cannot nest inside the card button). */
|
|
57
|
+
function FavoriteStar(props: { active: boolean; title: string; onToggle: () => void }) {
|
|
58
|
+
return (
|
|
59
|
+
<span
|
|
60
|
+
role="button"
|
|
61
|
+
tabIndex={0}
|
|
62
|
+
className={css.favStar}
|
|
63
|
+
data-active={props.active ? '' : undefined}
|
|
64
|
+
aria-label={props.title}
|
|
65
|
+
title={props.title}
|
|
66
|
+
onClick={(event) => { event.stopPropagation(); props.onToggle() }}
|
|
67
|
+
onKeyDown={(event) => {
|
|
68
|
+
if (event.key !== 'Enter' && event.key !== ' ') return
|
|
69
|
+
event.stopPropagation()
|
|
70
|
+
event.preventDefault()
|
|
71
|
+
props.onToggle()
|
|
72
|
+
}}
|
|
73
|
+
>
|
|
74
|
+
<svg viewBox="0 0 24 24" width="15" height="15" fill={props.active ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M12 3.6l2.6 5.3 5.8.8-4.2 4.1 1 5.8-5.2-2.7-5.2 2.7 1-5.8L3.6 9.7l5.8-.8z"/></svg>
|
|
75
|
+
</span>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
47
79
|
/** The template-library modal. Rendered through a portal above the studio. */
|
|
48
80
|
export function TemplateLibrary(props: {
|
|
49
81
|
api: ImageGenApi
|
|
@@ -52,31 +84,53 @@ export function TemplateLibrary(props: {
|
|
|
52
84
|
onClose: () => void
|
|
53
85
|
}) {
|
|
54
86
|
const { api, onUse, onClose } = props
|
|
55
|
-
const [
|
|
56
|
-
const [
|
|
87
|
+
const [activeSource, setActiveSource] = useState(TEMPLATE_SOURCES[0]!.id)
|
|
88
|
+
const [lists, setLists] = useState<Record<string, TemplateListResult>>({})
|
|
89
|
+
const [loadErrors, setLoadErrors] = useState<Record<string, string>>({})
|
|
90
|
+
const [favorites, setFavorites] = useState<TemplateFavorite[]>([])
|
|
57
91
|
const [query, setQuery] = useState('')
|
|
58
92
|
const [category, setCategory] = useState('')
|
|
93
|
+
const [favoritesOnly, setFavoritesOnly] = useState(false)
|
|
59
94
|
const [selected, setSelected] = useState<TemplateCase | null>(null)
|
|
60
95
|
const [copied, setCopied] = useState(false)
|
|
61
96
|
const [refreshing, setRefreshing] = useState(false)
|
|
62
97
|
const [notice, setNotice] = useState<string | null>(null)
|
|
63
98
|
const [cacheAll, setCacheAll] = useState<{ running: boolean; done: number; total: number }>({ running: false, done: 0, total: 0 })
|
|
64
99
|
const searchRef = useRef<HTMLInputElement>(null)
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
100
|
+
|
|
101
|
+
const list = lists[activeSource]
|
|
102
|
+
const loadError = loadErrors[activeSource] || null
|
|
103
|
+
|
|
104
|
+
/** Fetch one source's list into the per-source cache. */
|
|
105
|
+
const loadSource = (sourceId: string): void => {
|
|
106
|
+
api.templatesList(sourceId)
|
|
107
|
+
.then(result => {
|
|
108
|
+
setLists(current => ({ ...current, [sourceId]: result }))
|
|
109
|
+
setLoadErrors(current => ({ ...current, [sourceId]: '' }))
|
|
110
|
+
})
|
|
111
|
+
.catch(caught => { setLoadErrors(current => ({ ...current, [sourceId]: errorMessage(caught) })) })
|
|
69
112
|
}
|
|
70
113
|
|
|
71
|
-
// Load
|
|
114
|
+
// Load the first source plus the favorites on open; focus the search box.
|
|
72
115
|
useEffect(() => {
|
|
73
|
-
|
|
116
|
+
loadSource(TEMPLATE_SOURCES[0]!.id)
|
|
117
|
+
api.favoritesList().then(setFavorites).catch(() => { /* favorites stay empty; toggling retries */ })
|
|
74
118
|
searchRef.current?.focus()
|
|
75
119
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
76
120
|
}, [])
|
|
77
121
|
|
|
122
|
+
const switchSource = (sourceId: string): void => {
|
|
123
|
+
if (sourceId === activeSource) return
|
|
124
|
+
setActiveSource(sourceId)
|
|
125
|
+
setCategory('')
|
|
126
|
+
setFavoritesOnly(false)
|
|
127
|
+
setSelected(null)
|
|
128
|
+
setNotice(null)
|
|
129
|
+
if (lists[sourceId] === undefined) loadSource(sourceId)
|
|
130
|
+
}
|
|
131
|
+
|
|
78
132
|
const categories = useMemo(() => {
|
|
79
|
-
if (list ===
|
|
133
|
+
if (list === undefined) return [] as Array<{ key: string; label: string; count: number }>
|
|
80
134
|
const counts = new Map<string, { label: string; count: number }>()
|
|
81
135
|
for (const item of list.cases) {
|
|
82
136
|
const entry = counts.get(item.category) ?? { label: item.categoryZh || item.category, count: 0 }
|
|
@@ -86,17 +140,24 @@ export function TemplateLibrary(props: {
|
|
|
86
140
|
return [...counts.entries()].map(([key, value]) => ({ key, label: value.label, count: value.count }))
|
|
87
141
|
}, [list])
|
|
88
142
|
|
|
143
|
+
/** Favorites of the active source, as standalone case snapshots. */
|
|
144
|
+
const activeFavorites = useMemo(
|
|
145
|
+
() => favorites.filter(entry => entry.sourceId === activeSource).map(entry => entry.case),
|
|
146
|
+
[favorites, activeSource],
|
|
147
|
+
)
|
|
148
|
+
const favKeys = useMemo(() => new Set(favorites.map(entry => entry.key)), [favorites])
|
|
149
|
+
|
|
89
150
|
const filtered = useMemo(() => {
|
|
90
|
-
|
|
151
|
+
const pool = favoritesOnly ? activeFavorites : list?.cases ?? []
|
|
91
152
|
const needle = query.trim().toLowerCase()
|
|
92
|
-
return
|
|
153
|
+
return pool.filter(item => {
|
|
93
154
|
if (category !== '' && item.category !== category) return false
|
|
94
155
|
if (needle === '') return true
|
|
95
156
|
return item.title.toLowerCase().includes(needle)
|
|
96
157
|
|| item.prompt.toLowerCase().includes(needle)
|
|
97
158
|
|| item.sourceLabel.toLowerCase().includes(needle)
|
|
98
159
|
})
|
|
99
|
-
}, [list, query, category])
|
|
160
|
+
}, [list, activeFavorites, favoritesOnly, query, category])
|
|
100
161
|
|
|
101
162
|
// Escape backs out of the detail view first, then closes the modal.
|
|
102
163
|
useEffect(() => {
|
|
@@ -115,10 +176,9 @@ export function TemplateLibrary(props: {
|
|
|
115
176
|
setRefreshing(true)
|
|
116
177
|
setNotice(null)
|
|
117
178
|
try {
|
|
118
|
-
const result = await api.templatesRefresh()
|
|
119
|
-
const reloaded = await api.templatesList()
|
|
120
|
-
|
|
121
|
-
setLoadError(null)
|
|
179
|
+
const result = await api.templatesRefresh(activeSource)
|
|
180
|
+
const reloaded = await api.templatesList(activeSource)
|
|
181
|
+
setLists(current => ({ ...current, [activeSource]: reloaded }))
|
|
122
182
|
setNotice(tt('templates.refreshed', { count: result.total }))
|
|
123
183
|
} catch (caught) {
|
|
124
184
|
setNotice(tt('templates.refreshFailed', { error: errorMessage(caught) }))
|
|
@@ -127,9 +187,18 @@ export function TemplateLibrary(props: {
|
|
|
127
187
|
}
|
|
128
188
|
}
|
|
129
189
|
|
|
130
|
-
/**
|
|
190
|
+
/** Star / unstar one template of the active source. */
|
|
191
|
+
const toggleFavorite = (item: TemplateCase): void => {
|
|
192
|
+
const key = favoriteKeyOf(activeSource, item)
|
|
193
|
+
const pending = favKeys.has(key)
|
|
194
|
+
? api.favoritesRemove(key)
|
|
195
|
+
: api.favoritesAdd(activeSource, item)
|
|
196
|
+
pending.then(setFavorites).catch(() => { /* leave the star as-is on failure */ })
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Mirror every reference image of the active source through the host cache. */
|
|
131
200
|
const cacheAllImages = async (): Promise<void> => {
|
|
132
|
-
if (cacheAll.running || list ===
|
|
201
|
+
if (cacheAll.running || list === undefined) return
|
|
133
202
|
const files = [...new Set(list.cases.map(item => item.image).filter(name => name !== ''))]
|
|
134
203
|
setCacheAll({ running: true, done: 0, total: files.length })
|
|
135
204
|
let index = 0
|
|
@@ -138,7 +207,7 @@ export function TemplateLibrary(props: {
|
|
|
138
207
|
const file = files[index]!
|
|
139
208
|
index += 1
|
|
140
209
|
try {
|
|
141
|
-
await fetch(`${TEMPLATES_API.image}/${encodeURIComponent(file)}`)
|
|
210
|
+
await fetch(`${TEMPLATES_API.image}/${encodeURIComponent(activeSource)}/${encodeURIComponent(file)}`)
|
|
142
211
|
} catch { /* individual failures are retried on the next run */ }
|
|
143
212
|
setCacheAll(current => ({ ...current, done: current.done + 1 }))
|
|
144
213
|
}
|
|
@@ -169,7 +238,8 @@ export function TemplateLibrary(props: {
|
|
|
169
238
|
}
|
|
170
239
|
}
|
|
171
240
|
|
|
172
|
-
const originLabel = list ===
|
|
241
|
+
const originLabel = list === undefined ? '' : tt(list.origin === 'refreshed' ? 'templates.origin.refreshed' : 'templates.origin.bundled')
|
|
242
|
+
const activeMeta = TEMPLATE_SOURCES.find(source => source.id === activeSource)!
|
|
173
243
|
|
|
174
244
|
return createPortal(
|
|
175
245
|
<div className={css.overlay} role="dialog" aria-modal="true" aria-label={tt('templates.title')} onClick={onClose}>
|
|
@@ -177,7 +247,7 @@ export function TemplateLibrary(props: {
|
|
|
177
247
|
<header className={css.header}>
|
|
178
248
|
<span className={css.heading}>
|
|
179
249
|
<h3 className={css.title}>{tt('templates.title')}</h3>
|
|
180
|
-
{list !==
|
|
250
|
+
{list !== undefined ? (
|
|
181
251
|
<span className={css.meta}>{tt('templates.meta', { count: list.total, origin: originLabel })}</span>
|
|
182
252
|
) : null}
|
|
183
253
|
</span>
|
|
@@ -188,7 +258,7 @@ export function TemplateLibrary(props: {
|
|
|
188
258
|
<Button
|
|
189
259
|
variant="outline"
|
|
190
260
|
size="sm"
|
|
191
|
-
disabled={list ===
|
|
261
|
+
disabled={list === undefined || cacheAll.running}
|
|
192
262
|
title={tt('templates.cacheAllHint')}
|
|
193
263
|
onClick={() => { void cacheAllImages() }}
|
|
194
264
|
>
|
|
@@ -204,6 +274,24 @@ export function TemplateLibrary(props: {
|
|
|
204
274
|
</span>
|
|
205
275
|
</header>
|
|
206
276
|
|
|
277
|
+
<div className={css.sourceTabs} role="tablist" aria-label={tt('templates.sources')}>
|
|
278
|
+
{TEMPLATE_SOURCES.map(source => (
|
|
279
|
+
<button
|
|
280
|
+
key={source.id}
|
|
281
|
+
type="button"
|
|
282
|
+
role="tab"
|
|
283
|
+
aria-selected={source.id === activeSource}
|
|
284
|
+
className={css.sourceTab}
|
|
285
|
+
data-active={source.id === activeSource ? '' : undefined}
|
|
286
|
+
title={source.description}
|
|
287
|
+
onClick={() => { switchSource(source.id) }}
|
|
288
|
+
>
|
|
289
|
+
{source.label}
|
|
290
|
+
{lists[source.id] !== undefined ? <span className={css.sourceTabCount}>{lists[source.id]!.total}</span> : null}
|
|
291
|
+
</button>
|
|
292
|
+
))}
|
|
293
|
+
</div>
|
|
294
|
+
|
|
207
295
|
<div className={css.toolbar}>
|
|
208
296
|
<input
|
|
209
297
|
ref={searchRef}
|
|
@@ -217,18 +305,27 @@ export function TemplateLibrary(props: {
|
|
|
217
305
|
<button
|
|
218
306
|
type="button"
|
|
219
307
|
className={css.categoryPill}
|
|
220
|
-
data-active={
|
|
221
|
-
|
|
308
|
+
data-active={favoritesOnly ? '' : undefined}
|
|
309
|
+
title={tt('templates.favoritesHint')}
|
|
310
|
+
onClick={() => { setFavoritesOnly(value => !value) }}
|
|
311
|
+
>
|
|
312
|
+
★ {tt('templates.favorites')}{activeFavorites.length > 0 ? ` ${activeFavorites.length}` : ''}
|
|
313
|
+
</button>
|
|
314
|
+
<button
|
|
315
|
+
type="button"
|
|
316
|
+
className={css.categoryPill}
|
|
317
|
+
data-active={!favoritesOnly && category === '' ? '' : undefined}
|
|
318
|
+
onClick={() => { setFavoritesOnly(false); setCategory('') }}
|
|
222
319
|
>
|
|
223
|
-
{tt('templates.all')}{list !==
|
|
320
|
+
{tt('templates.all')}{list !== undefined ? ` ${list.total}` : ''}
|
|
224
321
|
</button>
|
|
225
322
|
{categories.map(entry => (
|
|
226
323
|
<button
|
|
227
324
|
key={entry.key}
|
|
228
325
|
type="button"
|
|
229
326
|
className={css.categoryPill}
|
|
230
|
-
data-active={category === entry.key ? '' : undefined}
|
|
231
|
-
onClick={() => { setCategory(entry.key) }}
|
|
327
|
+
data-active={!favoritesOnly && category === entry.key ? '' : undefined}
|
|
328
|
+
onClick={() => { setFavoritesOnly(false); setCategory(entry.key) }}
|
|
232
329
|
>
|
|
233
330
|
{entry.label} {entry.count}
|
|
234
331
|
</button>
|
|
@@ -239,7 +336,7 @@ export function TemplateLibrary(props: {
|
|
|
239
336
|
{notice !== null ? <div className={css.notice} role="status">{notice}</div> : null}
|
|
240
337
|
|
|
241
338
|
<div className={css.body}>
|
|
242
|
-
{list ===
|
|
339
|
+
{list === undefined && loadError === null ? (
|
|
243
340
|
<div className={css.state} role="status">
|
|
244
341
|
<span className={css.spinner} />
|
|
245
342
|
<span>{tt('templates.loading')}</span>
|
|
@@ -249,28 +346,35 @@ export function TemplateLibrary(props: {
|
|
|
249
346
|
{loadError !== null ? (
|
|
250
347
|
<div className={css.state} role="alert">
|
|
251
348
|
<span>{tt('templates.loadFailed', { error: loadError })}</span>
|
|
252
|
-
<Button variant="outline" size="sm" onClick={() => {
|
|
349
|
+
<Button variant="outline" size="sm" onClick={() => { setLoadErrors(current => ({ ...current, [activeSource]: '' })); loadSource(activeSource) }}>
|
|
253
350
|
{tt('templates.retry')}
|
|
254
351
|
</Button>
|
|
255
352
|
</div>
|
|
256
353
|
) : null}
|
|
257
354
|
|
|
258
|
-
{list !==
|
|
259
|
-
<div className={css.state}>
|
|
355
|
+
{loadError === null && (list !== undefined || favoritesOnly) && filtered.length === 0 ? (
|
|
356
|
+
<div className={css.state}>
|
|
357
|
+
{favoritesOnly && activeFavorites.length === 0 ? tt('templates.favoritesEmpty') : tt('templates.empty')}
|
|
358
|
+
</div>
|
|
260
359
|
) : null}
|
|
261
360
|
|
|
262
|
-
{
|
|
361
|
+
{filtered.length > 0 ? (
|
|
263
362
|
<div className={css.grid}>
|
|
264
363
|
{filtered.map(item => (
|
|
265
364
|
<button
|
|
266
|
-
key={item.id}
|
|
365
|
+
key={`${activeSource}:${item.id}`}
|
|
267
366
|
type="button"
|
|
268
367
|
className={css.card}
|
|
269
368
|
onClick={() => { setSelected(item); setCopied(false) }}
|
|
270
369
|
>
|
|
271
370
|
<span className={css.thumbWrap}>
|
|
272
|
-
<TemplateThumb item={item} />
|
|
371
|
+
<TemplateThumb sourceId={activeSource} item={item} />
|
|
273
372
|
{item.featured ? <span className={css.featuredBadge}>{tt('templates.featured')}</span> : null}
|
|
373
|
+
<FavoriteStar
|
|
374
|
+
active={favKeys.has(favoriteKeyOf(activeSource, item))}
|
|
375
|
+
title={favKeys.has(favoriteKeyOf(activeSource, item)) ? tt('templates.favoriteRemove') : tt('templates.favoriteAdd')}
|
|
376
|
+
onToggle={() => { toggleFavorite(item) }}
|
|
377
|
+
/>
|
|
274
378
|
</span>
|
|
275
379
|
<span className={css.cardBody}>
|
|
276
380
|
<span className={css.cardTitle}>{item.title}</span>
|
|
@@ -287,8 +391,8 @@ export function TemplateLibrary(props: {
|
|
|
287
391
|
|
|
288
392
|
<footer className={css.footer}>
|
|
289
393
|
<span className={css.attribution}>{tt('templates.attribution')}</span>
|
|
290
|
-
<a className={css.sourceLink} href=
|
|
291
|
-
{tt('templates.source')}
|
|
394
|
+
<a className={css.sourceLink} href={activeMeta.homepage} target="_blank" rel="noreferrer">
|
|
395
|
+
{tt('templates.source', { label: activeMeta.label })}
|
|
292
396
|
</a>
|
|
293
397
|
</footer>
|
|
294
398
|
</section>
|
|
@@ -298,7 +402,7 @@ export function TemplateLibrary(props: {
|
|
|
298
402
|
<section className={css.detail} onClick={(event) => { event.stopPropagation() }}>
|
|
299
403
|
<div className={css.detailMedia}>
|
|
300
404
|
{selected.image !== '' ? (
|
|
301
|
-
<img className={css.detailImage} src={imageUrlOf(selected)} alt={selected.title} />
|
|
405
|
+
<img className={css.detailImage} src={imageUrlOf(activeSource, selected)} alt={selected.title} />
|
|
302
406
|
) : (
|
|
303
407
|
<span className={css.thumbPlaceholder} aria-hidden="true" />
|
|
304
408
|
)}
|
|
@@ -322,6 +426,13 @@ export function TemplateLibrary(props: {
|
|
|
322
426
|
<Button variant="outline" size="md" onClick={() => { void copyPrompt(selected.prompt) }}>
|
|
323
427
|
{copied ? tt('templates.copied') : tt('templates.copy')}
|
|
324
428
|
</Button>
|
|
429
|
+
<Button
|
|
430
|
+
variant="outline"
|
|
431
|
+
size="md"
|
|
432
|
+
onClick={() => { toggleFavorite(selected) }}
|
|
433
|
+
>
|
|
434
|
+
{favKeys.has(favoriteKeyOf(activeSource, selected)) ? tt('templates.unfavorite') : tt('templates.favorite')}
|
|
435
|
+
</Button>
|
|
325
436
|
<Button variant="outline" size="md" onClick={() => { setSelected(null) }}>
|
|
326
437
|
{tt('templates.back')}
|
|
327
438
|
</Button>
|