@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.
Files changed (69) hide show
  1. package/README.md +1 -1
  2. package/dist/cli/cli.d.ts.map +1 -1
  3. package/dist/cli/cli.js +6 -0
  4. package/dist/cli/cli.js.map +1 -1
  5. package/dist/cli/help.d.ts.map +1 -1
  6. package/dist/cli/help.js +6 -8
  7. package/dist/cli/help.js.map +1 -1
  8. package/dist/components/board.d.ts +19 -9
  9. package/dist/components/board.d.ts.map +1 -1
  10. package/dist/components/board.js +210 -28
  11. package/dist/components/board.js.map +1 -1
  12. package/dist/components/comments.d.ts +0 -2
  13. package/dist/components/comments.d.ts.map +1 -1
  14. package/dist/components/comments.js +4 -39
  15. package/dist/components/comments.js.map +1 -1
  16. package/dist/components/documents.d.ts +2 -0
  17. package/dist/components/documents.d.ts.map +1 -1
  18. package/dist/components/documents.js +51 -5
  19. package/dist/components/documents.js.map +1 -1
  20. package/dist/components/files.d.ts +11 -0
  21. package/dist/components/files.d.ts.map +1 -0
  22. package/dist/components/files.js +44 -0
  23. package/dist/components/files.js.map +1 -0
  24. package/dist/components/runs.d.ts.map +1 -1
  25. package/dist/components/runs.js +34 -7
  26. package/dist/components/runs.js.map +1 -1
  27. package/dist/components/task-view.d.ts.map +1 -1
  28. package/dist/components/task-view.js +2 -4
  29. package/dist/components/task-view.js.map +1 -1
  30. package/dist/console.css +144 -80
  31. package/dist/console.d.ts.map +1 -1
  32. package/dist/console.js +3 -3
  33. package/dist/console.js.map +1 -1
  34. package/dist/docs-page.d.ts.map +1 -1
  35. package/dist/docs-page.js +14 -1
  36. package/dist/docs-page.js.map +1 -1
  37. package/dist/i18n/en.d.ts +6 -3
  38. package/dist/i18n/en.d.ts.map +1 -1
  39. package/dist/i18n/en.js +7 -4
  40. package/dist/i18n/en.js.map +1 -1
  41. package/dist/i18n/ko.d.ts.map +1 -1
  42. package/dist/i18n/ko.js +7 -4
  43. package/dist/i18n/ko.js.map +1 -1
  44. package/dist/index.d.ts +2 -1
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +2 -1
  47. package/dist/index.js.map +1 -1
  48. package/dist/rules.d.ts.map +1 -1
  49. package/dist/rules.js +2 -1
  50. package/dist/rules.js.map +1 -1
  51. package/docs/specs/cli.md +4 -4
  52. package/docs/specs/rest.md +3 -4
  53. package/package.json +1 -1
  54. package/skills/working-the-board/SKILL.md +4 -4
  55. package/src/cli/cli.ts +6 -0
  56. package/src/cli/help.ts +6 -8
  57. package/src/components/board.tsx +357 -77
  58. package/src/components/comments.tsx +10 -39
  59. package/src/components/documents.tsx +87 -4
  60. package/src/components/files.ts +49 -0
  61. package/src/components/runs.tsx +73 -15
  62. package/src/components/task-view.tsx +10 -12
  63. package/src/console.tsx +20 -8
  64. package/src/docs-page.tsx +18 -1
  65. package/src/i18n/en.ts +8 -4
  66. package/src/i18n/ko.ts +8 -4
  67. package/src/index.ts +1 -1
  68. package/src/rules.ts +2 -1
  69. 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 `![words](name)` or `![words](./name)`. */
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 = source.startsWith("./") ? source.slice(2) : source;
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(file.name, held);
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) => `![](${file.name})`).join("\n");
193
161
  setBody((current) =>
194
- current.trim().length === 0 ? lines : `${current.trimEnd()}\n\n${lines}`,
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, and the forms to write and attach. Each takes its data as props and nothing
27
- // from the network; what people and agents wrote is text or Markdown rendered to elements.
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 `![words](name)` or `![words](./name)`. */
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 `![](name)` a line. */
29
+ export function placing(body: string, names: readonly string[]): string {
30
+ const lines = names.map((name) => `![](${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
+ }
@@ -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. Takes its data as props and nothing from the network.
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 source={report.body} docHref={props.docHref} />
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
- <ArtifactLink artifact={artifact} fileHref={props.fileHref} />
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 source={run.summary} docHref={props.docHref} />
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
- /** A link as it was handed in; a file by its name, with its size, read from the console. */
124
- function ArtifactLink(props: {
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 fileHref: ((artifact: RestArtifact) => string) | undefined;
161
+ readonly href: string | undefined;
162
+ readonly onOpenPicture: (picture: Picture, opener: HTMLElement) => void;
127
163
  }) {
128
- const { artifact } = props;
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 href = props.fileHref?.(artifact);
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 ?? artifact.file.name}</span>
201
+ <span>{artifact.label ?? file.name}</span>
141
202
  ) : (
142
203
  <a href={href} rel="nofollow ugc">
143
- {artifact.label ?? artifact.file.name}
204
+ {artifact.label ?? file.name}
144
205
  </a>
145
206
  )}{" "}
146
- <span className="sc-muted">
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 { OPEN_TASK_STATES, TASK_STATES } from "../vocabulary.js";
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
- task.archivedAt === null &&
359
- !OPEN_TASK_STATES.includes(task.state) && (
360
- <Button
361
- variant="secondary"
362
- disabled={props.busy === true}
363
- onClick={() => props.onArchive?.(task)}
364
- >
365
- {words.task.archive}
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
- <h1 className="sc-page-title">{project.name}</h1>
743
- <LinkButton href={newTaskHref(key)} variant="primary">
744
- {words.board.writeTask}
745
- </LinkButton>
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
- archiveHref={projectHref(key, "archive")}
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
- archiveHref={projectHref(key, "archive")}
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 done or dropped is put away from its page, with its goal, or by the console after a while.",
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; ![words](its name) 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
  },