@skillcdn/console 0.1.24 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/README.md +8 -7
  2. package/dist/api.d.ts +2 -2
  3. package/dist/api.d.ts.map +1 -1
  4. package/dist/api.js +2 -2
  5. package/dist/api.js.map +1 -1
  6. package/dist/cli/cli.d.ts +1 -1
  7. package/dist/cli/cli.d.ts.map +1 -1
  8. package/dist/cli/cli.js +248 -92
  9. package/dist/cli/cli.js.map +1 -1
  10. package/dist/cli/credentials.d.ts +5 -0
  11. package/dist/cli/credentials.d.ts.map +1 -1
  12. package/dist/cli/credentials.js +2 -0
  13. package/dist/cli/credentials.js.map +1 -1
  14. package/dist/cli/directory.d.ts +7 -0
  15. package/dist/cli/directory.d.ts.map +1 -1
  16. package/dist/cli/directory.js +7 -0
  17. package/dist/cli/directory.js.map +1 -1
  18. package/dist/cli/format.d.ts +13 -28
  19. package/dist/cli/format.d.ts.map +1 -1
  20. package/dist/cli/format.js +74 -274
  21. package/dist/cli/format.js.map +1 -1
  22. package/dist/cli/help.d.ts.map +1 -1
  23. package/dist/cli/help.js +36 -19
  24. package/dist/cli/help.js.map +1 -1
  25. package/dist/cli/pack.d.ts +46 -0
  26. package/dist/cli/pack.d.ts.map +1 -0
  27. package/dist/cli/pack.js +250 -0
  28. package/dist/cli/pack.js.map +1 -0
  29. package/dist/client.d.ts +26 -1
  30. package/dist/client.d.ts.map +1 -1
  31. package/dist/client.js +14 -1
  32. package/dist/client.js.map +1 -1
  33. package/dist/compat.d.ts +1 -1
  34. package/dist/compat.js +1 -1
  35. package/dist/components/calendar.d.ts +5 -1
  36. package/dist/components/calendar.d.ts.map +1 -1
  37. package/dist/components/calendar.js +3 -3
  38. package/dist/components/calendar.js.map +1 -1
  39. package/dist/components/decision-list.d.ts +13 -1
  40. package/dist/components/decision-list.d.ts.map +1 -1
  41. package/dist/components/decision-list.js +74 -29
  42. package/dist/components/decision-list.js.map +1 -1
  43. package/dist/components/event-feed.d.ts.map +1 -1
  44. package/dist/components/event-feed.js +2 -0
  45. package/dist/components/event-feed.js.map +1 -1
  46. package/dist/components/pack-connect.d.ts +23 -0
  47. package/dist/components/pack-connect.d.ts.map +1 -0
  48. package/dist/components/pack-connect.js +86 -0
  49. package/dist/components/pack-connect.js.map +1 -0
  50. package/dist/components/pack-token.d.ts +27 -0
  51. package/dist/components/pack-token.d.ts.map +1 -0
  52. package/dist/components/pack-token.js +11 -0
  53. package/dist/components/pack-token.js.map +1 -0
  54. package/dist/components/paged.d.ts +24 -0
  55. package/dist/components/paged.d.ts.map +1 -1
  56. package/dist/components/paged.js +40 -4
  57. package/dist/components/paged.js.map +1 -1
  58. package/dist/components/projects.d.ts +13 -0
  59. package/dist/components/projects.d.ts.map +1 -1
  60. package/dist/components/projects.js +37 -5
  61. package/dist/components/projects.js.map +1 -1
  62. package/dist/components/skills.d.ts.map +1 -1
  63. package/dist/components/skills.js +8 -4
  64. package/dist/components/skills.js.map +1 -1
  65. package/dist/components/ui.d.ts +2 -0
  66. package/dist/components/ui.d.ts.map +1 -1
  67. package/dist/components/ui.js +7 -2
  68. package/dist/components/ui.js.map +1 -1
  69. package/dist/components/work.d.ts +3 -1
  70. package/dist/components/work.d.ts.map +1 -1
  71. package/dist/components/work.js +1 -1
  72. package/dist/components/work.js.map +1 -1
  73. package/dist/components/workspace.d.ts +46 -0
  74. package/dist/components/workspace.d.ts.map +1 -0
  75. package/dist/components/workspace.js +124 -0
  76. package/dist/components/workspace.js.map +1 -0
  77. package/dist/console.css +268 -5
  78. package/dist/console.d.ts.map +1 -1
  79. package/dist/console.js +62 -23
  80. package/dist/console.js.map +1 -1
  81. package/dist/data.d.ts +16 -1
  82. package/dist/data.d.ts.map +1 -1
  83. package/dist/data.js +64 -2
  84. package/dist/data.js.map +1 -1
  85. package/dist/dates.d.ts +12 -2
  86. package/dist/dates.d.ts.map +1 -1
  87. package/dist/dates.js +20 -2
  88. package/dist/dates.js.map +1 -1
  89. package/dist/i18n/en.d.ts +66 -0
  90. package/dist/i18n/en.d.ts.map +1 -1
  91. package/dist/i18n/en.js +80 -8
  92. package/dist/i18n/en.js.map +1 -1
  93. package/dist/i18n/ko.d.ts.map +1 -1
  94. package/dist/i18n/ko.js +75 -7
  95. package/dist/i18n/ko.js.map +1 -1
  96. package/dist/index.d.ts +5 -2
  97. package/dist/index.d.ts.map +1 -1
  98. package/dist/index.js +5 -2
  99. package/dist/index.js.map +1 -1
  100. package/dist/router.d.ts +10 -5
  101. package/dist/router.d.ts.map +1 -1
  102. package/dist/router.js +24 -9
  103. package/dist/router.js.map +1 -1
  104. package/dist/routes.d.ts +4 -0
  105. package/dist/routes.d.ts.map +1 -1
  106. package/dist/routes.js +4 -0
  107. package/dist/routes.js.map +1 -1
  108. package/dist/schemas.d.ts +500 -158
  109. package/dist/schemas.d.ts.map +1 -1
  110. package/dist/schemas.js +169 -165
  111. package/dist/schemas.js.map +1 -1
  112. package/dist/vocabulary.d.ts +19 -1
  113. package/dist/vocabulary.d.ts.map +1 -1
  114. package/dist/vocabulary.js +16 -0
  115. package/dist/vocabulary.js.map +1 -1
  116. package/docs/specs/cli.md +19 -14
  117. package/docs/specs/rest.md +34 -19
  118. package/package.json +2 -1
  119. package/skills/building-a-console/SKILL.md +5 -5
  120. package/skills/building-a-console/references/the-look-from-a-website.md +39 -0
  121. package/skills/deploying-a-console/SKILL.md +9 -8
  122. package/skills/working-the-board/SKILL.md +5 -5
  123. package/skills/working-the-board/references/setting-up.md +1 -1
  124. package/skills/working-the-board/references/terms.md +2 -0
  125. package/skills/working-the-board/references/the-sentence.md +1 -1
  126. package/src/api.ts +18 -7
  127. package/src/cli/cli.ts +345 -109
  128. package/src/cli/credentials.ts +7 -0
  129. package/src/cli/directory.ts +9 -0
  130. package/src/cli/format.ts +82 -341
  131. package/src/cli/help.ts +43 -19
  132. package/src/cli/pack.ts +356 -0
  133. package/src/client.ts +84 -0
  134. package/src/compat.ts +1 -1
  135. package/src/components/calendar.tsx +8 -2
  136. package/src/components/decision-list.tsx +227 -94
  137. package/src/components/event-feed.tsx +2 -0
  138. package/src/components/pack-connect.tsx +179 -0
  139. package/src/components/pack-token.tsx +74 -0
  140. package/src/components/paged.tsx +72 -3
  141. package/src/components/projects.tsx +108 -9
  142. package/src/components/skills.tsx +51 -33
  143. package/src/components/ui.tsx +16 -3
  144. package/src/components/work.tsx +4 -1
  145. package/src/components/workspace.tsx +298 -0
  146. package/src/console.tsx +176 -29
  147. package/src/data.ts +89 -2
  148. package/src/dates.ts +23 -2
  149. package/src/i18n/en.ts +94 -8
  150. package/src/i18n/ko.ts +84 -7
  151. package/src/index.ts +25 -1
  152. package/src/router.ts +37 -14
  153. package/src/routes.ts +4 -0
  154. package/src/schemas.ts +202 -189
  155. package/src/styles/console.css +268 -5
  156. package/src/vocabulary.ts +25 -0
@@ -0,0 +1,179 @@
1
+ import { useEffect, useState } from "react";
2
+ import type { PackConnectEnding, RestPackConnect, RestPackConnectStarted } from "../api.js";
3
+ import { useWords } from "../i18n/index.js";
4
+ import { Button, Time } from "./ui.js";
5
+
6
+ // A private pack connected with a code a person approves (ADR-0036): an owner, or for the
7
+ // workspace's token an administrator, asks, the console asks the deployment and shows what it
8
+ // answered, the code and the page, and the person approves there; the page asks the console
9
+ // again at the interval the deployment named until the token is kept or the request ended.
10
+ // Takes its data through the callbacks and keeps nothing itself.
11
+
12
+ /**
13
+ * How a connection stands, as the component needs it: what the API answers for a project's
14
+ * token and for the workspace's both say this much, with what was connected beside it.
15
+ */
16
+ export type PackConnectState =
17
+ | Extract<RestPackConnect, { status: "none" | "pending" | "ended" }>
18
+ | { readonly status: "connected" };
19
+
20
+ export interface PackConnectProps {
21
+ /** Asks the deployment to connect; answers what the person is shown. */
22
+ readonly onStart: () => Promise<RestPackConnectStarted>;
23
+ /** How the connection stands; the token is kept when the person approved. */
24
+ readonly poll: (signal?: AbortSignal) => Promise<PackConnectState>;
25
+ /** Gives the connection up. */
26
+ readonly onCancel: () => Promise<void>;
27
+ /** Called once the token is kept. */
28
+ readonly onConnected?: (() => void) | undefined;
29
+ readonly busy?: boolean | undefined;
30
+ }
31
+
32
+ type Shown =
33
+ | { readonly kind: "idle" }
34
+ | {
35
+ readonly kind: "waiting";
36
+ readonly code: string;
37
+ readonly url: string;
38
+ readonly expiresAt: string;
39
+ readonly intervalSeconds: number;
40
+ }
41
+ | { readonly kind: "ended"; readonly reason: PackConnectEnding }
42
+ | { readonly kind: "connected" };
43
+
44
+ /** The least often the console is asked again, whatever the deployment says. */
45
+ const MIN_INTERVAL_SECONDS = 2;
46
+
47
+ export function PackConnect(props: PackConnectProps) {
48
+ const words = useWords();
49
+ const pack = words.projects.packToken;
50
+ const { poll, onConnected } = props;
51
+ const [shown, setShown] = useState<Shown>({ kind: "idle" });
52
+ const [problem, setProblem] = useState<string | undefined>(undefined);
53
+
54
+ // A request begun before the page was opened is picked up where it stands.
55
+ useEffect(() => {
56
+ const controller = new AbortController();
57
+ poll(controller.signal)
58
+ .then((state) => {
59
+ if (!controller.signal.aborted && state.status === "pending") {
60
+ setShown({ kind: "waiting", ...state });
61
+ }
62
+ })
63
+ .catch(() => undefined);
64
+ return () => controller.abort();
65
+ }, [poll]);
66
+
67
+ // While waiting, the console is asked again at the interval the deployment named; a new
68
+ // answer, pending still, is a new state, which schedules the next asking.
69
+ useEffect(() => {
70
+ if (shown.kind !== "waiting") {
71
+ return;
72
+ }
73
+ const controller = new AbortController();
74
+ const timer = setTimeout(() => {
75
+ poll(controller.signal)
76
+ .then((state) => {
77
+ if (controller.signal.aborted) {
78
+ return;
79
+ }
80
+ setProblem(undefined);
81
+ if (state.status === "pending") {
82
+ setShown({ kind: "waiting", ...state });
83
+ } else if (state.status === "connected") {
84
+ setShown({ kind: "connected" });
85
+ onConnected?.();
86
+ } else if (state.status === "ended") {
87
+ setShown({ kind: "ended", reason: state.reason });
88
+ } else {
89
+ setShown({ kind: "idle" });
90
+ }
91
+ })
92
+ .catch((failure: unknown) => {
93
+ if (controller.signal.aborted) {
94
+ return;
95
+ }
96
+ // The console could not be asked this time; it is asked again.
97
+ setProblem(failure instanceof Error ? failure.message : String(failure));
98
+ setShown((current) => (current.kind === "waiting" ? { ...current } : current));
99
+ });
100
+ }, Math.max(shown.intervalSeconds, MIN_INTERVAL_SECONDS) * 1000);
101
+ return () => {
102
+ controller.abort();
103
+ clearTimeout(timer);
104
+ };
105
+ }, [shown, poll, onConnected]);
106
+
107
+ const start = () => {
108
+ setProblem(undefined);
109
+ props
110
+ .onStart()
111
+ .then((started) => setShown({ kind: "waiting", ...started }))
112
+ .catch((failure: unknown) => {
113
+ setProblem(failure instanceof Error ? failure.message : String(failure));
114
+ });
115
+ };
116
+ const cancel = () => {
117
+ props
118
+ .onCancel()
119
+ .then(() => setShown({ kind: "idle" }))
120
+ .catch((failure: unknown) => {
121
+ setProblem(failure instanceof Error ? failure.message : String(failure));
122
+ });
123
+ };
124
+
125
+ return (
126
+ <div className="sc-pack-connect" aria-live="polite">
127
+ {shown.kind === "waiting" ? (
128
+ <>
129
+ <p className="sc-pack-connect-code">
130
+ <span className="sc-field-label">{pack.connectCode}</span>
131
+ <code className="sc-pack-code">{shown.code}</code>
132
+ </p>
133
+ <div className="sc-form-actions">
134
+ <a
135
+ className="sc-button sc-button-primary"
136
+ href={shown.url}
137
+ target="_blank"
138
+ rel="noopener noreferrer"
139
+ >
140
+ {pack.connectOpen}
141
+ </a>
142
+ <Button variant="ghost" disabled={props.busy === true} onClick={cancel}>
143
+ {pack.connectCancel}
144
+ </Button>
145
+ </div>
146
+ <p className="sc-pack-connect-waiting">
147
+ <span className="sc-spinner" aria-hidden="true">
148
+ <span className="sc-spinner-ring" />
149
+ </span>
150
+ <span>
151
+ {pack.connectWaiting} {pack.connectExpires} <Time iso={shown.expiresAt} />
152
+ </span>
153
+ </p>
154
+ </>
155
+ ) : shown.kind === "connected" ? (
156
+ <p>{pack.connectDone}</p>
157
+ ) : (
158
+ <>
159
+ <p className="sc-field-hint">{pack.connectHint}</p>
160
+ {shown.kind === "ended" && (
161
+ <p className="sc-form-error" role="alert">
162
+ {pack.connectEnded[shown.reason]}
163
+ </p>
164
+ )}
165
+ <div className="sc-form-actions">
166
+ <Button variant="primary" disabled={props.busy === true} onClick={start}>
167
+ {pack.connect}
168
+ </Button>
169
+ </div>
170
+ </>
171
+ )}
172
+ {problem !== undefined && (
173
+ <p className="sc-form-error" role="alert">
174
+ {problem}
175
+ </p>
176
+ )}
177
+ </div>
178
+ );
179
+ }
@@ -0,0 +1,74 @@
1
+ import type { RestPackConnectStarted } from "../api.js";
2
+ import { useWords } from "../i18n/index.js";
3
+ import { PackConnect, type PackConnectState } from "./pack-connect.js";
4
+ import { PackTokenForm } from "./projects.js";
5
+ import { Button } from "./ui.js";
6
+
7
+ // The SkillCDN token a private pack is read with (ADR-0028, ADR-0036), as a project's Settings
8
+ // page and the workspace's offer it: what the token is for, then the two ways to one side by
9
+ // side, a code approved at SkillCDN and a token made there by hand. Takes its data through the
10
+ // callbacks and keeps nothing itself; whose token it is, the page says in the title and the intro.
11
+
12
+ export interface PackTokenWaysProps {
13
+ /** What the section is called, and what assistive technology reads it as. */
14
+ readonly title: string;
15
+ /** What the token is for, in the words of the page it is on. */
16
+ readonly intro: string;
17
+ /** Whether a token is held now, and since when. */
18
+ readonly set: boolean;
19
+ readonly setAt?: string | undefined;
20
+ readonly busy?: boolean | undefined;
21
+ /** Asks the deployment to connect; answers what the person is shown. */
22
+ readonly onStart: () => Promise<RestPackConnectStarted>;
23
+ /** How the connection stands; the token is kept when the person approved. */
24
+ readonly poll: (signal?: AbortSignal) => Promise<PackConnectState>;
25
+ /** Gives the connection up. */
26
+ readonly onCancelConnect: () => Promise<void>;
27
+ /** Called once the token is kept. */
28
+ readonly onConnected?: (() => void) | undefined;
29
+ /** Called with a token made by hand, as typed or pasted. */
30
+ readonly onSubmit: (token: string) => void;
31
+ readonly onRemove?: (() => void) | undefined;
32
+ /** Leaves the page without changing anything. */
33
+ readonly onBack?: (() => void) | undefined;
34
+ }
35
+
36
+ export function PackTokenWays(props: PackTokenWaysProps) {
37
+ const words = useWords();
38
+ const pack = words.projects.packToken;
39
+ return (
40
+ <section className="sc-panel sc-panel-form" aria-label={props.title}>
41
+ <h2 className="sc-section-title">{props.title}</h2>
42
+ <p className="sc-pack-intro">{props.intro}</p>
43
+ <div className="sc-pack-ways">
44
+ <section className="sc-pack-way" aria-label={pack.connectTitle}>
45
+ <h3 className="sc-pack-way-title">{pack.connectTitle}</h3>
46
+ <PackConnect
47
+ onStart={props.onStart}
48
+ poll={props.poll}
49
+ onCancel={props.onCancelConnect}
50
+ onConnected={props.onConnected}
51
+ busy={props.busy}
52
+ />
53
+ </section>
54
+ <section className="sc-pack-way" aria-label={pack.pasteTitle}>
55
+ <h3 className="sc-pack-way-title">{pack.pasteTitle}</h3>
56
+ <PackTokenForm
57
+ set={props.set}
58
+ setAt={props.setAt}
59
+ busy={props.busy}
60
+ onSubmit={props.onSubmit}
61
+ onRemove={props.onRemove}
62
+ />
63
+ </section>
64
+ </div>
65
+ {props.onBack !== undefined && (
66
+ <div className="sc-form-actions sc-pack-actions">
67
+ <Button variant="ghost" onClick={props.onBack}>
68
+ {words.common.cancel}
69
+ </Button>
70
+ </div>
71
+ )}
72
+ </section>
73
+ );
74
+ }
@@ -2,13 +2,17 @@ import { useState } from "react";
2
2
  import { useWords } from "../i18n/index.js";
3
3
  import { Button } from "./ui.js";
4
4
 
5
- // A long list shown a page at a time, from what the page already holds: the record of the
6
- // decisions, the feed, the tasks with no day. The next page is a click away and nothing is
7
- // asked of the server; the API's own pages (`limit`, `offset`) are for the command and scripts.
5
+ // A long list shown a page at a time, from what the page already holds: the feed and the tasks
6
+ // with no day grow by a page on a click; the record of the decisions turns its pages, each with
7
+ // an address. Nothing is asked of the server either way; the API's own pages (`limit`, `offset`)
8
+ // are for the command and scripts.
8
9
 
9
10
  /** How many items a list shows before it asks to be told to show more. */
10
11
  export const PAGE_SIZE = 50;
11
12
 
13
+ /** How many rows the record of the decisions shows on a page: each says what was asked and what was answered. */
14
+ export const RECORD_PAGE_SIZE = 20;
15
+
12
16
  /** The first `shown` items of a list, and the way to show the next page of it. */
13
17
  export function usePaged<T>(
14
18
  items: readonly T[],
@@ -37,3 +41,68 @@ export function ShowMore(props: { readonly rest: number; readonly onMore: () =>
37
41
  </div>
38
42
  );
39
43
  }
44
+
45
+ /**
46
+ * The page `page` of a list, counted from one and `pageSize` long, and which it is of how many:
47
+ * a page past the end, or no page at all, is the last or the first, and an empty list has one.
48
+ */
49
+ export function pageOf<T>(
50
+ items: readonly T[],
51
+ page: number | undefined,
52
+ pageSize = RECORD_PAGE_SIZE,
53
+ ): { readonly shown: readonly T[]; readonly page: number; readonly pages: number } {
54
+ const pages = Math.max(1, Math.ceil(items.length / pageSize));
55
+ const current =
56
+ page === undefined || !Number.isInteger(page) ? 1 : Math.min(Math.max(page, 1), pages);
57
+ return {
58
+ shown: items.slice((current - 1) * pageSize, current * pageSize),
59
+ page: current,
60
+ pages,
61
+ };
62
+ }
63
+
64
+ /**
65
+ * The way between the pages of a list: the page before, which items of how many are shown, the
66
+ * page after; nothing for a list of one page. Given `pageHref`, each page has an address and the
67
+ * steps lead there; else `onPage` turns the page in place.
68
+ */
69
+ export function Pager(props: {
70
+ readonly page: number;
71
+ readonly pages: number;
72
+ readonly total: number;
73
+ readonly pageSize: number;
74
+ readonly pageHref?: ((page: number) => string) | undefined;
75
+ readonly onPage?: ((page: number) => void) | undefined;
76
+ }) {
77
+ const words = useWords().common.pager;
78
+ if (props.pages <= 1) {
79
+ return null;
80
+ }
81
+ const from = (props.page - 1) * props.pageSize + 1;
82
+ const to = Math.min(props.page * props.pageSize, props.total);
83
+ const step = (label: string, target: number, there: boolean) => {
84
+ if (!there) {
85
+ return (
86
+ <span className="sc-button sc-button-secondary sc-button-sm" aria-disabled="true">
87
+ {label}
88
+ </span>
89
+ );
90
+ }
91
+ return props.pageHref === undefined ? (
92
+ <Button variant="secondary" size="sm" onClick={() => props.onPage?.(target)}>
93
+ {label}
94
+ </Button>
95
+ ) : (
96
+ <a className="sc-button sc-button-secondary sc-button-sm" href={props.pageHref(target)}>
97
+ {label}
98
+ </a>
99
+ );
100
+ };
101
+ return (
102
+ <nav className="sc-pager" aria-label={words.label}>
103
+ {step(words.previous, props.page - 1, props.page > 1)}
104
+ <span className="sc-pager-range">{words.range(from, to, props.total)}</span>
105
+ {step(words.next, props.page + 1, props.page < props.pages)}
106
+ </nav>
107
+ );
108
+ }
@@ -305,7 +305,7 @@ export function ProjectForm(props: ProjectFormProps) {
305
305
  onChange={(event) => setSkillsAddress(event.target.value)}
306
306
  />
307
307
  </span>
308
- <span className="sc-muted">
308
+ <span className="sc-field-hint">
309
309
  {form.skillsAddressHint(props.organizationAddress ?? null)}
310
310
  </span>
311
311
  </label>
@@ -383,8 +383,19 @@ export interface MemberListProps {
383
383
  readonly people: readonly RestPerson[];
384
384
  /** Whoever is looking, so that their own row says so. */
385
385
  readonly me?: RestPerson | undefined;
386
+ /**
387
+ * Whether the project is open to the workspace (ADR-0035): everyone of it is a member, the
388
+ * list is everyone, nobody is added or removed, and an owner is made from a row.
389
+ */
390
+ readonly open?: boolean | undefined;
386
391
  /** Called with a person to list, and what they are to be. Left out, the list is read here, not changed. */
387
392
  readonly onAdd?: ((input: RestMemberInput) => void) | undefined;
393
+ /**
394
+ * Finds a person of the workspace by their exact login, or nobody: given to an owner whose
395
+ * People page shows only whom they share a project with, so that they list someone it does
396
+ * not show (ADR-0035). With `onAdd` only.
397
+ */
398
+ readonly lookup?: ((login: string) => Promise<RestPerson | undefined>) | undefined;
388
399
  readonly onChangeRole?: ((member: RestMember, role: ProjectRole) => void) | undefined;
389
400
  readonly onRemove?: ((member: RestMember) => void) | undefined;
390
401
  /** Where a member's work is seen; given to the project's owners. */
@@ -400,6 +411,30 @@ export function MemberList(props: MemberListProps) {
400
411
  const candidates = props.people.filter((person) => !listed.has(person.id));
401
412
  const [personId, setPersonId] = useState("");
402
413
  const [role, setRole] = useState<ProjectRole>("member");
414
+ const [login, setLogin] = useState("");
415
+ const [notFound, setNotFound] = useState<string | undefined>(undefined);
416
+ const [looking, setLooking] = useState(false);
417
+ const addByLogin = async () => {
418
+ const wanted = login.trim();
419
+ if (wanted === "" || props.lookup === undefined) {
420
+ return;
421
+ }
422
+ setLooking(true);
423
+ try {
424
+ const found = await props.lookup(wanted);
425
+ if (found === undefined) {
426
+ setNotFound(wanted);
427
+ return;
428
+ }
429
+ setNotFound(undefined);
430
+ setLogin("");
431
+ props.onAdd?.({ personId: found.id, role });
432
+ } catch {
433
+ setNotFound(wanted);
434
+ } finally {
435
+ setLooking(false);
436
+ }
437
+ };
403
438
  return (
404
439
  <div className="sc-members">
405
440
  {props.members.length === 0 ? (
@@ -419,8 +454,14 @@ export function MemberList(props: MemberListProps) {
419
454
  {members.since} <Time iso={member.addedAt} />
420
455
  </span>
421
456
  </span>
422
- {props.onChangeRole === undefined ? (
423
- <ProjectRoleBadge role={member.role} />
457
+ {props.onChangeRole === undefined || member.person.role === "admin" ? (
458
+ // An administrator owns every project by office: nothing here changes that.
459
+ <span className="sc-member-role">
460
+ <ProjectRoleBadge role={member.role} />
461
+ {member.person.role === "admin" && (
462
+ <span className="sc-muted"> {words.vocabulary.role.admin}</span>
463
+ )}
464
+ </span>
424
465
  ) : (
425
466
  <label className="sc-field sc-field-inline">
426
467
  <span className="sc-visually-hidden">
@@ -447,7 +488,7 @@ export function MemberList(props: MemberListProps) {
447
488
  {words.people.work}
448
489
  </a>
449
490
  )}
450
- {props.onRemove !== undefined && (
491
+ {props.onRemove !== undefined && props.open !== true && (
451
492
  <Button
452
493
  variant="danger"
453
494
  size="sm"
@@ -461,7 +502,7 @@ export function MemberList(props: MemberListProps) {
461
502
  ))}
462
503
  </ul>
463
504
  )}
464
- {props.onAdd !== undefined && candidates.length > 0 && (
505
+ {props.onAdd !== undefined && props.open !== true && candidates.length > 0 && (
465
506
  <form
466
507
  className="sc-form sc-member-form"
467
508
  aria-label={members.addAria}
@@ -515,6 +556,57 @@ export function MemberList(props: MemberListProps) {
515
556
  </div>
516
557
  </form>
517
558
  )}
559
+ {props.onAdd !== undefined && props.lookup !== undefined && props.open !== true && (
560
+ <form
561
+ className="sc-form sc-member-form sc-member-lookup"
562
+ aria-label={members.addByLoginAria}
563
+ onSubmit={(event) => {
564
+ event.preventDefault();
565
+ void addByLogin();
566
+ }}
567
+ >
568
+ <div className="sc-field-row">
569
+ <label className="sc-field">
570
+ <span className="sc-field-label">{members.login}</span>
571
+ <input
572
+ className="sc-input"
573
+ value={login}
574
+ autoComplete="off"
575
+ onChange={(event) => {
576
+ setLogin(event.target.value);
577
+ setNotFound(undefined);
578
+ }}
579
+ />
580
+ </label>
581
+ <label className="sc-field">
582
+ <span className="sc-field-label">{members.as}</span>
583
+ <select
584
+ className="sc-input"
585
+ value={role}
586
+ onChange={(event) => setRole(event.target.value as ProjectRole)}
587
+ >
588
+ {PROJECT_ROLES.map((candidate) => (
589
+ <option key={candidate} value={candidate}>
590
+ {words.vocabulary.projectRole[candidate]}
591
+ </option>
592
+ ))}
593
+ </select>
594
+ </label>
595
+ <div className="sc-form-actions">
596
+ <Button
597
+ type="submit"
598
+ variant="primary"
599
+ disabled={props.busy === true || looking || login.trim() === ""}
600
+ >
601
+ {members.addByLogin}
602
+ </Button>
603
+ </div>
604
+ </div>
605
+ <p className="sc-field-hint">
606
+ {notFound === undefined ? members.lookupHint : members.nobodyCalled(notFound)}
607
+ </p>
608
+ </form>
609
+ )}
518
610
  </div>
519
611
  );
520
612
  }
@@ -522,6 +614,8 @@ export function MemberList(props: MemberListProps) {
522
614
  export interface PackTokenFormProps {
523
615
  /** Whether the project holds a pack token now, so that it can be taken away. */
524
616
  readonly set: boolean;
617
+ /** When the token held now was set, so that an owner knows what they replace. */
618
+ readonly setAt?: string | undefined;
525
619
  readonly busy?: boolean | undefined;
526
620
  readonly error?: string | undefined;
527
621
  /** Called with the token as typed or pasted; it travels to the console once and is shown nowhere after. */
@@ -539,8 +633,8 @@ export function PackTokenForm(props: PackTokenFormProps) {
539
633
  const ready = typed.length >= 8 && !/\s/.test(typed);
540
634
  return (
541
635
  <form
542
- className="sc-form"
543
- aria-label={pack.aria}
636
+ className="sc-form sc-pack-token-form"
637
+ aria-label={pack.pasteAria}
544
638
  onSubmit={(event: FormEvent) => {
545
639
  event.preventDefault();
546
640
  if (ready && props.busy !== true) {
@@ -550,6 +644,11 @@ export function PackTokenForm(props: PackTokenFormProps) {
550
644
  }}
551
645
  >
552
646
  <p className="sc-field-hint">{pack.hint}</p>
647
+ {props.set && props.setAt !== undefined && (
648
+ <p className="sc-muted">
649
+ {pack.setSince} <Time iso={props.setAt} />
650
+ </p>
651
+ )}
553
652
  <label className="sc-field">
554
653
  <span className="sc-field-label">{pack.label}</span>
555
654
  <input
@@ -569,8 +668,8 @@ export function PackTokenForm(props: PackTokenFormProps) {
569
668
  </p>
570
669
  )}
571
670
  <div className="sc-form-actions">
572
- <Button type="submit" variant="primary" disabled={!ready || props.busy === true}>
573
- {pack.set}
671
+ <Button type="submit" variant="secondary" disabled={!ready || props.busy === true}>
672
+ {props.set ? pack.replace : pack.set}
574
673
  </Button>
575
674
  {props.set && props.onRemove !== undefined && (
576
675
  <Button variant="danger" disabled={props.busy === true} onClick={props.onRemove}>
@@ -2,9 +2,10 @@ import type { RestSkills } from "../api.js";
2
2
  import { useWords } from "../i18n/index.js";
3
3
  import { Callout, EmptyState, Spinner } from "./ui.js";
4
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.
5
+ // A project's skills, as the pack lists them: at a SkillCDN deployment, where a person reads
6
+ // each and what an agent loads it by; or from a directory on the console's machine (ADR-0031),
7
+ // with nothing to link to. Takes its data as props and nothing from the network; what the
8
+ // console answered is shown as text, never as HTML.
8
9
 
9
10
  export interface SkillListProps {
10
11
  /** What the console answered, or nothing while it has not yet. */
@@ -21,35 +22,48 @@ export function SkillList(props: SkillListProps) {
21
22
  if (skills.status === "unavailable" && skills.address === null) {
22
23
  return <Callout tone="warning">{words.status.unavailable}</Callout>;
23
24
  }
24
- if (skills.status === "none" || skills.address === null || skills.page === null) {
25
+ if (skills.status === "none" || skills.address === null) {
25
26
  return <EmptyState title={words.noAddress.title} body={words.noAddress.body} />;
26
27
  }
27
- const where = (
28
- <>
29
- <p className="sc-lead">
30
- {words.servedBy}{" "}
31
- <a href={skills.source} target="_blank" rel="noopener noreferrer">
32
- SkillCDN
33
- </a>{" "}
34
- {words.at}{" "}
35
- <a href={skills.page} target="_blank" rel="noopener noreferrer">
36
- <code>{skills.address}</code>
37
- </a>
38
- </p>
39
- <p className="sc-muted">
40
- {words.connect}{" "}
41
- <a href={skills.page} target="_blank" rel="noopener noreferrer">
42
- {words.connectLink}
43
- </a>
44
- </p>
45
- </>
46
- );
28
+ const deployment =
29
+ skills.source !== null && skills.page !== null
30
+ ? { source: skills.source, page: skills.page }
31
+ : undefined;
32
+ const local = deployment === undefined;
33
+ const where =
34
+ deployment === undefined ? (
35
+ <>
36
+ <p className="sc-lead">
37
+ {words.fromMachine} <code>{skills.address}</code>
38
+ </p>
39
+ <p className="sc-muted">{words.connectLocal}</p>
40
+ </>
41
+ ) : (
42
+ <>
43
+ <p className="sc-lead">
44
+ {words.servedBy}{" "}
45
+ <a href={deployment.source} target="_blank" rel="noopener noreferrer">
46
+ SkillCDN
47
+ </a>{" "}
48
+ {words.at}{" "}
49
+ <a href={deployment.page} target="_blank" rel="noopener noreferrer">
50
+ <code>{skills.address}</code>
51
+ </a>
52
+ </p>
53
+ <p className="sc-muted">
54
+ {words.connect}{" "}
55
+ <a href={deployment.page} target="_blank" rel="noopener noreferrer">
56
+ {words.connectLink}
57
+ </a>
58
+ </p>
59
+ </>
60
+ );
47
61
  if (skills.status !== "ready") {
48
62
  return (
49
63
  <>
50
64
  {where}
51
65
  <Callout tone={skills.status === "indexing" ? "info" : "warning"}>
52
- {words.status[skills.status]}
66
+ {(local ? words.localStatus[skills.status] : undefined) ?? words.status[skills.status]}
53
67
  </Callout>
54
68
  </>
55
69
  );
@@ -69,14 +83,18 @@ export function SkillList(props: SkillListProps) {
69
83
  {skills.items.map((skill) => (
70
84
  <li key={skill.path} className="sc-skill">
71
85
  <p className="sc-skill-head">
72
- <a
73
- className="sc-skill-name"
74
- href={skill.page}
75
- target="_blank"
76
- rel="noopener noreferrer nofollow"
77
- >
78
- {skill.name}
79
- </a>
86
+ {skill.page === null ? (
87
+ <span className="sc-skill-name">{skill.name}</span>
88
+ ) : (
89
+ <a
90
+ className="sc-skill-name"
91
+ href={skill.page}
92
+ target="_blank"
93
+ rel="noopener noreferrer nofollow"
94
+ >
95
+ {skill.name}
96
+ </a>
97
+ )}
80
98
  <span className="sc-muted">{skill.directory === "" ? "/" : skill.directory}</span>
81
99
  </p>
82
100
  <p className="sc-skill-description">{skill.description}</p>