@skillcdn/console 0.1.6 → 0.1.7

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.
Files changed (88) hide show
  1. package/README.md +50 -2
  2. package/SKILLCDN.md +29 -0
  3. package/dist/cli/cli.d.ts +3 -0
  4. package/dist/cli/cli.d.ts.map +1 -1
  5. package/dist/cli/cli.js +61 -0
  6. package/dist/cli/cli.js.map +1 -1
  7. package/dist/cli/help.d.ts.map +1 -1
  8. package/dist/cli/help.js +5 -0
  9. package/dist/cli/help.js.map +1 -1
  10. package/dist/cli/main.js +2 -0
  11. package/dist/cli/main.js.map +1 -1
  12. package/dist/cli/serve.d.ts +37 -0
  13. package/dist/cli/serve.d.ts.map +1 -0
  14. package/dist/cli/serve.js +218 -0
  15. package/dist/cli/serve.js.map +1 -0
  16. package/dist/console.d.ts +6 -0
  17. package/dist/console.d.ts.map +1 -1
  18. package/dist/console.js +78 -13
  19. package/dist/console.js.map +1 -1
  20. package/dist/data.d.ts.map +1 -1
  21. package/dist/data.js +5 -1
  22. package/dist/data.js.map +1 -1
  23. package/dist/i18n/en.d.ts +4 -0
  24. package/dist/i18n/en.d.ts.map +1 -1
  25. package/dist/i18n/en.js +4 -0
  26. package/dist/i18n/en.js.map +1 -1
  27. package/dist/i18n/ko.d.ts.map +1 -1
  28. package/dist/i18n/ko.js +4 -0
  29. package/dist/i18n/ko.js.map +1 -1
  30. package/dist/schemas.d.ts +1 -0
  31. package/dist/schemas.d.ts.map +1 -1
  32. package/dist/schemas.js +5 -0
  33. package/dist/schemas.js.map +1 -1
  34. package/dist/web.d.ts +16 -0
  35. package/dist/web.d.ts.map +1 -0
  36. package/dist/web.js +149 -0
  37. package/dist/web.js.map +1 -0
  38. package/docs/specs/cli.md +92 -0
  39. package/docs/specs/rest.md +220 -0
  40. package/package.json +14 -3
  41. package/skills/building-a-console/SKILL.md +79 -0
  42. package/skills/working-the-board/SKILL.md +78 -0
  43. package/src/api.ts +235 -0
  44. package/src/cli/cli.ts +1114 -0
  45. package/src/cli/credentials.ts +68 -0
  46. package/src/cli/directory.ts +59 -0
  47. package/src/cli/format.ts +284 -0
  48. package/src/cli/help.ts +326 -0
  49. package/src/cli/main.ts +39 -0
  50. package/src/cli/serve.ts +276 -0
  51. package/src/cli/terminal.ts +28 -0
  52. package/src/client.ts +533 -0
  53. package/src/components/board.tsx +112 -0
  54. package/src/components/connect.tsx +126 -0
  55. package/src/components/decision-form.tsx +136 -0
  56. package/src/components/decision-list.tsx +278 -0
  57. package/src/components/documents.tsx +569 -0
  58. package/src/components/event-feed.tsx +129 -0
  59. package/src/components/markdown.tsx +72 -0
  60. package/src/components/people.tsx +59 -0
  61. package/src/components/projects.tsx +364 -0
  62. package/src/components/runs.tsx +175 -0
  63. package/src/components/shell.tsx +284 -0
  64. package/src/components/sign-in.tsx +61 -0
  65. package/src/components/skills.tsx +78 -0
  66. package/src/components/task-form.tsx +229 -0
  67. package/src/components/task-view.tsx +272 -0
  68. package/src/components/tokens.tsx +223 -0
  69. package/src/components/ui.tsx +219 -0
  70. package/src/connect-page.tsx +148 -0
  71. package/src/connect.ts +27 -0
  72. package/src/console.tsx +1166 -0
  73. package/src/data.ts +493 -0
  74. package/src/docs-page.tsx +334 -0
  75. package/src/documents.ts +76 -0
  76. package/src/i18n/en.ts +537 -0
  77. package/src/i18n/index.ts +76 -0
  78. package/src/i18n/ko.ts +511 -0
  79. package/src/index.ts +142 -0
  80. package/src/limits.ts +61 -0
  81. package/src/router.ts +295 -0
  82. package/src/routes.ts +106 -0
  83. package/src/schemas.ts +776 -0
  84. package/src/styles/console.css +2432 -0
  85. package/src/text.ts +33 -0
  86. package/src/use-action.ts +37 -0
  87. package/src/vocabulary.ts +109 -0
  88. package/src/web.ts +170 -0
@@ -0,0 +1,284 @@
1
+ import { type ReactNode, useEffect, useRef } from "react";
2
+ import type { RestPerson } from "../api.js";
3
+ import { useWords } from "../i18n/index.js";
4
+ import { Avatar, cx } from "./ui.js";
5
+
6
+ // The frame every page sits in (ADR-0010): a header that names the workspace and the project the
7
+ // page is on, with the person's menu at its end; a row of tabs below it with the pages of the
8
+ // level the person is on; the page itself. Takes its data as props and nothing from the network.
9
+
10
+ export interface NavItem {
11
+ readonly href: string;
12
+ readonly label: string;
13
+ readonly current: boolean;
14
+ /** A count beside the label: the decisions waiting, for example. */
15
+ readonly count?: number | undefined;
16
+ }
17
+
18
+ /** The addresses of a brand's marks (ADR-0013): shown beside the workspace's name, and above it when signing in. */
19
+ export interface Brand {
20
+ /** A small square mark, as an image the browser loads. */
21
+ readonly symbol?: string | undefined;
22
+ /** The name as a mark, as an image the browser loads. */
23
+ readonly wordmark?: string | undefined;
24
+ }
25
+
26
+ /** A language the person may switch to, as the menu offers it (ADR-0012). */
27
+ export interface LanguageChoice {
28
+ readonly tag: string;
29
+ readonly label: string;
30
+ readonly current: boolean;
31
+ }
32
+
33
+ export interface ShellProps {
34
+ readonly title: string;
35
+ /** The marks to show beside the name; none shows the name alone. */
36
+ readonly brand?: Brand | undefined;
37
+ /** The project the page is on, named after the workspace, or nothing on the workspace's own pages. */
38
+ readonly project?: { readonly name: string; readonly href: string } | undefined;
39
+ /** The pages of the level the person is on, as tabs: the project's, or the workspace's. */
40
+ readonly nav: readonly NavItem[];
41
+ /** The workspace's pages, reached from anywhere through the person's menu. */
42
+ readonly menu?: readonly NavItem[] | undefined;
43
+ /** The languages the pages speak, for the menu to switch between. */
44
+ readonly languages?: readonly LanguageChoice[] | undefined;
45
+ readonly onLanguage?: ((tag: string) => void) | undefined;
46
+ readonly person: RestPerson | undefined;
47
+ /** Follows a link inside the app without loading a document. */
48
+ readonly onNavigate: (href: string) => void;
49
+ readonly onSignOut?: (() => void) | undefined;
50
+ /** Whether the feed is connected: a dot in the header says so. */
51
+ readonly live?: boolean | undefined;
52
+ /** Whether the page takes the whole width of the window, as the board does, rather than the column the rest read in. */
53
+ readonly wide?: boolean | undefined;
54
+ readonly children: ReactNode;
55
+ }
56
+
57
+ /** Whether a click on a link is a plain one, to be followed in place; a modified one is the browser's. */
58
+ export function isPlainClick(event: {
59
+ readonly button: number;
60
+ readonly metaKey: boolean;
61
+ readonly ctrlKey: boolean;
62
+ readonly shiftKey: boolean;
63
+ readonly altKey: boolean;
64
+ }): boolean {
65
+ return event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey;
66
+ }
67
+
68
+ /** The person's menu: who they are, the workspace's pages, the languages, and the way out. Closes once used. */
69
+ function PersonMenu(props: {
70
+ readonly person: RestPerson;
71
+ readonly items: readonly NavItem[];
72
+ readonly languages: readonly LanguageChoice[];
73
+ readonly onLanguage: ((tag: string) => void) | undefined;
74
+ readonly onNavigate: (href: string) => void;
75
+ readonly onSignOut: (() => void) | undefined;
76
+ }) {
77
+ const words = useWords();
78
+ const details = useRef<HTMLDetailsElement>(null);
79
+ const close = () => {
80
+ if (details.current !== null) {
81
+ details.current.open = false;
82
+ }
83
+ };
84
+ // A click elsewhere, or the escape key, closes it, as a menu is expected to.
85
+ useEffect(() => {
86
+ const onClick = (event: MouseEvent) => {
87
+ const element = details.current;
88
+ if (
89
+ element?.open === true &&
90
+ !(event.target instanceof Node && element.contains(event.target))
91
+ ) {
92
+ element.open = false;
93
+ }
94
+ };
95
+ const onKey = (event: KeyboardEvent) => {
96
+ if (event.key === "Escape" && details.current !== null) {
97
+ details.current.open = false;
98
+ }
99
+ };
100
+ document.addEventListener("click", onClick);
101
+ document.addEventListener("keydown", onKey);
102
+ return () => {
103
+ document.removeEventListener("click", onClick);
104
+ document.removeEventListener("keydown", onKey);
105
+ };
106
+ }, []);
107
+ const { person } = props;
108
+ return (
109
+ <details className="sc-menu" ref={details}>
110
+ <summary className="sc-menu-summary" aria-label={words.shell.menu(person.login)}>
111
+ <Avatar person={person} size="sm" />
112
+ <span className="sc-person-login">{person.login}</span>
113
+ <svg className="sc-menu-caret" viewBox="0 0 16 16" aria-hidden="true">
114
+ <path
115
+ d="M4 6l4 4 4-4"
116
+ fill="none"
117
+ stroke="currentColor"
118
+ strokeWidth="1.5"
119
+ strokeLinecap="round"
120
+ strokeLinejoin="round"
121
+ />
122
+ </svg>
123
+ </summary>
124
+ <div className="sc-menu-panel">
125
+ <p className="sc-menu-who">
126
+ <span>{person.name ?? person.login}</span>
127
+ {person.name !== null && <span className="sc-muted">{person.login}</span>}
128
+ </p>
129
+ <nav className="sc-menu-list" aria-label={words.shell.workspace}>
130
+ {props.items.map((item) => (
131
+ <a
132
+ key={item.href}
133
+ href={item.href}
134
+ className="sc-menu-link"
135
+ aria-current={item.current ? "page" : undefined}
136
+ onClick={(event) => {
137
+ if (isPlainClick(event)) {
138
+ event.preventDefault();
139
+ close();
140
+ props.onNavigate(item.href);
141
+ }
142
+ }}
143
+ >
144
+ {item.label}
145
+ </a>
146
+ ))}
147
+ </nav>
148
+ {props.languages.length > 1 && props.onLanguage !== undefined && (
149
+ <fieldset className="sc-menu-languages">
150
+ <legend className="sc-menu-heading">{words.common.language}</legend>
151
+ {props.languages.map((language) => (
152
+ <button
153
+ key={language.tag}
154
+ type="button"
155
+ lang={language.tag}
156
+ className={cx("sc-menu-link", language.current && "sc-menu-chosen")}
157
+ aria-pressed={language.current}
158
+ onClick={() => {
159
+ close();
160
+ props.onLanguage?.(language.tag);
161
+ }}
162
+ >
163
+ {language.label}
164
+ </button>
165
+ ))}
166
+ </fieldset>
167
+ )}
168
+ {props.onSignOut !== undefined && (
169
+ <button
170
+ type="button"
171
+ className="sc-menu-link sc-menu-out"
172
+ onClick={() => {
173
+ close();
174
+ props.onSignOut?.();
175
+ }}
176
+ >
177
+ {words.shell.signOut}
178
+ </button>
179
+ )}
180
+ </div>
181
+ </details>
182
+ );
183
+ }
184
+
185
+ export function Shell(props: ShellProps) {
186
+ const words = useWords();
187
+ const follow = (href: string) => (event: React.MouseEvent) => {
188
+ if (isPlainClick(event)) {
189
+ event.preventDefault();
190
+ props.onNavigate(href);
191
+ }
192
+ };
193
+ return (
194
+ <div className="sc-shell">
195
+ <a className="sc-skip" href="#sc-content">
196
+ {words.shell.skipToContent}
197
+ </a>
198
+ <header className="sc-header">
199
+ <div className="sc-header-inner">
200
+ <a className="sc-brand" href="/" onClick={follow("/")} aria-label={props.title}>
201
+ {props.brand?.symbol !== undefined && (
202
+ <img className="sc-brand-symbol" src={props.brand.symbol} alt="" />
203
+ )}
204
+ {props.brand?.wordmark !== undefined ? (
205
+ <img className="sc-brand-wordmark" src={props.brand.wordmark} alt="" />
206
+ ) : (
207
+ <span className="sc-brand-name">{props.title}</span>
208
+ )}
209
+ </a>
210
+ {props.brand?.wordmark !== undefined && (
211
+ // With the marks in the brand's place, the workspace is named after them, as a crumb.
212
+ <span className="sc-crumb">
213
+ <span className="sc-crumb-separator" aria-hidden="true">
214
+ /
215
+ </span>
216
+ <a className="sc-crumb-link" href="/" onClick={follow("/")}>
217
+ {props.title}
218
+ </a>
219
+ </span>
220
+ )}
221
+ {props.project !== undefined && (
222
+ <span className="sc-crumb">
223
+ <span className="sc-crumb-separator" aria-hidden="true">
224
+ /
225
+ </span>
226
+ <a
227
+ className="sc-crumb-link"
228
+ href={props.project.href}
229
+ onClick={follow(props.project.href)}
230
+ >
231
+ {props.project.name}
232
+ </a>
233
+ </span>
234
+ )}
235
+ <div className="sc-header-end">
236
+ {props.live !== undefined && (
237
+ <span
238
+ className={cx("sc-live", props.live && "sc-live-on")}
239
+ title={props.live ? words.shell.live : words.shell.reconnecting}
240
+ >
241
+ <span className="sc-visually-hidden">
242
+ {props.live ? words.shell.live : words.shell.reconnecting}
243
+ </span>
244
+ </span>
245
+ )}
246
+ {props.person !== undefined && (
247
+ <PersonMenu
248
+ person={props.person}
249
+ items={props.menu ?? []}
250
+ languages={props.languages ?? []}
251
+ onLanguage={props.onLanguage}
252
+ onNavigate={props.onNavigate}
253
+ onSignOut={props.onSignOut}
254
+ />
255
+ )}
256
+ </div>
257
+ </div>
258
+ {props.nav.length > 0 && (
259
+ <nav className="sc-tabs" aria-label={words.shell.pages}>
260
+ <div className="sc-tabs-inner">
261
+ {props.nav.map((item) => (
262
+ <a
263
+ key={item.href}
264
+ href={item.href}
265
+ className={cx("sc-tab", item.current && "sc-tab-current")}
266
+ aria-current={item.current ? "page" : undefined}
267
+ onClick={follow(item.href)}
268
+ >
269
+ {item.label}
270
+ {item.count !== undefined && item.count > 0 && (
271
+ <span className="sc-nav-count">{item.count}</span>
272
+ )}
273
+ </a>
274
+ ))}
275
+ </div>
276
+ </nav>
277
+ )}
278
+ </header>
279
+ <main id="sc-content" className={cx("sc-main", props.wide === true && "sc-main-wide")}>
280
+ {props.children}
281
+ </main>
282
+ </div>
283
+ );
284
+ }
@@ -0,0 +1,61 @@
1
+ import { loginPath, type RestProvider, type SignInFailure } from "../api.js";
2
+ import { useWords } from "../i18n/index.js";
3
+ import type { Brand } from "./shell.js";
4
+ import { Callout } from "./ui.js";
5
+
6
+ // The page a person sees before they are anyone: the way to each provider the deployment has,
7
+ // and what went wrong the last time, when something did.
8
+
9
+ export interface SignInProps {
10
+ readonly title: string;
11
+ /** The marks to show above the name; none shows the name alone (ADR-0013). */
12
+ readonly brand?: Brand | undefined;
13
+ /** The identity providers people sign in through; none where nobody can. */
14
+ readonly providers: readonly RestProvider[];
15
+ /** The page to come back to, signed in: a path of this origin. */
16
+ readonly returnTo: string;
17
+ readonly failure?: SignInFailure | undefined;
18
+ }
19
+
20
+ export function SignIn(props: SignInProps) {
21
+ const words = useWords().signIn;
22
+ return (
23
+ <div className="sc-sign-in">
24
+ {props.brand?.symbol !== undefined && (
25
+ <div className="sc-sign-in-stage" aria-hidden="true">
26
+ <span className="sc-sign-in-badge">
27
+ <img className="sc-sign-in-symbol" src={props.brand.symbol} alt="" />
28
+ </span>
29
+ </div>
30
+ )}
31
+ {props.brand?.wordmark !== undefined && (
32
+ <img className="sc-sign-in-wordmark" src={props.brand.wordmark} alt="" />
33
+ )}
34
+ <h1 className="sc-sign-in-title">{props.title}</h1>
35
+ <p className="sc-sign-in-lead">{words.lead}</p>
36
+ {props.failure !== undefined && (
37
+ <Callout tone={props.failure === "refused" ? "warning" : "info"}>
38
+ {words.failure[props.failure]}
39
+ </Callout>
40
+ )}
41
+ {props.providers.length === 0 ? (
42
+ <Callout tone="warning" title={words.nobody.title}>
43
+ {words.nobody.body}
44
+ </Callout>
45
+ ) : (
46
+ <div className="sc-sign-in-ways">
47
+ {props.providers.map((provider) => (
48
+ // A real navigation: signing in happens at the provider, not in this page.
49
+ <a
50
+ key={provider.key}
51
+ className="sc-button sc-button-primary sc-sign-in-button"
52
+ href={loginPath(provider.key, props.returnTo)}
53
+ >
54
+ {words.continueWith(provider.label)}
55
+ </a>
56
+ ))}
57
+ </div>
58
+ )}
59
+ </div>
60
+ );
61
+ }
@@ -0,0 +1,78 @@
1
+ import type { RestSkills } from "../api.js";
2
+ import { useWords } from "../i18n/index.js";
3
+ import { Callout, EmptyState, Spinner } from "./ui.js";
4
+
5
+ // The organization's skills, as SkillCDN serves them at the console's address: where a person
6
+ // reads each, and what an agent loads it by. Takes its data as props and nothing from the
7
+ // network; what the console answered is shown as text, never as HTML.
8
+
9
+ export interface SkillListProps {
10
+ /** What the console answered, or nothing while it has not yet. */
11
+ readonly skills: RestSkills | undefined;
12
+ }
13
+
14
+ export function SkillList(props: SkillListProps) {
15
+ const words = useWords().skills;
16
+ const { skills } = props;
17
+ if (skills === undefined) {
18
+ return <Spinner label={words.loading} />;
19
+ }
20
+ if (skills.status === "none" || skills.address === null || skills.page === null) {
21
+ return <EmptyState title={words.noAddress.title} body={words.noAddress.body} />;
22
+ }
23
+ const where = (
24
+ <p className="sc-lead">
25
+ {words.servedBy}{" "}
26
+ <a href={skills.source} target="_blank" rel="noopener noreferrer">
27
+ SkillCDN
28
+ </a>{" "}
29
+ {words.at}{" "}
30
+ <a href={skills.page} target="_blank" rel="noopener noreferrer">
31
+ <code>{skills.address}</code>
32
+ </a>
33
+ {words.howLoaded}
34
+ </p>
35
+ );
36
+ if (skills.status !== "ready") {
37
+ return (
38
+ <>
39
+ {where}
40
+ <Callout tone={skills.status === "indexing" ? "info" : "warning"}>
41
+ {words.status[skills.status]}
42
+ </Callout>
43
+ </>
44
+ );
45
+ }
46
+ if (skills.items.length === 0) {
47
+ return (
48
+ <>
49
+ {where}
50
+ <EmptyState title={words.none.title} body={words.none.body} />
51
+ </>
52
+ );
53
+ }
54
+ return (
55
+ <>
56
+ {where}
57
+ <ul className="sc-skills" aria-label={words.list}>
58
+ {skills.items.map((skill) => (
59
+ <li key={skill.path} className="sc-skill">
60
+ <p className="sc-skill-head">
61
+ <a
62
+ className="sc-skill-name"
63
+ href={skill.page}
64
+ target="_blank"
65
+ rel="noopener noreferrer nofollow"
66
+ >
67
+ {skill.name}
68
+ </a>
69
+ <span className="sc-muted">{skill.directory === "" ? "/" : skill.directory}</span>
70
+ </p>
71
+ <p className="sc-skill-description">{skill.description}</p>
72
+ {skill.uri !== null && <code className="sc-skill-uri">{skill.uri}</code>}
73
+ </li>
74
+ ))}
75
+ </ul>
76
+ </>
77
+ );
78
+ }
@@ -0,0 +1,229 @@
1
+ import { type FormEvent, useState } from "react";
2
+ import {
3
+ MAX_BODY_LENGTH,
4
+ MAX_LINK_LABEL_LENGTH,
5
+ MAX_LINKS,
6
+ MAX_TITLE_LENGTH,
7
+ MAX_URL_LENGTH,
8
+ type RestPerson,
9
+ type RestTask,
10
+ type RestTaskInput,
11
+ TASK_PRIORITIES,
12
+ TASK_STATES,
13
+ } from "../api.js";
14
+ import { useWords } from "../i18n/index.js";
15
+ import { Button } from "./ui.js";
16
+
17
+ // Writing a task, or changing one. The bounds are the package's, said before a request is made.
18
+
19
+ export interface TaskFormProps {
20
+ readonly people: readonly RestPerson[];
21
+ /** The task to change; left out, the form writes a new one. */
22
+ readonly task?: RestTask | undefined;
23
+ /** The tasks a new one may be part of. */
24
+ readonly parents?: readonly RestTask[] | undefined;
25
+ readonly busy?: boolean | undefined;
26
+ readonly error?: string | undefined;
27
+ readonly onSubmit: (input: RestTaskInput) => void;
28
+ readonly onCancel?: (() => void) | undefined;
29
+ }
30
+
31
+ interface LinkDraft {
32
+ readonly url: string;
33
+ readonly label: string;
34
+ }
35
+
36
+ export function TaskForm(props: TaskFormProps) {
37
+ const words = useWords();
38
+ const form = words.task.form;
39
+ const { task } = props;
40
+ const [title, setTitle] = useState(task?.title ?? "");
41
+ const [body, setBody] = useState(task?.body ?? "");
42
+ const [state, setState] = useState(task?.state ?? "idea");
43
+ const [priority, setPriority] = useState(task?.priority ?? "normal");
44
+ const [assigneeId, setAssigneeId] = useState(task?.assignee?.id ?? "");
45
+ const [parentId, setParentId] = useState(task?.parentId ?? "");
46
+ const [links, setLinks] = useState<readonly LinkDraft[]>(
47
+ task?.links.map((link) => ({ url: link.url, label: link.label ?? "" })) ?? [],
48
+ );
49
+
50
+ const submit = (event: FormEvent) => {
51
+ event.preventDefault();
52
+ props.onSubmit({
53
+ title: title.trim(),
54
+ body,
55
+ state,
56
+ priority,
57
+ assigneeId: assigneeId === "" ? null : assigneeId,
58
+ parentId: parentId === "" ? null : parentId,
59
+ links: links
60
+ .filter((link) => link.url.trim().length > 0)
61
+ .map((link) => ({
62
+ url: link.url.trim(),
63
+ label: link.label.trim() === "" ? null : link.label.trim(),
64
+ })),
65
+ });
66
+ };
67
+
68
+ const parents = (props.parents ?? []).filter((candidate) => candidate.id !== task?.id);
69
+
70
+ return (
71
+ <form className="sc-form" onSubmit={submit}>
72
+ <label className="sc-field">
73
+ <span className="sc-field-label">{form.title}</span>
74
+ <input
75
+ className="sc-input"
76
+ value={title}
77
+ maxLength={MAX_TITLE_LENGTH}
78
+ required
79
+ onChange={(event) => setTitle(event.target.value)}
80
+ />
81
+ </label>
82
+ <label className="sc-field">
83
+ <span className="sc-field-label">{form.body}</span>
84
+ <textarea
85
+ className="sc-input sc-textarea"
86
+ value={body}
87
+ maxLength={MAX_BODY_LENGTH}
88
+ rows={6}
89
+ placeholder={form.bodyPlaceholder}
90
+ onChange={(event) => setBody(event.target.value)}
91
+ />
92
+ </label>
93
+ <div className="sc-field-row">
94
+ <label className="sc-field">
95
+ <span className="sc-field-label">{form.state}</span>
96
+ <select
97
+ className="sc-input"
98
+ value={state}
99
+ onChange={(event) => setState(event.target.value as typeof state)}
100
+ >
101
+ {TASK_STATES.map((candidate) => (
102
+ <option key={candidate} value={candidate}>
103
+ {words.vocabulary.state[candidate]}
104
+ </option>
105
+ ))}
106
+ </select>
107
+ </label>
108
+ <label className="sc-field">
109
+ <span className="sc-field-label">{form.priority}</span>
110
+ <select
111
+ className="sc-input"
112
+ value={priority}
113
+ onChange={(event) => setPriority(event.target.value as typeof priority)}
114
+ >
115
+ {TASK_PRIORITIES.map((candidate) => (
116
+ <option key={candidate} value={candidate}>
117
+ {words.vocabulary.priority[candidate]}
118
+ </option>
119
+ ))}
120
+ </select>
121
+ </label>
122
+ <label className="sc-field">
123
+ <span className="sc-field-label">{form.assignee}</span>
124
+ <select
125
+ className="sc-input"
126
+ value={assigneeId}
127
+ onChange={(event) => setAssigneeId(event.target.value)}
128
+ >
129
+ <option value="">{words.common.nobody}</option>
130
+ {props.people.map((person) => (
131
+ <option key={person.id} value={person.id}>
132
+ {person.login}
133
+ </option>
134
+ ))}
135
+ </select>
136
+ </label>
137
+ {parents.length > 0 && (
138
+ <label className="sc-field">
139
+ <span className="sc-field-label">{form.partOf}</span>
140
+ <select
141
+ className="sc-input"
142
+ value={parentId}
143
+ onChange={(event) => setParentId(event.target.value)}
144
+ >
145
+ <option value="">{words.common.nothing}</option>
146
+ {parents.map((candidate) => (
147
+ <option key={candidate.id} value={candidate.id}>
148
+ #{candidate.number} {candidate.title}
149
+ </option>
150
+ ))}
151
+ </select>
152
+ </label>
153
+ )}
154
+ </div>
155
+ <fieldset className="sc-field sc-links">
156
+ <legend className="sc-field-label">{form.links}</legend>
157
+ {links.map((link, index) => (
158
+ // Drafts have no identity of their own; their position is what tells them apart.
159
+ // biome-ignore lint/suspicious/noArrayIndexKey: ordered drafts without ids
160
+ <div key={index} className="sc-link-row">
161
+ <input
162
+ className="sc-input"
163
+ type="url"
164
+ placeholder="https://"
165
+ pattern="https://.*"
166
+ maxLength={MAX_URL_LENGTH}
167
+ value={link.url}
168
+ onChange={(event) =>
169
+ setLinks(
170
+ links.map((entry, at) =>
171
+ at === index ? { ...entry, url: event.target.value } : entry,
172
+ ),
173
+ )
174
+ }
175
+ />
176
+ <input
177
+ className="sc-input"
178
+ placeholder={form.label}
179
+ maxLength={MAX_LINK_LABEL_LENGTH}
180
+ value={link.label}
181
+ onChange={(event) =>
182
+ setLinks(
183
+ links.map((entry, at) =>
184
+ at === index ? { ...entry, label: event.target.value } : entry,
185
+ ),
186
+ )
187
+ }
188
+ />
189
+ <Button
190
+ variant="ghost"
191
+ size="sm"
192
+ onClick={() => setLinks(links.filter((_, at) => at !== index))}
193
+ >
194
+ {words.common.remove}
195
+ </Button>
196
+ </div>
197
+ ))}
198
+ {links.length < MAX_LINKS && (
199
+ <Button
200
+ variant="ghost"
201
+ size="sm"
202
+ onClick={() => setLinks([...links, { url: "", label: "" }])}
203
+ >
204
+ {form.addLink}
205
+ </Button>
206
+ )}
207
+ </fieldset>
208
+ {props.error !== undefined && (
209
+ <p className="sc-form-error" role="alert">
210
+ {props.error}
211
+ </p>
212
+ )}
213
+ <div className="sc-form-actions">
214
+ <Button
215
+ type="submit"
216
+ variant="primary"
217
+ disabled={props.busy === true || title.trim() === ""}
218
+ >
219
+ {task === undefined ? form.write : words.common.save}
220
+ </Button>
221
+ {props.onCancel !== undefined && (
222
+ <Button variant="ghost" onClick={props.onCancel}>
223
+ {words.common.cancel}
224
+ </Button>
225
+ )}
226
+ </div>
227
+ </form>
228
+ );
229
+ }