@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.
- package/README.md +6 -6
- package/dist/api.d.ts +6 -5
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +5 -4
- package/dist/api.js.map +1 -1
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +303 -18
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/format.d.ts +11 -1
- package/dist/cli/format.d.ts.map +1 -1
- package/dist/cli/format.js +87 -6
- package/dist/cli/format.js.map +1 -1
- package/dist/cli/help.d.ts.map +1 -1
- package/dist/cli/help.js +70 -14
- package/dist/cli/help.js.map +1 -1
- package/dist/client.d.ts +44 -8
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +32 -4
- package/dist/client.js.map +1 -1
- package/dist/components/board.d.ts +24 -1
- package/dist/components/board.d.ts.map +1 -1
- package/dist/components/board.js +66 -5
- package/dist/components/board.js.map +1 -1
- package/dist/components/calendar.d.ts +54 -0
- package/dist/components/calendar.d.ts.map +1 -0
- package/dist/components/calendar.js +156 -0
- package/dist/components/calendar.js.map +1 -0
- package/dist/components/categories.d.ts +13 -0
- package/dist/components/categories.d.ts.map +1 -0
- package/dist/components/categories.js +52 -0
- package/dist/components/categories.js.map +1 -0
- package/dist/components/decision-list.d.ts +10 -0
- package/dist/components/decision-list.d.ts.map +1 -1
- package/dist/components/decision-list.js +16 -1
- package/dist/components/decision-list.js.map +1 -1
- package/dist/components/event-feed.d.ts.map +1 -1
- package/dist/components/event-feed.js +23 -7
- package/dist/components/event-feed.js.map +1 -1
- package/dist/components/goals.d.ts +35 -0
- package/dist/components/goals.d.ts.map +1 -0
- package/dist/components/goals.js +45 -0
- package/dist/components/goals.js.map +1 -0
- package/dist/components/paged.d.ts +14 -0
- package/dist/components/paged.d.ts.map +1 -0
- package/dist/components/paged.js +28 -0
- package/dist/components/paged.js.map +1 -0
- package/dist/components/projects.d.ts +22 -2
- package/dist/components/projects.d.ts.map +1 -1
- package/dist/components/projects.js +35 -7
- package/dist/components/projects.js.map +1 -1
- package/dist/components/runs.d.ts.map +1 -1
- package/dist/components/runs.js +2 -2
- package/dist/components/runs.js.map +1 -1
- package/dist/components/shell.d.ts +2 -1
- package/dist/components/shell.d.ts.map +1 -1
- package/dist/components/shell.js +1 -1
- package/dist/components/shell.js.map +1 -1
- package/dist/components/task-form.d.ts +5 -1
- package/dist/components/task-form.d.ts.map +1 -1
- package/dist/components/task-form.js +13 -1
- package/dist/components/task-form.js.map +1 -1
- package/dist/components/task-list.d.ts +16 -0
- package/dist/components/task-list.d.ts.map +1 -0
- package/dist/components/task-list.js +16 -0
- package/dist/components/task-list.js.map +1 -0
- package/dist/components/task-view.d.ts +9 -1
- package/dist/components/task-view.d.ts.map +1 -1
- package/dist/components/task-view.js +10 -4
- package/dist/components/task-view.js.map +1 -1
- package/dist/components/ui.d.ts +30 -1
- package/dist/components/ui.d.ts.map +1 -1
- package/dist/components/ui.js +60 -0
- package/dist/components/ui.js.map +1 -1
- package/dist/components/work.d.ts +10 -0
- package/dist/components/work.d.ts.map +1 -0
- package/dist/components/work.js +33 -0
- package/dist/components/work.js.map +1 -0
- package/dist/console.css +658 -0
- package/dist/console.d.ts +8 -0
- package/dist/console.d.ts.map +1 -1
- package/dist/console.js +145 -13
- package/dist/console.js.map +1 -1
- package/dist/data.d.ts +19 -3
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +51 -4
- package/dist/data.js.map +1 -1
- package/dist/dates.d.ts +39 -0
- package/dist/dates.d.ts.map +1 -0
- package/dist/dates.js +120 -0
- package/dist/dates.js.map +1 -0
- package/dist/i18n/en.d.ts +172 -5
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +183 -6
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/ko.d.ts.map +1 -1
- package/dist/i18n/ko.js +178 -6
- package/dist/i18n/ko.js.map +1 -1
- package/dist/index.d.ts +12 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -5
- package/dist/index.js.map +1 -1
- package/dist/limits.d.ts +11 -0
- package/dist/limits.d.ts.map +1 -1
- package/dist/limits.js +11 -0
- package/dist/limits.js.map +1 -1
- package/dist/marks.d.ts +12 -0
- package/dist/marks.d.ts.map +1 -0
- package/dist/marks.js +31 -0
- package/dist/marks.js.map +1 -0
- package/dist/router.d.ts +51 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +80 -1
- package/dist/router.js.map +1 -1
- package/dist/routes.d.ts +14 -1
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +17 -0
- package/dist/routes.js.map +1 -1
- package/dist/schemas.d.ts +489 -3
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +150 -9
- package/dist/schemas.js.map +1 -1
- package/dist/vocabulary.d.ts +21 -1
- package/dist/vocabulary.d.ts.map +1 -1
- package/dist/vocabulary.js +31 -0
- package/dist/vocabulary.js.map +1 -1
- package/docs/specs/cli.md +17 -8
- package/docs/specs/rest.md +48 -17
- package/package.json +1 -1
- package/skills/working-the-board/SKILL.md +7 -6
- package/src/api.ts +42 -0
- package/src/cli/cli.ts +362 -18
- package/src/cli/format.ts +96 -6
- package/src/cli/help.ts +77 -15
- package/src/client.ts +98 -7
- package/src/components/board.tsx +173 -13
- package/src/components/calendar.tsx +367 -0
- package/src/components/categories.tsx +209 -0
- package/src/components/decision-list.tsx +67 -5
- package/src/components/event-feed.tsx +46 -27
- package/src/components/goals.tsx +264 -0
- package/src/components/paged.tsx +39 -0
- package/src/components/projects.tsx +128 -10
- package/src/components/runs.tsx +7 -1
- package/src/components/shell.tsx +5 -2
- package/src/components/task-form.tsx +78 -1
- package/src/components/task-list.tsx +64 -0
- package/src/components/task-view.tsx +75 -4
- package/src/components/ui.tsx +116 -1
- package/src/components/work.tsx +93 -0
- package/src/console.tsx +376 -9
- package/src/data.ts +83 -12
- package/src/dates.ts +143 -0
- package/src/i18n/en.ts +193 -6
- package/src/i18n/ko.ts +184 -6
- package/src/index.ts +62 -1
- package/src/limits.ts +11 -0
- package/src/marks.ts +40 -0
- package/src/router.ts +131 -6
- package/src/routes.ts +19 -0
- package/src/schemas.ts +187 -7
- package/src/styles/console.css +658 -0
- 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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
<
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
{words.feed.
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
83
|
-
|
|
127
|
+
{project.openDecisions > 0 && (
|
|
128
|
+
<p className="sc-project-meta">
|
|
84
129
|
<Badge tone="warning">{words.decisionsWaiting(project.openDecisions)}</Badge>
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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}
|
package/src/components/runs.tsx
CHANGED
|
@@ -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: "
|
|
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
|
package/src/components/shell.tsx
CHANGED
|
@@ -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?:
|
|
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>
|