@skillcdn/console 0.1.9 → 0.1.11

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 (162) hide show
  1. package/README.md +6 -6
  2. package/dist/api.d.ts +6 -5
  3. package/dist/api.d.ts.map +1 -1
  4. package/dist/api.js +5 -4
  5. package/dist/api.js.map +1 -1
  6. package/dist/cli/cli.d.ts.map +1 -1
  7. package/dist/cli/cli.js +303 -18
  8. package/dist/cli/cli.js.map +1 -1
  9. package/dist/cli/format.d.ts +11 -1
  10. package/dist/cli/format.d.ts.map +1 -1
  11. package/dist/cli/format.js +87 -6
  12. package/dist/cli/format.js.map +1 -1
  13. package/dist/cli/help.d.ts.map +1 -1
  14. package/dist/cli/help.js +70 -14
  15. package/dist/cli/help.js.map +1 -1
  16. package/dist/client.d.ts +44 -8
  17. package/dist/client.d.ts.map +1 -1
  18. package/dist/client.js +32 -4
  19. package/dist/client.js.map +1 -1
  20. package/dist/components/board.d.ts +24 -1
  21. package/dist/components/board.d.ts.map +1 -1
  22. package/dist/components/board.js +66 -5
  23. package/dist/components/board.js.map +1 -1
  24. package/dist/components/calendar.d.ts +54 -0
  25. package/dist/components/calendar.d.ts.map +1 -0
  26. package/dist/components/calendar.js +156 -0
  27. package/dist/components/calendar.js.map +1 -0
  28. package/dist/components/categories.d.ts +13 -0
  29. package/dist/components/categories.d.ts.map +1 -0
  30. package/dist/components/categories.js +52 -0
  31. package/dist/components/categories.js.map +1 -0
  32. package/dist/components/decision-list.d.ts +10 -0
  33. package/dist/components/decision-list.d.ts.map +1 -1
  34. package/dist/components/decision-list.js +16 -1
  35. package/dist/components/decision-list.js.map +1 -1
  36. package/dist/components/event-feed.d.ts.map +1 -1
  37. package/dist/components/event-feed.js +23 -7
  38. package/dist/components/event-feed.js.map +1 -1
  39. package/dist/components/goals.d.ts +35 -0
  40. package/dist/components/goals.d.ts.map +1 -0
  41. package/dist/components/goals.js +45 -0
  42. package/dist/components/goals.js.map +1 -0
  43. package/dist/components/paged.d.ts +14 -0
  44. package/dist/components/paged.d.ts.map +1 -0
  45. package/dist/components/paged.js +28 -0
  46. package/dist/components/paged.js.map +1 -0
  47. package/dist/components/projects.d.ts +22 -2
  48. package/dist/components/projects.d.ts.map +1 -1
  49. package/dist/components/projects.js +35 -7
  50. package/dist/components/projects.js.map +1 -1
  51. package/dist/components/runs.d.ts.map +1 -1
  52. package/dist/components/runs.js +2 -2
  53. package/dist/components/runs.js.map +1 -1
  54. package/dist/components/shell.d.ts +2 -1
  55. package/dist/components/shell.d.ts.map +1 -1
  56. package/dist/components/shell.js +1 -1
  57. package/dist/components/shell.js.map +1 -1
  58. package/dist/components/task-form.d.ts +5 -1
  59. package/dist/components/task-form.d.ts.map +1 -1
  60. package/dist/components/task-form.js +13 -1
  61. package/dist/components/task-form.js.map +1 -1
  62. package/dist/components/task-list.d.ts +16 -0
  63. package/dist/components/task-list.d.ts.map +1 -0
  64. package/dist/components/task-list.js +16 -0
  65. package/dist/components/task-list.js.map +1 -0
  66. package/dist/components/task-view.d.ts +9 -1
  67. package/dist/components/task-view.d.ts.map +1 -1
  68. package/dist/components/task-view.js +10 -4
  69. package/dist/components/task-view.js.map +1 -1
  70. package/dist/components/ui.d.ts +30 -1
  71. package/dist/components/ui.d.ts.map +1 -1
  72. package/dist/components/ui.js +60 -0
  73. package/dist/components/ui.js.map +1 -1
  74. package/dist/components/work.d.ts +10 -0
  75. package/dist/components/work.d.ts.map +1 -0
  76. package/dist/components/work.js +33 -0
  77. package/dist/components/work.js.map +1 -0
  78. package/dist/console.css +658 -0
  79. package/dist/console.d.ts +8 -0
  80. package/dist/console.d.ts.map +1 -1
  81. package/dist/console.js +145 -13
  82. package/dist/console.js.map +1 -1
  83. package/dist/data.d.ts +19 -3
  84. package/dist/data.d.ts.map +1 -1
  85. package/dist/data.js +51 -4
  86. package/dist/data.js.map +1 -1
  87. package/dist/dates.d.ts +39 -0
  88. package/dist/dates.d.ts.map +1 -0
  89. package/dist/dates.js +120 -0
  90. package/dist/dates.js.map +1 -0
  91. package/dist/i18n/en.d.ts +172 -5
  92. package/dist/i18n/en.d.ts.map +1 -1
  93. package/dist/i18n/en.js +183 -6
  94. package/dist/i18n/en.js.map +1 -1
  95. package/dist/i18n/ko.d.ts.map +1 -1
  96. package/dist/i18n/ko.js +178 -6
  97. package/dist/i18n/ko.js.map +1 -1
  98. package/dist/index.d.ts +12 -5
  99. package/dist/index.d.ts.map +1 -1
  100. package/dist/index.js +12 -5
  101. package/dist/index.js.map +1 -1
  102. package/dist/limits.d.ts +11 -0
  103. package/dist/limits.d.ts.map +1 -1
  104. package/dist/limits.js +11 -0
  105. package/dist/limits.js.map +1 -1
  106. package/dist/marks.d.ts +12 -0
  107. package/dist/marks.d.ts.map +1 -0
  108. package/dist/marks.js +31 -0
  109. package/dist/marks.js.map +1 -0
  110. package/dist/router.d.ts +51 -1
  111. package/dist/router.d.ts.map +1 -1
  112. package/dist/router.js +80 -1
  113. package/dist/router.js.map +1 -1
  114. package/dist/routes.d.ts +14 -1
  115. package/dist/routes.d.ts.map +1 -1
  116. package/dist/routes.js +17 -0
  117. package/dist/routes.js.map +1 -1
  118. package/dist/schemas.d.ts +489 -3
  119. package/dist/schemas.d.ts.map +1 -1
  120. package/dist/schemas.js +150 -9
  121. package/dist/schemas.js.map +1 -1
  122. package/dist/vocabulary.d.ts +21 -1
  123. package/dist/vocabulary.d.ts.map +1 -1
  124. package/dist/vocabulary.js +31 -0
  125. package/dist/vocabulary.js.map +1 -1
  126. package/docs/specs/cli.md +17 -8
  127. package/docs/specs/rest.md +48 -17
  128. package/package.json +1 -1
  129. package/skills/working-the-board/SKILL.md +7 -6
  130. package/src/api.ts +42 -0
  131. package/src/cli/cli.ts +362 -18
  132. package/src/cli/format.ts +96 -6
  133. package/src/cli/help.ts +77 -15
  134. package/src/client.ts +98 -7
  135. package/src/components/board.tsx +173 -13
  136. package/src/components/calendar.tsx +367 -0
  137. package/src/components/categories.tsx +209 -0
  138. package/src/components/decision-list.tsx +67 -5
  139. package/src/components/event-feed.tsx +46 -27
  140. package/src/components/goals.tsx +264 -0
  141. package/src/components/paged.tsx +39 -0
  142. package/src/components/projects.tsx +128 -10
  143. package/src/components/runs.tsx +7 -1
  144. package/src/components/shell.tsx +5 -2
  145. package/src/components/task-form.tsx +78 -1
  146. package/src/components/task-list.tsx +64 -0
  147. package/src/components/task-view.tsx +75 -4
  148. package/src/components/ui.tsx +116 -1
  149. package/src/components/work.tsx +93 -0
  150. package/src/console.tsx +376 -9
  151. package/src/data.ts +83 -12
  152. package/src/dates.ts +143 -0
  153. package/src/i18n/en.ts +193 -6
  154. package/src/i18n/ko.ts +184 -6
  155. package/src/index.ts +62 -1
  156. package/src/limits.ts +11 -0
  157. package/src/marks.ts +40 -0
  158. package/src/router.ts +131 -6
  159. package/src/routes.ts +19 -0
  160. package/src/schemas.ts +187 -7
  161. package/src/styles/console.css +658 -0
  162. package/src/vocabulary.ts +43 -0
@@ -2,6 +2,7 @@ import type { ReactNode } from "react";
2
2
  import type { RestEvent } from "../api.js";
3
3
  import { en, type Messages } from "../i18n/en.js";
4
4
  import { useWords } from "../i18n/index.js";
5
+ import { ShowMore, usePaged } from "./paged.js";
5
6
  import { PersonChip, Time } from "./ui.js";
6
7
 
7
8
  // The feed: one line per event, newest first, as a sentence a person reads at a glance: who,
@@ -30,6 +31,8 @@ export function describeEvent(event: RestEvent, words: Messages = en): string {
30
31
  : feed.role.other;
31
32
  const login = data.login ?? feed.someone;
32
33
  const question = data.question ?? feed.aQuestion;
34
+ const goal = data.title ?? feed.aGoal;
35
+ const category = data.name ?? data.key ?? feed.aCategory;
33
36
  switch (event.kind) {
34
37
  case "person.joined":
35
38
  return feed.event.personJoined();
@@ -45,6 +48,16 @@ export function describeEvent(event: RestEvent, words: Messages = en): string {
45
48
  return feed.event.memberChanged(login, role(data.role), project);
46
49
  case "project.member_removed":
47
50
  return feed.event.memberRemoved(login, project);
51
+ case "category.created":
52
+ return feed.event.categoryCreated(category);
53
+ case "category.updated":
54
+ return feed.event.categoryUpdated(fields, category);
55
+ case "category.removed":
56
+ return feed.event.categoryRemoved(category);
57
+ case "goal.created":
58
+ return feed.event.goalCreated(goal);
59
+ case "goal.updated":
60
+ return feed.event.goalUpdated(fields, goal);
48
61
  case "task.created":
49
62
  return feed.event.taskCreated(task);
50
63
  case "task.moved":
@@ -67,6 +80,8 @@ export function describeEvent(event: RestEvent, words: Messages = en): string {
67
80
  return feed.event.runReported(task, data.excerpt);
68
81
  case "run.handed_in":
69
82
  return feed.event.runHandedIn(data.label ?? feed.something, task);
83
+ case "run.taken_over":
84
+ return feed.event.runTakenOver(task, data.agent ?? feed.someone);
70
85
  case "run.ended":
71
86
  return data.status === "finished"
72
87
  ? feed.event.runFinished(task)
@@ -94,36 +109,40 @@ export interface EventFeedProps {
94
109
 
95
110
  export function EventFeed(props: EventFeedProps) {
96
111
  const words = useWords();
112
+ const newestFirst = [...props.events].reverse();
113
+ const { shown, rest, more } = usePaged(newestFirst);
97
114
  if (props.events.length === 0) {
98
115
  return <div className="sc-feed">{props.empty ?? null}</div>;
99
116
  }
100
- const newestFirst = [...props.events].reverse();
101
117
  return (
102
- <ol className="sc-feed" aria-label={words.feed.list}>
103
- {newestFirst.map((event) => {
104
- const href = props.href?.(event);
105
- const said = describeEvent(event, words);
106
- return (
107
- <li key={event.id} className="sc-feed-item">
108
- <span className="sc-feed-actor">
109
- {event.actor === null ? (
110
- <span className="sc-person-login">{words.feed.console}</span>
111
- ) : (
112
- <PersonChip person={event.actor} />
113
- )}
114
- {event.agent !== null && (
115
- <span className="sc-feed-agent" title={words.feed.asTitle}>
116
- {words.feed.as(event.agent)}
117
- </span>
118
- )}
119
- </span>
120
- <span className="sc-feed-words">
121
- {href === undefined ? said : <a href={href}>{said}</a>}
122
- </span>
123
- <Time iso={event.createdAt} />
124
- </li>
125
- );
126
- })}
127
- </ol>
118
+ <>
119
+ <ol className="sc-feed" aria-label={words.feed.list}>
120
+ {shown.map((event) => {
121
+ const href = props.href?.(event);
122
+ const said = describeEvent(event, words);
123
+ return (
124
+ <li key={event.id} className="sc-feed-item">
125
+ <span className="sc-feed-actor">
126
+ {event.actor === null ? (
127
+ <span className="sc-person-login">{words.feed.console}</span>
128
+ ) : (
129
+ <PersonChip person={event.actor} />
130
+ )}
131
+ {event.agent !== null && (
132
+ <span className="sc-feed-agent" title={words.feed.asTitle}>
133
+ {words.feed.as(event.agent)}
134
+ </span>
135
+ )}
136
+ </span>
137
+ <span className="sc-feed-words">
138
+ {href === undefined ? said : <a href={href}>{said}</a>}
139
+ </span>
140
+ <Time iso={event.createdAt} />
141
+ </li>
142
+ );
143
+ })}
144
+ </ol>
145
+ <ShowMore rest={rest} onMore={more} />
146
+ </>
128
147
  );
129
148
  }
@@ -0,0 +1,264 @@
1
+ import { type FormEvent, type ReactNode, useState } from "react";
2
+ import {
3
+ GOAL_STATES,
4
+ type GoalState,
5
+ MAX_BODY_LENGTH,
6
+ MAX_TITLE_LENGTH,
7
+ type RestGoal,
8
+ type RestGoalInput,
9
+ type RestTask,
10
+ TASK_STATES,
11
+ } from "../api.js";
12
+ import { useWords } from "../i18n/index.js";
13
+ import { Markdown } from "./markdown.js";
14
+ import { TaskList } from "./task-list.js";
15
+ import { Button, Day, DueBadge, GoalStateBadge, PersonChip, ProgressBar, Time } from "./ui.js";
16
+
17
+ // The roadmap of a project (ADR-0019): its goals, each with the day it is meant to be reached
18
+ // by and how far its tasks are, read from the board as it stands. The list, one goal with its
19
+ // tasks, and the form an owner sets one with. Each takes its data as props and nothing from
20
+ // the network; what people wrote is shown as text, never as HTML.
21
+
22
+ /** A goal's day on the calendar's terms: reached or let go, it is a plain fact; else it may be late. */
23
+ const dueState = (goal: RestGoal) =>
24
+ goal.state === "done" ? "done" : goal.state === "dropped" ? "dropped" : "ready";
25
+
26
+ export interface GoalListProps {
27
+ readonly goals: readonly RestGoal[];
28
+ readonly goalHref: (goal: RestGoal) => string;
29
+ /** The day to count from; the browser's today when left out. */
30
+ readonly today?: string | undefined;
31
+ readonly empty?: ReactNode;
32
+ }
33
+
34
+ export function GoalList(props: GoalListProps) {
35
+ const words = useWords().roadmap;
36
+ if (props.goals.length === 0) {
37
+ return <>{props.empty ?? null}</>;
38
+ }
39
+ return (
40
+ <ul className="sc-goals" aria-label={words.title}>
41
+ {props.goals.map((goal) => (
42
+ <li key={goal.id} className={`sc-goal sc-goal-${goal.state}`}>
43
+ <div className="sc-goal-head">
44
+ <a className="sc-goal-title" href={props.goalHref(goal)}>
45
+ {goal.title}
46
+ </a>
47
+ <GoalStateBadge state={goal.state} />
48
+ {goal.dueOn !== null && (
49
+ <DueBadge dueOn={goal.dueOn} state={dueState(goal)} today={props.today} />
50
+ )}
51
+ </div>
52
+ <ProgressBar counts={goal.taskCounts} />
53
+ </li>
54
+ ))}
55
+ </ul>
56
+ );
57
+ }
58
+
59
+ export interface GoalFormProps {
60
+ /** The goal to change; left out, the form sets a new one. */
61
+ readonly goal?: RestGoal | undefined;
62
+ readonly busy?: boolean | undefined;
63
+ readonly error?: string | undefined;
64
+ readonly onSubmit: (input: RestGoalInput) => void;
65
+ readonly onCancel?: (() => void) | undefined;
66
+ }
67
+
68
+ export function GoalForm(props: GoalFormProps) {
69
+ const words = useWords();
70
+ const form = words.roadmap.form;
71
+ const { goal } = props;
72
+ const [title, setTitle] = useState(goal?.title ?? "");
73
+ const [body, setBody] = useState(goal?.body ?? "");
74
+ const [state, setState] = useState<GoalState>(goal?.state ?? "planned");
75
+ const [dueOn, setDueOn] = useState(goal?.dueOn ?? "");
76
+
77
+ const submit = (event: FormEvent) => {
78
+ event.preventDefault();
79
+ props.onSubmit({ title: title.trim(), body, state, dueOn: dueOn === "" ? null : dueOn });
80
+ };
81
+
82
+ return (
83
+ <form className="sc-form" onSubmit={submit}>
84
+ <label className="sc-field">
85
+ <span className="sc-field-label">{form.title}</span>
86
+ <input
87
+ className="sc-input"
88
+ value={title}
89
+ maxLength={MAX_TITLE_LENGTH}
90
+ required
91
+ onChange={(event) => setTitle(event.target.value)}
92
+ />
93
+ </label>
94
+ <label className="sc-field">
95
+ <span className="sc-field-label">{form.body}</span>
96
+ <textarea
97
+ className="sc-input sc-textarea"
98
+ value={body}
99
+ maxLength={MAX_BODY_LENGTH}
100
+ rows={4}
101
+ placeholder={form.bodyPlaceholder}
102
+ onChange={(event) => setBody(event.target.value)}
103
+ />
104
+ </label>
105
+ <div className="sc-field-row">
106
+ <label className="sc-field">
107
+ <span className="sc-field-label">{form.state}</span>
108
+ <select
109
+ className="sc-input"
110
+ value={state}
111
+ onChange={(event) => setState(event.target.value as GoalState)}
112
+ >
113
+ {GOAL_STATES.map((candidate) => (
114
+ <option key={candidate} value={candidate}>
115
+ {words.vocabulary.goalState[candidate]}
116
+ </option>
117
+ ))}
118
+ </select>
119
+ </label>
120
+ <label className="sc-field">
121
+ <span className="sc-field-label">{form.dueOn}</span>
122
+ <input
123
+ className="sc-input"
124
+ type="date"
125
+ value={dueOn}
126
+ onChange={(event) => setDueOn(event.target.value)}
127
+ />
128
+ </label>
129
+ </div>
130
+ {props.error !== undefined && (
131
+ <p className="sc-form-error" role="alert">
132
+ {props.error}
133
+ </p>
134
+ )}
135
+ <div className="sc-form-actions">
136
+ <Button
137
+ type="submit"
138
+ variant="primary"
139
+ disabled={props.busy === true || title.trim() === ""}
140
+ >
141
+ {goal === undefined ? form.set : words.common.save}
142
+ </Button>
143
+ {props.onCancel !== undefined && (
144
+ <Button variant="ghost" onClick={props.onCancel}>
145
+ {words.common.cancel}
146
+ </Button>
147
+ )}
148
+ </div>
149
+ </form>
150
+ );
151
+ }
152
+
153
+ export interface GoalViewProps {
154
+ readonly goal: RestGoal;
155
+ /** The tasks of the board; those that work towards the goal are shown, by state. */
156
+ readonly tasks: readonly RestTask[];
157
+ readonly taskHref: (task: RestTask) => string;
158
+ /** Where a document of the project is read, by its path, for the links in what was written. */
159
+ readonly docHref?: ((path: string) => string) | undefined;
160
+ readonly today?: string | undefined;
161
+ /** Where the goal is changed; left out, it cannot be changed from here. */
162
+ readonly editHref?: string | undefined;
163
+ /** Called when an owner moves the goal to another state from here. */
164
+ readonly onMove?: ((goal: RestGoal, state: GoalState) => void) | undefined;
165
+ readonly busy?: boolean | undefined;
166
+ }
167
+
168
+ export function GoalView(props: GoalViewProps) {
169
+ const words = useWords();
170
+ const roadmap = words.roadmap;
171
+ const { goal } = props;
172
+ const towards = props.tasks.filter((task) => task.goalId === goal.id);
173
+ return (
174
+ <div className="sc-task sc-goal-view">
175
+ <header className="sc-task-header">
176
+ <h1 className="sc-task-title">{goal.title}</h1>
177
+ <div className="sc-task-badges">
178
+ <GoalStateBadge state={goal.state} />
179
+ {goal.dueOn !== null && (
180
+ <DueBadge dueOn={goal.dueOn} state={dueState(goal)} today={props.today} />
181
+ )}
182
+ </div>
183
+ </header>
184
+ <div className="sc-task-body">
185
+ <div className="sc-task-main">
186
+ <ProgressBar counts={goal.taskCounts} />
187
+ {goal.body.length > 0 ? (
188
+ <Markdown source={goal.body} docHref={props.docHref} />
189
+ ) : (
190
+ <p className="sc-muted">{roadmap.nothingMore}</p>
191
+ )}
192
+ <section className="sc-task-section" aria-label={roadmap.tasks}>
193
+ <h2 className="sc-section-title">{roadmap.tasks}</h2>
194
+ {towards.length === 0 ? (
195
+ <p className="sc-muted">{roadmap.noTasks}</p>
196
+ ) : (
197
+ TASK_STATES.map((state) => {
198
+ const inState = towards.filter((task) => task.state === state);
199
+ return inState.length === 0 ? null : (
200
+ <div key={state} className="sc-goal-tasks">
201
+ <h3 className="sc-goal-tasks-state">
202
+ {words.vocabulary.state[state]}{" "}
203
+ <span className="sc-column-count">{inState.length}</span>
204
+ </h3>
205
+ <TaskList tasks={inState} taskHref={props.taskHref} today={props.today} />
206
+ </div>
207
+ );
208
+ })
209
+ )}
210
+ </section>
211
+ </div>
212
+ <aside className="sc-task-aside" aria-label={words.task.facts}>
213
+ <dl className="sc-facts">
214
+ <div>
215
+ <dt>{roadmap.state}</dt>
216
+ <dd>
217
+ {props.onMove === undefined ? (
218
+ words.vocabulary.goalState[goal.state]
219
+ ) : (
220
+ <select
221
+ className="sc-input"
222
+ aria-label={roadmap.state}
223
+ value={goal.state}
224
+ disabled={props.busy === true}
225
+ onChange={(event) => props.onMove?.(goal, event.target.value as GoalState)}
226
+ >
227
+ {GOAL_STATES.map((state) => (
228
+ <option key={state} value={state}>
229
+ {words.vocabulary.goalState[state]}
230
+ </option>
231
+ ))}
232
+ </select>
233
+ )}
234
+ </dd>
235
+ </div>
236
+ <div>
237
+ <dt>{roadmap.target}</dt>
238
+ <dd>
239
+ {goal.dueOn === null ? (
240
+ <span className="sc-muted">{roadmap.noTarget}</span>
241
+ ) : (
242
+ <Day day={goal.dueOn} />
243
+ )}
244
+ </dd>
245
+ </div>
246
+ <div>
247
+ <dt>{roadmap.setBy}</dt>
248
+ <dd>
249
+ <PersonChip person={goal.createdBy} /> <Time iso={goal.createdAt} />
250
+ </dd>
251
+ </div>
252
+ </dl>
253
+ {props.editHref !== undefined && (
254
+ <div className="sc-task-actions">
255
+ <a className="sc-button sc-button-secondary" href={props.editHref}>
256
+ {words.common.edit}
257
+ </a>
258
+ </div>
259
+ )}
260
+ </aside>
261
+ </div>
262
+ </div>
263
+ );
264
+ }
@@ -0,0 +1,39 @@
1
+ import { useState } from "react";
2
+ import { useWords } from "../i18n/index.js";
3
+ import { Button } from "./ui.js";
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.
8
+
9
+ /** How many items a list shows before it asks to be told to show more. */
10
+ export const PAGE_SIZE = 50;
11
+
12
+ /** The first `shown` items of a list, and the way to show the next page of it. */
13
+ export function usePaged<T>(
14
+ items: readonly T[],
15
+ pageSize = PAGE_SIZE,
16
+ ): { readonly shown: readonly T[]; readonly rest: number; readonly more: () => void } {
17
+ const [pages, setPages] = useState(1);
18
+ const shown = items.slice(0, pages * pageSize);
19
+ return {
20
+ shown,
21
+ rest: items.length - shown.length,
22
+ more: () => setPages((current) => current + 1),
23
+ };
24
+ }
25
+
26
+ /** The button under a paged list, saying how many more there are; nothing when there are none. */
27
+ export function ShowMore(props: { readonly rest: number; readonly onMore: () => void }) {
28
+ const words = useWords();
29
+ if (props.rest <= 0) {
30
+ return null;
31
+ }
32
+ return (
33
+ <div className="sc-show-more">
34
+ <Button variant="secondary" size="sm" onClick={props.onMore}>
35
+ {words.common.showMore(Math.min(props.rest, PAGE_SIZE), props.rest)}
36
+ </Button>
37
+ </div>
38
+ );
39
+ }
@@ -1,13 +1,20 @@
1
- import { type FormEvent, type ReactNode, useState } from "react";
1
+ import { type FormEvent, type ReactNode, useEffect, useMemo, useState } from "react";
2
2
  import {
3
+ COLOR_NAMES,
4
+ type ColorName,
3
5
  MAX_PROJECT_DESCRIPTION_LENGTH,
4
6
  MAX_PROJECT_KEY_LENGTH,
5
7
  MAX_PROJECT_NAME_LENGTH,
8
+ MAX_PROJECT_SYMBOL_LENGTH,
6
9
  MAX_SKILLS_ADDRESS_LENGTH,
10
+ PROJECT_ICON_TYPES,
7
11
  PROJECT_ROLES,
8
12
  PROJECT_VISIBILITIES,
9
13
  type ProjectRole,
10
14
  type ProjectVisibility,
15
+ projectColorOf,
16
+ projectIconPath,
17
+ projectSymbolOf,
11
18
  type RestMember,
12
19
  type RestMemberInput,
13
20
  type RestPerson,
@@ -16,7 +23,44 @@ import {
16
23
  type RestProjectPatch,
17
24
  } from "../api.js";
18
25
  import { useWords } from "../i18n/index.js";
19
- import { Badge, Button, PersonChip, Time } from "./ui.js";
26
+ import { Badge, Button, cx, PersonChip, Time } from "./ui.js";
27
+
28
+ /** A project's mark: its symbol on its colour, so that projects tell apart at a glance. */
29
+ export function ProjectMark(props: {
30
+ readonly project: {
31
+ readonly key: string;
32
+ readonly name: string;
33
+ readonly color: ColorName | null;
34
+ readonly symbol: string | null;
35
+ /** The project's own image, when it has one; the symbol on the colour otherwise. */
36
+ readonly icon?: { readonly sha256: string } | null | undefined;
37
+ };
38
+ readonly size?: "sm" | "md" | "lg";
39
+ /** Where the image is read from; the console's own path for it when left out. */
40
+ readonly iconSrc?: string | undefined;
41
+ }) {
42
+ const size = props.size ?? "md";
43
+ const icon = props.project.icon ?? null;
44
+ const src =
45
+ props.iconSrc ?? (icon === null ? undefined : projectIconPath(props.project.key, icon));
46
+ return (
47
+ <span
48
+ className={cx(
49
+ "sc-mark",
50
+ `sc-mark-${size}`,
51
+ `sc-category-${projectColorOf(props.project)}`,
52
+ src !== undefined && "sc-mark-pictured",
53
+ )}
54
+ aria-hidden="true"
55
+ >
56
+ {src === undefined ? (
57
+ projectSymbolOf(props.project)
58
+ ) : (
59
+ <img className="sc-mark-image" src={src} alt="" />
60
+ )}
61
+ </span>
62
+ );
63
+ }
20
64
 
21
65
  // The projects of the workspace (ADR-0008): the list a person may see, the way to make one,
22
66
  // a project's settings for its owners, and those listed in it. Each takes its data as props
@@ -60,6 +104,7 @@ export function ProjectList(props: ProjectListProps) {
60
104
  {props.projects.map((project) => (
61
105
  <li key={project.id} className="sc-project">
62
106
  <div className="sc-project-head">
107
+ <ProjectMark project={project} size="md" />
63
108
  <a
64
109
  className="sc-project-name"
65
110
  href={props.projectHref(project)}
@@ -79,14 +124,11 @@ export function ProjectList(props: ProjectListProps) {
79
124
  {project.description.length > 0 && (
80
125
  <p className="sc-project-description">{project.description}</p>
81
126
  )}
82
- <p className="sc-project-meta">
83
- {project.openDecisions > 0 && (
127
+ {project.openDecisions > 0 && (
128
+ <p className="sc-project-meta">
84
129
  <Badge tone="warning">{words.decisionsWaiting(project.openDecisions)}</Badge>
85
- )}
86
- {project.openRuns > 0 && (
87
- <Badge tone="accent">{words.agentsAtWork(project.openRuns)}</Badge>
88
- )}
89
- </p>
130
+ </p>
131
+ )}
90
132
  </li>
91
133
  ))}
92
134
  </ul>
@@ -98,7 +140,11 @@ export interface ProjectFormProps {
98
140
  readonly project?: RestProject | undefined;
99
141
  readonly busy?: boolean | undefined;
100
142
  readonly error?: string | undefined;
101
- readonly onSubmit: (input: RestProjectInput | RestProjectPatch) => void;
143
+ /**
144
+ * Called with the settings as they are to be, and the image for the mark: a file chosen,
145
+ * `null` to take the image away, or nothing to keep what the project has.
146
+ */
147
+ readonly onSubmit: (input: RestProjectInput | RestProjectPatch, icon?: File | null) => void;
102
148
  readonly onCancel?: (() => void) | undefined;
103
149
  }
104
150
 
@@ -121,6 +167,31 @@ export function ProjectForm(props: ProjectFormProps) {
121
167
  const [description, setDescription] = useState(project?.description ?? "");
122
168
  const [visibility, setVisibility] = useState<ProjectVisibility>(project?.visibility ?? "private");
123
169
  const [skillsAddress, setSkillsAddress] = useState(project?.skillsAddress ?? "");
170
+ const [color, setColor] = useState<ColorName | "">(project?.color ?? "");
171
+ const [symbol, setSymbol] = useState(project?.symbol ?? "");
172
+ /** The image for the mark: a file chosen, `null` for none any more, nothing to keep the project's. */
173
+ const [icon, setIcon] = useState<File | null | undefined>(undefined);
174
+ const chosenSrc = useMemo(
175
+ () => (icon instanceof File ? URL.createObjectURL(icon) : undefined),
176
+ [icon],
177
+ );
178
+ useEffect(
179
+ () => () => {
180
+ if (chosenSrc !== undefined) {
181
+ URL.revokeObjectURL(chosenSrc);
182
+ }
183
+ },
184
+ [chosenSrc],
185
+ );
186
+ const kept = icon === undefined ? (project?.icon ?? null) : null;
187
+ /** The mark as it will be, from what is typed and chosen so far. */
188
+ const preview = {
189
+ key: key.trim() === "" ? keyOf(name) : key.trim(),
190
+ name: name.trim() === "" ? "?" : name.trim(),
191
+ color: color === "" ? null : color,
192
+ symbol: symbol.trim() === "" ? null : symbol.trim(),
193
+ icon: icon instanceof File ? { sha256: "chosen" } : kept,
194
+ };
124
195
 
125
196
  const submit = (event: FormEvent) => {
126
197
  event.preventDefault();
@@ -129,9 +200,12 @@ export function ProjectForm(props: ProjectFormProps) {
129
200
  description: description.trim(),
130
201
  visibility,
131
202
  skillsAddress: skillsAddress.trim() === "" ? null : skillsAddress.trim(),
203
+ color: preview.color,
204
+ symbol: preview.symbol,
132
205
  };
133
206
  props.onSubmit(
134
207
  project === undefined ? { key: key.trim(), ...settings, name: name.trim() } : settings,
208
+ icon,
135
209
  );
136
210
  };
137
211
 
@@ -207,6 +281,50 @@ export function ProjectForm(props: ProjectFormProps) {
207
281
  />
208
282
  </label>
209
283
  </div>
284
+ <div className="sc-field-row sc-mark-row">
285
+ <ProjectMark project={preview} size="lg" iconSrc={chosenSrc} />
286
+ <label className="sc-field">
287
+ <span className="sc-field-label">{form.symbol}</span>
288
+ <input
289
+ className="sc-input"
290
+ value={symbol}
291
+ maxLength={MAX_PROJECT_SYMBOL_LENGTH}
292
+ pattern="\S*"
293
+ placeholder={projectSymbolOf({ name: preview.name, symbol: null })}
294
+ onChange={(event) => setSymbol(event.target.value)}
295
+ />
296
+ </label>
297
+ <label className="sc-field">
298
+ <span className="sc-field-label">{form.color}</span>
299
+ <select
300
+ className="sc-input"
301
+ value={color}
302
+ onChange={(event) => setColor(event.target.value as ColorName | "")}
303
+ >
304
+ <option value="">{form.colorAuto}</option>
305
+ {COLOR_NAMES.map((candidate) => (
306
+ <option key={candidate} value={candidate}>
307
+ {words.vocabulary.categoryColor[candidate]}
308
+ </option>
309
+ ))}
310
+ </select>
311
+ </label>
312
+ <label className="sc-field">
313
+ <span className="sc-field-label">{form.image}</span>
314
+ <input
315
+ key={icon === null ? "none" : "some"}
316
+ className="sc-input sc-input-file"
317
+ type="file"
318
+ accept={PROJECT_ICON_TYPES.join(",")}
319
+ onChange={(event) => setIcon(event.target.files?.[0] ?? undefined)}
320
+ />
321
+ </label>
322
+ {(icon instanceof File || kept !== null) && (
323
+ <Button variant="ghost" size="sm" onClick={() => setIcon(null)}>
324
+ {form.imageRemove}
325
+ </Button>
326
+ )}
327
+ </div>
210
328
  {props.error !== undefined && (
211
329
  <p className="sc-form-error" role="alert">
212
330
  {props.error}
@@ -9,7 +9,7 @@ import { Badge, Button, formatBytes, PersonChip, Time } from "./ui.js";
9
9
 
10
10
  /** The words of a run's status in English: the default pack's. */
11
11
  export const RUN_STATUS_LABELS: Readonly<Record<RunStatus, string>> = {
12
- running: "At work",
12
+ running: "Open",
13
13
  waiting: "Waiting for a decision",
14
14
  finished: "Finished",
15
15
  failed: "Failed",
@@ -61,6 +61,12 @@ export function RunCard(props: RunCardProps) {
61
61
  {words.until} <Time iso={run.endedAt} />
62
62
  </>
63
63
  )}
64
+ {open && (
65
+ <>
66
+ {" "}
67
+ · {words.lastHeard} <Time iso={run.lastHeardAt} />
68
+ </>
69
+ )}
64
70
  </span>
65
71
  {open && props.onAbandon !== undefined && (
66
72
  <Button
@@ -34,8 +34,10 @@ export interface ShellProps {
34
34
  readonly title: string;
35
35
  /** The marks to show beside the name; none shows the name alone. */
36
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;
37
+ /** The project the page is on, named after the workspace, with its mark when the page has it, or nothing on the workspace's own pages. */
38
+ readonly project?:
39
+ | { readonly name: string; readonly href: string; readonly mark?: ReactNode }
40
+ | undefined;
39
41
  /** The pages of the level the person is on, as tabs: the project's, or the workspace's. */
40
42
  readonly nav: readonly NavItem[];
41
43
  /** The workspace's pages, reached from anywhere through the person's menu. */
@@ -228,6 +230,7 @@ export function Shell(props: ShellProps) {
228
230
  href={props.project.href}
229
231
  onClick={follow(props.project.href)}
230
232
  >
233
+ {props.project.mark}
231
234
  {props.project.name}
232
235
  </a>
233
236
  </span>