@odori/cli 0.0.7 → 0.0.9

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.
@@ -2,15 +2,24 @@ import {useEffect, useRef, useState} from "react";
2
2
  import {Button, Icon} from "./ui";
3
3
  import {useStartSound} from "../settings";
4
4
  import {useTheme, type Theme} from "../theme";
5
+ import {INTEGRATIONS_CHANGED, loadProviders, type ProviderStatus} from "../integrations";
5
6
 
6
7
  /**
7
8
  * The workspace preferences, behind one control in the header.
8
9
  *
9
- * This replaced a three-button theme switcher. Theme was the only preference
10
- * Studio had, so spending a permanent row of chrome on it was fine right up
11
- * until there was a second one; three more buttons for sound would have made
12
- * the header a settings panel that never closes. A gear says "there are
13
- * choices here" in the space one of the old buttons used.
10
+ * A dialog with pages rather than a popover: the menu was right when the
11
+ * choices were two radio groups, and wrong the moment a page's worth of
12
+ * audio configuration the start-sound default and the provider keys
13
+ * had to fold into a strip beside the gear. A dialog gives each area a page
14
+ * and room to grow one setting at a time, the way every tool this one sits
15
+ * beside does it.
16
+ *
17
+ * Provider keys live here because a key is configuration of the machine,
18
+ * not material of the project. The form is one-way about secrets: a key can
19
+ * be typed and sent to the local server, but it never comes back — status
20
+ * is a source ("environment", "stored") and nothing else. A key from the
21
+ * environment wins over a stored one and cannot be edited here, because
22
+ * the place to change an environment is the environment.
14
23
  */
15
24
  const THEMES: Array<{id: Theme; label: string; hint: string}> = [
16
25
  {id: "system", label: "System", hint: "Follow the operating system"},
@@ -23,87 +32,294 @@ const SOUNDS = [
23
32
  {id: "off" as const, label: "Off", hint: "Open every video muted"},
24
33
  ];
25
34
 
35
+ const PAGES = [
36
+ {id: "appearance" as const, label: "Appearance"},
37
+ {id: "audio" as const, label: "Audio"},
38
+ ];
39
+
40
+ type Page = (typeof PAGES)[number]["id"];
41
+
26
42
  export const Settings = () => {
27
43
  const [open, setOpen] = useState(false);
28
- const {theme, setTheme} = useTheme();
29
- const [sound, setSound] = useStartSound();
30
- const wrapper = useRef<HTMLDivElement>(null);
31
44
  const trigger = useRef<HTMLButtonElement>(null);
32
45
 
33
- useEffect(() => {
34
- if (!open) return undefined;
35
- const onPointerDown = (event: PointerEvent) => {
36
- if (!wrapper.current?.contains(event.target as Node)) setOpen(false);
37
- };
38
- const onKeyDown = (event: KeyboardEvent) => {
39
- if (event.key !== "Escape") return;
40
- setOpen(false);
41
- // Closing with the keyboard has to put the focus somewhere, and the
42
- // control that opened the menu is the only place that is not a surprise.
43
- trigger.current?.focus();
44
- };
45
- // Capture, so a click that also does something else still closes the menu.
46
- document.addEventListener("pointerdown", onPointerDown, true);
47
- document.addEventListener("keydown", onKeyDown);
48
- return () => {
49
- document.removeEventListener("pointerdown", onPointerDown, true);
50
- document.removeEventListener("keydown", onKeyDown);
51
- };
52
- }, [open]);
53
-
54
46
  return (
55
- <div className="settings" ref={wrapper}>
47
+ <div className="settings">
56
48
  <Button
57
49
  ref={trigger}
58
50
  icon
59
51
  active={open}
60
52
  aria-label="Settings"
61
53
  aria-expanded={open}
62
- aria-haspopup="menu"
54
+ aria-haspopup="dialog"
63
55
  title="Settings"
64
- onClick={() => setOpen((value) => !value)}
56
+ onClick={() => setOpen(true)}
65
57
  >
66
58
  <Icon name="settings" />
67
59
  </Button>
68
60
 
69
61
  {open ? (
70
- <div className="menu settings-menu" role="menu" aria-label="Settings">
71
- <p className="menu-heading">Theme</p>
72
- {THEMES.map((option) => (
62
+ <SettingsDialog
63
+ onClose={() => {
64
+ setOpen(false);
65
+ // Closing has to put the focus somewhere, and the control that
66
+ // opened the dialog is the only place that is not a surprise.
67
+ trigger.current?.focus();
68
+ }}
69
+ />
70
+ ) : null}
71
+ </div>
72
+ );
73
+ };
74
+
75
+ const SettingsDialog = ({onClose}: {onClose: () => void}) => {
76
+ const [page, setPage] = useState<Page>("appearance");
77
+
78
+ useEffect(() => {
79
+ const onKeyDown = (event: KeyboardEvent) => {
80
+ if (event.key === "Escape") onClose();
81
+ };
82
+ window.addEventListener("keydown", onKeyDown);
83
+ return () => window.removeEventListener("keydown", onKeyDown);
84
+ }, [onClose]);
85
+
86
+ return (
87
+ <div className="overlay" onPointerDown={onClose}>
88
+ <div
89
+ className="settings-dialog"
90
+ role="dialog"
91
+ aria-modal="true"
92
+ aria-label="Settings"
93
+ onPointerDown={(event) => event.stopPropagation()}
94
+ >
95
+ <nav className="settings-nav" aria-label="Settings pages">
96
+ <p className="settings-nav-title">Settings</p>
97
+ {PAGES.map((item) => (
73
98
  <button
74
- key={option.id}
99
+ key={item.id}
75
100
  type="button"
76
- role="menuitemradio"
77
- aria-checked={theme === option.id}
78
- className="menu-item"
79
- data-selected={theme === option.id ? "true" : undefined}
80
- onClick={() => setTheme(option.id)}
101
+ className="settings-nav-item"
102
+ data-selected={page === item.id ? "true" : undefined}
103
+ aria-current={page === item.id ? "page" : undefined}
104
+ onClick={() => setPage(item.id)}
81
105
  >
82
- <span>{option.label}</span>
83
- <span className="menu-hint">{option.hint}</span>
106
+ {item.label}
84
107
  </button>
85
108
  ))}
109
+ </nav>
86
110
 
87
- <p className="menu-heading">Sound</p>
111
+ <div className="settings-body">
112
+ <div className="settings-body-head">
113
+ <h2 className="settings-body-title">{PAGES.find((item) => item.id === page)?.label}</h2>
114
+ <Button icon aria-label="Close settings" title="Close" onClick={onClose}>
115
+ <Icon name="close" />
116
+ </Button>
117
+ </div>
118
+ {page === "appearance" ? <AppearancePage /> : null}
119
+ {page === "audio" ? <AudioPage /> : null}
120
+ </div>
121
+ </div>
122
+ </div>
123
+ );
124
+ };
125
+
126
+ const AppearancePage = () => {
127
+ const {theme, setTheme} = useTheme();
128
+
129
+ return (
130
+ <section className="settings-group" aria-label="Theme">
131
+ <p className="settings-group-title">Theme</p>
132
+ <div className="settings-options" role="radiogroup" aria-label="Theme">
133
+ {THEMES.map((option) => (
134
+ <button
135
+ key={option.id}
136
+ type="button"
137
+ role="radio"
138
+ aria-checked={theme === option.id}
139
+ className="settings-option"
140
+ data-selected={theme === option.id ? "true" : undefined}
141
+ onClick={() => setTheme(option.id)}
142
+ >
143
+ <span>{option.label}</span>
144
+ <span className="settings-option-hint">{option.hint}</span>
145
+ </button>
146
+ ))}
147
+ </div>
148
+ </section>
149
+ );
150
+ };
151
+
152
+ const AudioPage = () => {
153
+ const [sound, setSound] = useStartSound();
154
+ const [providers, setProviders] = useState<ProviderStatus[] | null>(null);
155
+
156
+ /* Fetched when the page shows rather than with Studio: the status can
157
+ change underneath a long session (a key exported in another shell), and
158
+ opening settings is the moment it has to be right. */
159
+ useEffect(() => {
160
+ loadProviders()
161
+ .then(setProviders)
162
+ .catch(() => setProviders(null));
163
+ }, []);
164
+
165
+ return (
166
+ <>
167
+ <section className="settings-group" aria-label="Sound">
168
+ <p className="settings-group-title">Sound</p>
169
+ <div className="settings-options" role="radiogroup" aria-label="Sound">
88
170
  {SOUNDS.map((option) => (
89
171
  <button
90
172
  key={option.id}
91
173
  type="button"
92
- role="menuitemradio"
174
+ role="radio"
93
175
  aria-checked={sound === option.id}
94
- className="menu-item"
176
+ className="settings-option"
95
177
  data-selected={sound === option.id ? "true" : undefined}
96
178
  onClick={() => setSound(option.id)}
97
179
  >
98
180
  <span>{option.label}</span>
99
- <span className="menu-hint">{option.hint}</span>
181
+ <span className="settings-option-hint">{option.hint}</span>
100
182
  </button>
101
183
  ))}
102
- {/* Changing it now would mute or unmute the video being watched,
103
- which is not what a default is. */}
104
- <p className="menu-note">Applies to the next video you open.</p>
105
184
  </div>
185
+ {/* Changing it now would mute or unmute the video being watched,
186
+ which is not what a default is. */}
187
+ <p className="settings-note">Applies to the next video you open.</p>
188
+ </section>
189
+
190
+ <section className="settings-group" aria-label="Integrations">
191
+ <p className="settings-group-title">Integrations</p>
192
+ {(providers ?? []).map((provider) => (
193
+ <ProviderKeyRow
194
+ key={provider.name}
195
+ provider={provider}
196
+ onChanged={async () => {
197
+ setProviders(await loadProviders());
198
+ window.dispatchEvent(new Event(INTEGRATIONS_CHANGED));
199
+ }}
200
+ />
201
+ ))}
202
+ </section>
203
+ </>
204
+ );
205
+ };
206
+
207
+ const ProviderKeyRow = ({provider, onChanged}: {provider: ProviderStatus; onChanged: () => Promise<void>}) => {
208
+ const [editing, setEditing] = useState(false);
209
+ const [draft, setDraft] = useState("");
210
+ const [busy, setBusy] = useState(false);
211
+ const [message, setMessage] = useState<string | null>(null);
212
+
213
+ const submit = async (key: string) => {
214
+ setBusy(true);
215
+ setMessage(null);
216
+ try {
217
+ const response = await fetch("/__odori/integrations", {
218
+ method: "POST",
219
+ headers: {"content-type": "application/json"},
220
+ body: JSON.stringify({provider: provider.name, key}),
221
+ });
222
+ const body = (await response.json()) as {error?: string; verified?: boolean | null};
223
+ if (!response.ok) {
224
+ setMessage(body.error ?? "The key was not accepted.");
225
+ return;
226
+ }
227
+ setDraft("");
228
+ setEditing(false);
229
+ setMessage(
230
+ key === ""
231
+ ? "Key removed."
232
+ : body.verified
233
+ ? "Checked against the provider and stored on this machine."
234
+ : "Stored. The provider could not be reached to check it, so the first generation will tell.",
235
+ );
236
+ await onChanged();
237
+ } catch {
238
+ setMessage("Studio could not reach its own server.");
239
+ } finally {
240
+ setBusy(false);
241
+ }
242
+ };
243
+
244
+ const connected = provider.source !== null;
245
+ const fromEnvironment = provider.source === "environment";
246
+ const status = fromEnvironment
247
+ ? `Connected from ${provider.keyVariable}`
248
+ : connected
249
+ ? "Connected"
250
+ : "Not connected";
251
+
252
+ return (
253
+ <div className="settings-provider">
254
+ <div className="settings-provider-head">
255
+ <div className="settings-provider-id">
256
+ <span className="settings-provider-name">{provider.title}</span>
257
+ <span className="settings-provider-sub" data-tone={connected ? "ok" : undefined}>
258
+ <span className="settings-provider-dot" aria-hidden="true" />
259
+ {status}
260
+ {" · "}
261
+ <a href={provider.docsUrl} target="_blank" rel="noreferrer">
262
+ docs
263
+ </a>
264
+ </span>
265
+ </div>
266
+ {/* One action at a time. The environment case has none: the place to
267
+ change an environment is the environment. */}
268
+ {fromEnvironment ? null : connected ? (
269
+ <Button disabled={busy} onClick={() => void submit("")}>
270
+ Disconnect
271
+ </Button>
272
+ ) : editing ? null : (
273
+ <Button
274
+ onClick={() => {
275
+ setEditing(true);
276
+ setMessage(null);
277
+ }}
278
+ >
279
+ Connect
280
+ </Button>
281
+ )}
282
+ </div>
283
+ {editing && !connected ? (
284
+ <>
285
+ <form
286
+ className="settings-provider-form"
287
+ onSubmit={(event) => {
288
+ event.preventDefault();
289
+ if (draft.trim()) void submit(draft.trim());
290
+ }}
291
+ >
292
+ <input
293
+ className="settings-integration-input"
294
+ type="password"
295
+ autoFocus
296
+ autoComplete="off"
297
+ placeholder={provider.keyVariable}
298
+ value={draft}
299
+ onChange={(event) => setDraft(event.target.value)}
300
+ disabled={busy}
301
+ aria-label={`${provider.title} API key`}
302
+ />
303
+ <Button variant="primary" disabled={busy || !draft.trim()} onClick={() => void submit(draft.trim())}>
304
+ {busy ? "Checking…" : "Save"}
305
+ </Button>
306
+ <Button
307
+ disabled={busy}
308
+ onClick={() => {
309
+ setEditing(false);
310
+ setDraft("");
311
+ }}
312
+ >
313
+ Cancel
314
+ </Button>
315
+ </form>
316
+ <p className="settings-note">
317
+ Checked against the provider, then stored in <code>~/.config/odori</code> on this machine — never shown
318
+ again. A key in the environment wins over a stored one.
319
+ </p>
320
+ </>
106
321
  ) : null}
322
+ {message ? <p className="settings-note">{message}</p> : null}
107
323
  </div>
108
324
  );
109
325
  };
@@ -72,7 +72,8 @@ export const Icon = ({
72
72
  | "external"
73
73
  | "sidebar"
74
74
  | "settings"
75
- | "search";
75
+ | "search"
76
+ | "close";
76
77
  }) => {
77
78
  const paths: Record<string, ReactNode> = {
78
79
  play: <path d="M4.5 2.8v8.4l7-4.2z" fill="currentColor" />,
@@ -92,6 +93,15 @@ export const Icon = ({
92
93
  <circle cx="12" cy="12" r="3" />
93
94
  </g>
94
95
  ),
96
+ close: (
97
+ <path
98
+ d="M3.6 3.6l6.8 6.8M10.4 3.6l-6.8 6.8"
99
+ fill="none"
100
+ stroke="currentColor"
101
+ strokeLinecap="round"
102
+ strokeWidth="1.2"
103
+ />
104
+ ),
95
105
  sun: (
96
106
  <>
97
107
  <circle cx="7" cy="7" r="2.5" fill="none" stroke="currentColor" strokeWidth="1.2" />
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Provider status, shared by the two places that show it.
3
+ *
4
+ * The settings menu owns configuration and the Integrations view owns
5
+ * generation, so both need to know what is connected. Status is a source and
6
+ * nothing else — a key can be sent to the local server, but it never comes
7
+ * back.
8
+ */
9
+ export type ProviderStatus = {
10
+ name: string;
11
+ title: string;
12
+ kind: string;
13
+ keyVariable: string;
14
+ docsUrl: string;
15
+ source: "environment" | "stored" | null;
16
+ };
17
+
18
+ export const loadProviders = async (): Promise<ProviderStatus[]> => {
19
+ const response = await fetch("/__odori/integrations");
20
+ if (!response.ok) throw new Error(`${response.status}`);
21
+ const body = (await response.json()) as {providers: ProviderStatus[]};
22
+ return body.providers;
23
+ };
24
+
25
+ /**
26
+ * Fired on `window` after a key is stored or removed, so a view that gates on
27
+ * connection state catches up without waiting for a reload.
28
+ */
29
+ export const INTEGRATIONS_CHANGED = "odori:integrations-changed";