@skillcdn/console 0.1.19 → 0.1.21
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 +1 -1
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +6 -0
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/help.d.ts.map +1 -1
- package/dist/cli/help.js +6 -8
- package/dist/cli/help.js.map +1 -1
- package/dist/components/board.d.ts +19 -9
- package/dist/components/board.d.ts.map +1 -1
- package/dist/components/board.js +210 -28
- 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 +4 -39
- 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/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/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.map +1 -1
- package/dist/components/task-view.js +2 -4
- package/dist/components/task-view.js.map +1 -1
- package/dist/console.css +144 -80
- package/dist/console.d.ts.map +1 -1
- package/dist/console.js +3 -3
- package/dist/console.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 +6 -3
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +7 -4
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/ko.d.ts.map +1 -1
- package/dist/i18n/ko.js +7 -4
- package/dist/i18n/ko.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/rules.d.ts.map +1 -1
- package/dist/rules.js +2 -1
- package/dist/rules.js.map +1 -1
- package/docs/specs/cli.md +4 -4
- package/docs/specs/rest.md +3 -4
- package/package.json +1 -1
- package/skills/working-the-board/SKILL.md +4 -4
- package/src/cli/cli.ts +6 -0
- package/src/cli/help.ts +6 -8
- package/src/components/board.tsx +357 -77
- package/src/components/comments.tsx +10 -39
- package/src/components/documents.tsx +87 -4
- package/src/components/files.ts +49 -0
- package/src/components/runs.tsx +73 -15
- package/src/components/task-view.tsx +10 -12
- package/src/console.tsx +20 -8
- package/src/docs-page.tsx +18 -1
- package/src/i18n/en.ts +8 -4
- package/src/i18n/ko.ts +8 -4
- package/src/index.ts +1 -1
- package/src/rules.ts +2 -1
- package/src/styles/console.css +144 -80
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from "../api.js";
|
|
12
12
|
import { useWords } from "../i18n/index.js";
|
|
13
13
|
import { DecisionCard, type DecisionCardProps } from "./decision-list.js";
|
|
14
|
+
import { isPicture, namedFile, placed, placing, unusedName } from "./files.js";
|
|
14
15
|
import { Markdown } from "./markdown.js";
|
|
15
16
|
import { Lightbox, PictureThumb, usePicture } from "./pictures.js";
|
|
16
17
|
import { RunCard } from "./runs.js";
|
|
@@ -21,24 +22,6 @@ import { Button, formatBytes, PersonChip, personName, Time } from "./ui.js";
|
|
|
21
22
|
// it, in the order they came. A picture is a thumbnail that opens large over the page. Takes
|
|
22
23
|
// its data as props and nothing from the network.
|
|
23
24
|
|
|
24
|
-
/** The kinds of picture the pages show in place: what a browser renders and cannot run. */
|
|
25
|
-
const PICTURE_TYPES: ReadonlySet<string> = new Set([
|
|
26
|
-
"image/png",
|
|
27
|
-
"image/jpeg",
|
|
28
|
-
"image/gif",
|
|
29
|
-
"image/webp",
|
|
30
|
-
]);
|
|
31
|
-
|
|
32
|
-
/** Whether a file is a picture the pages show in place, by the media type the console kept. */
|
|
33
|
-
export function isPicture(contentType: string): boolean {
|
|
34
|
-
return PICTURE_TYPES.has(contentType);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** Whether the text places a file by its name, as `` or ``. */
|
|
38
|
-
function placed(body: string, name: string): boolean {
|
|
39
|
-
return body.includes(`](${name})`) || body.includes(`](./${name})`);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
25
|
export interface CommentCardProps {
|
|
43
26
|
readonly comment: RestComment;
|
|
44
27
|
/** Where a file put in the comment is read: the project's address for it. Left out, a file is named and not linked. */
|
|
@@ -55,7 +38,7 @@ export function CommentCard(props: CommentCardProps) {
|
|
|
55
38
|
// A picture the text names by its file name is shown where the text puts it; the rest of
|
|
56
39
|
// the files follow the text, pictures in place and everything else as a link.
|
|
57
40
|
const named = (source: string): string | undefined => {
|
|
58
|
-
const name =
|
|
41
|
+
const name = namedFile(source);
|
|
59
42
|
const file = comment.files.find(
|
|
60
43
|
(candidate) => candidate.file.name === name && isPicture(candidate.file.contentType),
|
|
61
44
|
);
|
|
@@ -141,23 +124,6 @@ export interface CommentFormProps {
|
|
|
141
124
|
readonly busy?: boolean | undefined;
|
|
142
125
|
}
|
|
143
126
|
|
|
144
|
-
/** A name for a file among those chosen already: the same, or with a count before its extension. */
|
|
145
|
-
function unusedName(name: string, taken: readonly File[]): string {
|
|
146
|
-
const names = new Set(taken.map((file) => file.name));
|
|
147
|
-
if (!names.has(name)) {
|
|
148
|
-
return name;
|
|
149
|
-
}
|
|
150
|
-
const dot = name.lastIndexOf(".");
|
|
151
|
-
const stem = dot > 0 ? name.slice(0, dot) : name;
|
|
152
|
-
const extension = dot > 0 ? name.slice(dot) : "";
|
|
153
|
-
for (let n = 2; ; n += 1) {
|
|
154
|
-
const candidate = `${stem}-${n}${extension}`;
|
|
155
|
-
if (!names.has(candidate)) {
|
|
156
|
-
return candidate;
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
127
|
export function CommentForm(props: CommentFormProps) {
|
|
162
128
|
const words = useWords().comments;
|
|
163
129
|
const [body, setBody] = useState("");
|
|
@@ -171,7 +137,10 @@ export function CommentForm(props: CommentFormProps) {
|
|
|
171
137
|
if (held.length >= MAX_FILES_PER_COMMENT) {
|
|
172
138
|
break;
|
|
173
139
|
}
|
|
174
|
-
const name = unusedName(
|
|
140
|
+
const name = unusedName(
|
|
141
|
+
file.name,
|
|
142
|
+
held.map((kept) => kept.name),
|
|
143
|
+
);
|
|
175
144
|
const kept = name === file.name ? file : new File([file], name, { type: file.type });
|
|
176
145
|
held = [...held, kept];
|
|
177
146
|
added.push(kept);
|
|
@@ -189,9 +158,11 @@ export function CommentForm(props: CommentFormProps) {
|
|
|
189
158
|
event.preventDefault();
|
|
190
159
|
const added = add(pasted);
|
|
191
160
|
if (added.length > 0) {
|
|
192
|
-
const lines = added.map((file) => ``).join("\n");
|
|
193
161
|
setBody((current) =>
|
|
194
|
-
|
|
162
|
+
placing(
|
|
163
|
+
current,
|
|
164
|
+
added.map((file) => file.name),
|
|
165
|
+
),
|
|
195
166
|
);
|
|
196
167
|
}
|
|
197
168
|
};
|
|
@@ -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
|
)}
|
|
@@ -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/runs.tsx
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
2
|
import { agentLabel, type RestArtifact, type RestRun, type RunStatus } from "../api.js";
|
|
3
3
|
import { useWords } from "../i18n/index.js";
|
|
4
|
+
import { isPicture, namedFile } from "./files.js";
|
|
4
5
|
import { Markdown } from "./markdown.js";
|
|
6
|
+
import { Lightbox, type Picture, PictureThumb, usePicture } from "./pictures.js";
|
|
5
7
|
import { Badge, Button, formatBytes, PersonChip, personName, Time } from "./ui.js";
|
|
6
8
|
|
|
7
9
|
// A run: one agent at work on one task for one person, with what it reported, what it handed
|
|
8
|
-
// in, and what it waits for.
|
|
10
|
+
// in, and what it waits for. A picture handed in is seen small where it is listed and large
|
|
11
|
+
// over the page, as a picture in a comment is, and a report that names it by its file name
|
|
12
|
+
// shows it where the words are. Takes its data as props and nothing from the network.
|
|
9
13
|
|
|
10
14
|
/** The words of a run's status in English: the default pack's. */
|
|
11
15
|
export const RUN_STATUS_LABELS: Readonly<Record<RunStatus, string>> = {
|
|
@@ -48,6 +52,19 @@ export function RunCard(props: RunCardProps) {
|
|
|
48
52
|
const words = useWords().runs;
|
|
49
53
|
const { run } = props;
|
|
50
54
|
const open = run.status === "running" || run.status === "waiting";
|
|
55
|
+
const pictures = usePicture();
|
|
56
|
+
const hrefOf = (artifact: RestArtifact) => props.fileHref?.(artifact);
|
|
57
|
+
// A report that names a picture handed in, by its file name, shows it where the words are.
|
|
58
|
+
const named = (source: string): string | undefined => {
|
|
59
|
+
const name = namedFile(source);
|
|
60
|
+
const artifact = run.artifacts.find(
|
|
61
|
+
(candidate) =>
|
|
62
|
+
candidate.file !== null &&
|
|
63
|
+
candidate.file.name === name &&
|
|
64
|
+
isPicture(candidate.file.contentType),
|
|
65
|
+
);
|
|
66
|
+
return artifact === undefined ? undefined : hrefOf(artifact);
|
|
67
|
+
};
|
|
51
68
|
return (
|
|
52
69
|
<article className="sc-run" aria-label={words.label(run.agent, personName(run.person))}>
|
|
53
70
|
<header className="sc-run-header">
|
|
@@ -97,7 +114,12 @@ export function RunCard(props: RunCardProps) {
|
|
|
97
114
|
{run.reports.map((report) => (
|
|
98
115
|
<li key={report.id} className="sc-report">
|
|
99
116
|
<Time iso={report.createdAt} />
|
|
100
|
-
<Markdown
|
|
117
|
+
<Markdown
|
|
118
|
+
source={report.body}
|
|
119
|
+
docHref={props.docHref}
|
|
120
|
+
fileSrc={named}
|
|
121
|
+
onOpenPicture={pictures.open}
|
|
122
|
+
/>
|
|
101
123
|
</li>
|
|
102
124
|
))}
|
|
103
125
|
</ol>
|
|
@@ -106,26 +128,41 @@ export function RunCard(props: RunCardProps) {
|
|
|
106
128
|
<ul className="sc-link-list" aria-label={words.handedIn}>
|
|
107
129
|
{run.artifacts.map((artifact) => (
|
|
108
130
|
<li key={artifact.id}>
|
|
109
|
-
<
|
|
131
|
+
<ArtifactItem
|
|
132
|
+
artifact={artifact}
|
|
133
|
+
href={hrefOf(artifact)}
|
|
134
|
+
onOpenPicture={pictures.open}
|
|
135
|
+
/>
|
|
110
136
|
</li>
|
|
111
137
|
))}
|
|
112
138
|
</ul>
|
|
113
139
|
)}
|
|
114
140
|
{run.summary !== null && (
|
|
115
141
|
<div className="sc-run-summary">
|
|
116
|
-
<Markdown
|
|
142
|
+
<Markdown
|
|
143
|
+
source={run.summary}
|
|
144
|
+
docHref={props.docHref}
|
|
145
|
+
fileSrc={named}
|
|
146
|
+
onOpenPicture={pictures.open}
|
|
147
|
+
/>
|
|
117
148
|
</div>
|
|
118
149
|
)}
|
|
150
|
+
<Lightbox picture={pictures.shown} onClose={pictures.close} />
|
|
119
151
|
</article>
|
|
120
152
|
);
|
|
121
153
|
}
|
|
122
154
|
|
|
123
|
-
/**
|
|
124
|
-
|
|
155
|
+
/**
|
|
156
|
+
* What was handed in: a link as it was given; a file by its name, with its size, read from the
|
|
157
|
+
* console; a picture seen small, and large when pressed.
|
|
158
|
+
*/
|
|
159
|
+
function ArtifactItem(props: {
|
|
125
160
|
readonly artifact: RestArtifact;
|
|
126
|
-
readonly
|
|
161
|
+
readonly href: string | undefined;
|
|
162
|
+
readonly onOpenPicture: (picture: Picture, opener: HTMLElement) => void;
|
|
127
163
|
}) {
|
|
128
|
-
const
|
|
164
|
+
const words = useWords().runs;
|
|
165
|
+
const { artifact, href } = props;
|
|
129
166
|
if (artifact.file === null) {
|
|
130
167
|
return (
|
|
131
168
|
<a href={artifact.url ?? ""} target="_blank" rel="noopener noreferrer nofollow ugc">
|
|
@@ -133,20 +170,41 @@ function ArtifactLink(props: {
|
|
|
133
170
|
</a>
|
|
134
171
|
);
|
|
135
172
|
}
|
|
136
|
-
const
|
|
173
|
+
const { file } = artifact;
|
|
174
|
+
const about = (
|
|
175
|
+
<span className="sc-muted">
|
|
176
|
+
{artifact.label === null ? "" : `${file.name}, `}
|
|
177
|
+
{formatBytes(file.size)}
|
|
178
|
+
</span>
|
|
179
|
+
);
|
|
180
|
+
if (href !== undefined && isPicture(file.contentType)) {
|
|
181
|
+
return (
|
|
182
|
+
<div className="sc-artifact-picture">
|
|
183
|
+
<PictureThumb
|
|
184
|
+
picture={{
|
|
185
|
+
src: href,
|
|
186
|
+
alt: artifact.label ?? words.picture(file.name),
|
|
187
|
+
name: file.name,
|
|
188
|
+
size: file.size,
|
|
189
|
+
}}
|
|
190
|
+
onOpen={props.onOpenPicture}
|
|
191
|
+
/>
|
|
192
|
+
<span>
|
|
193
|
+
{artifact.label ?? file.name} {about}
|
|
194
|
+
</span>
|
|
195
|
+
</div>
|
|
196
|
+
);
|
|
197
|
+
}
|
|
137
198
|
return (
|
|
138
199
|
<>
|
|
139
200
|
{href === undefined ? (
|
|
140
|
-
<span>{artifact.label ??
|
|
201
|
+
<span>{artifact.label ?? file.name}</span>
|
|
141
202
|
) : (
|
|
142
203
|
<a href={href} rel="nofollow ugc">
|
|
143
|
-
{artifact.label ??
|
|
204
|
+
{artifact.label ?? file.name}
|
|
144
205
|
</a>
|
|
145
206
|
)}{" "}
|
|
146
|
-
|
|
147
|
-
{artifact.label === null ? "" : `${artifact.file.name}, `}
|
|
148
|
-
{formatBytes(artifact.file.size)}
|
|
149
|
-
</span>
|
|
207
|
+
{about}
|
|
150
208
|
</>
|
|
151
209
|
);
|
|
152
210
|
}
|
|
@@ -16,7 +16,7 @@ import type {
|
|
|
16
16
|
import { formatDay } from "../dates.js";
|
|
17
17
|
import { useLanguage, useWords } from "../i18n/index.js";
|
|
18
18
|
import type { TaskFormKind } from "../router.js";
|
|
19
|
-
import {
|
|
19
|
+
import { TASK_STATES } from "../vocabulary.js";
|
|
20
20
|
import { type CommentDraft, TaskThread } from "./comments.js";
|
|
21
21
|
import { DecisionForm } from "./decision-form.js";
|
|
22
22
|
import { EventFeed } from "./event-feed.js";
|
|
@@ -354,17 +354,15 @@ export function TaskView(props: TaskViewProps) {
|
|
|
354
354
|
<a className="sc-button sc-button-secondary" href={props.formHref("ask")}>
|
|
355
355
|
{words.task.raiseDecision}
|
|
356
356
|
</a>
|
|
357
|
-
{props.onArchive !== undefined &&
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
</Button>
|
|
367
|
-
)}
|
|
357
|
+
{props.onArchive !== undefined && task.archivedAt === null && (
|
|
358
|
+
<Button
|
|
359
|
+
variant="secondary"
|
|
360
|
+
disabled={props.busy === true}
|
|
361
|
+
onClick={() => props.onArchive?.(task)}
|
|
362
|
+
>
|
|
363
|
+
{words.task.archive}
|
|
364
|
+
</Button>
|
|
365
|
+
)}
|
|
368
366
|
{props.onRestore !== undefined && task.archivedAt !== null && (
|
|
369
367
|
<Button
|
|
370
368
|
variant="secondary"
|
package/src/console.tsx
CHANGED
|
@@ -490,7 +490,6 @@ function ArchivePage(props: { readonly project: RestProject; readonly data: Cons
|
|
|
490
490
|
<h1 className="sc-page-title">{archive.title}</h1>
|
|
491
491
|
<LinkButton href={projectHref(key)}>{words.nav.board}</LinkButton>
|
|
492
492
|
</div>
|
|
493
|
-
<p className="sc-lead">{archive.lead}</p>
|
|
494
493
|
{error !== undefined && <Callout tone="danger">{error}</Callout>}
|
|
495
494
|
{!loaded ? (
|
|
496
495
|
<Spinner label={archive.loading} />
|
|
@@ -739,12 +738,21 @@ function ProjectPage(props: {
|
|
|
739
738
|
return (
|
|
740
739
|
<>
|
|
741
740
|
<div className="sc-page-head">
|
|
742
|
-
<
|
|
743
|
-
|
|
744
|
-
{
|
|
745
|
-
|
|
741
|
+
<div className="sc-page-heading">
|
|
742
|
+
<h1 className="sc-page-title">{project.name}</h1>
|
|
743
|
+
{project.description.length > 0 && (
|
|
744
|
+
<p className="sc-page-lead" title={project.description}>
|
|
745
|
+
{project.description}
|
|
746
|
+
</p>
|
|
747
|
+
)}
|
|
748
|
+
</div>
|
|
749
|
+
<div className="sc-page-actions">
|
|
750
|
+
<LinkButton href={projectHref(key, "archive")}>{words.board.archive}</LinkButton>
|
|
751
|
+
<LinkButton href={newTaskHref(key)} variant="primary">
|
|
752
|
+
{words.board.writeTask}
|
|
753
|
+
</LinkButton>
|
|
754
|
+
</div>
|
|
746
755
|
</div>
|
|
747
|
-
{project.description.length > 0 && <p className="sc-lead">{project.description}</p>}
|
|
748
756
|
{error !== undefined && <Callout tone="danger">{error}</Callout>}
|
|
749
757
|
{data.tasks.length > 0 && (
|
|
750
758
|
<BoardFilters
|
|
@@ -763,7 +771,9 @@ function ProjectPage(props: {
|
|
|
763
771
|
taskHref={href}
|
|
764
772
|
onOpen={(task) => navigation.go(href(task))}
|
|
765
773
|
onMove={onMove}
|
|
766
|
-
|
|
774
|
+
editHref={(task) => taskHref(key, task.number, "edit")}
|
|
775
|
+
onEdit={(task) => navigation.go(taskHref(key, task.number, "edit"))}
|
|
776
|
+
onArchive={(task) => void act(() => data.actions.archiveTask(task.id))}
|
|
767
777
|
empty={
|
|
768
778
|
data.tasks.length === 0 ? (
|
|
769
779
|
<EmptyState
|
|
@@ -1051,7 +1061,9 @@ function ProjectPage(props: {
|
|
|
1051
1061
|
onOpen={(task) => navigation.go(href(task))}
|
|
1052
1062
|
onMove={onMove}
|
|
1053
1063
|
today={now}
|
|
1054
|
-
|
|
1064
|
+
editHref={(task) => taskHref(key, task.number, "edit")}
|
|
1065
|
+
onEdit={(task) => navigation.go(taskHref(key, task.number, "edit"))}
|
|
1066
|
+
onArchive={(task) => void act(() => data.actions.archiveTask(task.id))}
|
|
1055
1067
|
empty={
|
|
1056
1068
|
towards.length === 0 ? <p className="sc-muted">{roadmap.noTasks}</p> : undefined
|
|
1057
1069
|
}
|
package/src/docs-page.tsx
CHANGED
|
@@ -159,6 +159,22 @@ export function DocsPage(props: DocsPageProps) {
|
|
|
159
159
|
const toDecision = (decisionId: string) => decisionHref(key, decisionId);
|
|
160
160
|
/** The plain address of what the page is on. */
|
|
161
161
|
const here = toDoc(path);
|
|
162
|
+
// The pictures put into the body are attached once the page is written, one request each.
|
|
163
|
+
const attachAll = async (
|
|
164
|
+
at: string,
|
|
165
|
+
files: readonly File[],
|
|
166
|
+
written: RestDocument,
|
|
167
|
+
): Promise<RestDocument> => {
|
|
168
|
+
let page = written;
|
|
169
|
+
for (const file of files) {
|
|
170
|
+
page = await actions.attachDocumentFile(at, {
|
|
171
|
+
name: file.name,
|
|
172
|
+
bytes: file,
|
|
173
|
+
...(file.type === "" ? {} : { contentType: file.type }),
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
return page;
|
|
177
|
+
};
|
|
162
178
|
const page = document !== null && document !== undefined ? document : undefined;
|
|
163
179
|
/** The folder a new page goes in: this one, or the one the page shown is in. */
|
|
164
180
|
const folder = page === undefined ? path : folderOf(path);
|
|
@@ -218,6 +234,7 @@ export function DocsPage(props: DocsPageProps) {
|
|
|
218
234
|
title: input.title,
|
|
219
235
|
body: input.body,
|
|
220
236
|
});
|
|
237
|
+
await attachAll(written.path, input.files, written);
|
|
221
238
|
navigation.replace(toDoc(written.path));
|
|
222
239
|
})
|
|
223
240
|
}
|
|
@@ -255,7 +272,7 @@ export function DocsPage(props: DocsPageProps) {
|
|
|
255
272
|
body: input.body,
|
|
256
273
|
...(input.baseVersion === undefined ? {} : { baseVersion: input.baseVersion }),
|
|
257
274
|
});
|
|
258
|
-
setDocument(written);
|
|
275
|
+
setDocument(await attachAll(page.path, input.files, written));
|
|
259
276
|
navigation.replace(here);
|
|
260
277
|
})
|
|
261
278
|
}
|
package/src/i18n/en.ts
CHANGED
|
@@ -221,9 +221,8 @@ export const en = {
|
|
|
221
221
|
title: "Nothing on the board yet",
|
|
222
222
|
body: "Write the first task: what is to be done, and for whom.",
|
|
223
223
|
},
|
|
224
|
-
moveTo: "Move to",
|
|
225
|
-
grip: "Drag to another column",
|
|
226
224
|
archive: "Archive",
|
|
225
|
+
menu: "Task menu",
|
|
227
226
|
decisions: (n: number) => count(n, "decision", "decisions"),
|
|
228
227
|
decisionsTitle: "Decisions waiting for a person",
|
|
229
228
|
filters: {
|
|
@@ -603,11 +602,10 @@ export const en = {
|
|
|
603
602
|
|
|
604
603
|
archive: {
|
|
605
604
|
title: "Archive",
|
|
606
|
-
lead: "Tasks done or dropped and put away: out of the board, the lists and the calendar, each still open by its number. Brought back from here.",
|
|
607
605
|
loading: "Loading the archive",
|
|
608
606
|
empty: {
|
|
609
607
|
title: "Nothing is put away",
|
|
610
|
-
body: "A task
|
|
608
|
+
body: "A task is put away from its page or its card, with its goal once the goal is closed, or by the console a while after it was done or dropped.",
|
|
611
609
|
},
|
|
612
610
|
since: "put away",
|
|
613
611
|
restore: "Bring back",
|
|
@@ -624,6 +622,7 @@ export const en = {
|
|
|
624
622
|
answerIt: "answer it",
|
|
625
623
|
reports: "Reports",
|
|
626
624
|
handedIn: "Handed in",
|
|
625
|
+
picture: (name: string) => `The picture ${name}`,
|
|
627
626
|
label: (agent: string, name: string) => `${agent} for ${name}`,
|
|
628
627
|
},
|
|
629
628
|
|
|
@@ -734,6 +733,11 @@ export const en = {
|
|
|
734
733
|
body: "Body",
|
|
735
734
|
bodyPlaceholder:
|
|
736
735
|
"Markdown. A link to another page's path, such as guides/onboarding, refers to that page.",
|
|
736
|
+
pictures: "Pictures",
|
|
737
|
+
addPictures: "Add pictures",
|
|
738
|
+
pictureHint:
|
|
739
|
+
"A picture pasted or chosen here is attached to the page once it is written;  places it in the text.",
|
|
740
|
+
removeFile: (name: string) => `Take ${name} out`,
|
|
737
741
|
write: "Write the page",
|
|
738
742
|
writeVersion: "Write a new version",
|
|
739
743
|
},
|
package/src/i18n/ko.ts
CHANGED
|
@@ -190,9 +190,8 @@ export const ko: Messages = {
|
|
|
190
190
|
title: "보드에 아직 아무것도 없습니다",
|
|
191
191
|
body: "첫 작업을 쓰세요. 무엇을, 누구를 위해 할지.",
|
|
192
192
|
},
|
|
193
|
-
moveTo: "이동",
|
|
194
|
-
grip: "끌어서 다른 열로",
|
|
195
193
|
archive: "보관함",
|
|
194
|
+
menu: "작업 메뉴",
|
|
196
195
|
decisions: (n) => `결정 ${n}건`,
|
|
197
196
|
decisionsTitle: "사람을 기다리는 결정",
|
|
198
197
|
filters: {
|
|
@@ -567,11 +566,10 @@ export const ko: Messages = {
|
|
|
567
566
|
|
|
568
567
|
archive: {
|
|
569
568
|
title: "보관함",
|
|
570
|
-
lead: "완료·폐기된 뒤 보관된 작업입니다. 보드·목록·캘린더에서는 빠지고, 번호로는 여전히 열립니다. 여기서 되살립니다.",
|
|
571
569
|
loading: "보관함을 불러오는 중",
|
|
572
570
|
empty: {
|
|
573
571
|
title: "보관된 작업이 없습니다",
|
|
574
|
-
body: "
|
|
572
|
+
body: "작업은 작업 페이지나 카드 메뉴에서, 목표를 닫을 때 함께, 또는 완료·폐기된 뒤 일정 기간이 지나면 콘솔이 보관합니다.",
|
|
575
573
|
},
|
|
576
574
|
since: "보관",
|
|
577
575
|
restore: "되살리기",
|
|
@@ -588,6 +586,7 @@ export const ko: Messages = {
|
|
|
588
586
|
answerIt: "답변하기",
|
|
589
587
|
reports: "보고",
|
|
590
588
|
handedIn: "제출물",
|
|
589
|
+
picture: (name) => `그림 ${name}`,
|
|
591
590
|
label: (agent, name) => `${name}을(를) 위한 ${agent}`,
|
|
592
591
|
},
|
|
593
592
|
|
|
@@ -698,6 +697,11 @@ export const ko: Messages = {
|
|
|
698
697
|
body: "본문",
|
|
699
698
|
bodyPlaceholder:
|
|
700
699
|
"Markdown. guides/onboarding 같은 다른 페이지의 경로로 링크하면 그 페이지를 참조합니다.",
|
|
700
|
+
pictures: "그림",
|
|
701
|
+
addPictures: "그림 추가",
|
|
702
|
+
pictureHint:
|
|
703
|
+
"여기에 붙여 넣거나 고른 그림은 페이지를 쓸 때 첨부됩니다. 으로 본문에 넣을 수 있습니다.",
|
|
704
|
+
removeFile: (name) => `${name} 빼기`,
|
|
701
705
|
write: "페이지 쓰기",
|
|
702
706
|
writeVersion: "새 버전 쓰기",
|
|
703
707
|
},
|