@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.
Files changed (140) hide show
  1. package/README.md +2 -2
  2. package/SKILLCDN.md +1 -1
  3. package/dist/api.d.ts +1 -1
  4. package/dist/api.d.ts.map +1 -1
  5. package/dist/api.js +1 -1
  6. package/dist/api.js.map +1 -1
  7. package/dist/cli/cli.d.ts +1 -1
  8. package/dist/cli/cli.d.ts.map +1 -1
  9. package/dist/cli/cli.js +353 -39
  10. package/dist/cli/cli.js.map +1 -1
  11. package/dist/cli/credentials.d.ts +102 -3
  12. package/dist/cli/credentials.d.ts.map +1 -1
  13. package/dist/cli/credentials.js +177 -8
  14. package/dist/cli/credentials.js.map +1 -1
  15. package/dist/cli/directory.d.ts +6 -0
  16. package/dist/cli/directory.d.ts.map +1 -1
  17. package/dist/cli/directory.js +37 -6
  18. package/dist/cli/directory.js.map +1 -1
  19. package/dist/cli/format.d.ts +10 -1
  20. package/dist/cli/format.d.ts.map +1 -1
  21. package/dist/cli/format.js +106 -2
  22. package/dist/cli/format.js.map +1 -1
  23. package/dist/cli/help.d.ts.map +1 -1
  24. package/dist/cli/help.js +44 -22
  25. package/dist/cli/help.js.map +1 -1
  26. package/dist/client.d.ts +21 -1
  27. package/dist/client.d.ts.map +1 -1
  28. package/dist/client.js +14 -1
  29. package/dist/client.js.map +1 -1
  30. package/dist/components/board.d.ts +23 -5
  31. package/dist/components/board.d.ts.map +1 -1
  32. package/dist/components/board.js +239 -9
  33. package/dist/components/board.js.map +1 -1
  34. package/dist/components/comments.d.ts +0 -2
  35. package/dist/components/comments.d.ts.map +1 -1
  36. package/dist/components/comments.js +14 -41
  37. package/dist/components/comments.js.map +1 -1
  38. package/dist/components/documents.d.ts +2 -0
  39. package/dist/components/documents.d.ts.map +1 -1
  40. package/dist/components/documents.js +51 -5
  41. package/dist/components/documents.js.map +1 -1
  42. package/dist/components/event-feed.d.ts.map +1 -1
  43. package/dist/components/event-feed.js +4 -0
  44. package/dist/components/event-feed.js.map +1 -1
  45. package/dist/components/files.d.ts +11 -0
  46. package/dist/components/files.d.ts.map +1 -0
  47. package/dist/components/files.js +44 -0
  48. package/dist/components/files.js.map +1 -0
  49. package/dist/components/goals.d.ts +17 -1
  50. package/dist/components/goals.d.ts.map +1 -1
  51. package/dist/components/goals.js +34 -9
  52. package/dist/components/goals.js.map +1 -1
  53. package/dist/components/markdown.d.ts +6 -0
  54. package/dist/components/markdown.d.ts.map +1 -1
  55. package/dist/components/markdown.js +7 -0
  56. package/dist/components/markdown.js.map +1 -1
  57. package/dist/components/pictures.d.ts +68 -0
  58. package/dist/components/pictures.d.ts.map +1 -0
  59. package/dist/components/pictures.js +213 -0
  60. package/dist/components/pictures.js.map +1 -0
  61. package/dist/components/projects.d.ts +12 -0
  62. package/dist/components/projects.d.ts.map +1 -1
  63. package/dist/components/projects.js +15 -0
  64. package/dist/components/projects.js.map +1 -1
  65. package/dist/components/runs.d.ts.map +1 -1
  66. package/dist/components/runs.js +34 -7
  67. package/dist/components/runs.js.map +1 -1
  68. package/dist/components/task-view.d.ts +3 -0
  69. package/dist/components/task-view.d.ts.map +1 -1
  70. package/dist/components/task-view.js +6 -4
  71. package/dist/components/task-view.js.map +1 -1
  72. package/dist/console.css +338 -37
  73. package/dist/console.d.ts.map +1 -1
  74. package/dist/console.js +124 -10
  75. package/dist/console.js.map +1 -1
  76. package/dist/data.d.ts +16 -1
  77. package/dist/data.d.ts.map +1 -1
  78. package/dist/data.js +47 -2
  79. package/dist/data.js.map +1 -1
  80. package/dist/docs-page.d.ts.map +1 -1
  81. package/dist/docs-page.js +14 -1
  82. package/dist/docs-page.js.map +1 -1
  83. package/dist/i18n/en.d.ts +56 -1
  84. package/dist/i18n/en.d.ts.map +1 -1
  85. package/dist/i18n/en.js +54 -3
  86. package/dist/i18n/en.js.map +1 -1
  87. package/dist/i18n/ko.d.ts.map +1 -1
  88. package/dist/i18n/ko.js +54 -3
  89. package/dist/i18n/ko.js.map +1 -1
  90. package/dist/index.d.ts +7 -5
  91. package/dist/index.d.ts.map +1 -1
  92. package/dist/index.js +7 -5
  93. package/dist/index.js.map +1 -1
  94. package/dist/router.d.ts +25 -6
  95. package/dist/router.d.ts.map +1 -1
  96. package/dist/router.js +38 -7
  97. package/dist/router.js.map +1 -1
  98. package/dist/rules.js +5 -5
  99. package/dist/rules.js.map +1 -1
  100. package/dist/schemas.d.ts +158 -0
  101. package/dist/schemas.d.ts.map +1 -1
  102. package/dist/schemas.js +78 -0
  103. package/dist/schemas.js.map +1 -1
  104. package/dist/vocabulary.d.ts +2 -2
  105. package/dist/vocabulary.d.ts.map +1 -1
  106. package/dist/vocabulary.js +3 -0
  107. package/dist/vocabulary.js.map +1 -1
  108. package/docs/specs/cli.md +19 -11
  109. package/docs/specs/rest.md +24 -6
  110. package/package.json +1 -1
  111. package/skills/working-the-board/SKILL.md +12 -6
  112. package/src/api.ts +13 -0
  113. package/src/cli/cli.ts +441 -42
  114. package/src/cli/credentials.ts +276 -14
  115. package/src/cli/directory.ts +43 -6
  116. package/src/cli/format.ts +138 -2
  117. package/src/cli/help.ts +46 -22
  118. package/src/client.ts +60 -0
  119. package/src/components/board.tsx +401 -28
  120. package/src/components/comments.tsx +30 -49
  121. package/src/components/documents.tsx +87 -4
  122. package/src/components/event-feed.tsx +4 -0
  123. package/src/components/files.ts +49 -0
  124. package/src/components/goals.tsx +173 -106
  125. package/src/components/markdown.tsx +19 -0
  126. package/src/components/pictures.tsx +389 -0
  127. package/src/components/projects.tsx +68 -0
  128. package/src/components/runs.tsx +73 -15
  129. package/src/components/task-view.tsx +43 -2
  130. package/src/console.tsx +268 -7
  131. package/src/data.ts +72 -1
  132. package/src/docs-page.tsx +18 -1
  133. package/src/i18n/en.ts +58 -3
  134. package/src/i18n/ko.ts +58 -3
  135. package/src/index.ts +28 -1
  136. package/src/router.ts +57 -9
  137. package/src/rules.ts +5 -5
  138. package/src/schemas.ts +97 -0
  139. package/src/styles/console.css +338 -37
  140. package/src/vocabulary.ts +3 -0
@@ -1,4 +1,13 @@
1
- import type { ReactNode } from "react";
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. It takes its
27
- // data as props and nothing from the network; what it is told on a click goes back up.
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 board is read-only. */
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 function TaskCard(props: {
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 className="sc-card" aria-label={`#${task.number} ${task.title}`}>
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.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey) {
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 className="sc-board">
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("sc-column", `sc-column-${state}`)}
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. Takes its data as props and nothing from the network.
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 `![words](name)` or `![words](./name)`. */
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 = source.startsWith("./") ? source.slice(2) : source;
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 source={comment.body} docHref={props.docHref} fileSrc={named} />
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
- <a href={href} rel="noopener nofollow ugc" target="_blank">
91
- <img
92
- src={href}
93
- alt={words.comments.picture(file.name)}
94
- loading="lazy"
95
- decoding="async"
96
- />
97
- </a>
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(file.name, held);
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) => `![](${file.name})`).join("\n");
183
161
  setBody((current) =>
184
- current.trim().length === 0 ? lines : `${current.trimEnd()}\n\n${lines}`,
162
+ placing(
163
+ current,
164
+ added.map((file) => file.name),
165
+ ),
185
166
  );
186
167
  }
187
168
  };