@skillcdn/console 0.1.19 → 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 (56) hide show
  1. package/README.md +1 -1
  2. package/dist/cli/help.js +2 -2
  3. package/dist/cli/help.js.map +1 -1
  4. package/dist/components/board.d.ts +18 -8
  5. package/dist/components/board.d.ts.map +1 -1
  6. package/dist/components/board.js +180 -27
  7. package/dist/components/board.js.map +1 -1
  8. package/dist/components/comments.d.ts +0 -2
  9. package/dist/components/comments.d.ts.map +1 -1
  10. package/dist/components/comments.js +4 -39
  11. package/dist/components/comments.js.map +1 -1
  12. package/dist/components/documents.d.ts +2 -0
  13. package/dist/components/documents.d.ts.map +1 -1
  14. package/dist/components/documents.js +51 -5
  15. package/dist/components/documents.js.map +1 -1
  16. package/dist/components/files.d.ts +11 -0
  17. package/dist/components/files.d.ts.map +1 -0
  18. package/dist/components/files.js +44 -0
  19. package/dist/components/files.js.map +1 -0
  20. package/dist/components/runs.d.ts.map +1 -1
  21. package/dist/components/runs.js +34 -7
  22. package/dist/components/runs.js.map +1 -1
  23. package/dist/console.css +96 -74
  24. package/dist/console.d.ts.map +1 -1
  25. package/dist/console.js +3 -3
  26. package/dist/console.js.map +1 -1
  27. package/dist/docs-page.d.ts.map +1 -1
  28. package/dist/docs-page.js +14 -1
  29. package/dist/docs-page.js.map +1 -1
  30. package/dist/i18n/en.d.ts +6 -3
  31. package/dist/i18n/en.d.ts.map +1 -1
  32. package/dist/i18n/en.js +6 -3
  33. package/dist/i18n/en.js.map +1 -1
  34. package/dist/i18n/ko.d.ts.map +1 -1
  35. package/dist/i18n/ko.js +6 -3
  36. package/dist/i18n/ko.js.map +1 -1
  37. package/dist/index.d.ts +2 -1
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +2 -1
  40. package/dist/index.js.map +1 -1
  41. package/docs/specs/cli.md +2 -2
  42. package/docs/specs/rest.md +2 -2
  43. package/package.json +1 -1
  44. package/skills/working-the-board/SKILL.md +3 -3
  45. package/src/cli/help.ts +2 -2
  46. package/src/components/board.tsx +300 -73
  47. package/src/components/comments.tsx +10 -39
  48. package/src/components/documents.tsx +87 -4
  49. package/src/components/files.ts +49 -0
  50. package/src/components/runs.tsx +73 -15
  51. package/src/console.tsx +12 -6
  52. package/src/docs-page.tsx +18 -1
  53. package/src/i18n/en.ts +7 -3
  54. package/src/i18n/ko.ts +7 -3
  55. package/src/index.ts +1 -1
  56. package/src/styles/console.css +96 -74
@@ -1,4 +1,5 @@
1
1
  import {
2
+ type KeyboardEvent as ReactKeyboardEvent,
2
3
  type ReactNode,
3
4
  type PointerEvent as ReactPointerEvent,
4
5
  useCallback,
@@ -6,6 +7,7 @@ import {
6
7
  useRef,
7
8
  useState,
8
9
  } from "react";
10
+ import { createPortal } from "react-dom";
9
11
  import {
10
12
  type RestCategory,
11
13
  type RestGoal,
@@ -16,6 +18,8 @@ import {
16
18
  } from "../api.js";
17
19
  import { useWords } from "../i18n/index.js";
18
20
  import type { BoardFilter } from "../router.js";
21
+ import { OPEN_TASK_STATES } from "../vocabulary.js";
22
+ import { isPlainClick } from "./shell.js";
19
23
  import {
20
24
  Avatar,
21
25
  Badge,
@@ -31,27 +35,40 @@ import {
31
35
  // A card says what a person needs at a glance and nothing more: the number and the title, the
32
36
  // category, what is due, who is on it, and what blocks it. Whether an agent is at it is not
33
37
  // said, since the board cannot know (ADR-0020); the task's page shows its runs. A card is moved
34
- // to another column by its grip, with a mouse or a finger, and by the select on it with the
35
- // keyboard; either way the move goes back up and the board is told. It takes its data as props
36
- // and nothing from the network; what it is told on a click goes back up.
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.
37
44
 
38
45
  export interface BoardProps {
39
46
  readonly tasks: readonly RestTask[];
40
47
  /** The link to a task's own page, as the composition routes it. */
41
48
  readonly taskHref: (task: RestTask) => string;
42
49
  readonly onOpen: (task: RestTask) => void;
43
- /** 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. */
44
51
  readonly onMove?: ((task: RestTask, state: TaskState) => void) | undefined;
45
52
  /** The day to count due days from; the browser's today when left out. */
46
53
  readonly today?: string | undefined;
47
54
  /** What a column shows when it has nothing; the way to write the first task, for example. */
48
55
  readonly empty?: ReactNode;
49
- /** Where the tasks put away are (ADR-0029); given, the done and dropped columns lead there. */
50
- readonly archiveHref?: string | undefined;
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;
51
62
  }
52
63
 
53
- /** How far a pointer travels on the grip before the card is dragged, and not pressed. */
64
+ /** How far a mouse travels on a card before the press is a drag and not a click. */
54
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;
55
72
  /** How near the pointer comes to the board's edge before the board scrolls to show more columns. */
56
73
  const EDGE = 48;
57
74
  const EDGE_STEP = 12;
@@ -67,47 +84,54 @@ interface Drag {
67
84
  readonly from: { readonly x: number; readonly y: number };
68
85
  readonly x: number;
69
86
  readonly y: number;
70
- /** Whether the pointer has travelled far enough for this to be a drag. */
87
+ /** Whether the card has come loose: the mouse has travelled, or the finger has rested. */
71
88
  readonly moving: boolean;
89
+ /** Whether the press is a finger's, which must rest before the card comes loose. */
90
+ readonly hold: boolean;
72
91
  /** The column under the pointer, or none. */
73
92
  readonly over: TaskState | undefined;
74
93
  }
75
94
 
76
- export function TaskCard(props: {
95
+ export interface TaskCardProps {
77
96
  readonly task: RestTask;
78
97
  readonly href: string;
79
98
  readonly onOpen: (task: RestTask) => void;
80
- readonly onMove?: ((task: RestTask, state: TaskState) => void) | undefined;
81
99
  readonly today?: string | undefined;
82
- /** Called when the card's grip is pressed, to drag it to another column; left out, the card has no grip. */
83
- readonly onGrip?: ((task: RestTask, event: ReactPointerEvent<HTMLElement>) => void) | undefined;
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;
84
102
  /** Whether the card is on its way to another column. */
85
103
  readonly dragging?: boolean | undefined;
86
- }) {
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) {
87
113
  const words = useWords();
88
- const { task } = props;
114
+ const { task, onPress } = props;
115
+ const closed = !OPEN_TASK_STATES.includes(task.state);
116
+ const archive = closed ? props.onArchive : undefined;
89
117
  return (
90
118
  <article
91
- className={cx("sc-card", props.dragging === true && "sc-card-dragging")}
119
+ className={cx(
120
+ "sc-card",
121
+ onPress !== undefined && "sc-card-movable",
122
+ props.dragging === true && "sc-card-dragging",
123
+ )}
92
124
  aria-label={`#${task.number} ${task.title}`}
125
+ onPointerDown={onPress === undefined ? undefined : (event) => onPress(task, event)}
93
126
  >
94
- {props.onGrip !== undefined && (
95
- <button
96
- type="button"
97
- className="sc-card-grip"
98
- title={words.board.grip}
99
- aria-hidden="true"
100
- tabIndex={-1}
101
- onPointerDown={(event) => props.onGrip?.(task, event)}
102
- >
103
- ⋮⋮
104
- </button>
127
+ {(props.editHref !== undefined || archive !== undefined) && (
128
+ <CardMenu task={task} editHref={props.editHref} onEdit={props.onEdit} onArchive={archive} />
105
129
  )}
106
130
  <a
107
131
  className="sc-card-title"
108
132
  href={props.href}
109
133
  onClick={(event) => {
110
- if (event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey) {
134
+ if (isPlainClick(event)) {
111
135
  event.preventDefault();
112
136
  props.onOpen(task);
113
137
  }
@@ -129,25 +153,179 @@ export function TaskCard(props: {
129
153
  <span className="sc-card-spacer" />
130
154
  {task.assignee !== null && <Avatar person={task.assignee} size="sm" />}
131
155
  </div>
132
- {props.onMove !== undefined && (
133
- <label className="sc-card-move">
134
- <span className="sc-visually-hidden">{words.board.moveTo}</span>
135
- <select
136
- value={task.state}
137
- onChange={(event) => props.onMove?.(task, event.target.value as TaskState)}
138
- >
139
- {TASK_STATES.map((state) => (
140
- <option key={state} value={state}>
141
- {words.vocabulary.state[state]}
142
- </option>
143
- ))}
144
- </select>
145
- </label>
146
- )}
147
156
  </article>
148
157
  );
149
158
  }
150
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
+
151
329
  /** The order a column reads in: what is due soonest first, what has no day after, the newest first among those. */
152
330
  export function byDue(a: RestTask, b: RestTask): number {
153
331
  if (a.dueOn !== b.dueOn) {
@@ -170,43 +348,62 @@ export function Board(props: BoardProps) {
170
348
  // What the pointer's release reads, so that the move is made outside a state update.
171
349
  const current = useRef<Drag | null>(null);
172
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);
173
354
 
174
- const grip = useCallback((task: RestTask, event: ReactPointerEvent<HTMLElement>) => {
175
- if (event.pointerType === "mouse" && event.button !== 0) {
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
+ ) {
176
361
  return;
177
362
  }
178
- // The press is the drag's beginning and nothing else: no text is selected on the way.
179
- event.preventDefault();
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
+ }
180
368
  const from = { x: event.clientX, y: event.clientY };
181
- setDrag({ task, from, x: from.x, y: from.y, moving: false, over: undefined });
369
+ setDrag({ task, from, x: from.x, y: from.y, moving: false, hold, over: undefined });
182
370
  }, []);
183
371
 
184
372
  // While a card is held, the pointer is followed over the whole document: the column under it
185
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.
186
376
  const held = drag !== null;
187
377
  useEffect(() => {
188
378
  if (!held) {
189
379
  return;
190
380
  }
381
+ const columnUnder = (x: number, y: number) =>
382
+ columnStateOf(
383
+ document.elementFromPoint(x, y)?.closest<HTMLElement>("[data-column]")?.dataset.column,
384
+ );
191
385
  const follow = (event: PointerEvent) => {
192
386
  const { clientX: x, clientY: y } = event;
193
- const under = document.elementFromPoint(x, y)?.closest<HTMLElement>("[data-column]");
194
387
  setDrag((before) => {
195
388
  if (before === null) {
196
389
  return before;
197
390
  }
198
- const moving =
199
- before.moving || Math.hypot(x - before.from.x, y - before.from.y) > DRAG_SLACK;
200
- return {
201
- ...before,
202
- x,
203
- y,
204
- moving,
205
- over: moving ? columnStateOf(under?.dataset.column) : undefined,
206
- };
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) };
207
404
  });
208
405
  const element = board.current;
209
- if (element !== null) {
406
+ if (element !== null && loose.current) {
210
407
  const rect = element.getBoundingClientRect();
211
408
  if (x < rect.left + EDGE) {
212
409
  element.scrollLeft -= EDGE_STEP;
@@ -215,27 +412,62 @@ export function Board(props: BoardProps) {
215
412
  }
216
413
  }
217
414
  };
218
- const release = () => {
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) => {
219
425
  const last = current.current;
220
- if (last?.moving === true && last.over !== undefined && last.over !== last.task.state) {
221
- onMove?.(last.task, last.over);
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
+ }
222
431
  }
432
+ loose.current = false;
223
433
  setDrag(null);
224
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;
225
442
  window.addEventListener("pointermove", follow);
226
443
  window.addEventListener("pointerup", release);
227
444
  window.addEventListener("pointercancel", release);
445
+ document.addEventListener("touchmove", block, { passive: false });
446
+ document.addEventListener("contextmenu", quiet);
228
447
  return () => {
448
+ if (timer !== undefined) {
449
+ window.clearTimeout(timer);
450
+ }
229
451
  window.removeEventListener("pointermove", follow);
230
452
  window.removeEventListener("pointerup", release);
231
453
  window.removeEventListener("pointercancel", release);
454
+ document.removeEventListener("touchmove", block);
455
+ document.removeEventListener("contextmenu", quiet);
456
+ loose.current = false;
232
457
  };
233
458
  }, [held, onMove]);
234
459
 
235
- /** The drag under way, once the pointer has travelled; nothing while a card is merely pressed. */
460
+ /** The drag under way, once the card has come loose; nothing while a card is merely pressed. */
236
461
  const dragging = drag?.moving === true ? drag : null;
237
462
  return (
238
- <div ref={board} className={cx("sc-board", dragging !== null && "sc-board-dragging")}>
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
+ >
239
471
  {TASK_STATES.map((state) => {
240
472
  const tasks = props.tasks.filter((task) => task.state === state).sort(byDue);
241
473
  return (
@@ -251,14 +483,7 @@ export function Board(props: BoardProps) {
251
483
  >
252
484
  <header className="sc-column-header">
253
485
  <h2 className="sc-column-title">{words.vocabulary.state[state]}</h2>
254
- <span className="sc-column-meta">
255
- {props.archiveHref !== undefined && (state === "done" || state === "dropped") && (
256
- <a className="sc-column-archive" href={props.archiveHref}>
257
- {words.board.archive}
258
- </a>
259
- )}
260
- <span className="sc-column-count">{tasks.length}</span>
261
- </span>
486
+ <span className="sc-column-count">{tasks.length}</span>
262
487
  </header>
263
488
  <div className="sc-column-body">
264
489
  {tasks.map((task) => (
@@ -267,10 +492,12 @@ export function Board(props: BoardProps) {
267
492
  task={task}
268
493
  href={props.taskHref(task)}
269
494
  onOpen={props.onOpen}
270
- onMove={onMove}
271
495
  today={props.today}
272
- onGrip={onMove === undefined ? undefined : grip}
496
+ onPress={onMove === undefined ? undefined : press}
273
497
  dragging={dragging?.task.id === task.id}
498
+ editHref={props.editHref?.(task)}
499
+ onEdit={props.onEdit}
500
+ onArchive={props.onArchive}
274
501
  />
275
502
  ))}
276
503
  {tasks.length === 0 && state === "idea" && props.empty}
@@ -11,6 +11,7 @@ import {
11
11
  } from "../api.js";
12
12
  import { useWords } from "../i18n/index.js";
13
13
  import { DecisionCard, type DecisionCardProps } from "./decision-list.js";
14
+ import { isPicture, namedFile, placed, placing, unusedName } from "./files.js";
14
15
  import { Markdown } from "./markdown.js";
15
16
  import { Lightbox, PictureThumb, usePicture } from "./pictures.js";
16
17
  import { RunCard } from "./runs.js";
@@ -21,24 +22,6 @@ import { Button, formatBytes, PersonChip, personName, Time } from "./ui.js";
21
22
  // it, in the order they came. A picture is a thumbnail that opens large over the page. Takes
22
23
  // its data as props and nothing from the network.
23
24
 
24
- /** The kinds of picture the pages show in place: what a browser renders and cannot run. */
25
- const PICTURE_TYPES: ReadonlySet<string> = new Set([
26
- "image/png",
27
- "image/jpeg",
28
- "image/gif",
29
- "image/webp",
30
- ]);
31
-
32
- /** Whether a file is a picture the pages show in place, by the media type the console kept. */
33
- export function isPicture(contentType: string): boolean {
34
- return PICTURE_TYPES.has(contentType);
35
- }
36
-
37
- /** Whether the text places a file by its name, as `![words](name)` or `![words](./name)`. */
38
- function placed(body: string, name: string): boolean {
39
- return body.includes(`](${name})`) || body.includes(`](./${name})`);
40
- }
41
-
42
25
  export interface CommentCardProps {
43
26
  readonly comment: RestComment;
44
27
  /** Where a file put in the comment is read: the project's address for it. Left out, a file is named and not linked. */
@@ -55,7 +38,7 @@ export function CommentCard(props: CommentCardProps) {
55
38
  // A picture the text names by its file name is shown where the text puts it; the rest of
56
39
  // the files follow the text, pictures in place and everything else as a link.
57
40
  const named = (source: string): string | undefined => {
58
- const name = source.startsWith("./") ? source.slice(2) : source;
41
+ const name = namedFile(source);
59
42
  const file = comment.files.find(
60
43
  (candidate) => candidate.file.name === name && isPicture(candidate.file.contentType),
61
44
  );
@@ -141,23 +124,6 @@ export interface CommentFormProps {
141
124
  readonly busy?: boolean | undefined;
142
125
  }
143
126
 
144
- /** A name for a file among those chosen already: the same, or with a count before its extension. */
145
- function unusedName(name: string, taken: readonly File[]): string {
146
- const names = new Set(taken.map((file) => file.name));
147
- if (!names.has(name)) {
148
- return name;
149
- }
150
- const dot = name.lastIndexOf(".");
151
- const stem = dot > 0 ? name.slice(0, dot) : name;
152
- const extension = dot > 0 ? name.slice(dot) : "";
153
- for (let n = 2; ; n += 1) {
154
- const candidate = `${stem}-${n}${extension}`;
155
- if (!names.has(candidate)) {
156
- return candidate;
157
- }
158
- }
159
- }
160
-
161
127
  export function CommentForm(props: CommentFormProps) {
162
128
  const words = useWords().comments;
163
129
  const [body, setBody] = useState("");
@@ -171,7 +137,10 @@ export function CommentForm(props: CommentFormProps) {
171
137
  if (held.length >= MAX_FILES_PER_COMMENT) {
172
138
  break;
173
139
  }
174
- const name = unusedName(file.name, held);
140
+ const name = unusedName(
141
+ file.name,
142
+ held.map((kept) => kept.name),
143
+ );
175
144
  const kept = name === file.name ? file : new File([file], name, { type: file.type });
176
145
  held = [...held, kept];
177
146
  added.push(kept);
@@ -189,9 +158,11 @@ export function CommentForm(props: CommentFormProps) {
189
158
  event.preventDefault();
190
159
  const added = add(pasted);
191
160
  if (added.length > 0) {
192
- const lines = added.map((file) => `![](${file.name})`).join("\n");
193
161
  setBody((current) =>
194
- current.trim().length === 0 ? lines : `${current.trimEnd()}\n\n${lines}`,
162
+ placing(
163
+ current,
164
+ added.map((file) => file.name),
165
+ ),
195
166
  );
196
167
  }
197
168
  };