@crievo/omni 0.1.3 → 0.1.4

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 (96) hide show
  1. package/package.json +2 -1
  2. package/reference/learnings.md +12 -0
  3. package/studio/api/app.ts +2 -2
  4. package/studio/api/routes/sound.ts +22 -3
  5. package/studio/api/services/sound.ts +45 -0
  6. package/studio/studio.ts +1 -0
  7. package/studio/web/components/AppShell.tsx +3 -1
  8. package/studio/web/components/CloneVoiceDialog/RecordStep.tsx +3 -0
  9. package/studio/web/components/CloneVoiceDialog/RecorderBar.tsx +37 -20
  10. package/studio/web/components/CloneVoiceDialog/useCloneVoice.ts +39 -12
  11. package/studio/web/components/MissingDmsPanel/BatchSend.tsx +13 -4
  12. package/studio/web/components/Omni/OmniWidget.tsx +14 -5
  13. package/studio/web/components/Omni/PendingActions.tsx +25 -6
  14. package/studio/web/components/OmnichatRulePanel/RuleConfig.tsx +13 -3
  15. package/studio/web/components/OmnichatRulePanel/RuleEnableConfirm.tsx +14 -4
  16. package/studio/web/components/ResultMessage.tsx +12 -3
  17. package/studio/web/components/Sidebar.tsx +63 -24
  18. package/studio/web/components/StageStepper.tsx +105 -34
  19. package/studio/web/components/Toaster.tsx +25 -0
  20. package/studio/web/components/VoiceCarousel.tsx +19 -16
  21. package/studio/web/components/VoiceProfilesSettings.tsx +8 -3
  22. package/studio/web/components/bencho/ActionNode.tsx +456 -0
  23. package/studio/web/components/bencho/AssigneePicker.tsx +246 -0
  24. package/studio/web/components/bencho/BenchoScope.tsx +8 -0
  25. package/studio/web/components/bencho/Checklist.tsx +421 -0
  26. package/studio/web/components/bencho/CreateMenu.tsx +258 -0
  27. package/studio/web/components/bencho/GenerateImage.tsx +290 -0
  28. package/studio/web/components/bencho/ImageAccordion.tsx +273 -0
  29. package/studio/web/components/bencho/LiquidToggle.tsx +289 -0
  30. package/studio/web/components/bencho/MagnifyDock.tsx +200 -0
  31. package/studio/web/components/bencho/NowPlaying.tsx +403 -0
  32. package/studio/web/components/bencho/OneTimeCode.tsx +374 -0
  33. package/studio/web/components/bencho/PullToRefresh.tsx +527 -0
  34. package/studio/web/components/bencho/SlideToConfirm.tsx +385 -0
  35. package/studio/web/components/bencho/SloshSlider.tsx +394 -0
  36. package/studio/web/components/bencho/StackedBars.tsx +342 -0
  37. package/studio/web/components/bencho/ToastStack.tsx +263 -0
  38. package/studio/web/components/bencho/UploadDropzone.tsx +394 -0
  39. package/studio/web/components/bencho/VoiceNote.tsx +705 -0
  40. package/studio/web/components/icons.tsx +27 -0
  41. package/studio/web/components/ui/slider.tsx +34 -1
  42. package/studio/web/components/ui/switch.tsx +18 -5
  43. package/studio/web/index.html +1 -1
  44. package/studio/web/lib/omni-panel.ts +4 -1
  45. package/studio/web/lib/toast.ts +196 -0
  46. package/studio/web/lib/upload.ts +24 -0
  47. package/studio/web/pages/BenchoPage/index.tsx +74 -0
  48. package/studio/web/pages/BenchoPage/registry.ts +174 -0
  49. package/studio/web/pages/NewVideoPage/index.tsx +6 -2
  50. package/studio/web/pages/ReferencesPage/index.tsx +4 -1
  51. package/studio/web/pages/VideoPage/components/ChangedVoices.tsx +14 -17
  52. package/studio/web/pages/VideoPage/components/LineTextEditor.tsx +1 -1
  53. package/studio/web/pages/VideoPage/components/PublishForm.tsx +24 -17
  54. package/studio/web/pages/VideoPage/components/ScheduledInfo.tsx +2 -1
  55. package/studio/web/pages/VideoPage/components/SoundTimeline/AddSound.tsx +12 -14
  56. package/studio/web/pages/VideoPage/components/SoundTimeline/BedRow.tsx +27 -11
  57. package/studio/web/pages/VideoPage/components/SoundTimeline/CaptionSaveBar.tsx +22 -18
  58. package/studio/web/pages/VideoPage/components/SoundTimeline/TimelineFooter.tsx +3 -4
  59. package/studio/web/pages/VideoPage/components/SoundTimeline/UploadTrack.tsx +59 -0
  60. package/studio/web/pages/VideoPage/components/SoundTimeline/useSoundTimeline.ts +1 -0
  61. package/studio/web/pages/VideoPage/components/VoicePauseBanner.tsx +4 -6
  62. package/studio/web/pages/VideoPage/index.tsx +4 -0
  63. package/studio/web/pages/VideosPage/components/VideoRow.tsx +2 -4
  64. package/studio/web/router.tsx +8 -0
  65. package/studio/web/styles/components/bencho/action-node.css +227 -0
  66. package/studio/web/styles/components/bencho/checklist.css +182 -0
  67. package/studio/web/styles/components/bencho/dock.css +188 -0
  68. package/studio/web/styles/components/bencho/gallery.css +109 -0
  69. package/studio/web/styles/components/bencho/generate.css +151 -0
  70. package/studio/web/styles/components/bencho/image-accordion.css +37 -0
  71. package/studio/web/styles/components/bencho/liq-create.css +279 -0
  72. package/studio/web/styles/components/bencho/liq-toggle.css +106 -0
  73. package/studio/web/styles/components/bencho/one-time-code.css +148 -0
  74. package/studio/web/styles/components/bencho/picker.css +204 -0
  75. package/studio/web/styles/components/bencho/pull.css +313 -0
  76. package/studio/web/styles/components/bencho/scope.css +42 -0
  77. package/studio/web/styles/components/bencho/slide-confirm.css +139 -0
  78. package/studio/web/styles/components/bencho/slosh.css +137 -0
  79. package/studio/web/styles/components/bencho/sound.css +260 -0
  80. package/studio/web/styles/components/bencho/stacked-bars.css +334 -0
  81. package/studio/web/styles/components/bencho/toast-stack.css +192 -0
  82. package/studio/web/styles/components/bencho/tokens.css +243 -0
  83. package/studio/web/styles/components/bencho/upload-dropzone.css +352 -0
  84. package/studio/web/styles/components/bencho/voice-note.css +337 -0
  85. package/studio/web/styles/components/clone-voice.css +12 -2
  86. package/studio/web/styles/components/omni.css +6 -1
  87. package/studio/web/styles/components/stepper.css +123 -7
  88. package/studio/web/styles/components/toaster.css +10 -0
  89. package/studio/web/styles/components/ui/slider.css +6 -16
  90. package/studio/web/styles/components/ui/switch.css +2 -41
  91. package/studio/web/styles/components/voice-carousel.css +46 -4
  92. package/studio/web/styles/index.css +23 -0
  93. package/studio/web/styles/pages/video/layout.css +1 -4
  94. package/studio/web/styles/pages/video/sound-timeline.css +8 -0
  95. package/studio/web/styles/theme/identity/shell.css +10 -26
  96. package/tools/sound.mjs +67 -3
@@ -0,0 +1,394 @@
1
+ /* A drop box, and the files lying on a tray under it. Pick one up and carry it into the box: it uploads there (name, a
2
+ rolling percentage, a bar), ticks, and goes back to the tray so you can do it again.
3
+ Ported from bencho.dev (MIT).
4
+
5
+ No real files: the files are part of the block. Nothing is read from the desktop and nothing is sent anywhere; the
6
+ drag is an ordinary pointer drag inside the block.
7
+
8
+ The box inhales, and swallows: a file carried over it is answered before it is let go (the box grows with a small
9
+ bounce, the arrow lifts toward the hand). Let go and the file is sucked in, accelerating toward the box's middle,
10
+ shrinking and drawing out long as it goes, and the box gives a little gulp before the upload appears in it. Let go
11
+ anywhere else and the file springs home.
12
+ One of them is too big: the limit is fixed at 5 MB and the video is always over it, so dropping it shakes the box,
13
+ says so in red and the file springs home. The picture always goes. */
14
+
15
+ import {
16
+ type CSSProperties,
17
+ type PointerEvent as ReactPointerEvent,
18
+ useEffect,
19
+ useLayoutEffect,
20
+ useRef,
21
+ useState,
22
+ } from "react";
23
+ import { motion, useAnimationControls } from "motion/react";
24
+ import { Check, UploadSimple } from "../icons";
25
+
26
+ /* read once, a preference and not a live input */
27
+ const stillness = () =>
28
+ typeof window !== "undefined" && !!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
29
+
30
+ const MAX = 5;
31
+
32
+ /* what it says the second time, and after */
33
+ const SNARK = [
34
+ "Continuam 12,6 MB. Não encolheu.",
35
+ "Insistir não adianta",
36
+ "Já tentou comprimir?",
37
+ "A caixa já está cheia de desculpas",
38
+ "Não.",
39
+ ];
40
+
41
+ type F = { id: string; name: string; ext: string; size: number; tint: string };
42
+
43
+ const FILES: F[] = [
44
+ { id: "img", name: "mood.png", ext: "PNG", size: 2.4e6, tint: "#079455" },
45
+ { id: "vid", name: "demo.mp4", ext: "MP4", size: 12.6e6, tint: "#155EEF" },
46
+ ];
47
+
48
+ const fmt = (b: number) => (b >= 1e6 ? `${(b / 1e6).toFixed(1).replace(".", ",")} MB` : `${Math.round(b / 1e3)} KB`);
49
+ const clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v));
50
+
51
+ /* The file icon, after Untitled UI's: a rounded page on a 40 grid with a thin grey outline and a folded corner, and the
52
+ extension on a coloured label that sticks out past the page's left edge: the colour carries the kind before the word
53
+ is read. */
54
+ function Page({ f, size = 40 }: { f: F; size?: number }) {
55
+ return (
56
+ <svg className="bn-upl-page" width={size} height={size} viewBox="0 0 40 40" fill="none" aria-hidden="true">
57
+ <path
58
+ className="bn-upl-page-line"
59
+ d="M7.75 4A3.25 3.25 0 0 1 11 .75h16c.121 0 .238.048.323.134l10.793 10.793a.46.46 0 0 1 .134.323v24A3.25 3.25 0 0 1 35 39.25H11A3.25 3.25 0 0 1 7.75 36z"
60
+ />
61
+ <path className="bn-upl-page-line" d="M27 .5V8a4 4 0 0 0 4 4h7.5" />
62
+ <rect width="29" height="16" x="1" y="18" rx="8" fill={f.tint} />
63
+ {/* 9 units on the 40 grid: the mark is part of the drawing and scales with it, so its size lives here */}
64
+ <text x="15.5" y="29.1" textAnchor="middle" fontSize="9" className="bn-upl-page-ext">
65
+ {f.ext}
66
+ </text>
67
+ </svg>
68
+ );
69
+ }
70
+
71
+ function Roll({ value }: { value: number }) {
72
+ const s = String(Math.round(value));
73
+ return (
74
+ <span className="bn-upl-roll" aria-label={`${s}%`}>
75
+ {s.split("").map((d, i) => (
76
+ <span key={s.length - i} className="bn-upl-roll-col">
77
+ <span className="bn-upl-roll-strip" style={{ transform: `translateY(${-Number(d)}em)` }}>
78
+ {"0123456789".split("").map(n => (
79
+ <span key={n}>{n}</span>
80
+ ))}
81
+ </span>
82
+ </span>
83
+ ))}
84
+ %
85
+ </span>
86
+ );
87
+ }
88
+
89
+ type Phase = { kind: "idle" } | { kind: "up"; f: F; p: number } | { kind: "done"; f: F };
90
+
91
+ export function UploadDropzone({
92
+ /* how fast a file sends, 0..100 */
93
+ speed = 50,
94
+ /* how springy a file flies home, 0..100 */
95
+ bounce = 40,
96
+ corner = 20,
97
+ }: {
98
+ speed?: number;
99
+ bounce?: number;
100
+ corner?: number;
101
+ } = {}) {
102
+ const still = stillness();
103
+ const [phase, setPhase] = useState<Phase>({ kind: "idle" });
104
+ const [inBox, setInBox] = useState<string | null>(null);
105
+ const [held, setHeld] = useState<string | null>(null);
106
+ const [over, setOver] = useState(false);
107
+ /* what the box says when it refuses; null while it is not */
108
+ const [refuse, setRefuse] = useState<string | null>(null);
109
+ const tries = useRef(0);
110
+ const cool = useRef(0);
111
+ const [gulp, setGulp] = useState(false);
112
+ const zone = useRef<HTMLDivElement>(null);
113
+ const now = useRef<HTMLSpanElement>(null);
114
+ const tiles = useRef<Record<string, HTMLDivElement | null>>({});
115
+ const shake = useAnimationControls();
116
+ const raf = useRef(0);
117
+ const timers = useRef<number[]>([]);
118
+ const knobs = useRef({ speed });
119
+ knobs.current = { speed };
120
+
121
+ useEffect(
122
+ () => () => {
123
+ cancelAnimationFrame(raf.current);
124
+ raf.current = 0;
125
+ for (const t of timers.current) clearTimeout(t);
126
+ clearTimeout(cool.current);
127
+ },
128
+ [],
129
+ );
130
+ const later = (fn: () => void, ms: number) => {
131
+ timers.current.push(window.setTimeout(fn, ms));
132
+ };
133
+
134
+ /* the pretend wire */
135
+ const send = (f: F) => {
136
+ setInBox(f.id);
137
+ if (still) {
138
+ finish(f);
139
+ return;
140
+ }
141
+ setPhase({ kind: "up", f, p: 0 });
142
+ let p = 0;
143
+ let prev = 0;
144
+ const secs = (4 - (clamp(knobs.current.speed, 0, 100) / 100) * 3.2) * (0.7 + Math.min(1, f.size / 5e6) * 0.6);
145
+ const tick = (t: number) => {
146
+ const dt = prev ? Math.min(0.05, (t - prev) / 1000) : 0;
147
+ prev = t;
148
+ /* uneven on purpose: a steady climb reads as a timer */
149
+ p = Math.min(1, p + (dt / secs) * (0.4 + Math.random() * 1.2));
150
+ if (p >= 1) {
151
+ raf.current = 0;
152
+ finish(f);
153
+ return;
154
+ }
155
+ setPhase({ kind: "up", f, p });
156
+ raf.current = requestAnimationFrame(tick);
157
+ };
158
+ raf.current = requestAnimationFrame(tick);
159
+ };
160
+
161
+ const finish = (f: F) => {
162
+ tries.current = 0;
163
+ setPhase({ kind: "done", f });
164
+ /* the file goes back to the tray, ready to go again */
165
+ later(() => {
166
+ setPhase({ kind: "idle" });
167
+ setInBox(null);
168
+ }, 1500);
169
+ };
170
+
171
+ /* The first refusal is plain. Try the same file again and the box starts to answer back, a new line each time, the
172
+ last one repeating, until a file that fits goes through, which puts it back to being polite. */
173
+ const refuseIt = () => {
174
+ const n = tries.current++;
175
+ setRefuse(n === 0 ? `Grande demais, no máximo ${MAX} MB` : SNARK[Math.min(n - 1, SNARK.length - 1)]);
176
+ shake.start({ x: [0, -7, 6, -4, 2, 0], transition: { duration: 0.42 } });
177
+ later(() => setRefuse(null), 1800);
178
+ /* "in a row" means close together: leave it alone a while and the next refusal is the polite one again */
179
+ clearTimeout(cool.current);
180
+ cool.current = window.setTimeout(() => {
181
+ tries.current = 0;
182
+ }, 8000);
183
+ };
184
+
185
+ /* carrying a file */
186
+ const drag = useRef<null | { id: string; x0: number; y0: number; k: number }>(null);
187
+
188
+ const place = (id: string, dx: number, dy: number, lift: boolean) => {
189
+ const el = tiles.current[id];
190
+ if (!el) return;
191
+ el.style.transform = `translate(${dx}px, ${dy}px) scale(${lift ? 1.08 : 1}) rotate(${lift ? clamp(dx * 0.04, -6, 6) : 0}deg)`;
192
+ };
193
+
194
+ const hits = (e: ReactPointerEvent) => {
195
+ const r = zone.current?.getBoundingClientRect();
196
+ return !!r && e.clientX > r.left && e.clientX < r.right && e.clientY > r.top && e.clientY < r.bottom;
197
+ };
198
+
199
+ const onDown = (f: F) => (e: ReactPointerEvent<HTMLDivElement>) => {
200
+ if (inBox === f.id) return;
201
+ try {
202
+ e.currentTarget.setPointerCapture(e.pointerId);
203
+ } catch {
204
+ /* a scripted pointer */
205
+ }
206
+ /* the block may sit inside a scaled parent: pointer deltas are screen pixels, the transform is not */
207
+ const k = e.currentTarget.getBoundingClientRect().width / e.currentTarget.offsetWidth || 1;
208
+ drag.current = { id: f.id, x0: e.clientX, y0: e.clientY, k };
209
+ setHeld(f.id);
210
+ place(f.id, 0, 0, true);
211
+ };
212
+
213
+ const onMove = (e: ReactPointerEvent) => {
214
+ const g = drag.current;
215
+ if (!g) return;
216
+ place(g.id, (e.clientX - g.x0) / g.k, (e.clientY - g.y0) / g.k, true);
217
+ const on = hits(e) && phase.kind === "idle";
218
+ if (on !== over) setOver(on);
219
+ };
220
+
221
+ const onUp = (f: F) => (e: ReactPointerEvent<HTMLDivElement>) => {
222
+ const g = drag.current;
223
+ drag.current = null;
224
+ try {
225
+ e.currentTarget.releasePointerCapture(e.pointerId);
226
+ } catch {
227
+ /* never captured */
228
+ }
229
+ setHeld(null);
230
+ setOver(false);
231
+ if (!g) return;
232
+ const inside = hits(e);
233
+ if (inside && phase.kind === "idle") {
234
+ if (f.size > MAX * 1e6) refuseIt();
235
+ else {
236
+ suck(f, (e.clientX - g.x0) / g.k, (e.clientY - g.y0) / g.k, g.k);
237
+ return;
238
+ }
239
+ }
240
+ /* springs home */
241
+ place(f.id, 0, 0, false);
242
+ };
243
+
244
+ /* The suction: from where it was let go to the box's middle, on an ease-in (slow to leave and faster the nearer it
245
+ gets, which reads as being pulled rather than as flying). It shrinks as it goes and draws out long in the
246
+ direction of travel, then the box gulps and the upload takes its place. */
247
+ const suck = (f: F, dx: number, dy: number, k: number) => {
248
+ const el = tiles.current[f.id];
249
+ const z = zone.current;
250
+ if (!el || !z || still) {
251
+ place(f.id, 0, 0, false);
252
+ send(f);
253
+ return;
254
+ }
255
+ const a = el.getBoundingClientRect();
256
+ const b = z.getBoundingClientRect();
257
+ const tx = dx + (b.left + b.width / 2 - (a.left + a.width / 2)) / k;
258
+ const ty = dy + (b.top + b.height / 2 - (a.top + a.height / 2)) / k;
259
+ /* drawn out along the pull, never turned: a tile rotated to face its travel went upside down on the way up and
260
+ spun on a short pull. Up or down it goes tall, sideways it goes wide, and a pull too short to have a direction
261
+ only shrinks */
262
+ const ux = Math.abs(tx - dx);
263
+ const uy = Math.abs(ty - dy);
264
+ const squeeze = Math.hypot(ux, uy) < 24 ? "scale(0.16)" : uy >= ux ? "scale(0.14, 0.34)" : "scale(0.34, 0.14)";
265
+ el.style.transition = "transform 380ms cubic-bezier(0.55, 0, 0.85, 0.3), opacity 300ms ease-in 80ms";
266
+ el.style.transform = `translate(${tx}px, ${ty}px) ${squeeze}`;
267
+ el.style.opacity = "0";
268
+ /* the upload starts as the tile goes down the hole, not after it has finished vanishing: waiting out the whole
269
+ pull left a beat where the box sat empty, reading as a freeze */
270
+ later(() => {
271
+ setGulp(true);
272
+ later(() => setGulp(false), 460);
273
+ }, 200);
274
+ later(() => send(f), 220);
275
+ later(() => {
276
+ /* put the tile home unseen, ready to pop back later */
277
+ el.style.transition = "none";
278
+ el.style.transform = "";
279
+ el.style.opacity = "";
280
+ }, 390);
281
+ };
282
+
283
+ /* The box hugs the upload: while a file is in it the box closes in round the upload row (measured, because the row is
284
+ as wide as its name) and grows a little when the tick arrives; back to idle it opens out to the full drop box
285
+ again. Width and height, never a scale, so its corner stays the corner. It shrinks inside a slot of fixed size, so
286
+ nothing below it moves. */
287
+ useLayoutEffect(() => {
288
+ const z = zone.current;
289
+ const n = now.current;
290
+ if (!z) return;
291
+ if (phase.kind === "idle" || !n) {
292
+ z.style.width = "";
293
+ z.style.height = "";
294
+ return;
295
+ }
296
+ z.style.width = `${n.offsetWidth + 32}px`;
297
+ z.style.height = `${n.offsetHeight + 28}px`;
298
+ }, [phase.kind]);
299
+
300
+ const r = Math.max(0, corner);
301
+ const up = phase.kind !== "idle";
302
+ const f = phase.kind !== "idle" ? phase.f : null;
303
+
304
+ return (
305
+ <div
306
+ className="bn-upl"
307
+ data-held={held ? true : undefined}
308
+ style={
309
+ {
310
+ "--upl-r": `${r}px`,
311
+ "--upl-spring": `cubic-bezier(0.3, ${1 + (bounce / 100) * 0.9}, 0.4, 1)`,
312
+ } as CSSProperties
313
+ }
314
+ >
315
+ <motion.div animate={shake} className="bn-upl-zone-wrap">
316
+ <div
317
+ ref={zone}
318
+ className="bn-upl-zone"
319
+ data-over={over || undefined}
320
+ data-refuse={refuse ? true : undefined}
321
+ data-busy={up || undefined}
322
+ data-gulp={gulp || undefined}
323
+ aria-live="polite"
324
+ >
325
+ {!f ? (
326
+ <>
327
+ <span className="bn-upl-icon">
328
+ <UploadSimple size={18} weight="bold" />
329
+ </span>
330
+ <span className="bn-upl-say">
331
+ <b>{refuse ?? (over ? "Solte para enviar" : "Arraste um arquivo")}</b>
332
+ <span>Até {MAX} MB</span>
333
+ </span>
334
+ </>
335
+ ) : (
336
+ <span className="bn-upl-now" key={f.id} ref={now}>
337
+ <Page f={f} size={36} />
338
+ <span className="bn-upl-text">
339
+ <span className="bn-upl-name">{f.name}</span>
340
+ <span className="bn-upl-meta">
341
+ {fmt(f.size)} ·{" "}
342
+ {phase.kind === "done" ? "Enviado" : <Roll value={phase.kind === "up" ? phase.p * 100 : 0} />}
343
+ </span>
344
+ <span className="bn-upl-track">
345
+ <span className="bn-upl-bar" style={{ transform: `scaleX(${phase.kind === "up" ? phase.p : 1})` }} />
346
+ </span>
347
+ </span>
348
+ {/* the tick's room is kept from the start, so the box is its final size before the tick arrives */}
349
+ <span className="bn-upl-end">
350
+ {phase.kind === "done" && (
351
+ <span className="bn-upl-tick">
352
+ <Check size={14} weight="bold" />
353
+ </span>
354
+ )}
355
+ </span>
356
+ </span>
357
+ )}
358
+ </div>
359
+ </motion.div>
360
+
361
+ <div className="bn-upl-tray">
362
+ {FILES.map(file => (
363
+ <div key={file.id} className="bn-upl-slot" data-empty={inBox === file.id || undefined}>
364
+ <div
365
+ ref={el => {
366
+ tiles.current[file.id] = el;
367
+ }}
368
+ className="bn-upl-file"
369
+ data-held={held === file.id || undefined}
370
+ data-gone={inBox === file.id || undefined}
371
+ role="button"
372
+ tabIndex={0}
373
+ aria-label={`${file.name}, ${fmt(file.size)}. Arraste para a caixa para enviar, ou aperte Enter.`}
374
+ onPointerDown={onDown(file)}
375
+ onPointerMove={onMove}
376
+ onPointerUp={onUp(file)}
377
+ onPointerCancel={onUp(file)}
378
+ onKeyDown={e => {
379
+ if ((e.key === "Enter" || e.key === " ") && phase.kind === "idle" && inBox !== file.id) {
380
+ e.preventDefault();
381
+ if (file.size > MAX * 1e6) refuseIt();
382
+ else send(file);
383
+ }
384
+ }}
385
+ >
386
+ <Page f={file} />
387
+ <span className="bn-upl-label">{file.name}</span>
388
+ </div>
389
+ </div>
390
+ ))}
391
+ </div>
392
+ </div>
393
+ );
394
+ }