@skillcdn/console 0.1.6 → 0.1.8
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 +50 -2
- package/SKILLCDN.md +35 -0
- package/dist/cli/cli.d.ts +3 -0
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +61 -0
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/help.d.ts.map +1 -1
- package/dist/cli/help.js +5 -0
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/main.js +2 -0
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/serve.d.ts +37 -0
- package/dist/cli/serve.d.ts.map +1 -0
- package/dist/cli/serve.js +218 -0
- package/dist/cli/serve.js.map +1 -0
- package/dist/console.d.ts +6 -0
- package/dist/console.d.ts.map +1 -1
- package/dist/console.js +78 -13
- package/dist/console.js.map +1 -1
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +5 -1
- package/dist/data.js.map +1 -1
- package/dist/i18n/en.d.ts +4 -0
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +4 -0
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/ko.d.ts.map +1 -1
- package/dist/i18n/ko.js +4 -0
- package/dist/i18n/ko.js.map +1 -1
- package/dist/schemas.d.ts +1 -0
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +5 -0
- package/dist/schemas.js.map +1 -1
- package/dist/web.d.ts +16 -0
- package/dist/web.d.ts.map +1 -0
- package/dist/web.js +149 -0
- package/dist/web.js.map +1 -0
- package/docs/specs/cli.md +92 -0
- package/docs/specs/rest.md +221 -0
- package/package.json +14 -3
- package/skills/building-a-console/SKILL.md +102 -0
- package/skills/building-a-console/references/the-default-console.md +140 -0
- package/skills/deploying-a-console/SKILL.md +130 -0
- package/skills/deploying-a-console/assets/deployer-policy.json +118 -0
- package/skills/deploying-a-console/assets/secrets.example.json +11 -0
- package/skills/deploying-a-console/references/access.md +78 -0
- package/skills/deploying-a-console/references/resources.md +516 -0
- package/skills/deploying-a-console/references/sizing.md +90 -0
- package/skills/deploying-a-console/scripts/import-aws-key.mjs +35 -0
- package/skills/deploying-a-console/scripts/store-secrets.mjs +114 -0
- package/skills/working-the-board/SKILL.md +86 -0
- package/src/api.ts +235 -0
- package/src/cli/cli.ts +1114 -0
- package/src/cli/credentials.ts +68 -0
- package/src/cli/directory.ts +59 -0
- package/src/cli/format.ts +284 -0
- package/src/cli/help.ts +326 -0
- package/src/cli/main.ts +39 -0
- package/src/cli/serve.ts +276 -0
- package/src/cli/terminal.ts +28 -0
- package/src/client.ts +533 -0
- package/src/components/board.tsx +112 -0
- package/src/components/connect.tsx +126 -0
- package/src/components/decision-form.tsx +136 -0
- package/src/components/decision-list.tsx +278 -0
- package/src/components/documents.tsx +569 -0
- package/src/components/event-feed.tsx +129 -0
- package/src/components/markdown.tsx +72 -0
- package/src/components/people.tsx +59 -0
- package/src/components/projects.tsx +364 -0
- package/src/components/runs.tsx +175 -0
- package/src/components/shell.tsx +284 -0
- package/src/components/sign-in.tsx +61 -0
- package/src/components/skills.tsx +78 -0
- package/src/components/task-form.tsx +229 -0
- package/src/components/task-view.tsx +272 -0
- package/src/components/tokens.tsx +223 -0
- package/src/components/ui.tsx +219 -0
- package/src/connect-page.tsx +148 -0
- package/src/connect.ts +27 -0
- package/src/console.tsx +1166 -0
- package/src/data.ts +493 -0
- package/src/docs-page.tsx +334 -0
- package/src/documents.ts +76 -0
- package/src/i18n/en.ts +537 -0
- package/src/i18n/index.ts +76 -0
- package/src/i18n/ko.ts +511 -0
- package/src/index.ts +142 -0
- package/src/limits.ts +61 -0
- package/src/router.ts +295 -0
- package/src/routes.ts +106 -0
- package/src/schemas.ts +776 -0
- package/src/styles/console.css +2432 -0
- package/src/text.ts +33 -0
- package/src/use-action.ts +37 -0
- package/src/vocabulary.ts +109 -0
- package/src/web.ts +170 -0
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
import { type FormEvent, type ReactNode, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
foldersAbove,
|
|
4
|
+
isDocumentPath,
|
|
5
|
+
MAX_DOCUMENT_LENGTH,
|
|
6
|
+
MAX_DOCUMENT_PATH_LENGTH,
|
|
7
|
+
MAX_LINK_LABEL_LENGTH,
|
|
8
|
+
MAX_SEARCH_LENGTH,
|
|
9
|
+
MAX_TITLE_LENGTH,
|
|
10
|
+
nameOf,
|
|
11
|
+
type RestBacklink,
|
|
12
|
+
type RestDocument,
|
|
13
|
+
type RestDocumentFile,
|
|
14
|
+
type RestDocumentSummary,
|
|
15
|
+
type RestDocuments,
|
|
16
|
+
type RestVersion,
|
|
17
|
+
type RestVersionSummary,
|
|
18
|
+
} from "../api.js";
|
|
19
|
+
import { useWords } from "../i18n/index.js";
|
|
20
|
+
import { Markdown } from "./markdown.js";
|
|
21
|
+
import { Badge, Button, cx, formatBytes, PersonChip, Time } from "./ui.js";
|
|
22
|
+
|
|
23
|
+
// The documents of a project (ADR-0009): a folder with its pages and its folders, the way to
|
|
24
|
+
// find pages by their words, one page with its versions, what it links to, what refers to it
|
|
25
|
+
// and its files, and the forms to write and attach. Each takes its data as props and nothing
|
|
26
|
+
// from the network; what people and agents wrote is text or Markdown rendered to elements.
|
|
27
|
+
|
|
28
|
+
/** The folder a path is in, as crumbs from the root: the root, then each folder above, then the name. */
|
|
29
|
+
export function DocumentCrumbs(props: {
|
|
30
|
+
readonly path: string;
|
|
31
|
+
readonly folderHref: (folder: string) => string;
|
|
32
|
+
/** The last crumb is a link too, when the page is on something below it. */
|
|
33
|
+
readonly last?: boolean | undefined;
|
|
34
|
+
}) {
|
|
35
|
+
const words = useWords();
|
|
36
|
+
const folders = props.path === "" ? [] : foldersAbove(props.path);
|
|
37
|
+
const name = props.path === "" ? undefined : nameOf(props.path);
|
|
38
|
+
return (
|
|
39
|
+
<nav className="sc-doc-crumbs" aria-label={words.docs.folders}>
|
|
40
|
+
<a href={props.folderHref("")}>{words.docs.title}</a>
|
|
41
|
+
{folders.map((folder) => (
|
|
42
|
+
<span key={folder}>
|
|
43
|
+
<span className="sc-crumb-separator" aria-hidden="true">
|
|
44
|
+
/
|
|
45
|
+
</span>
|
|
46
|
+
<a href={props.folderHref(folder)}>{nameOf(folder)}</a>
|
|
47
|
+
</span>
|
|
48
|
+
))}
|
|
49
|
+
{name !== undefined && (
|
|
50
|
+
<span>
|
|
51
|
+
<span className="sc-crumb-separator" aria-hidden="true">
|
|
52
|
+
/
|
|
53
|
+
</span>
|
|
54
|
+
{props.last === true ? <a href={props.folderHref(props.path)}>{name}</a> : name}
|
|
55
|
+
</span>
|
|
56
|
+
)}
|
|
57
|
+
</nav>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Who wrote a version, and through which agent, as a chip and a word. */
|
|
62
|
+
function Writer(props: {
|
|
63
|
+
readonly by: RestDocumentSummary["updatedBy"];
|
|
64
|
+
readonly agent: string | null;
|
|
65
|
+
}) {
|
|
66
|
+
const words = useWords();
|
|
67
|
+
return (
|
|
68
|
+
<>
|
|
69
|
+
<PersonChip person={props.by} />
|
|
70
|
+
{props.agent !== null && (
|
|
71
|
+
<span className="sc-feed-agent" title={words.docs.asAgentTitle}>
|
|
72
|
+
{words.feed.as(props.agent)}
|
|
73
|
+
</span>
|
|
74
|
+
)}
|
|
75
|
+
</>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface DocumentListProps {
|
|
80
|
+
/** The pages, in the order the server gave them: by title in a folder, the best first in a search. */
|
|
81
|
+
readonly items: readonly RestDocumentSummary[];
|
|
82
|
+
readonly docHref: (path: string) => string;
|
|
83
|
+
readonly empty?: ReactNode;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The pages of a folder, or found by a search: each with its title, its path, and who wrote it last. */
|
|
87
|
+
export function DocumentList(props: DocumentListProps) {
|
|
88
|
+
const words = useWords().docs;
|
|
89
|
+
if (props.items.length === 0) {
|
|
90
|
+
return <>{props.empty ?? null}</>;
|
|
91
|
+
}
|
|
92
|
+
return (
|
|
93
|
+
<ul className="sc-docs" aria-label={words.pages}>
|
|
94
|
+
{props.items.map((document) => (
|
|
95
|
+
<li key={document.id} className="sc-doc-row">
|
|
96
|
+
<a className="sc-doc-title" href={props.docHref(document.path)}>
|
|
97
|
+
{document.title}
|
|
98
|
+
</a>
|
|
99
|
+
{document.archivedAt !== null && <Badge tone="neutral">{words.archived}</Badge>}
|
|
100
|
+
<span className="sc-doc-meta">
|
|
101
|
+
<code className="sc-doc-path">{document.path}</code> · {words.version(document.version)}{" "}
|
|
102
|
+
· <Writer by={document.updatedBy} agent={document.agent} />{" "}
|
|
103
|
+
<Time iso={document.updatedAt} />
|
|
104
|
+
</span>
|
|
105
|
+
</li>
|
|
106
|
+
))}
|
|
107
|
+
</ul>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface FolderViewProps {
|
|
112
|
+
/** The folder as the server listed it. */
|
|
113
|
+
readonly listing: RestDocuments;
|
|
114
|
+
readonly docHref: (path: string) => string;
|
|
115
|
+
readonly folderHref: (folder: string) => string;
|
|
116
|
+
readonly empty?: ReactNode;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** A folder: its folders, then its pages. */
|
|
120
|
+
export function FolderView(props: FolderViewProps) {
|
|
121
|
+
const words = useWords().docs;
|
|
122
|
+
const { listing } = props;
|
|
123
|
+
return (
|
|
124
|
+
<div className="sc-folder">
|
|
125
|
+
{listing.folders.length > 0 && (
|
|
126
|
+
<ul className="sc-folders" aria-label={words.folders}>
|
|
127
|
+
{listing.folders.map((folder) => (
|
|
128
|
+
<li key={folder}>
|
|
129
|
+
<a className="sc-folder-link" href={props.folderHref(folder)}>
|
|
130
|
+
{nameOf(folder)}/
|
|
131
|
+
</a>
|
|
132
|
+
</li>
|
|
133
|
+
))}
|
|
134
|
+
</ul>
|
|
135
|
+
)}
|
|
136
|
+
<DocumentList
|
|
137
|
+
items={listing.items}
|
|
138
|
+
docHref={props.docHref}
|
|
139
|
+
empty={listing.folders.length === 0 ? props.empty : undefined}
|
|
140
|
+
/>
|
|
141
|
+
</div>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface DocumentSearchProps {
|
|
146
|
+
/** The words searched for now, if any. */
|
|
147
|
+
readonly query?: string | undefined;
|
|
148
|
+
readonly busy?: boolean | undefined;
|
|
149
|
+
readonly onSearch: (query: string) => void;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The way to find pages by their words. An empty search goes back to the folders. */
|
|
153
|
+
export function DocumentSearch(props: DocumentSearchProps) {
|
|
154
|
+
const words = useWords().docs;
|
|
155
|
+
const [query, setQuery] = useState(props.query ?? "");
|
|
156
|
+
return (
|
|
157
|
+
<form
|
|
158
|
+
className="sc-doc-search"
|
|
159
|
+
aria-label={words.searchLabel}
|
|
160
|
+
onSubmit={(event) => {
|
|
161
|
+
event.preventDefault();
|
|
162
|
+
props.onSearch(query.trim());
|
|
163
|
+
}}
|
|
164
|
+
>
|
|
165
|
+
<input
|
|
166
|
+
className="sc-input"
|
|
167
|
+
type="search"
|
|
168
|
+
placeholder={words.searchPlaceholder}
|
|
169
|
+
aria-label={words.searchLabel}
|
|
170
|
+
maxLength={MAX_SEARCH_LENGTH}
|
|
171
|
+
value={query}
|
|
172
|
+
onChange={(event) => setQuery(event.target.value)}
|
|
173
|
+
/>
|
|
174
|
+
<Button type="submit" size="sm" disabled={props.busy === true}>
|
|
175
|
+
{words.find}
|
|
176
|
+
</Button>
|
|
177
|
+
</form>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export interface DocumentFormProps {
|
|
182
|
+
/** The document to write a new version of; left out, a new page is written. */
|
|
183
|
+
readonly document?: RestDocument | undefined;
|
|
184
|
+
/** The folder a new page goes in, as the path's first part. */
|
|
185
|
+
readonly folder?: string | undefined;
|
|
186
|
+
readonly busy?: boolean | undefined;
|
|
187
|
+
readonly error?: string | undefined;
|
|
188
|
+
readonly onSubmit: (input: {
|
|
189
|
+
readonly path: string;
|
|
190
|
+
readonly title: string;
|
|
191
|
+
readonly body: string;
|
|
192
|
+
readonly baseVersion?: number | undefined;
|
|
193
|
+
}) => void;
|
|
194
|
+
readonly onCancel?: (() => void) | undefined;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Writing a page: its path (once), its title, and its body in Markdown. */
|
|
198
|
+
export function DocumentForm(props: DocumentFormProps) {
|
|
199
|
+
const words = useWords();
|
|
200
|
+
const form = words.docs.form;
|
|
201
|
+
const existing = props.document;
|
|
202
|
+
const [path, setPath] = useState(
|
|
203
|
+
existing?.path ?? (props.folder === undefined || props.folder === "" ? "" : `${props.folder}/`),
|
|
204
|
+
);
|
|
205
|
+
const [title, setTitle] = useState(existing?.title ?? "");
|
|
206
|
+
const [body, setBody] = useState(existing?.body ?? "");
|
|
207
|
+
const ready = title.trim().length > 0 && (existing !== undefined || isDocumentPath(path));
|
|
208
|
+
|
|
209
|
+
const submit = (event: FormEvent) => {
|
|
210
|
+
event.preventDefault();
|
|
211
|
+
if (!ready) {
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
props.onSubmit({
|
|
215
|
+
path: existing?.path ?? path,
|
|
216
|
+
title: title.trim(),
|
|
217
|
+
body,
|
|
218
|
+
...(existing === undefined ? {} : { baseVersion: existing.version }),
|
|
219
|
+
});
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
return (
|
|
223
|
+
<form className="sc-form" onSubmit={submit}>
|
|
224
|
+
<label className="sc-field">
|
|
225
|
+
<span className="sc-field-label">{form.path}</span>
|
|
226
|
+
<input
|
|
227
|
+
className="sc-input"
|
|
228
|
+
value={path}
|
|
229
|
+
maxLength={MAX_DOCUMENT_PATH_LENGTH}
|
|
230
|
+
required
|
|
231
|
+
disabled={existing !== undefined}
|
|
232
|
+
placeholder="guides/onboarding"
|
|
233
|
+
onChange={(event) => setPath(event.target.value.trim().toLowerCase())}
|
|
234
|
+
/>
|
|
235
|
+
{existing === undefined && <span className="sc-muted">{form.pathHint}</span>}
|
|
236
|
+
</label>
|
|
237
|
+
<label className="sc-field">
|
|
238
|
+
<span className="sc-field-label">{form.title}</span>
|
|
239
|
+
<input
|
|
240
|
+
className="sc-input"
|
|
241
|
+
value={title}
|
|
242
|
+
maxLength={MAX_TITLE_LENGTH}
|
|
243
|
+
required
|
|
244
|
+
onChange={(event) => setTitle(event.target.value)}
|
|
245
|
+
/>
|
|
246
|
+
</label>
|
|
247
|
+
<label className="sc-field">
|
|
248
|
+
<span className="sc-field-label">{form.body}</span>
|
|
249
|
+
<textarea
|
|
250
|
+
className="sc-input sc-textarea sc-doc-editor"
|
|
251
|
+
value={body}
|
|
252
|
+
maxLength={MAX_DOCUMENT_LENGTH}
|
|
253
|
+
rows={16}
|
|
254
|
+
placeholder={form.bodyPlaceholder}
|
|
255
|
+
onChange={(event) => setBody(event.target.value)}
|
|
256
|
+
/>
|
|
257
|
+
</label>
|
|
258
|
+
{props.error !== undefined && (
|
|
259
|
+
<p className="sc-form-error" role="alert">
|
|
260
|
+
{props.error}
|
|
261
|
+
</p>
|
|
262
|
+
)}
|
|
263
|
+
<div className="sc-form-actions">
|
|
264
|
+
<Button type="submit" variant="primary" disabled={props.busy === true || !ready}>
|
|
265
|
+
{existing === undefined ? form.write : form.writeVersion}
|
|
266
|
+
</Button>
|
|
267
|
+
{props.onCancel !== undefined && (
|
|
268
|
+
<Button variant="ghost" onClick={props.onCancel}>
|
|
269
|
+
{words.common.cancel}
|
|
270
|
+
</Button>
|
|
271
|
+
)}
|
|
272
|
+
</div>
|
|
273
|
+
</form>
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** What refers to a page, each by what people call it, linked where the page knows where. */
|
|
278
|
+
function BacklinkItem(props: {
|
|
279
|
+
readonly backlink: RestBacklink;
|
|
280
|
+
readonly docHref: (path: string) => string;
|
|
281
|
+
readonly taskHref?: ((backlink: RestBacklink) => string) | undefined;
|
|
282
|
+
readonly decisionHref?: ((decisionId: string) => string) | undefined;
|
|
283
|
+
}) {
|
|
284
|
+
const words = useWords().docs;
|
|
285
|
+
const { backlink } = props;
|
|
286
|
+
if (backlink.kind === "document") {
|
|
287
|
+
return (
|
|
288
|
+
<a href={props.docHref(backlink.path ?? "")}>
|
|
289
|
+
{backlink.title} <code className="sc-doc-path">{backlink.path}</code>
|
|
290
|
+
</a>
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
if (backlink.kind === "task") {
|
|
294
|
+
const said = (
|
|
295
|
+
<>
|
|
296
|
+
<span className="sc-card-number">#{backlink.number}</span> {backlink.title}
|
|
297
|
+
</>
|
|
298
|
+
);
|
|
299
|
+
return props.taskHref === undefined ? (
|
|
300
|
+
<span>{said}</span>
|
|
301
|
+
) : (
|
|
302
|
+
<a href={props.taskHref(backlink)}>{said}</a>
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
const said = (
|
|
306
|
+
<>
|
|
307
|
+
{words.decisionPrefix}
|
|
308
|
+
{backlink.title}
|
|
309
|
+
</>
|
|
310
|
+
);
|
|
311
|
+
return props.decisionHref === undefined ? (
|
|
312
|
+
<span>{said}</span>
|
|
313
|
+
) : (
|
|
314
|
+
<a href={props.decisionHref(backlink.id)}>{said}</a>
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export interface DocumentFilesProps {
|
|
319
|
+
readonly files: readonly RestDocumentFile[];
|
|
320
|
+
/** Where a file's bytes are read, by its id. */
|
|
321
|
+
readonly fileHref: (file: RestDocumentFile) => string;
|
|
322
|
+
/** Called with a file to attach; left out, nothing can be attached here. */
|
|
323
|
+
readonly onAttach?: ((file: File, label: string | undefined) => void) | undefined;
|
|
324
|
+
readonly busy?: boolean | undefined;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** The files attached to a page, and the way to attach one. */
|
|
328
|
+
export function DocumentFiles(props: DocumentFilesProps) {
|
|
329
|
+
const words = useWords().docs;
|
|
330
|
+
const [label, setLabel] = useState("");
|
|
331
|
+
const [chosen, setChosen] = useState<File | undefined>(undefined);
|
|
332
|
+
return (
|
|
333
|
+
<section className="sc-task-section" aria-label={words.files}>
|
|
334
|
+
<h2 className="sc-section-title">{words.files}</h2>
|
|
335
|
+
{props.files.length > 0 && (
|
|
336
|
+
<ul className="sc-link-list">
|
|
337
|
+
{props.files.map((attached) => (
|
|
338
|
+
<li key={attached.id}>
|
|
339
|
+
<a href={props.fileHref(attached)} rel="nofollow ugc">
|
|
340
|
+
{attached.label ?? attached.file.name}
|
|
341
|
+
</a>{" "}
|
|
342
|
+
<span className="sc-muted">
|
|
343
|
+
{attached.label === null ? "" : `${attached.file.name}, `}
|
|
344
|
+
{formatBytes(attached.file.size)}, {words.by}{" "}
|
|
345
|
+
</span>
|
|
346
|
+
<Writer by={attached.addedBy} agent={attached.agent} />{" "}
|
|
347
|
+
<Time iso={attached.createdAt} />
|
|
348
|
+
</li>
|
|
349
|
+
))}
|
|
350
|
+
</ul>
|
|
351
|
+
)}
|
|
352
|
+
{props.onAttach !== undefined && (
|
|
353
|
+
<form
|
|
354
|
+
className="sc-doc-attach"
|
|
355
|
+
onSubmit={(event) => {
|
|
356
|
+
event.preventDefault();
|
|
357
|
+
if (chosen !== undefined) {
|
|
358
|
+
props.onAttach?.(chosen, label.trim() === "" ? undefined : label.trim());
|
|
359
|
+
setChosen(undefined);
|
|
360
|
+
setLabel("");
|
|
361
|
+
}
|
|
362
|
+
}}
|
|
363
|
+
>
|
|
364
|
+
<input
|
|
365
|
+
className="sc-input"
|
|
366
|
+
type="file"
|
|
367
|
+
aria-label={words.fileToAttach}
|
|
368
|
+
onChange={(event) => setChosen(event.target.files?.[0] ?? undefined)}
|
|
369
|
+
/>
|
|
370
|
+
<input
|
|
371
|
+
className="sc-input"
|
|
372
|
+
placeholder={words.labelPlaceholder}
|
|
373
|
+
maxLength={MAX_LINK_LABEL_LENGTH}
|
|
374
|
+
value={label}
|
|
375
|
+
onChange={(event) => setLabel(event.target.value)}
|
|
376
|
+
/>
|
|
377
|
+
<Button type="submit" size="sm" disabled={chosen === undefined || props.busy === true}>
|
|
378
|
+
{words.attach}
|
|
379
|
+
</Button>
|
|
380
|
+
</form>
|
|
381
|
+
)}
|
|
382
|
+
</section>
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export interface DocumentViewProps {
|
|
387
|
+
readonly document: RestDocument;
|
|
388
|
+
/** An earlier version shown in place of the latest, when a person asked for one. */
|
|
389
|
+
readonly version?: RestVersion | undefined;
|
|
390
|
+
/** The versions, newest first, when the page has read them. */
|
|
391
|
+
readonly versions?: readonly RestVersionSummary[] | undefined;
|
|
392
|
+
readonly docHref: (path: string) => string;
|
|
393
|
+
readonly folderHref: (folder: string) => string;
|
|
394
|
+
readonly fileHref: (file: RestDocumentFile) => string;
|
|
395
|
+
/** Where a task that refers to the page is read. */
|
|
396
|
+
readonly taskHref?: ((backlink: RestBacklink) => string) | undefined;
|
|
397
|
+
readonly decisionHref?: ((decisionId: string) => string) | undefined;
|
|
398
|
+
readonly busy?: boolean | undefined;
|
|
399
|
+
readonly error?: string | undefined;
|
|
400
|
+
/** Where the page is edited; left out where the person may not, or the page is archived. */
|
|
401
|
+
readonly editHref?: string | undefined;
|
|
402
|
+
/** Where a version of the page is read, by its number, or the latest again with nothing. */
|
|
403
|
+
readonly versionHref?: ((number: number | undefined) => string) | undefined;
|
|
404
|
+
/** The ways to change the page; each left out where the person may not. */
|
|
405
|
+
readonly onArchive?: (() => void) | undefined;
|
|
406
|
+
readonly onRestore?: (() => void) | undefined;
|
|
407
|
+
readonly onAttach?: ((file: File, label: string | undefined) => void) | undefined;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** One page: its latest version or an earlier one, its links both ways, its files, its versions. */
|
|
411
|
+
export function DocumentView(props: DocumentViewProps) {
|
|
412
|
+
const words = useWords();
|
|
413
|
+
const docs = words.docs;
|
|
414
|
+
const { document, version } = props;
|
|
415
|
+
const archived = document.archivedAt !== null;
|
|
416
|
+
const shown = version ?? {
|
|
417
|
+
number: document.version,
|
|
418
|
+
title: document.title,
|
|
419
|
+
body: document.body,
|
|
420
|
+
author: document.updatedBy,
|
|
421
|
+
agent: document.agent,
|
|
422
|
+
createdAt: document.updatedAt,
|
|
423
|
+
};
|
|
424
|
+
return (
|
|
425
|
+
<article className={cx("sc-doc", archived && "sc-doc-archived")}>
|
|
426
|
+
<DocumentCrumbs path={document.path} folderHref={props.folderHref} />
|
|
427
|
+
<header className="sc-task-header">
|
|
428
|
+
<h1 className="sc-task-title">{shown.title}</h1>
|
|
429
|
+
<div className="sc-task-badges">
|
|
430
|
+
{archived && <Badge tone="neutral">{docs.archived}</Badge>}
|
|
431
|
+
{version !== undefined && version.number !== document.version && (
|
|
432
|
+
<Badge tone="warning">{docs.versionOf(version.number, document.version)}</Badge>
|
|
433
|
+
)}
|
|
434
|
+
</div>
|
|
435
|
+
</header>
|
|
436
|
+
<div className="sc-task-body">
|
|
437
|
+
<div className="sc-task-main">
|
|
438
|
+
{archived && <p className="sc-doc-notice">{docs.archivedNotice}</p>}
|
|
439
|
+
{props.error !== undefined && (
|
|
440
|
+
<p className="sc-form-error" role="alert">
|
|
441
|
+
{props.error}
|
|
442
|
+
</p>
|
|
443
|
+
)}
|
|
444
|
+
{shown.body.trim().length > 0 ? (
|
|
445
|
+
<Markdown source={shown.body} docHref={props.docHref} />
|
|
446
|
+
) : (
|
|
447
|
+
<p className="sc-muted">{docs.nothingOnPage}</p>
|
|
448
|
+
)}
|
|
449
|
+
</div>
|
|
450
|
+
<aside className="sc-task-aside" aria-label={docs.facts}>
|
|
451
|
+
<dl className="sc-facts">
|
|
452
|
+
<div>
|
|
453
|
+
<dt>{docs.form.path}</dt>
|
|
454
|
+
<dd>
|
|
455
|
+
<code className="sc-doc-path">{document.path}</code>
|
|
456
|
+
</dd>
|
|
457
|
+
</div>
|
|
458
|
+
<div>
|
|
459
|
+
<dt>{docs.versions}</dt>
|
|
460
|
+
<dd>
|
|
461
|
+
{docs.version(shown.number)}
|
|
462
|
+
{version !== undefined && props.versionHref !== undefined && (
|
|
463
|
+
<>
|
|
464
|
+
{" "}
|
|
465
|
+
· <a href={props.versionHref(undefined)}>{docs.showLatest}</a>
|
|
466
|
+
</>
|
|
467
|
+
)}
|
|
468
|
+
</dd>
|
|
469
|
+
</div>
|
|
470
|
+
<div>
|
|
471
|
+
<dt>{docs.writtenBy}</dt>
|
|
472
|
+
<dd>
|
|
473
|
+
<Writer by={shown.author} agent={shown.agent} /> <Time iso={shown.createdAt} />
|
|
474
|
+
</dd>
|
|
475
|
+
</div>
|
|
476
|
+
</dl>
|
|
477
|
+
{(props.editHref !== undefined ||
|
|
478
|
+
props.onArchive !== undefined ||
|
|
479
|
+
props.onRestore !== undefined) && (
|
|
480
|
+
<div className="sc-task-actions">
|
|
481
|
+
{!archived && props.editHref !== undefined && (
|
|
482
|
+
<a className="sc-button sc-button-secondary" href={props.editHref}>
|
|
483
|
+
{words.common.edit}
|
|
484
|
+
</a>
|
|
485
|
+
)}
|
|
486
|
+
{!archived && props.onArchive !== undefined && (
|
|
487
|
+
<Button variant="ghost" onClick={props.onArchive} disabled={props.busy === true}>
|
|
488
|
+
{docs.archive}
|
|
489
|
+
</Button>
|
|
490
|
+
)}
|
|
491
|
+
{archived && props.onRestore !== undefined && (
|
|
492
|
+
<Button onClick={props.onRestore} disabled={props.busy === true}>
|
|
493
|
+
{docs.restore}
|
|
494
|
+
</Button>
|
|
495
|
+
)}
|
|
496
|
+
</div>
|
|
497
|
+
)}
|
|
498
|
+
{document.links.length > 0 && (
|
|
499
|
+
<section className="sc-task-section" aria-label={docs.refersTo}>
|
|
500
|
+
<h2 className="sc-section-title">{docs.refersTo}</h2>
|
|
501
|
+
<ul className="sc-link-list">
|
|
502
|
+
{document.links.map((link) => (
|
|
503
|
+
<li key={link.path}>
|
|
504
|
+
<a href={props.docHref(link.path)}>{link.title ?? link.path}</a>{" "}
|
|
505
|
+
{link.title === null ? (
|
|
506
|
+
<span className="sc-muted">{docs.noPageThereYet}</span>
|
|
507
|
+
) : (
|
|
508
|
+
<code className="sc-doc-path">{link.path}</code>
|
|
509
|
+
)}
|
|
510
|
+
</li>
|
|
511
|
+
))}
|
|
512
|
+
</ul>
|
|
513
|
+
</section>
|
|
514
|
+
)}
|
|
515
|
+
{document.backlinks.length > 0 && (
|
|
516
|
+
<section className="sc-task-section" aria-label={docs.referredToBy}>
|
|
517
|
+
<h2 className="sc-section-title">{docs.referredToBy}</h2>
|
|
518
|
+
<ul className="sc-link-list">
|
|
519
|
+
{document.backlinks.map((backlink) => (
|
|
520
|
+
<li key={`${backlink.kind}-${backlink.id}`}>
|
|
521
|
+
<BacklinkItem
|
|
522
|
+
backlink={backlink}
|
|
523
|
+
docHref={props.docHref}
|
|
524
|
+
taskHref={props.taskHref}
|
|
525
|
+
decisionHref={props.decisionHref}
|
|
526
|
+
/>
|
|
527
|
+
</li>
|
|
528
|
+
))}
|
|
529
|
+
</ul>
|
|
530
|
+
</section>
|
|
531
|
+
)}
|
|
532
|
+
{(document.files.length > 0 || (!archived && props.onAttach !== undefined)) && (
|
|
533
|
+
<DocumentFiles
|
|
534
|
+
files={document.files}
|
|
535
|
+
fileHref={props.fileHref}
|
|
536
|
+
onAttach={archived ? undefined : props.onAttach}
|
|
537
|
+
busy={props.busy}
|
|
538
|
+
/>
|
|
539
|
+
)}
|
|
540
|
+
{props.versions !== undefined && props.versions.length > 0 && (
|
|
541
|
+
<section className="sc-task-section" aria-label={docs.versions}>
|
|
542
|
+
<h2 className="sc-section-title">{docs.versions}</h2>
|
|
543
|
+
<ol className="sc-versions">
|
|
544
|
+
{props.versions.map((entry) => (
|
|
545
|
+
<li key={entry.number} className="sc-version">
|
|
546
|
+
{props.versionHref === undefined || entry.number === shown.number ? (
|
|
547
|
+
<span className="sc-version-number">{docs.version(entry.number)}</span>
|
|
548
|
+
) : (
|
|
549
|
+
<a
|
|
550
|
+
className="sc-version-number"
|
|
551
|
+
href={props.versionHref(
|
|
552
|
+
entry.number === document.version ? undefined : entry.number,
|
|
553
|
+
)}
|
|
554
|
+
>
|
|
555
|
+
{docs.version(entry.number)}
|
|
556
|
+
</a>
|
|
557
|
+
)}{" "}
|
|
558
|
+
{entry.title} · <Writer by={entry.author} agent={entry.agent} />{" "}
|
|
559
|
+
<Time iso={entry.createdAt} />
|
|
560
|
+
</li>
|
|
561
|
+
))}
|
|
562
|
+
</ol>
|
|
563
|
+
</section>
|
|
564
|
+
)}
|
|
565
|
+
</aside>
|
|
566
|
+
</div>
|
|
567
|
+
</article>
|
|
568
|
+
);
|
|
569
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { RestEvent } from "../api.js";
|
|
3
|
+
import { en, type Messages } from "../i18n/en.js";
|
|
4
|
+
import { useWords } from "../i18n/index.js";
|
|
5
|
+
import { PersonChip, Time } from "./ui.js";
|
|
6
|
+
|
|
7
|
+
// The feed: one line per event, newest first, as a sentence a person reads at a glance: who,
|
|
8
|
+
// as which agent when through one, and what. It takes its data as props and nothing from the
|
|
9
|
+
// network; the sentence is the language's (ADR-0012).
|
|
10
|
+
|
|
11
|
+
/** What an event says, without its actor: the words after the name, in the words given. */
|
|
12
|
+
export function describeEvent(event: RestEvent, words: Messages = en): string {
|
|
13
|
+
const { data } = event;
|
|
14
|
+
const { feed, vocabulary } = words;
|
|
15
|
+
const task =
|
|
16
|
+
data.number === undefined
|
|
17
|
+
? feed.aTask
|
|
18
|
+
: `#${data.number}${data.title === undefined ? "" : ` ${data.title}`}`;
|
|
19
|
+
const project = data.name ?? data.key ?? feed.aProject;
|
|
20
|
+
const changed = (data.fields ?? []).map((field) => feed.field[field] ?? field);
|
|
21
|
+
const fields = changed.length === 0 ? feed.something : changed.join(", ");
|
|
22
|
+
const page = feed.thePage(data.title ?? data.path ?? "");
|
|
23
|
+
const role = (value: string | undefined): string =>
|
|
24
|
+
value === "admin"
|
|
25
|
+
? feed.role.admin
|
|
26
|
+
: value === "owner"
|
|
27
|
+
? feed.role.owner
|
|
28
|
+
: value === "member"
|
|
29
|
+
? feed.role.member
|
|
30
|
+
: feed.role.other;
|
|
31
|
+
const login = data.login ?? feed.someone;
|
|
32
|
+
const question = data.question ?? feed.aQuestion;
|
|
33
|
+
switch (event.kind) {
|
|
34
|
+
case "person.joined":
|
|
35
|
+
return feed.event.personJoined();
|
|
36
|
+
case "person.role_changed":
|
|
37
|
+
return feed.event.roleChanged(login, role(data.role));
|
|
38
|
+
case "project.created":
|
|
39
|
+
return feed.event.projectCreated(project);
|
|
40
|
+
case "project.updated":
|
|
41
|
+
return feed.event.projectUpdated(fields, project);
|
|
42
|
+
case "project.member_added":
|
|
43
|
+
return feed.event.memberAdded(login, project, role(data.role));
|
|
44
|
+
case "project.member_changed":
|
|
45
|
+
return feed.event.memberChanged(login, role(data.role), project);
|
|
46
|
+
case "project.member_removed":
|
|
47
|
+
return feed.event.memberRemoved(login, project);
|
|
48
|
+
case "task.created":
|
|
49
|
+
return feed.event.taskCreated(task);
|
|
50
|
+
case "task.moved":
|
|
51
|
+
return feed.event.taskMoved(
|
|
52
|
+
task,
|
|
53
|
+
vocabulary.state[data.from ?? "idea"],
|
|
54
|
+
vocabulary.state[data.to ?? "idea"],
|
|
55
|
+
);
|
|
56
|
+
case "task.updated":
|
|
57
|
+
return feed.event.taskUpdated(fields, task);
|
|
58
|
+
case "decision.raised":
|
|
59
|
+
return feed.event.decisionRaised(question);
|
|
60
|
+
case "decision.answered":
|
|
61
|
+
return feed.event.decisionAnswered(question, data.option ?? "");
|
|
62
|
+
case "decision.updated":
|
|
63
|
+
return feed.event.decisionUpdated(fields, data.question ?? "");
|
|
64
|
+
case "run.started":
|
|
65
|
+
return feed.event.runStarted(task);
|
|
66
|
+
case "run.reported":
|
|
67
|
+
return feed.event.runReported(task, data.excerpt);
|
|
68
|
+
case "run.handed_in":
|
|
69
|
+
return feed.event.runHandedIn(data.label ?? feed.something, task);
|
|
70
|
+
case "run.ended":
|
|
71
|
+
return data.status === "finished"
|
|
72
|
+
? feed.event.runFinished(task)
|
|
73
|
+
: data.status === "failed"
|
|
74
|
+
? feed.event.runFailed(task)
|
|
75
|
+
: feed.event.runAbandoned(task);
|
|
76
|
+
case "document.written":
|
|
77
|
+
return feed.event.documentWritten(page, data.version);
|
|
78
|
+
case "document.archived":
|
|
79
|
+
return feed.event.documentArchived(page);
|
|
80
|
+
case "document.restored":
|
|
81
|
+
return feed.event.documentRestored(page);
|
|
82
|
+
case "document.file_attached":
|
|
83
|
+
return feed.event.fileAttached(data.label ?? feed.aFile, page);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface EventFeedProps {
|
|
88
|
+
/** Oldest first, as the API hands them over; the feed shows them newest first. */
|
|
89
|
+
readonly events: readonly RestEvent[];
|
|
90
|
+
/** Where the line of an event leads, when it is about a task or a decision. */
|
|
91
|
+
readonly href?: ((event: RestEvent) => string | undefined) | undefined;
|
|
92
|
+
readonly empty?: ReactNode;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function EventFeed(props: EventFeedProps) {
|
|
96
|
+
const words = useWords();
|
|
97
|
+
if (props.events.length === 0) {
|
|
98
|
+
return <div className="sc-feed">{props.empty ?? null}</div>;
|
|
99
|
+
}
|
|
100
|
+
const newestFirst = [...props.events].reverse();
|
|
101
|
+
return (
|
|
102
|
+
<ol className="sc-feed" aria-label={words.feed.list}>
|
|
103
|
+
{newestFirst.map((event) => {
|
|
104
|
+
const href = props.href?.(event);
|
|
105
|
+
const said = describeEvent(event, words);
|
|
106
|
+
return (
|
|
107
|
+
<li key={event.id} className="sc-feed-item">
|
|
108
|
+
<span className="sc-feed-actor">
|
|
109
|
+
{event.actor === null ? (
|
|
110
|
+
<span className="sc-person-login">{words.feed.console}</span>
|
|
111
|
+
) : (
|
|
112
|
+
<PersonChip person={event.actor} />
|
|
113
|
+
)}
|
|
114
|
+
{event.agent !== null && (
|
|
115
|
+
<span className="sc-feed-agent" title={words.feed.asTitle}>
|
|
116
|
+
{words.feed.as(event.agent)}
|
|
117
|
+
</span>
|
|
118
|
+
)}
|
|
119
|
+
</span>
|
|
120
|
+
<span className="sc-feed-words">
|
|
121
|
+
{href === undefined ? said : <a href={href}>{said}</a>}
|
|
122
|
+
</span>
|
|
123
|
+
<Time iso={event.createdAt} />
|
|
124
|
+
</li>
|
|
125
|
+
);
|
|
126
|
+
})}
|
|
127
|
+
</ol>
|
|
128
|
+
);
|
|
129
|
+
}
|