@skillcdn/console 0.1.18 → 0.1.20
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 +2 -2
- package/SKILLCDN.md +1 -1
- package/dist/api.d.ts +1 -1
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +1 -1
- package/dist/api.js.map +1 -1
- package/dist/cli/cli.d.ts +1 -1
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +353 -39
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/credentials.d.ts +102 -3
- package/dist/cli/credentials.d.ts.map +1 -1
- package/dist/cli/credentials.js +177 -8
- package/dist/cli/credentials.js.map +1 -1
- package/dist/cli/directory.d.ts +6 -0
- package/dist/cli/directory.d.ts.map +1 -1
- package/dist/cli/directory.js +37 -6
- package/dist/cli/directory.js.map +1 -1
- package/dist/cli/format.d.ts +10 -1
- package/dist/cli/format.d.ts.map +1 -1
- package/dist/cli/format.js +106 -2
- package/dist/cli/format.js.map +1 -1
- package/dist/cli/help.d.ts.map +1 -1
- package/dist/cli/help.js +44 -22
- package/dist/cli/help.js.map +1 -1
- package/dist/client.d.ts +21 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +14 -1
- package/dist/client.js.map +1 -1
- package/dist/components/board.d.ts +23 -5
- package/dist/components/board.d.ts.map +1 -1
- package/dist/components/board.js +239 -9
- package/dist/components/board.js.map +1 -1
- package/dist/components/comments.d.ts +0 -2
- package/dist/components/comments.d.ts.map +1 -1
- package/dist/components/comments.js +14 -41
- package/dist/components/comments.js.map +1 -1
- package/dist/components/documents.d.ts +2 -0
- package/dist/components/documents.d.ts.map +1 -1
- package/dist/components/documents.js +51 -5
- package/dist/components/documents.js.map +1 -1
- package/dist/components/event-feed.d.ts.map +1 -1
- package/dist/components/event-feed.js +4 -0
- package/dist/components/event-feed.js.map +1 -1
- package/dist/components/files.d.ts +11 -0
- package/dist/components/files.d.ts.map +1 -0
- package/dist/components/files.js +44 -0
- package/dist/components/files.js.map +1 -0
- package/dist/components/goals.d.ts +17 -1
- package/dist/components/goals.d.ts.map +1 -1
- package/dist/components/goals.js +34 -9
- package/dist/components/goals.js.map +1 -1
- package/dist/components/markdown.d.ts +6 -0
- package/dist/components/markdown.d.ts.map +1 -1
- package/dist/components/markdown.js +7 -0
- package/dist/components/markdown.js.map +1 -1
- package/dist/components/pictures.d.ts +68 -0
- package/dist/components/pictures.d.ts.map +1 -0
- package/dist/components/pictures.js +213 -0
- package/dist/components/pictures.js.map +1 -0
- package/dist/components/projects.d.ts +12 -0
- package/dist/components/projects.d.ts.map +1 -1
- package/dist/components/projects.js +15 -0
- package/dist/components/projects.js.map +1 -1
- package/dist/components/runs.d.ts.map +1 -1
- package/dist/components/runs.js +34 -7
- package/dist/components/runs.js.map +1 -1
- package/dist/components/task-view.d.ts +3 -0
- package/dist/components/task-view.d.ts.map +1 -1
- package/dist/components/task-view.js +6 -4
- package/dist/components/task-view.js.map +1 -1
- package/dist/console.css +338 -37
- package/dist/console.d.ts.map +1 -1
- package/dist/console.js +124 -10
- package/dist/console.js.map +1 -1
- package/dist/data.d.ts +16 -1
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +47 -2
- package/dist/data.js.map +1 -1
- package/dist/docs-page.d.ts.map +1 -1
- package/dist/docs-page.js +14 -1
- package/dist/docs-page.js.map +1 -1
- package/dist/i18n/en.d.ts +56 -1
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +54 -3
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/ko.d.ts.map +1 -1
- package/dist/i18n/ko.js +54 -3
- package/dist/i18n/ko.js.map +1 -1
- package/dist/index.d.ts +7 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -5
- package/dist/index.js.map +1 -1
- package/dist/router.d.ts +25 -6
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +38 -7
- package/dist/router.js.map +1 -1
- package/dist/rules.js +5 -5
- package/dist/rules.js.map +1 -1
- package/dist/schemas.d.ts +158 -0
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +78 -0
- package/dist/schemas.js.map +1 -1
- package/dist/vocabulary.d.ts +2 -2
- package/dist/vocabulary.d.ts.map +1 -1
- package/dist/vocabulary.js +3 -0
- package/dist/vocabulary.js.map +1 -1
- package/docs/specs/cli.md +19 -11
- package/docs/specs/rest.md +24 -6
- package/package.json +1 -1
- package/skills/working-the-board/SKILL.md +12 -6
- package/src/api.ts +13 -0
- package/src/cli/cli.ts +441 -42
- package/src/cli/credentials.ts +276 -14
- package/src/cli/directory.ts +43 -6
- package/src/cli/format.ts +138 -2
- package/src/cli/help.ts +46 -22
- package/src/client.ts +60 -0
- package/src/components/board.tsx +401 -28
- package/src/components/comments.tsx +30 -49
- package/src/components/documents.tsx +87 -4
- package/src/components/event-feed.tsx +4 -0
- package/src/components/files.ts +49 -0
- package/src/components/goals.tsx +173 -106
- package/src/components/markdown.tsx +19 -0
- package/src/components/pictures.tsx +389 -0
- package/src/components/projects.tsx +68 -0
- package/src/components/runs.tsx +73 -15
- package/src/components/task-view.tsx +43 -2
- package/src/console.tsx +268 -7
- package/src/data.ts +72 -1
- package/src/docs-page.tsx +18 -1
- package/src/i18n/en.ts +58 -3
- package/src/i18n/ko.ts +58 -3
- package/src/index.ts +28 -1
- package/src/router.ts +57 -9
- package/src/rules.ts +5 -5
- package/src/schemas.ts +97 -0
- package/src/styles/console.css +338 -37
- package/src/vocabulary.ts +3 -0
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { type FormEvent, type ReactNode, useState } from "react";
|
|
1
|
+
import { type ClipboardEvent, type FormEvent, type ReactNode, useState } from "react";
|
|
2
2
|
import { agentLabel } from "../agent-name.js";
|
|
3
3
|
import {
|
|
4
4
|
foldersAbove,
|
|
5
5
|
isDocumentPath,
|
|
6
6
|
MAX_DOCUMENT_LENGTH,
|
|
7
7
|
MAX_DOCUMENT_PATH_LENGTH,
|
|
8
|
+
MAX_FILES_PER_DOCUMENT,
|
|
8
9
|
MAX_LINK_LABEL_LENGTH,
|
|
9
10
|
MAX_SEARCH_LENGTH,
|
|
10
11
|
MAX_TITLE_LENGTH,
|
|
@@ -18,13 +19,16 @@ import {
|
|
|
18
19
|
type RestVersionSummary,
|
|
19
20
|
} from "../api.js";
|
|
20
21
|
import { useWords } from "../i18n/index.js";
|
|
22
|
+
import { isPicture, namedFile, placing, unusedName } from "./files.js";
|
|
21
23
|
import { Markdown } from "./markdown.js";
|
|
22
24
|
import { Badge, Button, cx, formatBytes, PersonChip, Time } from "./ui.js";
|
|
23
25
|
|
|
24
26
|
// The documents of a project (ADR-0009): a folder with its pages and its folders, the way to
|
|
25
27
|
// find pages by their words, one page with its versions, what it links to, what refers to it
|
|
26
|
-
// and its files,
|
|
27
|
-
//
|
|
28
|
+
// and its files, a picture among them shown where the body names it, and the forms to write and
|
|
29
|
+
// attach, a picture pasted into the body attached once the page is written. Each takes its data
|
|
30
|
+
// as props and nothing from the network; what people and agents wrote is text or Markdown
|
|
31
|
+
// rendered to elements.
|
|
28
32
|
|
|
29
33
|
/** The folder a path is in, as crumbs from the root: the root, then each folder above, then the name. */
|
|
30
34
|
export function DocumentCrumbs(props: {
|
|
@@ -194,6 +198,8 @@ export interface DocumentFormProps {
|
|
|
194
198
|
readonly title: string;
|
|
195
199
|
readonly body: string;
|
|
196
200
|
readonly baseVersion?: number | undefined;
|
|
201
|
+
/** The pictures pasted or chosen into the body, to attach once the page is written. */
|
|
202
|
+
readonly files: readonly File[];
|
|
197
203
|
}) => void;
|
|
198
204
|
readonly onCancel?: (() => void) | undefined;
|
|
199
205
|
}
|
|
@@ -208,8 +214,42 @@ export function DocumentForm(props: DocumentFormProps) {
|
|
|
208
214
|
);
|
|
209
215
|
const [title, setTitle] = useState(existing?.title ?? "");
|
|
210
216
|
const [body, setBody] = useState(existing?.body ?? "");
|
|
217
|
+
const [files, setFiles] = useState<readonly File[]>([]);
|
|
211
218
|
const ready = title.trim().length > 0 && (existing !== undefined || isDocumentPath(path));
|
|
212
219
|
|
|
220
|
+
// A picture pasted or chosen goes with the page, placed in the text where pictures are read,
|
|
221
|
+
// under a name no file of the page has yet; the page carries only so many files.
|
|
222
|
+
const room = MAX_FILES_PER_DOCUMENT - (existing?.files.length ?? 0);
|
|
223
|
+
const add = (chosen: readonly File[]): void => {
|
|
224
|
+
const added: string[] = [];
|
|
225
|
+
let held = files;
|
|
226
|
+
for (const file of chosen) {
|
|
227
|
+
if (!isPicture(file.type) || held.length >= room) {
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
const taken = [
|
|
231
|
+
...(existing?.files.map((attached) => attached.file.name) ?? []),
|
|
232
|
+
...held.map((kept) => kept.name),
|
|
233
|
+
];
|
|
234
|
+
const name = unusedName(file.name, taken);
|
|
235
|
+
const kept = name === file.name ? file : new File([file], name, { type: file.type });
|
|
236
|
+
held = [...held, kept];
|
|
237
|
+
added.push(name);
|
|
238
|
+
}
|
|
239
|
+
setFiles(held);
|
|
240
|
+
if (added.length > 0) {
|
|
241
|
+
setBody((current) => placing(current, added));
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
const paste = (event: ClipboardEvent<HTMLTextAreaElement>) => {
|
|
245
|
+
const pasted = [...event.clipboardData.files].filter((file) => isPicture(file.type));
|
|
246
|
+
if (pasted.length === 0) {
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
event.preventDefault();
|
|
250
|
+
add(pasted);
|
|
251
|
+
};
|
|
252
|
+
|
|
213
253
|
const submit = (event: FormEvent) => {
|
|
214
254
|
event.preventDefault();
|
|
215
255
|
if (!ready) {
|
|
@@ -219,6 +259,7 @@ export function DocumentForm(props: DocumentFormProps) {
|
|
|
219
259
|
path: existing?.path ?? path,
|
|
220
260
|
title: title.trim(),
|
|
221
261
|
body,
|
|
262
|
+
files,
|
|
222
263
|
...(existing === undefined ? {} : { baseVersion: existing.version }),
|
|
223
264
|
});
|
|
224
265
|
};
|
|
@@ -257,8 +298,42 @@ export function DocumentForm(props: DocumentFormProps) {
|
|
|
257
298
|
rows={16}
|
|
258
299
|
placeholder={form.bodyPlaceholder}
|
|
259
300
|
onChange={(event) => setBody(event.target.value)}
|
|
301
|
+
onPaste={paste}
|
|
260
302
|
/>
|
|
261
303
|
</label>
|
|
304
|
+
{files.length > 0 && (
|
|
305
|
+
<ul className="sc-chosen-files" aria-label={form.pictures}>
|
|
306
|
+
{files.map((file) => (
|
|
307
|
+
<li key={file.name} className="sc-chosen-file">
|
|
308
|
+
<span>{file.name}</span> <span className="sc-muted">{formatBytes(file.size)}</span>{" "}
|
|
309
|
+
<Button
|
|
310
|
+
variant="ghost"
|
|
311
|
+
size="sm"
|
|
312
|
+
aria-label={form.removeFile(file.name)}
|
|
313
|
+
onClick={() => setFiles((current) => current.filter((held) => held !== file))}
|
|
314
|
+
>
|
|
315
|
+
×
|
|
316
|
+
</Button>
|
|
317
|
+
</li>
|
|
318
|
+
))}
|
|
319
|
+
</ul>
|
|
320
|
+
)}
|
|
321
|
+
<div className="sc-doc-form-pictures">
|
|
322
|
+
<label className="sc-button sc-button-ghost sc-button-sm sc-file-choice">
|
|
323
|
+
{form.addPictures}
|
|
324
|
+
<input
|
|
325
|
+
className="sc-file-input"
|
|
326
|
+
type="file"
|
|
327
|
+
accept="image/*"
|
|
328
|
+
multiple
|
|
329
|
+
onChange={(event) => {
|
|
330
|
+
add([...(event.target.files ?? [])]);
|
|
331
|
+
event.target.value = "";
|
|
332
|
+
}}
|
|
333
|
+
/>
|
|
334
|
+
</label>
|
|
335
|
+
<span className="sc-field-hint">{form.pictureHint}</span>
|
|
336
|
+
</div>
|
|
262
337
|
{props.error !== undefined && (
|
|
263
338
|
<p className="sc-form-error" role="alert">
|
|
264
339
|
{props.error}
|
|
@@ -417,6 +492,14 @@ export function DocumentView(props: DocumentViewProps) {
|
|
|
417
492
|
const docs = words.docs;
|
|
418
493
|
const { document, version } = props;
|
|
419
494
|
const archived = document.archivedAt !== null;
|
|
495
|
+
// A picture attached to the page is shown where the body names it by its file name, as it is.
|
|
496
|
+
const named = (source: string): string | undefined => {
|
|
497
|
+
const name = namedFile(source);
|
|
498
|
+
const attached = document.files.find(
|
|
499
|
+
(candidate) => candidate.file.name === name && isPicture(candidate.file.contentType),
|
|
500
|
+
);
|
|
501
|
+
return attached === undefined ? undefined : props.fileHref(attached);
|
|
502
|
+
};
|
|
420
503
|
const shown = version ?? {
|
|
421
504
|
number: document.version,
|
|
422
505
|
title: document.title,
|
|
@@ -446,7 +529,7 @@ export function DocumentView(props: DocumentViewProps) {
|
|
|
446
529
|
</p>
|
|
447
530
|
)}
|
|
448
531
|
{shown.body.trim().length > 0 ? (
|
|
449
|
-
<Markdown source={shown.body} docHref={props.docHref} />
|
|
532
|
+
<Markdown source={shown.body} docHref={props.docHref} fileSrc={named} />
|
|
450
533
|
) : (
|
|
451
534
|
<p className="sc-muted">{docs.nothingOnPage}</p>
|
|
452
535
|
)}
|
|
@@ -78,6 +78,10 @@ export function describeEvent(event: RestEvent, words: Messages = en): string {
|
|
|
78
78
|
return feed.event.taskUpdated(fields, task);
|
|
79
79
|
case "task.commented":
|
|
80
80
|
return feed.event.taskCommented(task, data.excerpt);
|
|
81
|
+
case "task.archived":
|
|
82
|
+
return feed.event.taskArchived(task);
|
|
83
|
+
case "task.restored":
|
|
84
|
+
return feed.event.taskRestored(task);
|
|
81
85
|
case "decision.raised":
|
|
82
86
|
return feed.event.decisionRaised(question);
|
|
83
87
|
case "decision.answered":
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// The files people and agents put on the board, as the pages treat them: which are pictures the
|
|
2
|
+
// pages show in place, whether a text names one, the Markdown that places one, and a name for
|
|
3
|
+
// one more among those taken. Shared by the discussion, the runs and the documents.
|
|
4
|
+
|
|
5
|
+
/** The kinds of picture the pages show in place: what a browser renders and cannot run. */
|
|
6
|
+
const PICTURE_TYPES: ReadonlySet<string> = new Set([
|
|
7
|
+
"image/png",
|
|
8
|
+
"image/jpeg",
|
|
9
|
+
"image/gif",
|
|
10
|
+
"image/webp",
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
/** Whether a file is a picture the pages show in place, by the media type the console kept. */
|
|
14
|
+
export function isPicture(contentType: string): boolean {
|
|
15
|
+
return PICTURE_TYPES.has(contentType);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The file name a picture's source names in a text, written as `name` or `./name`. */
|
|
19
|
+
export function namedFile(source: string): string {
|
|
20
|
+
return source.startsWith("./") ? source.slice(2) : source;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Whether the text places a file by its name, as `` or ``. */
|
|
24
|
+
export function placed(body: string, name: string): boolean {
|
|
25
|
+
return body.includes(`](${name})`) || body.includes(`](./${name})`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The text with the pictures named placed after what is written, one `` a line. */
|
|
29
|
+
export function placing(body: string, names: readonly string[]): string {
|
|
30
|
+
const lines = names.map((name) => ``).join("\n");
|
|
31
|
+
return body.trim().length === 0 ? lines : `${body.trimEnd()}\n\n${lines}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A name for a file among those taken already: the same, or with a count before its extension. */
|
|
35
|
+
export function unusedName(name: string, taken: Iterable<string>): string {
|
|
36
|
+
const names = new Set(taken);
|
|
37
|
+
if (!names.has(name)) {
|
|
38
|
+
return name;
|
|
39
|
+
}
|
|
40
|
+
const dot = name.lastIndexOf(".");
|
|
41
|
+
const stem = dot > 0 ? name.slice(0, dot) : name;
|
|
42
|
+
const extension = dot > 0 ? name.slice(dot) : "";
|
|
43
|
+
for (let n = 2; ; n += 1) {
|
|
44
|
+
const candidate = `${stem}-${n}${extension}`;
|
|
45
|
+
if (!names.has(candidate)) {
|
|
46
|
+
return candidate;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/components/goals.tsx
CHANGED
|
@@ -10,13 +10,15 @@ import {
|
|
|
10
10
|
type RestTask,
|
|
11
11
|
TASK_STATES,
|
|
12
12
|
} from "../api.js";
|
|
13
|
-
import { formatDays } from "../dates.js";
|
|
13
|
+
import { formatDays, monthOf } from "../dates.js";
|
|
14
14
|
import { useLanguage, useWords } from "../i18n/index.js";
|
|
15
|
+
import { GOAL_TASK_VIEWS, type GoalTaskView } from "../router.js";
|
|
15
16
|
import { Markdown } from "./markdown.js";
|
|
16
17
|
import { TaskList } from "./task-list.js";
|
|
17
18
|
import {
|
|
18
19
|
Button,
|
|
19
20
|
CategoryBadge,
|
|
21
|
+
cx,
|
|
20
22
|
Day,
|
|
21
23
|
DueBadge,
|
|
22
24
|
GoalStateBadge,
|
|
@@ -27,9 +29,10 @@ import {
|
|
|
27
29
|
|
|
28
30
|
// The roadmap of a project (ADR-0019): its goals, each planned in days as a task is, a start
|
|
29
31
|
// and a target, sorted into a category of the workspace (ADR-0026), with how far its tasks
|
|
30
|
-
// are, read from the board as it stands. The list, one goal with its tasks
|
|
31
|
-
//
|
|
32
|
-
//
|
|
32
|
+
// are, read from the board as it stands. The list, one goal with its tasks (as the board of
|
|
33
|
+
// them, the list by state, or the calendar, the first two given by the composition from the
|
|
34
|
+
// board's own parts), and the form an owner sets one with. Each takes its data as props and
|
|
35
|
+
// nothing from the network; what people wrote is shown as text, never as HTML.
|
|
33
36
|
|
|
34
37
|
/** A goal's day on the calendar's terms: reached or let go, it is a plain fact; else it may be late. */
|
|
35
38
|
const dueState = (goal: RestGoal) =>
|
|
@@ -208,9 +211,23 @@ export function GoalForm(props: GoalFormProps) {
|
|
|
208
211
|
);
|
|
209
212
|
}
|
|
210
213
|
|
|
214
|
+
/**
|
|
215
|
+
* The month a goal's calendar opens on: the month it starts while that is still to come, the
|
|
216
|
+
* month it is meant to be reached by once that is past, and this month otherwise.
|
|
217
|
+
*/
|
|
218
|
+
export function goalMonth(goal: RestGoal, today: string): string {
|
|
219
|
+
if (goal.startsOn !== null && today < goal.startsOn) {
|
|
220
|
+
return monthOf(goal.startsOn);
|
|
221
|
+
}
|
|
222
|
+
if (goal.dueOn !== null && today > goal.dueOn) {
|
|
223
|
+
return monthOf(goal.dueOn);
|
|
224
|
+
}
|
|
225
|
+
return monthOf(today);
|
|
226
|
+
}
|
|
227
|
+
|
|
211
228
|
export interface GoalViewProps {
|
|
212
229
|
readonly goal: RestGoal;
|
|
213
|
-
/** The tasks of the board; those that work towards the goal are shown
|
|
230
|
+
/** The tasks of the board; those that work towards the goal are shown. */
|
|
214
231
|
readonly tasks: readonly RestTask[];
|
|
215
232
|
readonly taskHref: (task: RestTask) => string;
|
|
216
233
|
/** Where a document of the project is read, by its path, for the links in what was written. */
|
|
@@ -221,6 +238,16 @@ export interface GoalViewProps {
|
|
|
221
238
|
/** Called when an owner moves the goal to another state from here. */
|
|
222
239
|
readonly onMove?: ((goal: RestGoal, state: GoalState) => void) | undefined;
|
|
223
240
|
readonly busy?: boolean | undefined;
|
|
241
|
+
/** Which view of the goal's tasks is shown: the list when left out. */
|
|
242
|
+
readonly view?: GoalTaskView | undefined;
|
|
243
|
+
/** Where each view is; left out, the views cannot be switched from here. */
|
|
244
|
+
readonly viewHref?: ((view: GoalTaskView) => string) | undefined;
|
|
245
|
+
/** The board of the goal's tasks, built by the composition from the board's own parts; the list stands in without it. */
|
|
246
|
+
readonly board?: ReactNode;
|
|
247
|
+
/** The calendar of the goal's tasks, the same way. */
|
|
248
|
+
readonly calendar?: ReactNode;
|
|
249
|
+
/** Called to put every done or dropped task of the goal away (ADR-0029); left out, it is not offered. */
|
|
250
|
+
readonly onArchiveTasks?: (() => void) | undefined;
|
|
224
251
|
}
|
|
225
252
|
|
|
226
253
|
export function GoalView(props: GoalViewProps) {
|
|
@@ -228,116 +255,156 @@ export function GoalView(props: GoalViewProps) {
|
|
|
228
255
|
const roadmap = words.roadmap;
|
|
229
256
|
const { goal } = props;
|
|
230
257
|
const towards = props.tasks.filter((task) => task.goalId === goal.id);
|
|
258
|
+
const view: GoalTaskView = props.view ?? "list";
|
|
259
|
+
const { viewHref } = props;
|
|
260
|
+
const done = goal.taskCounts.done;
|
|
261
|
+
const total = Object.values(goal.taskCounts).reduce((sum, n) => sum + n, 0);
|
|
262
|
+
const list =
|
|
263
|
+
towards.length === 0 ? (
|
|
264
|
+
<p className="sc-muted">{roadmap.noTasks}</p>
|
|
265
|
+
) : (
|
|
266
|
+
TASK_STATES.map((state) => {
|
|
267
|
+
const inState = towards.filter((task) => task.state === state);
|
|
268
|
+
return inState.length === 0 ? null : (
|
|
269
|
+
<div key={state} className="sc-goal-tasks">
|
|
270
|
+
<h3 className="sc-goal-tasks-state">
|
|
271
|
+
{words.vocabulary.state[state]}{" "}
|
|
272
|
+
<span className="sc-column-count">{inState.length}</span>
|
|
273
|
+
</h3>
|
|
274
|
+
<TaskList tasks={inState} taskHref={props.taskHref} today={props.today} />
|
|
275
|
+
</div>
|
|
276
|
+
);
|
|
277
|
+
})
|
|
278
|
+
);
|
|
279
|
+
const shown =
|
|
280
|
+
view === "board" && props.board !== undefined
|
|
281
|
+
? props.board
|
|
282
|
+
: view === "calendar" && props.calendar !== undefined
|
|
283
|
+
? props.calendar
|
|
284
|
+
: list;
|
|
231
285
|
return (
|
|
232
|
-
<div className="sc-
|
|
233
|
-
<header className="sc-
|
|
234
|
-
<
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
286
|
+
<div className="sc-goal-page">
|
|
287
|
+
<header className="sc-goal-header">
|
|
288
|
+
<div className="sc-task-header">
|
|
289
|
+
<h1 className="sc-task-title">{goal.title}</h1>
|
|
290
|
+
<div className="sc-task-badges">
|
|
291
|
+
{goal.category !== null && <CategoryBadge category={goal.category} />}
|
|
292
|
+
<GoalStateBadge state={goal.state} />
|
|
293
|
+
{goal.dueOn !== null && (
|
|
294
|
+
<DueBadge dueOn={goal.dueOn} state={dueState(goal)} today={props.today} />
|
|
295
|
+
)}
|
|
296
|
+
</div>
|
|
241
297
|
</div>
|
|
242
|
-
|
|
243
|
-
<div className="sc-task-body">
|
|
244
|
-
<div className="sc-task-main">
|
|
298
|
+
<div className="sc-goal-progress">
|
|
245
299
|
<ProgressBar counts={goal.taskCounts} />
|
|
246
|
-
{
|
|
247
|
-
<Markdown source={goal.body} docHref={props.docHref} />
|
|
248
|
-
) : (
|
|
249
|
-
<p className="sc-muted">{roadmap.nothingMore}</p>
|
|
250
|
-
)}
|
|
251
|
-
<section className="sc-task-section" aria-label={roadmap.tasks}>
|
|
252
|
-
<h2 className="sc-section-title">{roadmap.tasks}</h2>
|
|
253
|
-
{towards.length === 0 ? (
|
|
254
|
-
<p className="sc-muted">{roadmap.noTasks}</p>
|
|
255
|
-
) : (
|
|
256
|
-
TASK_STATES.map((state) => {
|
|
257
|
-
const inState = towards.filter((task) => task.state === state);
|
|
258
|
-
return inState.length === 0 ? null : (
|
|
259
|
-
<div key={state} className="sc-goal-tasks">
|
|
260
|
-
<h3 className="sc-goal-tasks-state">
|
|
261
|
-
{words.vocabulary.state[state]}{" "}
|
|
262
|
-
<span className="sc-column-count">{inState.length}</span>
|
|
263
|
-
</h3>
|
|
264
|
-
<TaskList tasks={inState} taskHref={props.taskHref} today={props.today} />
|
|
265
|
-
</div>
|
|
266
|
-
);
|
|
267
|
-
})
|
|
268
|
-
)}
|
|
269
|
-
</section>
|
|
300
|
+
<span className="sc-muted">{roadmap.progress(done, total)}</span>
|
|
270
301
|
</div>
|
|
271
|
-
<
|
|
272
|
-
<
|
|
273
|
-
<
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
{
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
<
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
{
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
<
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
{
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
<
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
{
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
<
|
|
326
|
-
|
|
327
|
-
<
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
</div>
|
|
331
|
-
</dl>
|
|
302
|
+
<dl className="sc-facts-row">
|
|
303
|
+
<div>
|
|
304
|
+
<dt>{roadmap.state}</dt>
|
|
305
|
+
<dd>
|
|
306
|
+
{props.onMove === undefined ? (
|
|
307
|
+
words.vocabulary.goalState[goal.state]
|
|
308
|
+
) : (
|
|
309
|
+
<select
|
|
310
|
+
className="sc-input"
|
|
311
|
+
aria-label={roadmap.state}
|
|
312
|
+
value={goal.state}
|
|
313
|
+
disabled={props.busy === true}
|
|
314
|
+
onChange={(event) => props.onMove?.(goal, event.target.value as GoalState)}
|
|
315
|
+
>
|
|
316
|
+
{GOAL_STATES.map((state) => (
|
|
317
|
+
<option key={state} value={state}>
|
|
318
|
+
{words.vocabulary.goalState[state]}
|
|
319
|
+
</option>
|
|
320
|
+
))}
|
|
321
|
+
</select>
|
|
322
|
+
)}
|
|
323
|
+
</dd>
|
|
324
|
+
</div>
|
|
325
|
+
<div>
|
|
326
|
+
<dt>{roadmap.category}</dt>
|
|
327
|
+
<dd>
|
|
328
|
+
{goal.category === null ? (
|
|
329
|
+
<span className="sc-muted">{roadmap.noCategory}</span>
|
|
330
|
+
) : (
|
|
331
|
+
<CategoryBadge category={goal.category} />
|
|
332
|
+
)}
|
|
333
|
+
</dd>
|
|
334
|
+
</div>
|
|
335
|
+
<div>
|
|
336
|
+
<dt>{roadmap.starts}</dt>
|
|
337
|
+
<dd>
|
|
338
|
+
{goal.startsOn === null ? (
|
|
339
|
+
<span className="sc-muted">{roadmap.noStart}</span>
|
|
340
|
+
) : (
|
|
341
|
+
<Day day={goal.startsOn} />
|
|
342
|
+
)}
|
|
343
|
+
</dd>
|
|
344
|
+
</div>
|
|
345
|
+
<div>
|
|
346
|
+
<dt>{roadmap.target}</dt>
|
|
347
|
+
<dd>
|
|
348
|
+
{goal.dueOn === null ? (
|
|
349
|
+
<span className="sc-muted">{roadmap.noTarget}</span>
|
|
350
|
+
) : (
|
|
351
|
+
<Day day={goal.dueOn} />
|
|
352
|
+
)}
|
|
353
|
+
</dd>
|
|
354
|
+
</div>
|
|
355
|
+
<div>
|
|
356
|
+
<dt>{roadmap.setBy}</dt>
|
|
357
|
+
<dd>
|
|
358
|
+
<PersonChip person={goal.createdBy} /> <Time iso={goal.createdAt} />
|
|
359
|
+
</dd>
|
|
360
|
+
</div>
|
|
332
361
|
{props.editHref !== undefined && (
|
|
333
|
-
<div
|
|
334
|
-
<a className="sc-button sc-button-secondary" href={props.editHref}>
|
|
362
|
+
<div>
|
|
363
|
+
<a className="sc-button sc-button-secondary sc-button-sm" href={props.editHref}>
|
|
335
364
|
{words.common.edit}
|
|
336
365
|
</a>
|
|
337
366
|
</div>
|
|
338
367
|
)}
|
|
339
|
-
|
|
340
|
-
|
|
368
|
+
{props.onArchiveTasks !== undefined && (
|
|
369
|
+
<div>
|
|
370
|
+
<Button
|
|
371
|
+
variant="secondary"
|
|
372
|
+
size="sm"
|
|
373
|
+
disabled={props.busy === true}
|
|
374
|
+
onClick={props.onArchiveTasks}
|
|
375
|
+
>
|
|
376
|
+
{roadmap.archiveTasks}
|
|
377
|
+
</Button>
|
|
378
|
+
</div>
|
|
379
|
+
)}
|
|
380
|
+
</dl>
|
|
381
|
+
{goal.body.length > 0 && (
|
|
382
|
+
<details className="sc-goal-about">
|
|
383
|
+
<summary className="sc-goal-about-summary">{roadmap.about}</summary>
|
|
384
|
+
<Markdown source={goal.body} docHref={props.docHref} />
|
|
385
|
+
</details>
|
|
386
|
+
)}
|
|
387
|
+
</header>
|
|
388
|
+
{viewHref !== undefined && (
|
|
389
|
+
<nav className="sc-views" aria-label={roadmap.view}>
|
|
390
|
+
{GOAL_TASK_VIEWS.map((candidate) => (
|
|
391
|
+
<a
|
|
392
|
+
key={candidate}
|
|
393
|
+
className={cx("sc-view", candidate === view && "sc-view-current")}
|
|
394
|
+
href={viewHref(candidate)}
|
|
395
|
+
aria-current={candidate === view ? "page" : undefined}
|
|
396
|
+
>
|
|
397
|
+
{roadmap.views[candidate]}
|
|
398
|
+
</a>
|
|
399
|
+
))}
|
|
400
|
+
</nav>
|
|
401
|
+
)}
|
|
402
|
+
<section
|
|
403
|
+
className={cx("sc-goal-tasks-view", `sc-goal-view-${view}`)}
|
|
404
|
+
aria-label={roadmap.tasks}
|
|
405
|
+
>
|
|
406
|
+
{shown}
|
|
407
|
+
</section>
|
|
341
408
|
</div>
|
|
342
409
|
);
|
|
343
410
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import ReactMarkdown from "react-markdown";
|
|
2
2
|
import remarkGfm from "remark-gfm";
|
|
3
3
|
import { isDocumentPath } from "../documents.js";
|
|
4
|
+
import { type Picture, PictureThumb } from "./pictures.js";
|
|
4
5
|
|
|
5
6
|
// What people and agents write is untrusted. It is rendered as React elements, never as HTML:
|
|
6
7
|
// raw HTML in the source stays text, links are limited to a few schemes and open elsewhere,
|
|
@@ -20,6 +21,11 @@ export interface MarkdownProps {
|
|
|
20
21
|
* is neither one of those nor over https stays text.
|
|
21
22
|
*/
|
|
22
23
|
readonly fileSrc?: ((source: string) => string | undefined) | undefined;
|
|
24
|
+
/**
|
|
25
|
+
* Called when a picture that came with the text is pressed, to show it large over the page;
|
|
26
|
+
* left out, such a picture is only seen where it is.
|
|
27
|
+
*/
|
|
28
|
+
readonly onOpenPicture?: ((picture: Picture, opener: HTMLElement) => void) | undefined;
|
|
23
29
|
}
|
|
24
30
|
|
|
25
31
|
export function Markdown(props: MarkdownProps) {
|
|
@@ -60,6 +66,19 @@ export function Markdown(props: MarkdownProps) {
|
|
|
60
66
|
if (url === undefined) {
|
|
61
67
|
return <span className="sc-prose-image-text">{alt ?? ""}</span>;
|
|
62
68
|
}
|
|
69
|
+
if (
|
|
70
|
+
named !== undefined &&
|
|
71
|
+
props.onOpenPicture !== undefined &&
|
|
72
|
+
typeof src === "string"
|
|
73
|
+
) {
|
|
74
|
+
const name = src.startsWith("./") ? src.slice(2) : src;
|
|
75
|
+
return (
|
|
76
|
+
<PictureThumb
|
|
77
|
+
picture={{ src: named, alt: alt ?? "", name }}
|
|
78
|
+
onOpen={props.onOpenPicture}
|
|
79
|
+
/>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
63
82
|
return (
|
|
64
83
|
<img
|
|
65
84
|
src={url}
|