@skillcdn/console 0.1.18 → 0.1.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/SKILLCDN.md +1 -1
- package/dist/api.d.ts +1 -1
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +1 -1
- package/dist/api.js.map +1 -1
- package/dist/cli/cli.d.ts +1 -1
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +353 -39
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/credentials.d.ts +102 -3
- package/dist/cli/credentials.d.ts.map +1 -1
- package/dist/cli/credentials.js +177 -8
- package/dist/cli/credentials.js.map +1 -1
- package/dist/cli/directory.d.ts +6 -0
- package/dist/cli/directory.d.ts.map +1 -1
- package/dist/cli/directory.js +37 -6
- package/dist/cli/directory.js.map +1 -1
- package/dist/cli/format.d.ts +10 -1
- package/dist/cli/format.d.ts.map +1 -1
- package/dist/cli/format.js +106 -2
- package/dist/cli/format.js.map +1 -1
- package/dist/cli/help.d.ts.map +1 -1
- package/dist/cli/help.js +44 -22
- package/dist/cli/help.js.map +1 -1
- package/dist/client.d.ts +21 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +14 -1
- package/dist/client.js.map +1 -1
- package/dist/components/board.d.ts +23 -5
- package/dist/components/board.d.ts.map +1 -1
- package/dist/components/board.js +239 -9
- package/dist/components/board.js.map +1 -1
- package/dist/components/comments.d.ts +0 -2
- package/dist/components/comments.d.ts.map +1 -1
- package/dist/components/comments.js +14 -41
- package/dist/components/comments.js.map +1 -1
- package/dist/components/documents.d.ts +2 -0
- package/dist/components/documents.d.ts.map +1 -1
- package/dist/components/documents.js +51 -5
- package/dist/components/documents.js.map +1 -1
- package/dist/components/event-feed.d.ts.map +1 -1
- package/dist/components/event-feed.js +4 -0
- package/dist/components/event-feed.js.map +1 -1
- package/dist/components/files.d.ts +11 -0
- package/dist/components/files.d.ts.map +1 -0
- package/dist/components/files.js +44 -0
- package/dist/components/files.js.map +1 -0
- package/dist/components/goals.d.ts +17 -1
- package/dist/components/goals.d.ts.map +1 -1
- package/dist/components/goals.js +34 -9
- package/dist/components/goals.js.map +1 -1
- package/dist/components/markdown.d.ts +6 -0
- package/dist/components/markdown.d.ts.map +1 -1
- package/dist/components/markdown.js +7 -0
- package/dist/components/markdown.js.map +1 -1
- package/dist/components/pictures.d.ts +68 -0
- package/dist/components/pictures.d.ts.map +1 -0
- package/dist/components/pictures.js +213 -0
- package/dist/components/pictures.js.map +1 -0
- package/dist/components/projects.d.ts +12 -0
- package/dist/components/projects.d.ts.map +1 -1
- package/dist/components/projects.js +15 -0
- package/dist/components/projects.js.map +1 -1
- package/dist/components/runs.d.ts.map +1 -1
- package/dist/components/runs.js +34 -7
- package/dist/components/runs.js.map +1 -1
- package/dist/components/task-view.d.ts +3 -0
- package/dist/components/task-view.d.ts.map +1 -1
- package/dist/components/task-view.js +6 -4
- package/dist/components/task-view.js.map +1 -1
- package/dist/console.css +338 -37
- package/dist/console.d.ts.map +1 -1
- package/dist/console.js +124 -10
- package/dist/console.js.map +1 -1
- package/dist/data.d.ts +16 -1
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +47 -2
- package/dist/data.js.map +1 -1
- package/dist/docs-page.d.ts.map +1 -1
- package/dist/docs-page.js +14 -1
- package/dist/docs-page.js.map +1 -1
- package/dist/i18n/en.d.ts +56 -1
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +54 -3
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/ko.d.ts.map +1 -1
- package/dist/i18n/ko.js +54 -3
- package/dist/i18n/ko.js.map +1 -1
- package/dist/index.d.ts +7 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -5
- package/dist/index.js.map +1 -1
- package/dist/router.d.ts +25 -6
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +38 -7
- package/dist/router.js.map +1 -1
- package/dist/rules.js +5 -5
- package/dist/rules.js.map +1 -1
- package/dist/schemas.d.ts +158 -0
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +78 -0
- package/dist/schemas.js.map +1 -1
- package/dist/vocabulary.d.ts +2 -2
- package/dist/vocabulary.d.ts.map +1 -1
- package/dist/vocabulary.js +3 -0
- package/dist/vocabulary.js.map +1 -1
- package/docs/specs/cli.md +19 -11
- package/docs/specs/rest.md +24 -6
- package/package.json +1 -1
- package/skills/working-the-board/SKILL.md +12 -6
- package/src/api.ts +13 -0
- package/src/cli/cli.ts +441 -42
- package/src/cli/credentials.ts +276 -14
- package/src/cli/directory.ts +43 -6
- package/src/cli/format.ts +138 -2
- package/src/cli/help.ts +46 -22
- package/src/client.ts +60 -0
- package/src/components/board.tsx +401 -28
- package/src/components/comments.tsx +30 -49
- package/src/components/documents.tsx +87 -4
- package/src/components/event-feed.tsx +4 -0
- package/src/components/files.ts +49 -0
- package/src/components/goals.tsx +173 -106
- package/src/components/markdown.tsx +19 -0
- package/src/components/pictures.tsx +389 -0
- package/src/components/projects.tsx +68 -0
- package/src/components/runs.tsx +73 -15
- package/src/components/task-view.tsx +43 -2
- package/src/console.tsx +268 -7
- package/src/data.ts +72 -1
- package/src/docs-page.tsx +18 -1
- package/src/i18n/en.ts +58 -3
- package/src/i18n/ko.ts +58 -3
- package/src/index.ts +28 -1
- package/src/router.ts +57 -9
- package/src/rules.ts +5 -5
- package/src/schemas.ts +97 -0
- package/src/styles/console.css +338 -37
- package/src/vocabulary.ts +3 -0
package/src/components/board.tsx
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
import
|
|
1
|
+
import {
|
|
2
|
+
type KeyboardEvent as ReactKeyboardEvent,
|
|
3
|
+
type ReactNode,
|
|
4
|
+
type PointerEvent as ReactPointerEvent,
|
|
5
|
+
useCallback,
|
|
6
|
+
useEffect,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
} from "react";
|
|
10
|
+
import { createPortal } from "react-dom";
|
|
2
11
|
import {
|
|
3
12
|
type RestCategory,
|
|
4
13
|
type RestGoal,
|
|
@@ -9,6 +18,8 @@ import {
|
|
|
9
18
|
} from "../api.js";
|
|
10
19
|
import { useWords } from "../i18n/index.js";
|
|
11
20
|
import type { BoardFilter } from "../router.js";
|
|
21
|
+
import { OPEN_TASK_STATES } from "../vocabulary.js";
|
|
22
|
+
import { isPlainClick } from "./shell.js";
|
|
12
23
|
import {
|
|
13
24
|
Avatar,
|
|
14
25
|
Badge,
|
|
@@ -23,38 +34,104 @@ import {
|
|
|
23
34
|
// The board: one column per state, the tasks in it, the sooner due first and the newest after.
|
|
24
35
|
// A card says what a person needs at a glance and nothing more: the number and the title, the
|
|
25
36
|
// category, what is due, who is on it, and what blocks it. Whether an agent is at it is not
|
|
26
|
-
// said, since the board cannot know (ADR-0020); the task's page shows its runs.
|
|
27
|
-
//
|
|
37
|
+
// said, since the board cannot know (ADR-0020); the task's page shows its runs. A card is moved
|
|
38
|
+
// to another column by hand: pressed anywhere but on its title and its menu, then dragged, with
|
|
39
|
+
// a mouse at once and under a finger once it has rested a moment, so that a finger that moves
|
|
40
|
+
// at once scrolls as it always did. The card's menu holds the rest: the way to the task's form,
|
|
41
|
+
// and the way to put the task away once it is done or dropped (ADR-0029). The keyboard changes a
|
|
42
|
+
// state on the task's page. The board takes its data as props and nothing from the network;
|
|
43
|
+
// what it is told on a click goes back up.
|
|
28
44
|
|
|
29
45
|
export interface BoardProps {
|
|
30
46
|
readonly tasks: readonly RestTask[];
|
|
31
47
|
/** The link to a task's own page, as the composition routes it. */
|
|
32
48
|
readonly taskHref: (task: RestTask) => string;
|
|
33
49
|
readonly onOpen: (task: RestTask) => void;
|
|
34
|
-
/** Called when a person moves a task to another column. Left out, the
|
|
50
|
+
/** Called when a person moves a task to another column. Left out, the cards stay where they are. */
|
|
35
51
|
readonly onMove?: ((task: RestTask, state: TaskState) => void) | undefined;
|
|
36
52
|
/** The day to count due days from; the browser's today when left out. */
|
|
37
53
|
readonly today?: string | undefined;
|
|
38
54
|
/** What a column shows when it has nothing; the way to write the first task, for example. */
|
|
39
55
|
readonly empty?: ReactNode;
|
|
56
|
+
/** Where a task is edited, as the composition routes it; given, each card's menu leads there. */
|
|
57
|
+
readonly editHref?: ((task: RestTask) => string) | undefined;
|
|
58
|
+
/** Called when the menu's edit is chosen, to show the form without leaving the page; left out, the link is followed. */
|
|
59
|
+
readonly onEdit?: ((task: RestTask) => void) | undefined;
|
|
60
|
+
/** Called when a task done or dropped is put away from its card's menu (ADR-0029). Left out, the menu offers nothing of it. */
|
|
61
|
+
readonly onArchive?: ((task: RestTask) => void) | undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** How far a mouse travels on a card before the press is a drag and not a click. */
|
|
65
|
+
const DRAG_SLACK = 4;
|
|
66
|
+
/**
|
|
67
|
+
* How long a finger rests on a card before the card comes loose, and how far it may wander
|
|
68
|
+
* meanwhile; a finger that moves sooner is scrolling the page, not moving the card.
|
|
69
|
+
*/
|
|
70
|
+
const HOLD_MS = 300;
|
|
71
|
+
const HOLD_SLACK = 8;
|
|
72
|
+
/** How near the pointer comes to the board's edge before the board scrolls to show more columns. */
|
|
73
|
+
const EDGE = 48;
|
|
74
|
+
const EDGE_STEP = 12;
|
|
75
|
+
|
|
76
|
+
/** The state a column stands for, read off its `data-column`; nothing for a value that is not one. */
|
|
77
|
+
export function columnStateOf(value: string | null | undefined): TaskState | undefined {
|
|
78
|
+
return TASK_STATES.find((state) => state === value);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A card on its way to another column. */
|
|
82
|
+
interface Drag {
|
|
83
|
+
readonly task: RestTask;
|
|
84
|
+
readonly from: { readonly x: number; readonly y: number };
|
|
85
|
+
readonly x: number;
|
|
86
|
+
readonly y: number;
|
|
87
|
+
/** Whether the card has come loose: the mouse has travelled, or the finger has rested. */
|
|
88
|
+
readonly moving: boolean;
|
|
89
|
+
/** Whether the press is a finger's, which must rest before the card comes loose. */
|
|
90
|
+
readonly hold: boolean;
|
|
91
|
+
/** The column under the pointer, or none. */
|
|
92
|
+
readonly over: TaskState | undefined;
|
|
40
93
|
}
|
|
41
94
|
|
|
42
|
-
export
|
|
95
|
+
export interface TaskCardProps {
|
|
43
96
|
readonly task: RestTask;
|
|
44
97
|
readonly href: string;
|
|
45
98
|
readonly onOpen: (task: RestTask) => void;
|
|
46
|
-
readonly onMove?: ((task: RestTask, state: TaskState) => void) | undefined;
|
|
47
99
|
readonly today?: string | undefined;
|
|
48
|
-
|
|
100
|
+
/** Called when the card is pressed anywhere but on its controls, to drag it to another column; left out, the card stays where it is. */
|
|
101
|
+
readonly onPress?: ((task: RestTask, event: ReactPointerEvent<HTMLElement>) => void) | undefined;
|
|
102
|
+
/** Whether the card is on its way to another column. */
|
|
103
|
+
readonly dragging?: boolean | undefined;
|
|
104
|
+
/** Where the task is edited; given, the card's menu leads there. */
|
|
105
|
+
readonly editHref?: string | undefined;
|
|
106
|
+
/** Called when the menu's edit is chosen, to show the form without leaving the page; left out, the link is followed. */
|
|
107
|
+
readonly onEdit?: ((task: RestTask) => void) | undefined;
|
|
108
|
+
/** Called when the task is put away from the card's menu; offered once the task is done or dropped (ADR-0029). */
|
|
109
|
+
readonly onArchive?: ((task: RestTask) => void) | undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function TaskCard(props: TaskCardProps) {
|
|
49
113
|
const words = useWords();
|
|
50
|
-
const { task } = props;
|
|
114
|
+
const { task, onPress } = props;
|
|
115
|
+
const closed = !OPEN_TASK_STATES.includes(task.state);
|
|
116
|
+
const archive = closed ? props.onArchive : undefined;
|
|
51
117
|
return (
|
|
52
|
-
<article
|
|
118
|
+
<article
|
|
119
|
+
className={cx(
|
|
120
|
+
"sc-card",
|
|
121
|
+
onPress !== undefined && "sc-card-movable",
|
|
122
|
+
props.dragging === true && "sc-card-dragging",
|
|
123
|
+
)}
|
|
124
|
+
aria-label={`#${task.number} ${task.title}`}
|
|
125
|
+
onPointerDown={onPress === undefined ? undefined : (event) => onPress(task, event)}
|
|
126
|
+
>
|
|
127
|
+
{(props.editHref !== undefined || archive !== undefined) && (
|
|
128
|
+
<CardMenu task={task} editHref={props.editHref} onEdit={props.onEdit} onArchive={archive} />
|
|
129
|
+
)}
|
|
53
130
|
<a
|
|
54
131
|
className="sc-card-title"
|
|
55
132
|
href={props.href}
|
|
56
133
|
onClick={(event) => {
|
|
57
|
-
if (event
|
|
134
|
+
if (isPlainClick(event)) {
|
|
58
135
|
event.preventDefault();
|
|
59
136
|
props.onOpen(task);
|
|
60
137
|
}
|
|
@@ -76,25 +153,179 @@ export function TaskCard(props: {
|
|
|
76
153
|
<span className="sc-card-spacer" />
|
|
77
154
|
{task.assignee !== null && <Avatar person={task.assignee} size="sm" />}
|
|
78
155
|
</div>
|
|
79
|
-
{props.onMove !== undefined && (
|
|
80
|
-
<label className="sc-card-move">
|
|
81
|
-
<span className="sc-visually-hidden">{words.board.moveTo}</span>
|
|
82
|
-
<select
|
|
83
|
-
value={task.state}
|
|
84
|
-
onChange={(event) => props.onMove?.(task, event.target.value as TaskState)}
|
|
85
|
-
>
|
|
86
|
-
{TASK_STATES.map((state) => (
|
|
87
|
-
<option key={state} value={state}>
|
|
88
|
-
{words.vocabulary.state[state]}
|
|
89
|
-
</option>
|
|
90
|
-
))}
|
|
91
|
-
</select>
|
|
92
|
-
</label>
|
|
93
|
-
)}
|
|
94
156
|
</article>
|
|
95
157
|
);
|
|
96
158
|
}
|
|
97
159
|
|
|
160
|
+
/** Where the menu stands: from the top and the right edge of the window, over everything. */
|
|
161
|
+
interface MenuPlace {
|
|
162
|
+
readonly top: number;
|
|
163
|
+
readonly right: number;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const MENU_GAP = 4;
|
|
167
|
+
const MENU_MARGIN = 8;
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The card's menu: three dots in its corner, which open the ways a card has beyond being moved,
|
|
171
|
+
* each given by the composition. The menu is rendered over the document, so that no column
|
|
172
|
+
* clips it; a press elsewhere, the escape key, a scroll or a change of the window closes it.
|
|
173
|
+
*/
|
|
174
|
+
function CardMenu(props: {
|
|
175
|
+
readonly task: RestTask;
|
|
176
|
+
readonly editHref: string | undefined;
|
|
177
|
+
readonly onEdit: ((task: RestTask) => void) | undefined;
|
|
178
|
+
readonly onArchive: ((task: RestTask) => void) | undefined;
|
|
179
|
+
}) {
|
|
180
|
+
const words = useWords();
|
|
181
|
+
const [place, setPlace] = useState<MenuPlace | null>(null);
|
|
182
|
+
const button = useRef<HTMLButtonElement>(null);
|
|
183
|
+
const panel = useRef<HTMLDivElement>(null);
|
|
184
|
+
const open = place !== null;
|
|
185
|
+
const close = useCallback(() => setPlace(null), []);
|
|
186
|
+
const toggle = () => {
|
|
187
|
+
const rect = button.current?.getBoundingClientRect();
|
|
188
|
+
if (open || rect === undefined) {
|
|
189
|
+
close();
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
setPlace({
|
|
193
|
+
top: rect.bottom + MENU_GAP,
|
|
194
|
+
right: Math.max(MENU_MARGIN, window.innerWidth - rect.right),
|
|
195
|
+
});
|
|
196
|
+
};
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
if (!open) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
// Opened near the bottom of the window, the menu stands above its button instead.
|
|
202
|
+
const shown = panel.current?.getBoundingClientRect();
|
|
203
|
+
const anchor = button.current?.getBoundingClientRect();
|
|
204
|
+
if (
|
|
205
|
+
shown !== undefined &&
|
|
206
|
+
anchor !== undefined &&
|
|
207
|
+
shown.bottom > window.innerHeight - MENU_MARGIN
|
|
208
|
+
) {
|
|
209
|
+
setPlace((current) =>
|
|
210
|
+
current === null
|
|
211
|
+
? current
|
|
212
|
+
: { ...current, top: Math.max(MENU_MARGIN, anchor.top - MENU_GAP - shown.height) },
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
panel.current?.querySelector<HTMLElement>("[role='menuitem']")?.focus({ preventScroll: true });
|
|
216
|
+
const away = (event: PointerEvent) => {
|
|
217
|
+
const target = event.target;
|
|
218
|
+
if (
|
|
219
|
+
target instanceof Node &&
|
|
220
|
+
(panel.current?.contains(target) === true || button.current?.contains(target) === true)
|
|
221
|
+
) {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
close();
|
|
225
|
+
};
|
|
226
|
+
const key = (event: KeyboardEvent) => {
|
|
227
|
+
if (event.key === "Escape") {
|
|
228
|
+
close();
|
|
229
|
+
button.current?.focus();
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
document.addEventListener("pointerdown", away);
|
|
233
|
+
document.addEventListener("keydown", key);
|
|
234
|
+
window.addEventListener("scroll", close, true);
|
|
235
|
+
window.addEventListener("resize", close);
|
|
236
|
+
return () => {
|
|
237
|
+
document.removeEventListener("pointerdown", away);
|
|
238
|
+
document.removeEventListener("keydown", key);
|
|
239
|
+
window.removeEventListener("scroll", close, true);
|
|
240
|
+
window.removeEventListener("resize", close);
|
|
241
|
+
};
|
|
242
|
+
}, [open, close]);
|
|
243
|
+
// The arrow keys walk the items, around the ends.
|
|
244
|
+
const walk = (event: ReactKeyboardEvent<HTMLDivElement>) => {
|
|
245
|
+
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") {
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
event.preventDefault();
|
|
249
|
+
const items = [...(panel.current?.querySelectorAll<HTMLElement>("[role='menuitem']") ?? [])];
|
|
250
|
+
if (items.length === 0) {
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const active = document.activeElement;
|
|
254
|
+
const at = active instanceof HTMLElement ? items.indexOf(active) : -1;
|
|
255
|
+
const step = event.key === "ArrowDown" ? 1 : -1;
|
|
256
|
+
items[(at + step + items.length) % items.length]?.focus();
|
|
257
|
+
};
|
|
258
|
+
const { task } = props;
|
|
259
|
+
return (
|
|
260
|
+
<>
|
|
261
|
+
<button
|
|
262
|
+
type="button"
|
|
263
|
+
ref={button}
|
|
264
|
+
className="sc-card-menu-button"
|
|
265
|
+
aria-label={words.board.menu}
|
|
266
|
+
aria-haspopup="menu"
|
|
267
|
+
aria-expanded={open}
|
|
268
|
+
onClick={toggle}
|
|
269
|
+
>
|
|
270
|
+
<svg viewBox="0 0 16 16" aria-hidden="true">
|
|
271
|
+
<circle cx="8" cy="3" r="1.5" fill="currentColor" />
|
|
272
|
+
<circle cx="8" cy="8" r="1.5" fill="currentColor" />
|
|
273
|
+
<circle cx="8" cy="13" r="1.5" fill="currentColor" />
|
|
274
|
+
</svg>
|
|
275
|
+
</button>
|
|
276
|
+
{place !== null &&
|
|
277
|
+
typeof document !== "undefined" &&
|
|
278
|
+
createPortal(
|
|
279
|
+
<div
|
|
280
|
+
ref={panel}
|
|
281
|
+
className="sc-card-menu"
|
|
282
|
+
role="menu"
|
|
283
|
+
aria-label={`#${task.number} ${task.title}`}
|
|
284
|
+
style={{ top: place.top, right: place.right }}
|
|
285
|
+
onKeyDown={walk}
|
|
286
|
+
onBlur={(event) => {
|
|
287
|
+
const next = event.relatedTarget;
|
|
288
|
+
if (!(next instanceof Node && panel.current?.contains(next) === true)) {
|
|
289
|
+
close();
|
|
290
|
+
}
|
|
291
|
+
}}
|
|
292
|
+
>
|
|
293
|
+
{props.editHref !== undefined && (
|
|
294
|
+
<a
|
|
295
|
+
role="menuitem"
|
|
296
|
+
className="sc-menu-link"
|
|
297
|
+
href={props.editHref}
|
|
298
|
+
onClick={(event) => {
|
|
299
|
+
close();
|
|
300
|
+
if (props.onEdit !== undefined && isPlainClick(event)) {
|
|
301
|
+
event.preventDefault();
|
|
302
|
+
props.onEdit(task);
|
|
303
|
+
}
|
|
304
|
+
}}
|
|
305
|
+
>
|
|
306
|
+
{words.common.edit}
|
|
307
|
+
</a>
|
|
308
|
+
)}
|
|
309
|
+
{props.onArchive !== undefined && (
|
|
310
|
+
<button
|
|
311
|
+
type="button"
|
|
312
|
+
role="menuitem"
|
|
313
|
+
className="sc-menu-link"
|
|
314
|
+
onClick={() => {
|
|
315
|
+
close();
|
|
316
|
+
props.onArchive?.(task);
|
|
317
|
+
}}
|
|
318
|
+
>
|
|
319
|
+
{words.task.archive}
|
|
320
|
+
</button>
|
|
321
|
+
)}
|
|
322
|
+
</div>,
|
|
323
|
+
document.body,
|
|
324
|
+
)}
|
|
325
|
+
</>
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
98
329
|
/** The order a column reads in: what is due soonest first, what has no day after, the newest first among those. */
|
|
99
330
|
export function byDue(a: RestTask, b: RestTask): number {
|
|
100
331
|
if (a.dueOn !== b.dueOn) {
|
|
@@ -111,15 +342,144 @@ export function byDue(a: RestTask, b: RestTask): number {
|
|
|
111
342
|
|
|
112
343
|
export function Board(props: BoardProps) {
|
|
113
344
|
const words = useWords();
|
|
345
|
+
const { onMove } = props;
|
|
346
|
+
const board = useRef<HTMLDivElement>(null);
|
|
347
|
+
const [drag, setDrag] = useState<Drag | null>(null);
|
|
348
|
+
// What the pointer's release reads, so that the move is made outside a state update.
|
|
349
|
+
const current = useRef<Drag | null>(null);
|
|
350
|
+
current.current = drag;
|
|
351
|
+
// Whether the held card has come loose, kept outside the state so that the touch that follows
|
|
352
|
+
// the hold at once is answered before the state has rendered.
|
|
353
|
+
const loose = useRef(false);
|
|
354
|
+
|
|
355
|
+
const press = useCallback((task: RestTask, event: ReactPointerEvent<HTMLElement>) => {
|
|
356
|
+
// The card's own controls, its title and its menu, are pressed for themselves.
|
|
357
|
+
if (
|
|
358
|
+
event.button !== 0 ||
|
|
359
|
+
(event.target instanceof Element && event.target.closest("a, button") !== null)
|
|
360
|
+
) {
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
const hold = event.pointerType !== "mouse";
|
|
364
|
+
if (!hold) {
|
|
365
|
+
// The press is the drag's beginning and nothing else: no text is selected on the way.
|
|
366
|
+
event.preventDefault();
|
|
367
|
+
}
|
|
368
|
+
const from = { x: event.clientX, y: event.clientY };
|
|
369
|
+
setDrag({ task, from, x: from.x, y: from.y, moving: false, hold, over: undefined });
|
|
370
|
+
}, []);
|
|
371
|
+
|
|
372
|
+
// While a card is held, the pointer is followed over the whole document: the column under it
|
|
373
|
+
// is marked, the board scrolls when the pointer nears its edge, and the release moves the task.
|
|
374
|
+
// A finger's hold is timed here, and once the card has come loose the page no longer scrolls
|
|
375
|
+
// under the finger, nor asks for the menu a long press would otherwise bring up.
|
|
376
|
+
const held = drag !== null;
|
|
377
|
+
useEffect(() => {
|
|
378
|
+
if (!held) {
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
const columnUnder = (x: number, y: number) =>
|
|
382
|
+
columnStateOf(
|
|
383
|
+
document.elementFromPoint(x, y)?.closest<HTMLElement>("[data-column]")?.dataset.column,
|
|
384
|
+
);
|
|
385
|
+
const follow = (event: PointerEvent) => {
|
|
386
|
+
const { clientX: x, clientY: y } = event;
|
|
387
|
+
setDrag((before) => {
|
|
388
|
+
if (before === null) {
|
|
389
|
+
return before;
|
|
390
|
+
}
|
|
391
|
+
const travelled = Math.hypot(x - before.from.x, y - before.from.y);
|
|
392
|
+
if (!before.moving) {
|
|
393
|
+
// A finger that wanders before it has rested is scrolling, and lets the card go; a
|
|
394
|
+
// mouse that has barely moved is still only pressing.
|
|
395
|
+
if (before.hold) {
|
|
396
|
+
return travelled > HOLD_SLACK ? null : { ...before, x, y };
|
|
397
|
+
}
|
|
398
|
+
if (travelled <= DRAG_SLACK) {
|
|
399
|
+
return { ...before, x, y };
|
|
400
|
+
}
|
|
401
|
+
loose.current = true;
|
|
402
|
+
}
|
|
403
|
+
return { ...before, x, y, moving: true, over: columnUnder(x, y) };
|
|
404
|
+
});
|
|
405
|
+
const element = board.current;
|
|
406
|
+
if (element !== null && loose.current) {
|
|
407
|
+
const rect = element.getBoundingClientRect();
|
|
408
|
+
if (x < rect.left + EDGE) {
|
|
409
|
+
element.scrollLeft -= EDGE_STEP;
|
|
410
|
+
} else if (x > rect.right - EDGE) {
|
|
411
|
+
element.scrollLeft += EDGE_STEP;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
const loosen = () => {
|
|
416
|
+
setDrag((before) => {
|
|
417
|
+
if (before === null || before.moving) {
|
|
418
|
+
return before;
|
|
419
|
+
}
|
|
420
|
+
loose.current = true;
|
|
421
|
+
return { ...before, moving: true, over: columnUnder(before.x, before.y) };
|
|
422
|
+
});
|
|
423
|
+
};
|
|
424
|
+
const release = (event: PointerEvent) => {
|
|
425
|
+
const last = current.current;
|
|
426
|
+
if (last?.moving === true && event.type === "pointerup") {
|
|
427
|
+
const over = columnUnder(event.clientX, event.clientY);
|
|
428
|
+
if (over !== undefined && over !== last.task.state) {
|
|
429
|
+
onMove?.(last.task, over);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
loose.current = false;
|
|
433
|
+
setDrag(null);
|
|
434
|
+
};
|
|
435
|
+
const block = (event: TouchEvent) => {
|
|
436
|
+
if (loose.current) {
|
|
437
|
+
event.preventDefault();
|
|
438
|
+
}
|
|
439
|
+
};
|
|
440
|
+
const quiet = (event: Event) => event.preventDefault();
|
|
441
|
+
const timer = current.current?.hold === true ? window.setTimeout(loosen, HOLD_MS) : undefined;
|
|
442
|
+
window.addEventListener("pointermove", follow);
|
|
443
|
+
window.addEventListener("pointerup", release);
|
|
444
|
+
window.addEventListener("pointercancel", release);
|
|
445
|
+
document.addEventListener("touchmove", block, { passive: false });
|
|
446
|
+
document.addEventListener("contextmenu", quiet);
|
|
447
|
+
return () => {
|
|
448
|
+
if (timer !== undefined) {
|
|
449
|
+
window.clearTimeout(timer);
|
|
450
|
+
}
|
|
451
|
+
window.removeEventListener("pointermove", follow);
|
|
452
|
+
window.removeEventListener("pointerup", release);
|
|
453
|
+
window.removeEventListener("pointercancel", release);
|
|
454
|
+
document.removeEventListener("touchmove", block);
|
|
455
|
+
document.removeEventListener("contextmenu", quiet);
|
|
456
|
+
loose.current = false;
|
|
457
|
+
};
|
|
458
|
+
}, [held, onMove]);
|
|
459
|
+
|
|
460
|
+
/** The drag under way, once the card has come loose; nothing while a card is merely pressed. */
|
|
461
|
+
const dragging = drag?.moving === true ? drag : null;
|
|
114
462
|
return (
|
|
115
|
-
<div
|
|
463
|
+
<div
|
|
464
|
+
ref={board}
|
|
465
|
+
className={cx(
|
|
466
|
+
"sc-board",
|
|
467
|
+
onMove !== undefined && "sc-board-movable",
|
|
468
|
+
dragging !== null && "sc-board-dragging",
|
|
469
|
+
)}
|
|
470
|
+
>
|
|
116
471
|
{TASK_STATES.map((state) => {
|
|
117
472
|
const tasks = props.tasks.filter((task) => task.state === state).sort(byDue);
|
|
118
473
|
return (
|
|
119
474
|
<section
|
|
120
475
|
key={state}
|
|
121
|
-
className={cx(
|
|
476
|
+
className={cx(
|
|
477
|
+
"sc-column",
|
|
478
|
+
`sc-column-${state}`,
|
|
479
|
+
dragging?.over === state && "sc-column-over",
|
|
480
|
+
)}
|
|
122
481
|
aria-label={words.vocabulary.state[state]}
|
|
482
|
+
data-column={state}
|
|
123
483
|
>
|
|
124
484
|
<header className="sc-column-header">
|
|
125
485
|
<h2 className="sc-column-title">{words.vocabulary.state[state]}</h2>
|
|
@@ -132,8 +492,12 @@ export function Board(props: BoardProps) {
|
|
|
132
492
|
task={task}
|
|
133
493
|
href={props.taskHref(task)}
|
|
134
494
|
onOpen={props.onOpen}
|
|
135
|
-
onMove={props.onMove}
|
|
136
495
|
today={props.today}
|
|
496
|
+
onPress={onMove === undefined ? undefined : press}
|
|
497
|
+
dragging={dragging?.task.id === task.id}
|
|
498
|
+
editHref={props.editHref?.(task)}
|
|
499
|
+
onEdit={props.onEdit}
|
|
500
|
+
onArchive={props.onArchive}
|
|
137
501
|
/>
|
|
138
502
|
))}
|
|
139
503
|
{tasks.length === 0 && state === "idea" && props.empty}
|
|
@@ -141,6 +505,15 @@ export function Board(props: BoardProps) {
|
|
|
141
505
|
</section>
|
|
142
506
|
);
|
|
143
507
|
})}
|
|
508
|
+
{dragging !== null && (
|
|
509
|
+
<div
|
|
510
|
+
className="sc-drag-ghost"
|
|
511
|
+
aria-hidden="true"
|
|
512
|
+
style={{ transform: `translate(${dragging.x + 12}px, ${dragging.y + 12}px)` }}
|
|
513
|
+
>
|
|
514
|
+
<span className="sc-card-number">#{dragging.task.number}</span> {dragging.task.title}
|
|
515
|
+
</div>
|
|
516
|
+
)}
|
|
144
517
|
</div>
|
|
145
518
|
);
|
|
146
519
|
}
|
|
@@ -11,31 +11,16 @@ 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";
|
|
16
|
+
import { Lightbox, PictureThumb, usePicture } from "./pictures.js";
|
|
15
17
|
import { RunCard } from "./runs.js";
|
|
16
18
|
import { Button, formatBytes, PersonChip, personName, Time } from "./ui.js";
|
|
17
19
|
|
|
18
20
|
// The discussion on a task (ADR-0024): what people and agents said about it, each word with
|
|
19
21
|
// the files put in it, read in one thread with the runs on the task and the decisions about
|
|
20
|
-
// it, in the order they came.
|
|
21
|
-
|
|
22
|
-
/** The kinds of picture the pages show in place: what a browser renders and cannot run. */
|
|
23
|
-
const PICTURE_TYPES: ReadonlySet<string> = new Set([
|
|
24
|
-
"image/png",
|
|
25
|
-
"image/jpeg",
|
|
26
|
-
"image/gif",
|
|
27
|
-
"image/webp",
|
|
28
|
-
]);
|
|
29
|
-
|
|
30
|
-
/** Whether a file is a picture the pages show in place, by the media type the console kept. */
|
|
31
|
-
export function isPicture(contentType: string): boolean {
|
|
32
|
-
return PICTURE_TYPES.has(contentType);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** Whether the text places a file by its name, as `` or ``. */
|
|
36
|
-
function placed(body: string, name: string): boolean {
|
|
37
|
-
return body.includes(`](${name})`) || body.includes(`](./${name})`);
|
|
38
|
-
}
|
|
22
|
+
// it, in the order they came. A picture is a thumbnail that opens large over the page. Takes
|
|
23
|
+
// its data as props and nothing from the network.
|
|
39
24
|
|
|
40
25
|
export interface CommentCardProps {
|
|
41
26
|
readonly comment: RestComment;
|
|
@@ -48,11 +33,12 @@ export interface CommentCardProps {
|
|
|
48
33
|
export function CommentCard(props: CommentCardProps) {
|
|
49
34
|
const words = useWords();
|
|
50
35
|
const { comment } = props;
|
|
36
|
+
const pictures = usePicture();
|
|
51
37
|
const hrefOf = (file: RestCommentFile) => props.fileHref?.(file);
|
|
52
38
|
// A picture the text names by its file name is shown where the text puts it; the rest of
|
|
53
39
|
// the files follow the text, pictures in place and everything else as a link.
|
|
54
40
|
const named = (source: string): string | undefined => {
|
|
55
|
-
const name =
|
|
41
|
+
const name = namedFile(source);
|
|
56
42
|
const file = comment.files.find(
|
|
57
43
|
(candidate) => candidate.file.name === name && isPicture(candidate.file.contentType),
|
|
58
44
|
);
|
|
@@ -77,7 +63,12 @@ export function CommentCard(props: CommentCardProps) {
|
|
|
77
63
|
</span>
|
|
78
64
|
</header>
|
|
79
65
|
{comment.body.trim().length > 0 && (
|
|
80
|
-
<Markdown
|
|
66
|
+
<Markdown
|
|
67
|
+
source={comment.body}
|
|
68
|
+
docHref={props.docHref}
|
|
69
|
+
fileSrc={named}
|
|
70
|
+
onOpenPicture={pictures.open}
|
|
71
|
+
/>
|
|
81
72
|
)}
|
|
82
73
|
{following.length > 0 && (
|
|
83
74
|
<ul className="sc-comment-files" aria-label={words.comments.files}>
|
|
@@ -87,14 +78,15 @@ export function CommentCard(props: CommentCardProps) {
|
|
|
87
78
|
if (href !== undefined && isPicture(file.contentType)) {
|
|
88
79
|
return (
|
|
89
80
|
<li key={attached.id} className="sc-comment-picture">
|
|
90
|
-
<
|
|
91
|
-
|
|
92
|
-
src
|
|
93
|
-
alt
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
81
|
+
<PictureThumb
|
|
82
|
+
picture={{
|
|
83
|
+
src: href,
|
|
84
|
+
alt: words.comments.picture(file.name),
|
|
85
|
+
name: file.name,
|
|
86
|
+
size: file.size,
|
|
87
|
+
}}
|
|
88
|
+
onOpen={pictures.open}
|
|
89
|
+
/>
|
|
98
90
|
<span className="sc-muted">
|
|
99
91
|
{file.name}, {formatBytes(file.size)}
|
|
100
92
|
</span>
|
|
@@ -116,6 +108,7 @@ export function CommentCard(props: CommentCardProps) {
|
|
|
116
108
|
})}
|
|
117
109
|
</ul>
|
|
118
110
|
)}
|
|
111
|
+
<Lightbox picture={pictures.shown} onClose={pictures.close} />
|
|
119
112
|
</article>
|
|
120
113
|
);
|
|
121
114
|
}
|
|
@@ -131,23 +124,6 @@ export interface CommentFormProps {
|
|
|
131
124
|
readonly busy?: boolean | undefined;
|
|
132
125
|
}
|
|
133
126
|
|
|
134
|
-
/** A name for a file among those chosen already: the same, or with a count before its extension. */
|
|
135
|
-
function unusedName(name: string, taken: readonly File[]): string {
|
|
136
|
-
const names = new Set(taken.map((file) => file.name));
|
|
137
|
-
if (!names.has(name)) {
|
|
138
|
-
return name;
|
|
139
|
-
}
|
|
140
|
-
const dot = name.lastIndexOf(".");
|
|
141
|
-
const stem = dot > 0 ? name.slice(0, dot) : name;
|
|
142
|
-
const extension = dot > 0 ? name.slice(dot) : "";
|
|
143
|
-
for (let n = 2; ; n += 1) {
|
|
144
|
-
const candidate = `${stem}-${n}${extension}`;
|
|
145
|
-
if (!names.has(candidate)) {
|
|
146
|
-
return candidate;
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
127
|
export function CommentForm(props: CommentFormProps) {
|
|
152
128
|
const words = useWords().comments;
|
|
153
129
|
const [body, setBody] = useState("");
|
|
@@ -161,7 +137,10 @@ export function CommentForm(props: CommentFormProps) {
|
|
|
161
137
|
if (held.length >= MAX_FILES_PER_COMMENT) {
|
|
162
138
|
break;
|
|
163
139
|
}
|
|
164
|
-
const name = unusedName(
|
|
140
|
+
const name = unusedName(
|
|
141
|
+
file.name,
|
|
142
|
+
held.map((kept) => kept.name),
|
|
143
|
+
);
|
|
165
144
|
const kept = name === file.name ? file : new File([file], name, { type: file.type });
|
|
166
145
|
held = [...held, kept];
|
|
167
146
|
added.push(kept);
|
|
@@ -179,9 +158,11 @@ export function CommentForm(props: CommentFormProps) {
|
|
|
179
158
|
event.preventDefault();
|
|
180
159
|
const added = add(pasted);
|
|
181
160
|
if (added.length > 0) {
|
|
182
|
-
const lines = added.map((file) => ``).join("\n");
|
|
183
161
|
setBody((current) =>
|
|
184
|
-
|
|
162
|
+
placing(
|
|
163
|
+
current,
|
|
164
|
+
added.map((file) => file.name),
|
|
165
|
+
),
|
|
185
166
|
);
|
|
186
167
|
}
|
|
187
168
|
};
|