@skillcdn/console 0.1.18 → 0.1.19
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/SKILLCDN.md +1 -1
- package/dist/api.d.ts +1 -1
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +1 -1
- package/dist/api.js.map +1 -1
- package/dist/cli/cli.d.ts +1 -1
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +353 -39
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/credentials.d.ts +102 -3
- package/dist/cli/credentials.d.ts.map +1 -1
- package/dist/cli/credentials.js +177 -8
- package/dist/cli/credentials.js.map +1 -1
- package/dist/cli/directory.d.ts +6 -0
- package/dist/cli/directory.d.ts.map +1 -1
- package/dist/cli/directory.js +37 -6
- package/dist/cli/directory.js.map +1 -1
- package/dist/cli/format.d.ts +10 -1
- package/dist/cli/format.d.ts.map +1 -1
- package/dist/cli/format.js +106 -2
- package/dist/cli/format.js.map +1 -1
- package/dist/cli/help.d.ts.map +1 -1
- package/dist/cli/help.js +42 -20
- package/dist/cli/help.js.map +1 -1
- package/dist/client.d.ts +21 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +14 -1
- package/dist/client.js.map +1 -1
- package/dist/components/board.d.ts +9 -1
- package/dist/components/board.d.ts.map +1 -1
- package/dist/components/board.js +82 -5
- package/dist/components/board.js.map +1 -1
- package/dist/components/comments.d.ts.map +1 -1
- package/dist/components/comments.js +12 -4
- package/dist/components/comments.js.map +1 -1
- package/dist/components/event-feed.d.ts.map +1 -1
- package/dist/components/event-feed.js +4 -0
- package/dist/components/event-feed.js.map +1 -1
- package/dist/components/goals.d.ts +17 -1
- package/dist/components/goals.d.ts.map +1 -1
- package/dist/components/goals.js +34 -9
- package/dist/components/goals.js.map +1 -1
- package/dist/components/markdown.d.ts +6 -0
- package/dist/components/markdown.d.ts.map +1 -1
- package/dist/components/markdown.js +7 -0
- package/dist/components/markdown.js.map +1 -1
- package/dist/components/pictures.d.ts +68 -0
- package/dist/components/pictures.d.ts.map +1 -0
- package/dist/components/pictures.js +213 -0
- package/dist/components/pictures.js.map +1 -0
- package/dist/components/projects.d.ts +12 -0
- package/dist/components/projects.d.ts.map +1 -1
- package/dist/components/projects.js +15 -0
- package/dist/components/projects.js.map +1 -1
- package/dist/components/task-view.d.ts +3 -0
- package/dist/components/task-view.d.ts.map +1 -1
- package/dist/components/task-view.js +6 -4
- package/dist/components/task-view.js.map +1 -1
- package/dist/console.css +282 -3
- package/dist/console.d.ts.map +1 -1
- package/dist/console.js +124 -10
- package/dist/console.js.map +1 -1
- package/dist/data.d.ts +16 -1
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +47 -2
- package/dist/data.js.map +1 -1
- package/dist/i18n/en.d.ts +52 -0
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +50 -2
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/ko.d.ts.map +1 -1
- package/dist/i18n/ko.js +50 -2
- package/dist/i18n/ko.js.map +1 -1
- package/dist/index.d.ts +5 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -4
- package/dist/index.js.map +1 -1
- package/dist/router.d.ts +25 -6
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +38 -7
- package/dist/router.js.map +1 -1
- package/dist/rules.js +5 -5
- package/dist/rules.js.map +1 -1
- package/dist/schemas.d.ts +158 -0
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +78 -0
- package/dist/schemas.js.map +1 -1
- package/dist/vocabulary.d.ts +2 -2
- package/dist/vocabulary.d.ts.map +1 -1
- package/dist/vocabulary.js +3 -0
- package/dist/vocabulary.js.map +1 -1
- package/docs/specs/cli.md +17 -9
- package/docs/specs/rest.md +22 -4
- package/package.json +1 -1
- package/skills/working-the-board/SKILL.md +10 -4
- package/src/api.ts +13 -0
- package/src/cli/cli.ts +441 -42
- package/src/cli/credentials.ts +276 -14
- package/src/cli/directory.ts +43 -6
- package/src/cli/format.ts +138 -2
- package/src/cli/help.ts +44 -20
- package/src/client.ts +60 -0
- package/src/components/board.tsx +154 -8
- package/src/components/comments.tsx +20 -10
- package/src/components/event-feed.tsx +4 -0
- package/src/components/goals.tsx +173 -106
- package/src/components/markdown.tsx +19 -0
- package/src/components/pictures.tsx +389 -0
- package/src/components/projects.tsx +68 -0
- package/src/components/task-view.tsx +43 -2
- package/src/console.tsx +259 -4
- package/src/data.ts +72 -1
- package/src/i18n/en.ts +53 -2
- package/src/i18n/ko.ts +53 -2
- package/src/index.ts +27 -0
- package/src/router.ts +57 -9
- package/src/rules.ts +5 -5
- package/src/schemas.ts +97 -0
- package/src/styles/console.css +282 -3
- package/src/vocabulary.ts +3 -0
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type KeyboardEvent,
|
|
3
|
+
type MouseEvent,
|
|
4
|
+
type PointerEvent,
|
|
5
|
+
type ReactNode,
|
|
6
|
+
useCallback,
|
|
7
|
+
useEffect,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
type WheelEvent,
|
|
11
|
+
} from "react";
|
|
12
|
+
import { createPortal } from "react-dom";
|
|
13
|
+
import { useWords } from "../i18n/index.js";
|
|
14
|
+
import { Button, cx, formatBytes } from "./ui.js";
|
|
15
|
+
|
|
16
|
+
// A picture put on the board is seen small where it is and large over the page: a thumbnail no
|
|
17
|
+
// taller than a few lines of text, and an overlay that keeps the page under it, zooms with the
|
|
18
|
+
// wheel, the buttons and two fingers, moves with a drag, and closes with Escape, its button or a
|
|
19
|
+
// click beside the picture, giving the focus back to the thumbnail that opened it. The console
|
|
20
|
+
// keeps a file as it was handed in and makes no smaller copy of it, so the thumbnail and the
|
|
21
|
+
// overlay show the same bytes, loaded once and lazily. Takes its data as props and nothing from
|
|
22
|
+
// the network; the overlay is rendered into the document's body, over everything.
|
|
23
|
+
|
|
24
|
+
/** A picture the pages can show: where it is read, what it is called, and how large the file is. */
|
|
25
|
+
export interface Picture {
|
|
26
|
+
readonly src: string;
|
|
27
|
+
readonly alt: string;
|
|
28
|
+
readonly name: string;
|
|
29
|
+
readonly size?: number | undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** How the picture is seen in the overlay: its scale, and where its centre is, in pixels from the stage's centre. */
|
|
33
|
+
export interface PictureView {
|
|
34
|
+
readonly scale: number;
|
|
35
|
+
readonly x: number;
|
|
36
|
+
readonly y: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** How far the picture zooms: from fitting the stage to eight times that. */
|
|
40
|
+
export const MIN_SCALE = 1;
|
|
41
|
+
export const MAX_SCALE = 8;
|
|
42
|
+
/** What one press of a zoom button does to the scale. */
|
|
43
|
+
export const ZOOM_STEP = 1.25;
|
|
44
|
+
/** How far a pointer travels before a press is a drag and no longer a click. */
|
|
45
|
+
const DRAG_SLACK = 4;
|
|
46
|
+
|
|
47
|
+
/** The picture as it first appears: fitted to the stage, centred. */
|
|
48
|
+
export const FITTED: PictureView = { scale: 1, x: 0, y: 0 };
|
|
49
|
+
|
|
50
|
+
export function clampScale(scale: number): number {
|
|
51
|
+
if (!Number.isFinite(scale)) {
|
|
52
|
+
return MIN_SCALE;
|
|
53
|
+
}
|
|
54
|
+
return Math.min(MAX_SCALE, Math.max(MIN_SCALE, scale));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The view after zooming by `factor` around a point of the stage, so that what was under the
|
|
59
|
+
* pointer stays under it. Zooming back to the fitted scale puts the picture back in the centre.
|
|
60
|
+
*/
|
|
61
|
+
export function zoomAround(
|
|
62
|
+
view: PictureView,
|
|
63
|
+
factor: number,
|
|
64
|
+
point: { readonly x: number; readonly y: number },
|
|
65
|
+
): PictureView {
|
|
66
|
+
const scale = clampScale(view.scale * factor);
|
|
67
|
+
if (scale === view.scale) {
|
|
68
|
+
return view;
|
|
69
|
+
}
|
|
70
|
+
if (scale === MIN_SCALE) {
|
|
71
|
+
return FITTED;
|
|
72
|
+
}
|
|
73
|
+
const ratio = scale / view.scale;
|
|
74
|
+
return {
|
|
75
|
+
scale,
|
|
76
|
+
x: point.x - (point.x - view.x) * ratio,
|
|
77
|
+
y: point.y - (point.y - view.y) * ratio,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** What a notch of the wheel does to the scale: in for a wheel rolled up, out for one rolled down, a step per hundred units. */
|
|
82
|
+
export function wheelFactor(deltaY: number): number {
|
|
83
|
+
if (!Number.isFinite(deltaY) || deltaY === 0) {
|
|
84
|
+
return 1;
|
|
85
|
+
}
|
|
86
|
+
return ZOOM_STEP ** (-deltaY / 100);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The view moved by a drag; a fitted picture stays where it is. */
|
|
90
|
+
export function panBy(view: PictureView, dx: number, dy: number): PictureView {
|
|
91
|
+
if (view.scale === MIN_SCALE) {
|
|
92
|
+
return view;
|
|
93
|
+
}
|
|
94
|
+
return { ...view, x: view.x + dx, y: view.y + dy };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface PictureThumbProps {
|
|
98
|
+
readonly picture: Picture;
|
|
99
|
+
/** Called with the picture and the thumbnail that was pressed, so that the focus comes back to it. */
|
|
100
|
+
readonly onOpen: (picture: Picture, opener: HTMLElement) => void;
|
|
101
|
+
readonly className?: string | undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A picture seen small where it is, which opens large over the page. */
|
|
105
|
+
export function PictureThumb(props: PictureThumbProps) {
|
|
106
|
+
const words = useWords().pictures;
|
|
107
|
+
const { picture } = props;
|
|
108
|
+
return (
|
|
109
|
+
<button
|
|
110
|
+
type="button"
|
|
111
|
+
className={cx("sc-picture-thumb", props.className)}
|
|
112
|
+
aria-label={words.open(picture.name)}
|
|
113
|
+
onClick={(event) => props.onOpen(picture, event.currentTarget)}
|
|
114
|
+
>
|
|
115
|
+
<img src={picture.src} alt={picture.alt} loading="lazy" decoding="async" draggable={false} />
|
|
116
|
+
</button>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface PictureOverlayProps {
|
|
121
|
+
readonly picture: Picture;
|
|
122
|
+
readonly onClose: () => void;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
interface Point {
|
|
126
|
+
readonly x: number;
|
|
127
|
+
readonly y: number;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const distance = (a: Point, b: Point): number => Math.hypot(a.x - b.x, a.y - b.y);
|
|
131
|
+
const midpoint = (a: Point, b: Point): Point => ({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 });
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The picture large, over the page: a dialog that holds the focus, with the ways to zoom, to move
|
|
135
|
+
* and to close. Rendered where it is given; `Lightbox` puts it over the document.
|
|
136
|
+
*/
|
|
137
|
+
export function PictureOverlay(props: PictureOverlayProps) {
|
|
138
|
+
const words = useWords().pictures;
|
|
139
|
+
const { picture, onClose } = props;
|
|
140
|
+
const [view, setView] = useState<PictureView>(FITTED);
|
|
141
|
+
const [dragging, setDragging] = useState(false);
|
|
142
|
+
const dialog = useRef<HTMLDivElement>(null);
|
|
143
|
+
const stage = useRef<HTMLDivElement>(null);
|
|
144
|
+
const closeButton = useRef<HTMLButtonElement>(null);
|
|
145
|
+
/** The pointers on the stage, by id, where each was last seen. */
|
|
146
|
+
const pointers = useRef(new Map<number, Point>());
|
|
147
|
+
/** Whether the press under way has travelled: then its release is no click. */
|
|
148
|
+
const moved = useRef(false);
|
|
149
|
+
|
|
150
|
+
// The dialog takes the focus when it opens, and the page under it stops scrolling.
|
|
151
|
+
useEffect(() => {
|
|
152
|
+
closeButton.current?.focus();
|
|
153
|
+
const { body } = document;
|
|
154
|
+
const before = body.style.overflow;
|
|
155
|
+
body.style.overflow = "hidden";
|
|
156
|
+
return () => {
|
|
157
|
+
body.style.overflow = before;
|
|
158
|
+
};
|
|
159
|
+
}, []);
|
|
160
|
+
|
|
161
|
+
/** A point of the stage, in pixels from its centre. */
|
|
162
|
+
const pointOf = useCallback((event: { clientX: number; clientY: number }): Point => {
|
|
163
|
+
const rect = stage.current?.getBoundingClientRect();
|
|
164
|
+
if (rect === undefined) {
|
|
165
|
+
return { x: 0, y: 0 };
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
x: event.clientX - (rect.left + rect.width / 2),
|
|
169
|
+
y: event.clientY - (rect.top + rect.height / 2),
|
|
170
|
+
};
|
|
171
|
+
}, []);
|
|
172
|
+
|
|
173
|
+
const zoom = (factor: number, point: Point = { x: 0, y: 0 }) =>
|
|
174
|
+
setView((current) => zoomAround(current, factor, point));
|
|
175
|
+
|
|
176
|
+
const onWheel = (event: WheelEvent<HTMLDivElement>) => {
|
|
177
|
+
zoom(wheelFactor(event.deltaY), pointOf(event));
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
|
181
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
182
|
+
pointers.current.set(event.pointerId, pointOf(event));
|
|
183
|
+
moved.current = false;
|
|
184
|
+
setDragging(true);
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
|
|
188
|
+
const before = pointers.current.get(event.pointerId);
|
|
189
|
+
if (before === undefined) {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const now = pointOf(event);
|
|
193
|
+
pointers.current.set(event.pointerId, now);
|
|
194
|
+
if (!moved.current && distance(before, now) > DRAG_SLACK) {
|
|
195
|
+
moved.current = true;
|
|
196
|
+
}
|
|
197
|
+
const others = [...pointers.current.entries()].filter(([id]) => id !== event.pointerId);
|
|
198
|
+
const other = others[0]?.[1];
|
|
199
|
+
if (other !== undefined) {
|
|
200
|
+
// Two fingers: the picture zooms by how much they moved apart, around the point between them,
|
|
201
|
+
// and moves with that point.
|
|
202
|
+
const factor =
|
|
203
|
+
distance(before, other) === 0 ? 1 : distance(now, other) / distance(before, other);
|
|
204
|
+
const from = midpoint(before, other);
|
|
205
|
+
const to = midpoint(now, other);
|
|
206
|
+
setView((current) => zoomAround(panBy(current, to.x - from.x, to.y - from.y), factor, to));
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
setView((current) => panBy(current, now.x - before.x, now.y - before.y));
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const onPointerUp = (event: PointerEvent<HTMLDivElement>) => {
|
|
213
|
+
pointers.current.delete(event.pointerId);
|
|
214
|
+
if (pointers.current.size === 0) {
|
|
215
|
+
setDragging(false);
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
// A click beside the picture closes the overlay; a drag that ends there does not.
|
|
220
|
+
const onBackdropClick = (event: MouseEvent<HTMLDivElement>) => {
|
|
221
|
+
const beside = event.target === event.currentTarget || event.target === stage.current;
|
|
222
|
+
if (beside && !moved.current) {
|
|
223
|
+
onClose();
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const onDoubleClick = (event: MouseEvent<HTMLDivElement>) => {
|
|
228
|
+
const point = pointOf(event);
|
|
229
|
+
setView((current) => (current.scale > MIN_SCALE ? FITTED : zoomAround(current, 2.5, point)));
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
// Escape closes; the keys zoom; Tab stays within the dialog.
|
|
233
|
+
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
|
234
|
+
if (event.key === "Escape") {
|
|
235
|
+
event.preventDefault();
|
|
236
|
+
onClose();
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (event.key === "+" || event.key === "=") {
|
|
240
|
+
zoom(ZOOM_STEP);
|
|
241
|
+
} else if (event.key === "-") {
|
|
242
|
+
zoom(1 / ZOOM_STEP);
|
|
243
|
+
} else if (event.key === "0") {
|
|
244
|
+
setView(FITTED);
|
|
245
|
+
} else if (event.key === "Tab" && dialog.current !== null) {
|
|
246
|
+
const focusable = [
|
|
247
|
+
...dialog.current.querySelectorAll<HTMLElement>("a[href], button:not(:disabled)"),
|
|
248
|
+
];
|
|
249
|
+
const first = focusable[0];
|
|
250
|
+
const last = focusable[focusable.length - 1];
|
|
251
|
+
if (first === undefined || last === undefined) {
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (event.shiftKey && document.activeElement === first) {
|
|
255
|
+
event.preventDefault();
|
|
256
|
+
last.focus();
|
|
257
|
+
} else if (!event.shiftKey && document.activeElement === last) {
|
|
258
|
+
event.preventDefault();
|
|
259
|
+
first.focus();
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const transform = `translate(${view.x}px, ${view.y}px) scale(${view.scale})`;
|
|
265
|
+
return (
|
|
266
|
+
<div
|
|
267
|
+
ref={dialog}
|
|
268
|
+
className="sc-overlay"
|
|
269
|
+
role="dialog"
|
|
270
|
+
aria-modal="true"
|
|
271
|
+
aria-label={words.label(picture.name)}
|
|
272
|
+
onKeyDown={onKeyDown}
|
|
273
|
+
onClick={onBackdropClick}
|
|
274
|
+
>
|
|
275
|
+
<div className="sc-overlay-bar">
|
|
276
|
+
<a
|
|
277
|
+
className="sc-overlay-name"
|
|
278
|
+
href={picture.src}
|
|
279
|
+
target="_blank"
|
|
280
|
+
rel="noopener noreferrer nofollow ugc"
|
|
281
|
+
title={words.file}
|
|
282
|
+
>
|
|
283
|
+
{picture.name}
|
|
284
|
+
{picture.size !== undefined && (
|
|
285
|
+
<span className="sc-muted">, {formatBytes(picture.size)}</span>
|
|
286
|
+
)}
|
|
287
|
+
</a>
|
|
288
|
+
<div className="sc-overlay-actions">
|
|
289
|
+
<Button
|
|
290
|
+
variant="ghost"
|
|
291
|
+
size="sm"
|
|
292
|
+
aria-label={words.zoomOut}
|
|
293
|
+
onClick={() => zoom(1 / ZOOM_STEP)}
|
|
294
|
+
>
|
|
295
|
+
−
|
|
296
|
+
</Button>
|
|
297
|
+
<Button
|
|
298
|
+
variant="ghost"
|
|
299
|
+
size="sm"
|
|
300
|
+
aria-label={words.zoomIn}
|
|
301
|
+
onClick={() => zoom(ZOOM_STEP)}
|
|
302
|
+
>
|
|
303
|
+
+
|
|
304
|
+
</Button>
|
|
305
|
+
<Button variant="ghost" size="sm" aria-label={words.fit} onClick={() => setView(FITTED)}>
|
|
306
|
+
{Math.round(view.scale * 100)}%
|
|
307
|
+
</Button>
|
|
308
|
+
<button
|
|
309
|
+
ref={closeButton}
|
|
310
|
+
type="button"
|
|
311
|
+
className="sc-button sc-button-ghost sc-button-sm"
|
|
312
|
+
aria-label={words.close}
|
|
313
|
+
onClick={onClose}
|
|
314
|
+
>
|
|
315
|
+
×
|
|
316
|
+
</button>
|
|
317
|
+
</div>
|
|
318
|
+
</div>
|
|
319
|
+
{/* biome-ignore lint/a11y/noStaticElementInteractions: the stage is where the picture is dragged and pinched; the keys are read on the dialog around it */}
|
|
320
|
+
<div
|
|
321
|
+
ref={stage}
|
|
322
|
+
className="sc-overlay-stage"
|
|
323
|
+
role="presentation"
|
|
324
|
+
data-dragging={dragging ? "true" : undefined}
|
|
325
|
+
onWheel={onWheel}
|
|
326
|
+
onPointerDown={onPointerDown}
|
|
327
|
+
onPointerMove={onPointerMove}
|
|
328
|
+
onPointerUp={onPointerUp}
|
|
329
|
+
onPointerCancel={onPointerUp}
|
|
330
|
+
onDoubleClick={onDoubleClick}
|
|
331
|
+
>
|
|
332
|
+
<img
|
|
333
|
+
className="sc-overlay-picture"
|
|
334
|
+
src={picture.src}
|
|
335
|
+
alt={picture.alt}
|
|
336
|
+
decoding="async"
|
|
337
|
+
draggable={false}
|
|
338
|
+
style={{ transform }}
|
|
339
|
+
/>
|
|
340
|
+
</div>
|
|
341
|
+
<p className="sc-overlay-hint">{words.hint}</p>
|
|
342
|
+
</div>
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export interface LightboxProps {
|
|
347
|
+
/** The picture shown large, or nothing. */
|
|
348
|
+
readonly picture: Picture | null;
|
|
349
|
+
readonly onClose: () => void;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** The overlay over the whole document, while a picture is shown; nothing otherwise, and nothing where there is no document. */
|
|
353
|
+
export function Lightbox(props: LightboxProps): ReactNode {
|
|
354
|
+
if (props.picture === null || typeof document === "undefined") {
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
return createPortal(
|
|
358
|
+
<PictureOverlay picture={props.picture} onClose={props.onClose} />,
|
|
359
|
+
document.body,
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export interface PictureState {
|
|
364
|
+
/** The picture shown large, or nothing. */
|
|
365
|
+
readonly shown: Picture | null;
|
|
366
|
+
readonly open: (picture: Picture, opener: HTMLElement) => void;
|
|
367
|
+
/** Closes the overlay and gives the focus back to what opened it. */
|
|
368
|
+
readonly close: () => void;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Which picture is shown large, and the ways to show one and to close it. */
|
|
372
|
+
export function usePicture(): PictureState {
|
|
373
|
+
const [shown, setShown] = useState<Picture | null>(null);
|
|
374
|
+
const opener = useRef<HTMLElement | null>(null);
|
|
375
|
+
const open = useCallback((picture: Picture, element: HTMLElement) => {
|
|
376
|
+
opener.current = element;
|
|
377
|
+
setShown(picture);
|
|
378
|
+
}, []);
|
|
379
|
+
const close = useCallback(() => {
|
|
380
|
+
const back = opener.current;
|
|
381
|
+
opener.current = null;
|
|
382
|
+
setShown(null);
|
|
383
|
+
// The focus returns once the overlay is gone, so that it is not taken back by it.
|
|
384
|
+
if (back !== null) {
|
|
385
|
+
setTimeout(() => back.focus(), 0);
|
|
386
|
+
}
|
|
387
|
+
}, []);
|
|
388
|
+
return { shown, open, close };
|
|
389
|
+
}
|
|
@@ -518,3 +518,71 @@ export function MemberList(props: MemberListProps) {
|
|
|
518
518
|
</div>
|
|
519
519
|
);
|
|
520
520
|
}
|
|
521
|
+
|
|
522
|
+
export interface PackTokenFormProps {
|
|
523
|
+
/** Whether the project holds a pack token now, so that it can be taken away. */
|
|
524
|
+
readonly set: boolean;
|
|
525
|
+
readonly busy?: boolean | undefined;
|
|
526
|
+
readonly error?: string | undefined;
|
|
527
|
+
/** Called with the token as typed or pasted; it travels to the console once and is shown nowhere after. */
|
|
528
|
+
readonly onSubmit: (token: string) => void;
|
|
529
|
+
readonly onRemove?: (() => void) | undefined;
|
|
530
|
+
readonly onCancel?: (() => void) | undefined;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** Where an owner gives the project the pack token it reads a private pack with (ADR-0028), or takes it away. */
|
|
534
|
+
export function PackTokenForm(props: PackTokenFormProps) {
|
|
535
|
+
const words = useWords();
|
|
536
|
+
const pack = words.projects.packToken;
|
|
537
|
+
const [token, setToken] = useState("");
|
|
538
|
+
const typed = token.trim();
|
|
539
|
+
const ready = typed.length >= 8 && !/\s/.test(typed);
|
|
540
|
+
return (
|
|
541
|
+
<form
|
|
542
|
+
className="sc-form"
|
|
543
|
+
aria-label={pack.aria}
|
|
544
|
+
onSubmit={(event: FormEvent) => {
|
|
545
|
+
event.preventDefault();
|
|
546
|
+
if (ready && props.busy !== true) {
|
|
547
|
+
props.onSubmit(typed);
|
|
548
|
+
setToken("");
|
|
549
|
+
}
|
|
550
|
+
}}
|
|
551
|
+
>
|
|
552
|
+
<p className="sc-field-hint">{pack.hint}</p>
|
|
553
|
+
<label className="sc-field">
|
|
554
|
+
<span className="sc-field-label">{pack.label}</span>
|
|
555
|
+
<input
|
|
556
|
+
className="sc-input"
|
|
557
|
+
type="password"
|
|
558
|
+
autoComplete="off"
|
|
559
|
+
spellCheck={false}
|
|
560
|
+
value={token}
|
|
561
|
+
placeholder={pack.placeholder}
|
|
562
|
+
disabled={props.busy === true}
|
|
563
|
+
onChange={(event) => setToken(event.target.value)}
|
|
564
|
+
/>
|
|
565
|
+
</label>
|
|
566
|
+
{props.error !== undefined && (
|
|
567
|
+
<p className="sc-form-error" role="alert">
|
|
568
|
+
{props.error}
|
|
569
|
+
</p>
|
|
570
|
+
)}
|
|
571
|
+
<div className="sc-form-actions">
|
|
572
|
+
<Button type="submit" variant="primary" disabled={!ready || props.busy === true}>
|
|
573
|
+
{pack.set}
|
|
574
|
+
</Button>
|
|
575
|
+
{props.set && props.onRemove !== undefined && (
|
|
576
|
+
<Button variant="danger" disabled={props.busy === true} onClick={props.onRemove}>
|
|
577
|
+
{pack.remove}
|
|
578
|
+
</Button>
|
|
579
|
+
)}
|
|
580
|
+
{props.onCancel !== undefined && (
|
|
581
|
+
<Button variant="ghost" onClick={props.onCancel}>
|
|
582
|
+
{words.common.cancel}
|
|
583
|
+
</Button>
|
|
584
|
+
)}
|
|
585
|
+
</div>
|
|
586
|
+
</form>
|
|
587
|
+
);
|
|
588
|
+
}
|
|
@@ -16,13 +16,22 @@ 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 { TASK_STATES } from "../vocabulary.js";
|
|
19
|
+
import { OPEN_TASK_STATES, 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";
|
|
23
23
|
import { Markdown } from "./markdown.js";
|
|
24
24
|
import { TaskForm } from "./task-form.js";
|
|
25
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
Badge,
|
|
27
|
+
Button,
|
|
28
|
+
CategoryBadge,
|
|
29
|
+
DueBadge,
|
|
30
|
+
PersonChip,
|
|
31
|
+
PriorityBadge,
|
|
32
|
+
StateBadge,
|
|
33
|
+
Time,
|
|
34
|
+
} from "./ui.js";
|
|
26
35
|
|
|
27
36
|
// One task: what it is, who it is on, what it links to, the discussion on it, which is what was
|
|
28
37
|
// said about it with the agents at work on it and the decisions about it in the order they
|
|
@@ -76,6 +85,9 @@ export interface TaskViewProps {
|
|
|
76
85
|
| ((decision: RestDecision, patch: RestDecisionPatch) => void)
|
|
77
86
|
| undefined;
|
|
78
87
|
readonly onAbandonRun?: ((run: RestRun) => void) | undefined;
|
|
88
|
+
/** Called to put a done or dropped task away, and to bring one back (ADR-0029); left out, neither is offered. */
|
|
89
|
+
readonly onArchive?: ((task: RestTask) => void) | undefined;
|
|
90
|
+
readonly onRestore?: ((task: RestTask) => void) | undefined;
|
|
79
91
|
/** Called with a word for the discussion, and the files put in it. Left out, nothing can be said here. */
|
|
80
92
|
readonly onComment?: ((draft: CommentDraft) => void) | undefined;
|
|
81
93
|
}
|
|
@@ -135,6 +147,7 @@ export function TaskView(props: TaskViewProps) {
|
|
|
135
147
|
</h1>
|
|
136
148
|
<div className="sc-task-badges">
|
|
137
149
|
<StateBadge state={task.state} />
|
|
150
|
+
{task.archivedAt !== null && <Badge tone="neutral">{words.task.archived}</Badge>}
|
|
138
151
|
{task.category !== null && <CategoryBadge category={task.category} />}
|
|
139
152
|
<PriorityBadge priority={task.priority} />
|
|
140
153
|
{task.dueOn !== null && (
|
|
@@ -312,6 +325,14 @@ export function TaskView(props: TaskViewProps) {
|
|
|
312
325
|
</dd>
|
|
313
326
|
</div>
|
|
314
327
|
)}
|
|
328
|
+
{task.archivedAt !== null && (
|
|
329
|
+
<div>
|
|
330
|
+
<dt>{words.task.archivedAt}</dt>
|
|
331
|
+
<dd>
|
|
332
|
+
<Time iso={task.archivedAt} />
|
|
333
|
+
</dd>
|
|
334
|
+
</div>
|
|
335
|
+
)}
|
|
315
336
|
<div>
|
|
316
337
|
<dt>{words.task.written}</dt>
|
|
317
338
|
<dd>
|
|
@@ -333,6 +354,26 @@ export function TaskView(props: TaskViewProps) {
|
|
|
333
354
|
<a className="sc-button sc-button-secondary" href={props.formHref("ask")}>
|
|
334
355
|
{words.task.raiseDecision}
|
|
335
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
|
+
)}
|
|
368
|
+
{props.onRestore !== undefined && task.archivedAt !== null && (
|
|
369
|
+
<Button
|
|
370
|
+
variant="secondary"
|
|
371
|
+
disabled={props.busy === true}
|
|
372
|
+
onClick={() => props.onRestore?.(task)}
|
|
373
|
+
>
|
|
374
|
+
{words.task.restore}
|
|
375
|
+
</Button>
|
|
376
|
+
)}
|
|
336
377
|
</div>
|
|
337
378
|
)}
|
|
338
379
|
</aside>
|