@1agh/maude 0.43.0 → 0.44.0
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/apps/studio/acp/bridge.ts +44 -10
- package/apps/studio/acp/env.ts +23 -0
- package/apps/studio/acp/login-state.ts +263 -0
- package/apps/studio/acp/plugin-bootstrap.ts +31 -25
- package/apps/studio/acp/probe.ts +152 -1
- package/apps/studio/annotations-layer.tsx +396 -46
- package/apps/studio/api.ts +395 -2
- package/apps/studio/bin/_audio-search.mjs +140 -0
- package/apps/studio/bin/_import-asset-pdf-worker.mjs +34 -0
- package/apps/studio/bin/_import-asset.mjs +815 -0
- package/apps/studio/bin/_import-brand.mjs +635 -0
- package/apps/studio/bin/_import-tokens-alias-resolver.mjs +95 -0
- package/apps/studio/bin/_import-tokens.mjs +1201 -0
- package/apps/studio/bin/_transcribe.mjs +419 -0
- package/apps/studio/bin/_transcribe.test.mjs +80 -0
- package/apps/studio/bin/audio-search.sh +28 -0
- package/apps/studio/bin/generate.sh +154 -0
- package/apps/studio/bin/import-asset.sh +34 -0
- package/apps/studio/bin/import-brand.sh +29 -0
- package/apps/studio/bin/import-tokens.sh +33 -0
- package/apps/studio/bin/photo-bg-remove.sh +119 -21
- package/apps/studio/bin/transcribe.sh +39 -0
- package/apps/studio/canvas-edit.ts +197 -2
- package/apps/studio/canvas-lib.tsx +115 -6
- package/apps/studio/client/app.jsx +1954 -685
- package/apps/studio/client/generate-dialog.jsx +352 -0
- package/apps/studio/client/github.js +14 -0
- package/apps/studio/client/inspector-controls.jsx +781 -0
- package/apps/studio/client/panels/BrandUploadPanel.jsx +233 -0
- package/apps/studio/client/panels/ChatPanel.jsx +15 -33
- package/apps/studio/client/panels/IntroVideoDialog.jsx +44 -0
- package/apps/studio/client/panels/OnboardingWizard.jsx +12 -0
- package/apps/studio/client/panels/ReadinessList.jsx +227 -7
- package/apps/studio/client/panels/SettingsPanel.jsx +828 -0
- package/apps/studio/client/panels/SetupChecklist.jsx +223 -0
- package/apps/studio/client/photo-knobs.jsx +167 -172
- package/apps/studio/client/styles/3-shell-maude.css +334 -28
- package/apps/studio/client/styles/4-components.css +223 -0
- package/apps/studio/client/styles/6-acp-chat.css +86 -0
- package/apps/studio/client/tour/quick-setup-tour.js +39 -0
- package/apps/studio/commands/annotation-strokes-command.ts +13 -3
- package/apps/studio/config.schema.json +70 -0
- package/apps/studio/context-menu.tsx +11 -2
- package/apps/studio/context.ts +16 -0
- package/apps/studio/design-setup-readiness.ts +115 -0
- package/apps/studio/dist/client.bundle.js +2097 -2088
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/draw/palette.ts +34 -0
- package/apps/studio/footage/schema.test.ts +92 -0
- package/apps/studio/footage/schema.ts +179 -1
- package/apps/studio/generation/adapters/elevenlabs.ts +370 -0
- package/apps/studio/generation/adapters/gemini.ts +542 -0
- package/apps/studio/generation/adapters/groq.test.ts +80 -0
- package/apps/studio/generation/adapters/groq.ts +247 -0
- package/apps/studio/generation/audio-library.test.ts +95 -0
- package/apps/studio/generation/audio-library.ts +156 -0
- package/apps/studio/generation/captions.test.ts +78 -0
- package/apps/studio/generation/captions.ts +132 -0
- package/apps/studio/generation/download.ts +76 -0
- package/apps/studio/generation/elevenlabs.test.ts +223 -0
- package/apps/studio/generation/gemini.test.ts +350 -0
- package/apps/studio/generation/jobs.test.ts +128 -0
- package/apps/studio/generation/jobs.ts +243 -0
- package/apps/studio/generation/keys.test.ts +75 -0
- package/apps/studio/generation/keys.ts +162 -0
- package/apps/studio/generation/prefs.test.ts +95 -0
- package/apps/studio/generation/prefs.ts +82 -0
- package/apps/studio/generation/registry.ts +88 -0
- package/apps/studio/generation/types.test.ts +123 -0
- package/apps/studio/generation/types.ts +266 -0
- package/apps/studio/generation/whisper-models.test.ts +152 -0
- package/apps/studio/generation/whisper-models.ts +257 -0
- package/apps/studio/generation/whisper-spike-results.md +61 -0
- package/apps/studio/git/service.ts +3 -1
- package/apps/studio/http.ts +867 -9
- package/apps/studio/media/intro.mp4 +0 -0
- package/apps/studio/media-commit-chain.ts +119 -0
- package/apps/studio/paths.ts +10 -0
- package/apps/studio/readiness.ts +108 -20
- package/apps/studio/scaffold-design.ts +1145 -0
- package/apps/studio/server.ts +13 -1
- package/apps/studio/test/_helpers.ts +15 -2
- package/apps/studio/test/acp-bridge.test.ts +34 -1
- package/apps/studio/test/acp-env.test.ts +30 -0
- package/apps/studio/test/acp-plugin-bootstrap.test.ts +23 -57
- package/apps/studio/test/acp-session-plugins.test.ts +72 -1
- package/apps/studio/test/annotation-strokes-command.test.ts +17 -0
- package/apps/studio/test/annotations-layer.test.ts +174 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +37 -0
- package/apps/studio/test/csp-canvas-shell.test.ts +11 -0
- package/apps/studio/test/csrf-write-guard.test.ts +23 -1
- package/apps/studio/test/design-setup-readiness.test.ts +155 -0
- package/apps/studio/test/element-structural-edit.test.ts +108 -0
- package/apps/studio/test/fixtures/fake-claude-auth.mjs +13 -0
- package/apps/studio/test/generate-route.test.ts +106 -0
- package/apps/studio/test/import-asset-browser.test.ts +64 -0
- package/apps/studio/test/import-asset-route.test.ts +71 -0
- package/apps/studio/test/import-asset.test.ts +399 -0
- package/apps/studio/test/import-brand-browser.test.ts +88 -0
- package/apps/studio/test/import-brand-route.test.ts +120 -0
- package/apps/studio/test/import-brand.test.ts +206 -0
- package/apps/studio/test/import-tokens.test.ts +722 -0
- package/apps/studio/test/media-commit-chain.test.ts +210 -0
- package/apps/studio/test/photo-bg-remove-validation.test.ts +71 -0
- package/apps/studio/test/readiness.test.ts +13 -11
- package/apps/studio/test/scaffold-design.test.ts +122 -0
- package/apps/studio/test/tour-overlay.test.tsx +16 -0
- package/apps/studio/tool-palette.tsx +11 -6
- package/apps/studio/ui-prefs.ts +130 -0
- package/apps/studio/use-canvas-media-drop.tsx +4 -2
- package/apps/studio/use-chrome-visibility.tsx +8 -2
- package/apps/studio/whats-new.json +96 -0
- package/apps/studio/ws.ts +5 -0
- package/cli/bin/maude.mjs +6 -6
- package/cli/commands/design.mjs +33 -0
- package/cli/lib/gitignore-block.mjs +1 -0
- package/cli/lib/pkg-root.mjs +107 -0
- package/cli/lib/pkg-root.test.mjs +79 -0
- package/cli/lib/reconstruct-toolset.test.mjs +194 -0
- package/cli/lib/update-check.mjs +8 -0
- package/package.json +12 -10
- package/plugins/design/dependencies.json +18 -0
|
@@ -0,0 +1,828 @@
|
|
|
1
|
+
// SettingsPanel.jsx — BYOK AI-media generation settings (feature-ai-media-
|
|
2
|
+
// generation, DDR-16x). Manages provider API keys + enable toggles from the UI.
|
|
3
|
+
//
|
|
4
|
+
// Trust model (load-bearing): a key is POSTed to the PRIVILEGED main-origin
|
|
5
|
+
// /_api/generate/keys route and NEVER echoed back — GET returns only a
|
|
6
|
+
// { configured: [...] } presence list. So this panel shows a masked "••••
|
|
7
|
+
// configured" state, never the key value. The panel lives in the app shell
|
|
8
|
+
// (main origin); the untrusted canvas iframe can't reach any of these routes.
|
|
9
|
+
//
|
|
10
|
+
// Layout mirrors OnboardingWizard's density + the ExportDialog modal shell
|
|
11
|
+
// (st-scrim / st-dialog). Icons are local Lucide-line paths (IdentityBar
|
|
12
|
+
// precedent); colors are theme tokens only (no hardcoded hex).
|
|
13
|
+
|
|
14
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
15
|
+
|
|
16
|
+
function Icon({ name, size = 16 }) {
|
|
17
|
+
const p = {
|
|
18
|
+
x: (
|
|
19
|
+
<>
|
|
20
|
+
<line x1="4" y1="4" x2="12" y2="12" />
|
|
21
|
+
<line x1="12" y1="4" x2="4" y2="12" />
|
|
22
|
+
</>
|
|
23
|
+
),
|
|
24
|
+
key: (
|
|
25
|
+
<>
|
|
26
|
+
<circle cx="5.5" cy="5.5" r="3" />
|
|
27
|
+
<path d="M7.6 7.6 13 13" />
|
|
28
|
+
<line x1="11" y1="11" x2="12.5" y2="9.5" />
|
|
29
|
+
</>
|
|
30
|
+
),
|
|
31
|
+
check: <polyline points="3.5 8.5 6.5 11.5 12.5 4.5" />,
|
|
32
|
+
external: (
|
|
33
|
+
<>
|
|
34
|
+
<path d="M6 3.5H3.2A.7.7 0 0 0 2.5 4.2v8.6a.7.7 0 0 0 .7.7h8.6a.7.7 0 0 0 .7-.7V10" />
|
|
35
|
+
<line x1="8" y1="8" x2="13" y2="3" />
|
|
36
|
+
<polyline points="9.5 3 13 3 13 6.5" />
|
|
37
|
+
</>
|
|
38
|
+
),
|
|
39
|
+
cloud: <path d="M4.5 12h6a2.5 2.5 0 0 0 .2-5 3.5 3.5 0 0 0-6.7-1A2.75 2.75 0 0 0 4.5 12Z" />,
|
|
40
|
+
}[name];
|
|
41
|
+
return (
|
|
42
|
+
<svg
|
|
43
|
+
width={size}
|
|
44
|
+
height={size}
|
|
45
|
+
viewBox="0 0 16 16"
|
|
46
|
+
fill="none"
|
|
47
|
+
stroke="currentColor"
|
|
48
|
+
strokeWidth="1.4"
|
|
49
|
+
strokeLinecap="round"
|
|
50
|
+
strokeLinejoin="round"
|
|
51
|
+
aria-hidden="true"
|
|
52
|
+
>
|
|
53
|
+
{p}
|
|
54
|
+
</svg>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function ProviderCard({ provider, onChanged }) {
|
|
59
|
+
const [keyInput, setKeyInput] = useState('');
|
|
60
|
+
const [busy, setBusy] = useState(false);
|
|
61
|
+
const [status, setStatus] = useState(null); // { ok, msg }
|
|
62
|
+
const configured = provider.configured;
|
|
63
|
+
|
|
64
|
+
async function save() {
|
|
65
|
+
const key = keyInput.trim();
|
|
66
|
+
if (!key) return;
|
|
67
|
+
setBusy(true);
|
|
68
|
+
setStatus(null);
|
|
69
|
+
try {
|
|
70
|
+
const res = await fetch('/_api/generate/keys', {
|
|
71
|
+
method: 'POST',
|
|
72
|
+
headers: { 'content-type': 'text/plain' },
|
|
73
|
+
body: JSON.stringify({ provider: provider.id, key }),
|
|
74
|
+
});
|
|
75
|
+
if (!res.ok) throw new Error(await res.text());
|
|
76
|
+
const json = await res.json();
|
|
77
|
+
setKeyInput('');
|
|
78
|
+
setStatus({ ok: true, msg: json.configured ? 'Key saved.' : 'Saved.' });
|
|
79
|
+
onChanged();
|
|
80
|
+
} catch (err) {
|
|
81
|
+
setStatus({ ok: false, msg: err && err.message ? err.message : 'save failed' });
|
|
82
|
+
} finally {
|
|
83
|
+
setBusy(false);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function remove() {
|
|
88
|
+
setBusy(true);
|
|
89
|
+
setStatus(null);
|
|
90
|
+
try {
|
|
91
|
+
const res = await fetch('/_api/generate/keys', {
|
|
92
|
+
method: 'DELETE',
|
|
93
|
+
headers: { 'content-type': 'text/plain' },
|
|
94
|
+
body: JSON.stringify({ provider: provider.id }),
|
|
95
|
+
});
|
|
96
|
+
if (!res.ok) throw new Error(await res.text());
|
|
97
|
+
setStatus({ ok: true, msg: 'Key removed.' });
|
|
98
|
+
onChanged();
|
|
99
|
+
} catch (err) {
|
|
100
|
+
setStatus({ ok: false, msg: err && err.message ? err.message : 'remove failed' });
|
|
101
|
+
} finally {
|
|
102
|
+
setBusy(false);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<div className="st-provider-card">
|
|
108
|
+
<div className="st-provider-hd">
|
|
109
|
+
<span className="st-provider-name">{provider.label}</span>
|
|
110
|
+
<span className={'st-pill' + (provider.kind === 'local' ? ' is-local' : '')}>
|
|
111
|
+
{provider.kind === 'local' ? 'Local' : (
|
|
112
|
+
<>
|
|
113
|
+
<Icon name="cloud" size={12} /> Cloud
|
|
114
|
+
</>
|
|
115
|
+
)}
|
|
116
|
+
</span>
|
|
117
|
+
{configured && (
|
|
118
|
+
<span className="st-provider-configured">
|
|
119
|
+
<Icon name="check" size={12} /> configured
|
|
120
|
+
</span>
|
|
121
|
+
)}
|
|
122
|
+
</div>
|
|
123
|
+
<div className="st-provider-modalities">{provider.modalities.join(' · ')}</div>
|
|
124
|
+
{provider.notes && <div className="st-provider-notes">{provider.notes}</div>}
|
|
125
|
+
{provider.keyUrl && (
|
|
126
|
+
<a className="st-provider-keylink" href={provider.keyUrl} target="_blank" rel="noreferrer">
|
|
127
|
+
Get a key <Icon name="external" size={12} />
|
|
128
|
+
</a>
|
|
129
|
+
)}
|
|
130
|
+
{provider.auth === 'api-key' && (
|
|
131
|
+
<div className="st-provider-keyrow">
|
|
132
|
+
<input
|
|
133
|
+
className="input st-provider-keyinput"
|
|
134
|
+
type="password"
|
|
135
|
+
autoComplete="off"
|
|
136
|
+
spellCheck={false}
|
|
137
|
+
value={keyInput}
|
|
138
|
+
placeholder={configured ? '•••••••• configured — paste to replace' : 'paste API key'}
|
|
139
|
+
aria-label={`${provider.label} API key`}
|
|
140
|
+
onChange={(e) => setKeyInput(e.target.value)}
|
|
141
|
+
onKeyDown={(e) => {
|
|
142
|
+
if (e.key === 'Enter') save();
|
|
143
|
+
}}
|
|
144
|
+
/>
|
|
145
|
+
<button type="button" className="st-btn" disabled={busy || !keyInput.trim()} onClick={save}>
|
|
146
|
+
<Icon name="key" size={13} /> Save
|
|
147
|
+
</button>
|
|
148
|
+
{configured && (
|
|
149
|
+
<button type="button" className="st-btn" disabled={busy} onClick={remove}>
|
|
150
|
+
Remove
|
|
151
|
+
</button>
|
|
152
|
+
)}
|
|
153
|
+
</div>
|
|
154
|
+
)}
|
|
155
|
+
{status && (
|
|
156
|
+
<div
|
|
157
|
+
className="st-provider-status"
|
|
158
|
+
style={{ color: status.ok ? 'var(--accent)' : 'var(--danger, #e5484d)' }}
|
|
159
|
+
>
|
|
160
|
+
{status.msg}
|
|
161
|
+
</div>
|
|
162
|
+
)}
|
|
163
|
+
</div>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Task 2.6 — the subtitle/transcription engine is an EXPLICIT user choice, never
|
|
168
|
+
// an automatic fallback. This radio persists to generation.transcription.provider
|
|
169
|
+
// (the /_api/generate/prefs route); `maude design transcribe` reads the same
|
|
170
|
+
// default. Local whisper is free/offline/no-key; the cloud engines need a key
|
|
171
|
+
// (managed in the provider cards above).
|
|
172
|
+
const TRANSCRIPTION_ENGINES = [
|
|
173
|
+
{
|
|
174
|
+
id: 'whisper',
|
|
175
|
+
label: 'Local whisper.cpp',
|
|
176
|
+
note: 'Free · offline · no key · runs on your hardware',
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
id: 'elevenlabs',
|
|
180
|
+
label: 'ElevenLabs Scribe',
|
|
181
|
+
note: 'Cloud · accuracy · needs an ElevenLabs key',
|
|
182
|
+
},
|
|
183
|
+
{ id: 'groq', label: 'Groq Whisper', note: 'Cloud · fast · needs a Groq key' },
|
|
184
|
+
];
|
|
185
|
+
|
|
186
|
+
function TranscriptionEngineCard() {
|
|
187
|
+
const [engine, setEngine] = useState(null); // null = loading
|
|
188
|
+
const [busy, setBusy] = useState(false);
|
|
189
|
+
const [status, setStatus] = useState(null);
|
|
190
|
+
|
|
191
|
+
useEffect(() => {
|
|
192
|
+
fetch('/_api/generate/prefs')
|
|
193
|
+
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
|
|
194
|
+
.then((d) => setEngine(d?.transcriptionProvider || 'whisper'))
|
|
195
|
+
.catch(() => setEngine('whisper'));
|
|
196
|
+
}, []);
|
|
197
|
+
|
|
198
|
+
async function choose(id) {
|
|
199
|
+
if (id === engine) return;
|
|
200
|
+
const prev = engine;
|
|
201
|
+
setEngine(id);
|
|
202
|
+
setBusy(true);
|
|
203
|
+
setStatus(null);
|
|
204
|
+
try {
|
|
205
|
+
const res = await fetch('/_api/generate/prefs', {
|
|
206
|
+
method: 'POST',
|
|
207
|
+
headers: { 'content-type': 'text/plain' },
|
|
208
|
+
body: JSON.stringify({ transcriptionProvider: id }),
|
|
209
|
+
});
|
|
210
|
+
if (!res.ok) throw new Error(await res.text());
|
|
211
|
+
setStatus({ ok: true, msg: 'Saved.' });
|
|
212
|
+
} catch (err) {
|
|
213
|
+
setEngine(prev); // revert on failure — no silent state drift
|
|
214
|
+
setStatus({ ok: false, msg: err && err.message ? err.message : 'save failed' });
|
|
215
|
+
} finally {
|
|
216
|
+
setBusy(false);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return (
|
|
221
|
+
<div className="st-provider-card">
|
|
222
|
+
<div className="st-provider-hd">
|
|
223
|
+
<span className="st-provider-name">Subtitles — transcription engine</span>
|
|
224
|
+
</div>
|
|
225
|
+
<div className="st-provider-notes">
|
|
226
|
+
Which engine turns audio into subtitles. This is an explicit choice — Maude never silently
|
|
227
|
+
switches engines; a chosen-but-unavailable engine tells you how to fix it.
|
|
228
|
+
</div>
|
|
229
|
+
<div className="st-engine-radios" role="radiogroup" aria-label="Transcription engine">
|
|
230
|
+
{TRANSCRIPTION_ENGINES.map((e) => (
|
|
231
|
+
<label key={e.id} className={'st-engine-radio' + (engine === e.id ? ' is-selected' : '')}>
|
|
232
|
+
<input
|
|
233
|
+
type="radio"
|
|
234
|
+
name="transcription-engine"
|
|
235
|
+
value={e.id}
|
|
236
|
+
checked={engine === e.id}
|
|
237
|
+
disabled={busy || engine === null}
|
|
238
|
+
onChange={() => choose(e.id)}
|
|
239
|
+
/>
|
|
240
|
+
<span className="st-engine-radio-body">
|
|
241
|
+
<span className="st-engine-radio-label">{e.label}</span>
|
|
242
|
+
<span className="st-engine-radio-note">{e.note}</span>
|
|
243
|
+
</span>
|
|
244
|
+
</label>
|
|
245
|
+
))}
|
|
246
|
+
</div>
|
|
247
|
+
{status && (
|
|
248
|
+
<div
|
|
249
|
+
className="st-provider-status"
|
|
250
|
+
style={{ color: status.ok ? 'var(--accent)' : 'var(--danger, #e5484d)' }}
|
|
251
|
+
>
|
|
252
|
+
{status.msg}
|
|
253
|
+
</div>
|
|
254
|
+
)}
|
|
255
|
+
</div>
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Task 2.7 (approach A) — one-click local whisper.cpp models. The engine binary
|
|
260
|
+
// is still a soft dep, but the MODEL half is one click: download a ggml model
|
|
261
|
+
// into a Maude-managed cache and `--provider whisper` auto-resolves it. Polls
|
|
262
|
+
// the download progress from the privileged /_api/generate/whisper-model route.
|
|
263
|
+
function WhisperModelCard() {
|
|
264
|
+
const [state, setState] = useState(null); // { models, downloading } | null
|
|
265
|
+
const [err, setErr] = useState(null);
|
|
266
|
+
|
|
267
|
+
const load = useCallback(() => {
|
|
268
|
+
fetch('/_api/generate/whisper-model')
|
|
269
|
+
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
|
|
270
|
+
.then(setState)
|
|
271
|
+
.catch((e) => setErr(e && e.message ? e.message : 'failed to load models'));
|
|
272
|
+
}, []);
|
|
273
|
+
|
|
274
|
+
useEffect(() => {
|
|
275
|
+
load();
|
|
276
|
+
}, [load]);
|
|
277
|
+
|
|
278
|
+
// Poll while a download is in flight.
|
|
279
|
+
useEffect(() => {
|
|
280
|
+
if (!state?.downloading) return undefined;
|
|
281
|
+
const t = setInterval(load, 1000);
|
|
282
|
+
return () => clearInterval(t);
|
|
283
|
+
}, [state?.downloading, load]);
|
|
284
|
+
|
|
285
|
+
async function download(id) {
|
|
286
|
+
setErr(null);
|
|
287
|
+
try {
|
|
288
|
+
const res = await fetch('/_api/generate/whisper-model', {
|
|
289
|
+
method: 'POST',
|
|
290
|
+
headers: { 'content-type': 'text/plain' },
|
|
291
|
+
body: JSON.stringify({ id }),
|
|
292
|
+
});
|
|
293
|
+
if (!res.ok) throw new Error(await res.text());
|
|
294
|
+
load();
|
|
295
|
+
} catch (e) {
|
|
296
|
+
setErr(e && e.message ? e.message : 'download failed');
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function remove(id) {
|
|
301
|
+
setErr(null);
|
|
302
|
+
try {
|
|
303
|
+
const res = await fetch('/_api/generate/whisper-model', {
|
|
304
|
+
method: 'DELETE',
|
|
305
|
+
headers: { 'content-type': 'text/plain' },
|
|
306
|
+
body: JSON.stringify({ id }),
|
|
307
|
+
});
|
|
308
|
+
if (!res.ok) throw new Error(await res.text());
|
|
309
|
+
load();
|
|
310
|
+
} catch (e) {
|
|
311
|
+
setErr(e && e.message ? e.message : 'remove failed');
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const dl = state?.downloading;
|
|
316
|
+
const pctOf = (r, t) => (t > 0 ? Math.min(100, Math.round((r / t) * 100)) : 0);
|
|
317
|
+
|
|
318
|
+
return (
|
|
319
|
+
<div className="st-provider-card">
|
|
320
|
+
<div className="st-provider-hd">
|
|
321
|
+
<span className="st-provider-name">Local subtitle models (whisper.cpp)</span>
|
|
322
|
+
</div>
|
|
323
|
+
<div className="st-provider-notes">
|
|
324
|
+
Download a model once and local subtitles work offline, free, with no key. Requires the
|
|
325
|
+
whisper.cpp binary (<code>brew install whisper-cpp</code>). Models are stored on this machine
|
|
326
|
+
only and never committed.
|
|
327
|
+
</div>
|
|
328
|
+
{err && (
|
|
329
|
+
<div className="st-provider-status" style={{ color: 'var(--danger, #e5484d)' }}>
|
|
330
|
+
{err}
|
|
331
|
+
</div>
|
|
332
|
+
)}
|
|
333
|
+
{dl?.error && (
|
|
334
|
+
<div className="st-provider-status" style={{ color: 'var(--danger, #e5484d)' }}>
|
|
335
|
+
Download of {dl.id} failed: {dl.error}
|
|
336
|
+
</div>
|
|
337
|
+
)}
|
|
338
|
+
{state === null && !err && <div className="st-settings-intro">Loading…</div>}
|
|
339
|
+
<div className="st-model-list">
|
|
340
|
+
{(state?.models || []).map((m) => {
|
|
341
|
+
const busy = dl && !dl.error && dl.id === m.id;
|
|
342
|
+
return (
|
|
343
|
+
<div key={m.id} className="st-model-row">
|
|
344
|
+
<div className="st-model-info">
|
|
345
|
+
<span className="st-model-label">
|
|
346
|
+
{m.label}
|
|
347
|
+
<span className={'st-pill' + (m.multilingual ? '' : ' is-local')}>
|
|
348
|
+
{m.multilingual ? 'multilingual' : 'English-only'}
|
|
349
|
+
</span>
|
|
350
|
+
<span className="st-model-size">~{m.sizeMB} MB</span>
|
|
351
|
+
</span>
|
|
352
|
+
<span className="st-engine-radio-note">{m.note}</span>
|
|
353
|
+
{busy && (
|
|
354
|
+
<span className="st-model-progress">
|
|
355
|
+
Downloading… {pctOf(dl.received, dl.total)}%
|
|
356
|
+
</span>
|
|
357
|
+
)}
|
|
358
|
+
</div>
|
|
359
|
+
<div className="st-model-actions">
|
|
360
|
+
{m.downloaded ? (
|
|
361
|
+
<>
|
|
362
|
+
<span className="st-provider-configured">
|
|
363
|
+
<Icon name="check" size={12} /> ready
|
|
364
|
+
</span>
|
|
365
|
+
<button type="button" className="st-btn" onClick={() => remove(m.id)}>
|
|
366
|
+
Remove
|
|
367
|
+
</button>
|
|
368
|
+
</>
|
|
369
|
+
) : (
|
|
370
|
+
<button
|
|
371
|
+
type="button"
|
|
372
|
+
className="st-btn"
|
|
373
|
+
disabled={!!(dl && !dl.error)}
|
|
374
|
+
onClick={() => download(m.id)}
|
|
375
|
+
>
|
|
376
|
+
{busy ? 'Downloading…' : 'Download'}
|
|
377
|
+
</button>
|
|
378
|
+
)}
|
|
379
|
+
</div>
|
|
380
|
+
</div>
|
|
381
|
+
);
|
|
382
|
+
})}
|
|
383
|
+
</div>
|
|
384
|
+
</div>
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// feature-unified-settings-modal — one modal for every Maude preference, laid
|
|
389
|
+
// out as a left vertical tab rail + an internally-scrolling pane. Categories:
|
|
390
|
+
const TABS = [
|
|
391
|
+
{ id: 'appearance', label: 'Appearance' },
|
|
392
|
+
{ id: 'canvas-view', label: 'Canvas & View' },
|
|
393
|
+
{ id: 'layout', label: 'Layout' },
|
|
394
|
+
{ id: 'ai-generation', label: 'AI generation' },
|
|
395
|
+
{ id: 'subtitles', label: 'Subtitles' },
|
|
396
|
+
];
|
|
397
|
+
const SETTINGS_TAB_STORE = 'mdcc-settings-tab';
|
|
398
|
+
|
|
399
|
+
// feature-configurable-panel-docking — the panels the Layout tab can dock. Order
|
|
400
|
+
// = display order. (Assistant is native-only; moving it is a no-op in the browser.)
|
|
401
|
+
const LAYOUT_PANELS = [
|
|
402
|
+
{ id: 'tree', label: 'Files' },
|
|
403
|
+
{ id: 'layers', label: 'Layers' },
|
|
404
|
+
{ id: 'inspector', label: 'Inspector' },
|
|
405
|
+
{ id: 'comments', label: 'Comments' },
|
|
406
|
+
{ id: 'changes', label: 'Changes' },
|
|
407
|
+
{ id: 'assistant', label: 'Assistant' },
|
|
408
|
+
];
|
|
409
|
+
|
|
410
|
+
function SideToggle({ value, disabled, onChange }) {
|
|
411
|
+
return (
|
|
412
|
+
<span className="st-sidetoggle" role="radiogroup" aria-label="Panel side">
|
|
413
|
+
{['left', 'right'].map((s) => (
|
|
414
|
+
<button
|
|
415
|
+
key={s}
|
|
416
|
+
type="button"
|
|
417
|
+
role="radio"
|
|
418
|
+
aria-checked={value === s}
|
|
419
|
+
disabled={disabled}
|
|
420
|
+
className={'st-sidebtn' + (value === s ? ' is-active' : '')}
|
|
421
|
+
onClick={() => onChange(s)}
|
|
422
|
+
>
|
|
423
|
+
{s === 'left' ? 'Left' : 'Right'}
|
|
424
|
+
</button>
|
|
425
|
+
))}
|
|
426
|
+
</span>
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const LAYERS_MODE_OPTIONS = [
|
|
431
|
+
{ id: 'separate', label: 'Separate panel', note: 'Layers docks on its own (default: left).' },
|
|
432
|
+
{ id: 'in-inspector', label: 'Inside Inspector', note: 'Layers is a tab within the Inspector.' },
|
|
433
|
+
];
|
|
434
|
+
function LayoutTab({ panelSide, onSetPanelSide, layersMode, onSetLayersMode }) {
|
|
435
|
+
return (
|
|
436
|
+
<>
|
|
437
|
+
<div className="st-provider-card">
|
|
438
|
+
<div className="st-provider-hd">
|
|
439
|
+
<span className="st-provider-name">Layers panel</span>
|
|
440
|
+
</div>
|
|
441
|
+
<div className="st-provider-notes">
|
|
442
|
+
Show Layers as its own dockable panel, or as a tab inside the Inspector.
|
|
443
|
+
</div>
|
|
444
|
+
<div className="st-engine-radios" role="radiogroup" aria-label="Layers mode">
|
|
445
|
+
{LAYERS_MODE_OPTIONS.map((o) => (
|
|
446
|
+
<label
|
|
447
|
+
key={o.id}
|
|
448
|
+
className={'st-engine-radio' + (layersMode === o.id ? ' is-selected' : '')}
|
|
449
|
+
>
|
|
450
|
+
<input
|
|
451
|
+
type="radio"
|
|
452
|
+
name="layers-mode"
|
|
453
|
+
checked={layersMode === o.id}
|
|
454
|
+
onChange={() => onSetLayersMode(o.id)}
|
|
455
|
+
/>
|
|
456
|
+
<span className="st-engine-radio-body">
|
|
457
|
+
<span className="st-engine-radio-label">{o.label}</span>
|
|
458
|
+
<span className="st-engine-radio-note">{o.note}</span>
|
|
459
|
+
</span>
|
|
460
|
+
</label>
|
|
461
|
+
))}
|
|
462
|
+
</div>
|
|
463
|
+
</div>
|
|
464
|
+
<div className="st-provider-card">
|
|
465
|
+
<div className="st-provider-hd">
|
|
466
|
+
<span className="st-provider-name">Panel positions</span>
|
|
467
|
+
</div>
|
|
468
|
+
<div className="st-provider-notes">
|
|
469
|
+
Dock each panel to the left or right side. Each side shows one panel at a time, with tabs
|
|
470
|
+
to switch between the panels docked there.
|
|
471
|
+
</div>
|
|
472
|
+
{LAYOUT_PANELS.map((p) => {
|
|
473
|
+
const disabled = p.id === 'layers' && layersMode !== 'separate';
|
|
474
|
+
return (
|
|
475
|
+
<div key={p.id} className={'st-pref-row' + (disabled ? ' is-disabled' : '')}>
|
|
476
|
+
<span className="st-pref-body">
|
|
477
|
+
<span className="st-pref-label">{p.label}</span>
|
|
478
|
+
{disabled && (
|
|
479
|
+
<span className="st-pref-note">
|
|
480
|
+
Layers is inside the Inspector — choose “Separate panel” above to dock it.
|
|
481
|
+
</span>
|
|
482
|
+
)}
|
|
483
|
+
</span>
|
|
484
|
+
<SideToggle
|
|
485
|
+
value={panelSide?.[p.id] || 'left'}
|
|
486
|
+
disabled={disabled}
|
|
487
|
+
onChange={(s) => onSetPanelSide(p.id, s)}
|
|
488
|
+
/>
|
|
489
|
+
</div>
|
|
490
|
+
);
|
|
491
|
+
})}
|
|
492
|
+
</div>
|
|
493
|
+
</>
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// A labeled switch row. The `checked`/`onChange` are owned by app.jsx state
|
|
498
|
+
// (single source of truth) so the modal and the View menu never diverge.
|
|
499
|
+
function PrefToggleRow({ label, note, checked, disabled, reason, onChange }) {
|
|
500
|
+
return (
|
|
501
|
+
<label className={'st-pref-row' + (disabled ? ' is-disabled' : '')}>
|
|
502
|
+
<span className="st-pref-body">
|
|
503
|
+
<span className="st-pref-label">{label}</span>
|
|
504
|
+
{(reason || note) && <span className="st-pref-note">{disabled && reason ? reason : note}</span>}
|
|
505
|
+
</span>
|
|
506
|
+
<input
|
|
507
|
+
type="checkbox"
|
|
508
|
+
className="st-switch"
|
|
509
|
+
role="switch"
|
|
510
|
+
aria-checked={!!checked}
|
|
511
|
+
checked={!!checked}
|
|
512
|
+
disabled={disabled}
|
|
513
|
+
onChange={(e) => onChange?.(e.target.checked)}
|
|
514
|
+
/>
|
|
515
|
+
</label>
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Appearance — theme control. Reuses the transcription-engine radio density so
|
|
520
|
+
// the whole modal reads as one system. Wired to app.jsx `theme` + `onSetTheme`.
|
|
521
|
+
const THEME_OPTIONS = [
|
|
522
|
+
{ id: 'light', label: 'Light', note: 'Bright shell chrome' },
|
|
523
|
+
{ id: 'dark', label: 'Dark', note: 'Dim shell chrome' },
|
|
524
|
+
];
|
|
525
|
+
// DDR-171 — the CSS-panel (Inspector) vocabulary. Advanced = raw CSS property
|
|
526
|
+
// names (the default, unchanged); Designer = Figma-familiar clusters (Fill,
|
|
527
|
+
// Stroke, Auto layout…). Same underlying controls + write path, just relabeled
|
|
528
|
+
// and regrouped. Persisted via the App-owned `maude-cp-mode` state, so this
|
|
529
|
+
// radio and the in-panel corner toggle stay in lockstep.
|
|
530
|
+
const CP_MODE_OPTIONS = [
|
|
531
|
+
{ id: 'advanced', label: 'Advanced', note: 'Raw CSS property names — border-radius, flex-direction…' },
|
|
532
|
+
{ id: 'designer', label: 'Designer', note: 'Figma vocabulary — Fill, Stroke, Corner radius, Auto layout…' },
|
|
533
|
+
];
|
|
534
|
+
function AppearanceTab({ theme, onSetTheme, cpMode, onSetCpMode }) {
|
|
535
|
+
return (
|
|
536
|
+
<>
|
|
537
|
+
<div className="st-provider-card">
|
|
538
|
+
<div className="st-provider-hd">
|
|
539
|
+
<span className="st-provider-name">Theme</span>
|
|
540
|
+
</div>
|
|
541
|
+
<div className="st-provider-notes">
|
|
542
|
+
Controls Maude’s own chrome — menubar, sidebar, canvas plane, minimap, zoom HUD. Artboards
|
|
543
|
+
keep their own design-system theme.
|
|
544
|
+
</div>
|
|
545
|
+
<div className="st-engine-radios" role="radiogroup" aria-label="Theme">
|
|
546
|
+
{THEME_OPTIONS.map((o) => (
|
|
547
|
+
<label key={o.id} className={'st-engine-radio' + (theme === o.id ? ' is-selected' : '')}>
|
|
548
|
+
<input
|
|
549
|
+
type="radio"
|
|
550
|
+
name="maude-theme"
|
|
551
|
+
value={o.id}
|
|
552
|
+
checked={theme === o.id}
|
|
553
|
+
onChange={() => onSetTheme?.(o.id)}
|
|
554
|
+
/>
|
|
555
|
+
<span className="st-engine-radio-body">
|
|
556
|
+
<span className="st-engine-radio-label">{o.label}</span>
|
|
557
|
+
<span className="st-engine-radio-note">{o.note}</span>
|
|
558
|
+
</span>
|
|
559
|
+
</label>
|
|
560
|
+
))}
|
|
561
|
+
</div>
|
|
562
|
+
</div>
|
|
563
|
+
<div className="st-provider-card">
|
|
564
|
+
<div className="st-provider-hd">
|
|
565
|
+
<span className="st-provider-name">Inspector vocabulary</span>
|
|
566
|
+
</div>
|
|
567
|
+
<div className="st-provider-notes">
|
|
568
|
+
How the CSS panel labels controls. You can also flip this from the small toggle in the
|
|
569
|
+
panel’s own corner — this choice is remembered.
|
|
570
|
+
</div>
|
|
571
|
+
<div className="st-engine-radios" role="radiogroup" aria-label="Inspector vocabulary">
|
|
572
|
+
{CP_MODE_OPTIONS.map((o) => (
|
|
573
|
+
<label key={o.id} className={'st-engine-radio' + (cpMode === o.id ? ' is-selected' : '')}>
|
|
574
|
+
<input
|
|
575
|
+
type="radio"
|
|
576
|
+
name="maude-cp-mode"
|
|
577
|
+
value={o.id}
|
|
578
|
+
checked={cpMode === o.id}
|
|
579
|
+
onChange={() => onSetCpMode?.(o.id)}
|
|
580
|
+
/>
|
|
581
|
+
<span className="st-engine-radio-body">
|
|
582
|
+
<span className="st-engine-radio-label">{o.label}</span>
|
|
583
|
+
<span className="st-engine-radio-note">{o.note}</span>
|
|
584
|
+
</span>
|
|
585
|
+
</label>
|
|
586
|
+
))}
|
|
587
|
+
</div>
|
|
588
|
+
</div>
|
|
589
|
+
</>
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
export default function SettingsPanel({
|
|
594
|
+
onClose,
|
|
595
|
+
initialTab,
|
|
596
|
+
theme,
|
|
597
|
+
onSetTheme,
|
|
598
|
+
cpMode,
|
|
599
|
+
onSetCpMode,
|
|
600
|
+
minimapVisible,
|
|
601
|
+
onToggleMinimap,
|
|
602
|
+
zoomCtlVisible,
|
|
603
|
+
onToggleZoomCtl,
|
|
604
|
+
annotationsVisible,
|
|
605
|
+
onToggleAnnotations,
|
|
606
|
+
autoOpenInspector,
|
|
607
|
+
onToggleAutoOpenInspector,
|
|
608
|
+
hasCanvas = false,
|
|
609
|
+
panelSide,
|
|
610
|
+
onSetPanelSide,
|
|
611
|
+
layersMode,
|
|
612
|
+
onSetLayersMode,
|
|
613
|
+
}) {
|
|
614
|
+
const [providers, setProviders] = useState(null); // null = loading
|
|
615
|
+
const [error, setError] = useState(null);
|
|
616
|
+
const [tab, setTab] = useState(() => {
|
|
617
|
+
if (initialTab && TABS.some((t) => t.id === initialTab)) return initialTab;
|
|
618
|
+
try {
|
|
619
|
+
const v = localStorage.getItem(SETTINGS_TAB_STORE);
|
|
620
|
+
if (v && TABS.some((t) => t.id === v)) return v;
|
|
621
|
+
} catch {}
|
|
622
|
+
return 'appearance';
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
useEffect(() => {
|
|
626
|
+
try {
|
|
627
|
+
localStorage.setItem(SETTINGS_TAB_STORE, tab);
|
|
628
|
+
} catch {}
|
|
629
|
+
}, [tab]);
|
|
630
|
+
|
|
631
|
+
const load = useCallback(() => {
|
|
632
|
+
fetch('/_api/generate/providers')
|
|
633
|
+
.then((r) => {
|
|
634
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
635
|
+
return r.json();
|
|
636
|
+
})
|
|
637
|
+
.then((d) => setProviders(Array.isArray(d?.providers) ? d.providers : []))
|
|
638
|
+
.catch((err) => setError(err && err.message ? err.message : 'failed to load providers'));
|
|
639
|
+
}, []);
|
|
640
|
+
|
|
641
|
+
useEffect(() => {
|
|
642
|
+
load();
|
|
643
|
+
}, [load]);
|
|
644
|
+
|
|
645
|
+
useEffect(() => {
|
|
646
|
+
function onKey(e) {
|
|
647
|
+
if (e.key === 'Escape') {
|
|
648
|
+
e.preventDefault();
|
|
649
|
+
onClose();
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
window.addEventListener('keydown', onKey);
|
|
653
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
654
|
+
}, [onClose]);
|
|
655
|
+
|
|
656
|
+
// Vertical roving focus over the tab rail (native-menu parity).
|
|
657
|
+
function onRailKey(e) {
|
|
658
|
+
const idx = TABS.findIndex((t) => t.id === tab);
|
|
659
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowRight') {
|
|
660
|
+
e.preventDefault();
|
|
661
|
+
setTab(TABS[(idx + 1) % TABS.length].id);
|
|
662
|
+
} else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') {
|
|
663
|
+
e.preventDefault();
|
|
664
|
+
setTab(TABS[(idx - 1 + TABS.length) % TABS.length].id);
|
|
665
|
+
} else if (e.key === 'Home') {
|
|
666
|
+
e.preventDefault();
|
|
667
|
+
setTab(TABS[0].id);
|
|
668
|
+
} else if (e.key === 'End') {
|
|
669
|
+
e.preventDefault();
|
|
670
|
+
setTab(TABS[TABS.length - 1].id);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// Minimap / zoom-controls are canvas-scoped — mirror the View menu's
|
|
675
|
+
// `disabled: !activePath || isSystem` gate and say why inline.
|
|
676
|
+
const canvasReason = !hasCanvas ? 'Open a canvas to use this.' : undefined;
|
|
677
|
+
|
|
678
|
+
return (
|
|
679
|
+
<div
|
|
680
|
+
className="st-scrim"
|
|
681
|
+
role="presentation"
|
|
682
|
+
onMouseDown={(e) => {
|
|
683
|
+
if (e.target === e.currentTarget) onClose();
|
|
684
|
+
}}
|
|
685
|
+
>
|
|
686
|
+
<div className="st-dialog is-settings" role="dialog" aria-modal="true" aria-label="Settings">
|
|
687
|
+
<div className="st-dialog-hd">
|
|
688
|
+
<span className="st-dialog-title">Settings</span>
|
|
689
|
+
<button type="button" className="st-iconbtn" aria-label="Close" onClick={onClose}>
|
|
690
|
+
<Icon name="x" size={15} />
|
|
691
|
+
</button>
|
|
692
|
+
</div>
|
|
693
|
+
<div className="st-settings-tabs">
|
|
694
|
+
<div
|
|
695
|
+
className="st-settings-rail"
|
|
696
|
+
role="tablist"
|
|
697
|
+
aria-orientation="vertical"
|
|
698
|
+
aria-label="Settings categories"
|
|
699
|
+
onKeyDown={onRailKey}
|
|
700
|
+
>
|
|
701
|
+
{TABS.map((t) => (
|
|
702
|
+
<button
|
|
703
|
+
key={t.id}
|
|
704
|
+
type="button"
|
|
705
|
+
role="tab"
|
|
706
|
+
id={`st-stab-${t.id}`}
|
|
707
|
+
aria-controls={`st-spanel-${t.id}`}
|
|
708
|
+
aria-selected={tab === t.id}
|
|
709
|
+
tabIndex={tab === t.id ? 0 : -1}
|
|
710
|
+
className={'st-settings-tab' + (tab === t.id ? ' is-active' : '')}
|
|
711
|
+
onClick={() => setTab(t.id)}
|
|
712
|
+
>
|
|
713
|
+
{t.label}
|
|
714
|
+
</button>
|
|
715
|
+
))}
|
|
716
|
+
</div>
|
|
717
|
+
<div className="st-settings-pane">
|
|
718
|
+
{/* Appearance */}
|
|
719
|
+
<section
|
|
720
|
+
role="tabpanel"
|
|
721
|
+
id="st-spanel-appearance"
|
|
722
|
+
aria-labelledby="st-stab-appearance"
|
|
723
|
+
hidden={tab !== 'appearance'}
|
|
724
|
+
>
|
|
725
|
+
<div className="st-rp-hd">Appearance</div>
|
|
726
|
+
<AppearanceTab theme={theme} onSetTheme={onSetTheme} cpMode={cpMode} onSetCpMode={onSetCpMode} />
|
|
727
|
+
</section>
|
|
728
|
+
|
|
729
|
+
{/* Canvas & View — the persistent View-menu prefs, one canonical home. */}
|
|
730
|
+
<section
|
|
731
|
+
role="tabpanel"
|
|
732
|
+
id="st-spanel-canvas-view"
|
|
733
|
+
aria-labelledby="st-stab-canvas-view"
|
|
734
|
+
hidden={tab !== 'canvas-view'}
|
|
735
|
+
>
|
|
736
|
+
<div className="st-rp-hd">Canvas & View</div>
|
|
737
|
+
<PrefToggleRow
|
|
738
|
+
label="Minimap"
|
|
739
|
+
note="Show the canvas minimap overlay."
|
|
740
|
+
checked={minimapVisible}
|
|
741
|
+
disabled={!hasCanvas}
|
|
742
|
+
reason={canvasReason}
|
|
743
|
+
onChange={() => onToggleMinimap?.()}
|
|
744
|
+
/>
|
|
745
|
+
<PrefToggleRow
|
|
746
|
+
label="Zoom controls"
|
|
747
|
+
note="Show the floating zoom controls on the canvas."
|
|
748
|
+
checked={zoomCtlVisible}
|
|
749
|
+
disabled={!hasCanvas}
|
|
750
|
+
reason={canvasReason}
|
|
751
|
+
onChange={() => onToggleZoomCtl?.()}
|
|
752
|
+
/>
|
|
753
|
+
<PrefToggleRow
|
|
754
|
+
label="Annotations"
|
|
755
|
+
note="Show annotation pins and overlays on the canvas."
|
|
756
|
+
checked={annotationsVisible}
|
|
757
|
+
onChange={() => onToggleAnnotations?.()}
|
|
758
|
+
/>
|
|
759
|
+
<PrefToggleRow
|
|
760
|
+
label="Auto-open Inspector on select"
|
|
761
|
+
note="Open the Inspector automatically when you select an element."
|
|
762
|
+
checked={autoOpenInspector}
|
|
763
|
+
onChange={() => onToggleAutoOpenInspector?.()}
|
|
764
|
+
/>
|
|
765
|
+
</section>
|
|
766
|
+
|
|
767
|
+
{/* Layout — dock panels left/right + Layers mode. */}
|
|
768
|
+
<section
|
|
769
|
+
role="tabpanel"
|
|
770
|
+
id="st-spanel-layout"
|
|
771
|
+
aria-labelledby="st-stab-layout"
|
|
772
|
+
hidden={tab !== 'layout'}
|
|
773
|
+
>
|
|
774
|
+
<div className="st-rp-hd">Layout</div>
|
|
775
|
+
<LayoutTab
|
|
776
|
+
panelSide={panelSide}
|
|
777
|
+
onSetPanelSide={onSetPanelSide}
|
|
778
|
+
layersMode={layersMode}
|
|
779
|
+
onSetLayersMode={onSetLayersMode}
|
|
780
|
+
/>
|
|
781
|
+
</section>
|
|
782
|
+
|
|
783
|
+
{/* AI generation — BYOK provider keys (kept mounted so the fetch runs once). */}
|
|
784
|
+
<section
|
|
785
|
+
role="tabpanel"
|
|
786
|
+
id="st-spanel-ai-generation"
|
|
787
|
+
aria-labelledby="st-stab-ai-generation"
|
|
788
|
+
hidden={tab !== 'ai-generation'}
|
|
789
|
+
>
|
|
790
|
+
<div className="st-rp-hd">Provider keys</div>
|
|
791
|
+
<p className="st-settings-intro">
|
|
792
|
+
Bring your own API keys to generate images (and, soon, audio + video) inside Maude.
|
|
793
|
+
Keys are stored on this machine only — in your OS keychain or a private{' '}
|
|
794
|
+
<code>~/.config/maude/keys.json</code> (mode 0600) — sent straight to the provider,
|
|
795
|
+
and never committed, logged, or exposed to a canvas.
|
|
796
|
+
</p>
|
|
797
|
+
{error && (
|
|
798
|
+
<div className="st-provider-status" style={{ color: 'var(--danger, #e5484d)' }}>
|
|
799
|
+
{error}
|
|
800
|
+
</div>
|
|
801
|
+
)}
|
|
802
|
+
{providers === null && !error && <div className="st-settings-intro">Loading…</div>}
|
|
803
|
+
{providers?.map((p) => (
|
|
804
|
+
<ProviderCard key={p.id} provider={p} onChanged={load} />
|
|
805
|
+
))}
|
|
806
|
+
{providers?.length === 0 && (
|
|
807
|
+
<div className="st-settings-intro">No providers registered.</div>
|
|
808
|
+
)}
|
|
809
|
+
</section>
|
|
810
|
+
|
|
811
|
+
{/* Subtitles — kept mounted so WhisperModelCard's download poll survives
|
|
812
|
+
a tab switch (unmounting mid-download would drop the interval). */}
|
|
813
|
+
<section
|
|
814
|
+
role="tabpanel"
|
|
815
|
+
id="st-spanel-subtitles"
|
|
816
|
+
aria-labelledby="st-stab-subtitles"
|
|
817
|
+
hidden={tab !== 'subtitles'}
|
|
818
|
+
>
|
|
819
|
+
<div className="st-rp-hd">Subtitles</div>
|
|
820
|
+
<TranscriptionEngineCard />
|
|
821
|
+
<WhisperModelCard />
|
|
822
|
+
</section>
|
|
823
|
+
</div>
|
|
824
|
+
</div>
|
|
825
|
+
</div>
|
|
826
|
+
</div>
|
|
827
|
+
);
|
|
828
|
+
}
|