@crievo/omni 0.1.2 → 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 (122) hide show
  1. package/docs/onboarding.md +69 -161
  2. package/omnivideo.config.example.json +1 -1
  3. package/package.json +2 -1
  4. package/reference/learnings.md +12 -0
  5. package/reference/omnichat.md +5 -4
  6. package/reference/publish.md +9 -9
  7. package/reference/studio.md +1 -1
  8. package/studio/api/app.ts +2 -2
  9. package/studio/api/routes/sound.ts +22 -3
  10. package/studio/api/services/onboarding.ts +242 -21
  11. package/studio/api/services/settings.ts +4 -2
  12. package/studio/api/services/sound.ts +45 -0
  13. package/studio/shared/types/api.ts +26 -1
  14. package/studio/studio.ts +1 -0
  15. package/studio/web/components/AppShell.tsx +3 -1
  16. package/studio/web/components/CloneVoiceDialog/RecordStep.tsx +3 -0
  17. package/studio/web/components/CloneVoiceDialog/RecorderBar.tsx +37 -20
  18. package/studio/web/components/CloneVoiceDialog/useCloneVoice.ts +39 -12
  19. package/studio/web/components/MissingDmsPanel/BatchSend.tsx +13 -4
  20. package/studio/web/components/Omni/OmniWidget.tsx +14 -5
  21. package/studio/web/components/Omni/PendingActions.tsx +25 -6
  22. package/studio/web/components/OmnichatRulePanel/RuleConfig.tsx +13 -3
  23. package/studio/web/components/OmnichatRulePanel/RuleEnableConfirm.tsx +14 -4
  24. package/studio/web/components/ResultMessage.tsx +12 -3
  25. package/studio/web/components/Sidebar.tsx +63 -24
  26. package/studio/web/components/StageStepper.tsx +105 -34
  27. package/studio/web/components/Toaster.tsx +25 -0
  28. package/studio/web/components/VoiceCarousel.tsx +19 -16
  29. package/studio/web/components/VoiceProfilesSettings.tsx +8 -3
  30. package/studio/web/components/bencho/ActionNode.tsx +456 -0
  31. package/studio/web/components/bencho/AssigneePicker.tsx +246 -0
  32. package/studio/web/components/bencho/BenchoScope.tsx +8 -0
  33. package/studio/web/components/bencho/Checklist.tsx +421 -0
  34. package/studio/web/components/bencho/CreateMenu.tsx +258 -0
  35. package/studio/web/components/bencho/GenerateImage.tsx +290 -0
  36. package/studio/web/components/bencho/ImageAccordion.tsx +273 -0
  37. package/studio/web/components/bencho/LiquidToggle.tsx +289 -0
  38. package/studio/web/components/bencho/MagnifyDock.tsx +200 -0
  39. package/studio/web/components/bencho/NowPlaying.tsx +403 -0
  40. package/studio/web/components/bencho/OneTimeCode.tsx +374 -0
  41. package/studio/web/components/bencho/PullToRefresh.tsx +527 -0
  42. package/studio/web/components/bencho/SlideToConfirm.tsx +385 -0
  43. package/studio/web/components/bencho/SloshSlider.tsx +394 -0
  44. package/studio/web/components/bencho/StackedBars.tsx +342 -0
  45. package/studio/web/components/bencho/ToastStack.tsx +263 -0
  46. package/studio/web/components/bencho/UploadDropzone.tsx +394 -0
  47. package/studio/web/components/bencho/VoiceNote.tsx +705 -0
  48. package/studio/web/components/icons.tsx +27 -0
  49. package/studio/web/components/ui/slider.tsx +34 -1
  50. package/studio/web/components/ui/switch.tsx +18 -5
  51. package/studio/web/hooks/useOnboarding.ts +24 -6
  52. package/studio/web/index.html +1 -1
  53. package/studio/web/lib/alerts.ts +10 -0
  54. package/studio/web/lib/omni-panel.ts +4 -1
  55. package/studio/web/lib/toast.ts +196 -0
  56. package/studio/web/lib/upload.ts +24 -0
  57. package/studio/web/pages/BenchoPage/index.tsx +74 -0
  58. package/studio/web/pages/BenchoPage/registry.ts +174 -0
  59. package/studio/web/pages/GettingStartedPage/components/ComputerCard.tsx +102 -0
  60. package/studio/web/pages/GettingStartedPage/index.tsx +84 -58
  61. package/studio/web/pages/NewVideoPage/index.tsx +6 -2
  62. package/studio/web/pages/ReferencesPage/index.tsx +4 -1
  63. package/studio/web/pages/SettingsPage/components/AgentAccounts.tsx +30 -3
  64. package/studio/web/pages/VideoPage/components/ChangedVoices.tsx +14 -17
  65. package/studio/web/pages/VideoPage/components/LineTextEditor.tsx +1 -1
  66. package/studio/web/pages/VideoPage/components/PublishForm.tsx +24 -17
  67. package/studio/web/pages/VideoPage/components/ScheduledInfo.tsx +2 -1
  68. package/studio/web/pages/VideoPage/components/SoundTimeline/AddSound.tsx +12 -14
  69. package/studio/web/pages/VideoPage/components/SoundTimeline/BedRow.tsx +27 -11
  70. package/studio/web/pages/VideoPage/components/SoundTimeline/CaptionSaveBar.tsx +22 -18
  71. package/studio/web/pages/VideoPage/components/SoundTimeline/TimelineFooter.tsx +3 -4
  72. package/studio/web/pages/VideoPage/components/SoundTimeline/UploadTrack.tsx +59 -0
  73. package/studio/web/pages/VideoPage/components/SoundTimeline/useSoundTimeline.ts +1 -0
  74. package/studio/web/pages/VideoPage/components/VoicePauseBanner.tsx +4 -6
  75. package/studio/web/pages/VideoPage/index.tsx +4 -0
  76. package/studio/web/pages/VideosPage/components/VideoRow.tsx +2 -4
  77. package/studio/web/router.tsx +8 -0
  78. package/studio/web/styles/components/bencho/action-node.css +227 -0
  79. package/studio/web/styles/components/bencho/checklist.css +182 -0
  80. package/studio/web/styles/components/bencho/dock.css +188 -0
  81. package/studio/web/styles/components/bencho/gallery.css +109 -0
  82. package/studio/web/styles/components/bencho/generate.css +151 -0
  83. package/studio/web/styles/components/bencho/image-accordion.css +37 -0
  84. package/studio/web/styles/components/bencho/liq-create.css +279 -0
  85. package/studio/web/styles/components/bencho/liq-toggle.css +106 -0
  86. package/studio/web/styles/components/bencho/one-time-code.css +148 -0
  87. package/studio/web/styles/components/bencho/picker.css +204 -0
  88. package/studio/web/styles/components/bencho/pull.css +313 -0
  89. package/studio/web/styles/components/bencho/scope.css +42 -0
  90. package/studio/web/styles/components/bencho/slide-confirm.css +139 -0
  91. package/studio/web/styles/components/bencho/slosh.css +137 -0
  92. package/studio/web/styles/components/bencho/sound.css +260 -0
  93. package/studio/web/styles/components/bencho/stacked-bars.css +334 -0
  94. package/studio/web/styles/components/bencho/toast-stack.css +192 -0
  95. package/studio/web/styles/components/bencho/tokens.css +243 -0
  96. package/studio/web/styles/components/bencho/upload-dropzone.css +352 -0
  97. package/studio/web/styles/components/bencho/voice-note.css +337 -0
  98. package/studio/web/styles/components/clone-voice.css +12 -2
  99. package/studio/web/styles/components/omni.css +6 -1
  100. package/studio/web/styles/components/stepper.css +123 -7
  101. package/studio/web/styles/components/toaster.css +10 -0
  102. package/studio/web/styles/components/ui/slider.css +6 -16
  103. package/studio/web/styles/components/ui/switch.css +2 -41
  104. package/studio/web/styles/components/voice-carousel.css +46 -4
  105. package/studio/web/styles/index.css +23 -0
  106. package/studio/web/styles/pages/getting-started.css +46 -0
  107. package/studio/web/styles/pages/settings/shell.css +4 -0
  108. package/studio/web/styles/pages/video/layout.css +1 -4
  109. package/studio/web/styles/pages/video/sound-timeline.css +8 -0
  110. package/studio/web/styles/theme/identity/shell.css +10 -26
  111. package/tools/lib/agents.mjs +24 -13
  112. package/tools/lib/claude-settings.mjs +32 -3
  113. package/tools/lib/deps.mjs +24 -0
  114. package/tools/lib/platforms/instagram.mjs +1 -1
  115. package/tools/lib/up.mjs +5 -2
  116. package/tools/omni.mjs +7 -5
  117. package/tools/omnichat.mjs +5 -7
  118. package/tools/package.mjs +5 -3
  119. package/tools/publish.mjs +40 -137
  120. package/tools/reference.mjs +1 -1
  121. package/tools/setup.mjs +16 -15
  122. package/tools/sound.mjs +67 -3
@@ -37,6 +37,8 @@ export function useCloneVoice({ settings, onCreate, onClose, redo, onRedone }: C
37
37
  const [device, setDevice] = useState("");
38
38
  const [recording, setRecording] = useState(false);
39
39
  const [elapsed, setElapsed] = useState(0);
40
+ /* the take just recorded, which the recorder shows as a clip to play back (it is also in `samples`) */
41
+ const [takeId, setTakeId] = useState<string | null>(null);
40
42
  const [playing, setPlaying] = useState<string | null>(null);
41
43
  const [warning, setWarning] = useState<string | null>(null);
42
44
  const [name, setName] = useState(redo?.name ?? "");
@@ -61,6 +63,8 @@ export function useCloneVoice({ settings, onCreate, onClose, redo, onRedone }: C
61
63
 
62
64
  const mic = useRef<{ stream: MediaStream; ctx: AudioContext; timer: number } | null>(null);
63
65
  const recorder = useRef<{ mr: MediaRecorder; start: number; timer: number } | null>(null);
66
+ /* set by cancel(): the stopped recording is dropped instead of becoming a sample */
67
+ const discarding = useRef(false);
64
68
  const fileInput = useRef<HTMLInputElement>(null);
65
69
  const player = useRef<HTMLAudioElement | null>(null);
66
70
  const counter = useRef(0);
@@ -198,6 +202,7 @@ export function useCloneVoice({ settings, onCreate, onClose, redo, onRedone }: C
198
202
 
199
203
  function openRecorder() {
200
204
  setView("record");
205
+ setTakeId(null);
201
206
  setLanguageOk(false);
202
207
  setWarning(null);
203
208
  }
@@ -241,6 +246,7 @@ export function useCloneVoice({ settings, onCreate, onClose, redo, onRedone }: C
241
246
  return;
242
247
  }
243
248
  closeMic();
249
+ setTakeId(null);
244
250
  if (languageOk) {
245
251
  setLanguageOk(false);
246
252
  return;
@@ -260,23 +266,26 @@ export function useCloneVoice({ settings, onCreate, onClose, redo, onRedone }: C
260
266
  const s = g ? (performance.now() - g.start) / 1000 : 0;
261
267
  recorder.current = null;
262
268
  setRecording(false);
269
+ if (discarding.current) {
270
+ discarding.current = false;
271
+ return;
272
+ }
263
273
  if (s < 1.5) {
264
274
  setWarning("A gravação ficou curta demais: leia um trecho maior do roteiro.");
265
275
  return;
266
276
  }
267
277
  const blob = new Blob(parts, { type: mr.mimeType || "audio/webm" });
268
278
  counter.current += 1;
269
- setSamples(l => [
270
- ...l,
271
- {
272
- id: `g${Date.now()}`,
273
- name: `Gravação ${counter.current}`,
274
- origin: "recording",
275
- blob,
276
- url: URL.createObjectURL(blob),
277
- s,
278
- },
279
- ]);
279
+ const take: Sample = {
280
+ id: `g${Date.now()}`,
281
+ name: `Gravação ${counter.current}`,
282
+ origin: "recording",
283
+ blob,
284
+ url: URL.createObjectURL(blob),
285
+ s,
286
+ };
287
+ setSamples(l => [...l, take]);
288
+ setTakeId(take.id);
280
289
  };
281
290
  const startedAt = performance.now();
282
291
  /* stops by itself at the 3 min limit, adding what was already recorded */
@@ -288,11 +297,25 @@ export function useCloneVoice({ settings, onCreate, onClose, redo, onRedone }: C
288
297
  recorder.current = { mr, start: startedAt, timer };
289
298
  setElapsed(0);
290
299
  setWarning(null);
300
+ setTakeId(null);
301
+ discarding.current = false;
291
302
  setRecording(true);
292
303
  mr.start();
293
304
  }
294
305
 
295
- const stop = () => recorder.current?.mr.stop();
306
+ /* a second tap, or the limit, can come before the recorder says it stopped: stopping twice throws */
307
+ const stop = () => {
308
+ const mr = recorder.current?.mr;
309
+ if (mr?.state === "recording") mr.stop();
310
+ };
311
+
312
+ /* stops and throws the recording away: it never becomes a sample */
313
+ const cancel = () => {
314
+ const mr = recorder.current?.mr;
315
+ if (mr?.state !== "recording") return;
316
+ discarding.current = true;
317
+ mr.stop();
318
+ };
296
319
 
297
320
  function openSave() {
298
321
  closeMic();
@@ -338,6 +361,7 @@ export function useCloneVoice({ settings, onCreate, onClose, redo, onRedone }: C
338
361
  player.current?.pause();
339
362
  setPlaying(null);
340
363
  }
364
+ setTakeId(t => (t === id ? null : t));
341
365
  }
342
366
 
343
367
  function listen(m: { id: string; url: string }) {
@@ -384,6 +408,7 @@ export function useCloneVoice({ settings, onCreate, onClose, redo, onRedone }: C
384
408
  const called = recording ? starts.filter(t => t <= elapsed).length : 0;
385
409
  const hasFish = !!(settings.voice.fish.key.masked || settings.voice.fish.key.in_env);
386
410
  const hasCartesia = !!(settings.voice.cartesia.key.masked || settings.voice.cartesia.key.in_env);
411
+ const take = takeId ? (samples.find(m => m.id === takeId) ?? null) : null;
387
412
  const total = samples.reduce((s, m) => s + m.s, 0);
388
413
  totalRef.current = total;
389
414
  const measured = total + (recording ? elapsed : 0);
@@ -502,6 +527,8 @@ export function useCloneVoice({ settings, onCreate, onClose, redo, onRedone }: C
502
527
  back,
503
528
  start,
504
529
  stop,
530
+ cancel,
531
+ take,
505
532
  openSave,
506
533
  onPickFiles,
507
534
  remove,
@@ -3,6 +3,8 @@
3
3
 
4
4
  import type { MissingDms } from "../../hooks/useMissingDms";
5
5
  import { SEND_MODES } from "../../lib/missing-dms";
6
+ import { BenchoScope } from "../bencho/BenchoScope";
7
+ import { SlideToConfirm } from "../bencho/SlideToConfirm";
6
8
  import { Check, Power, X } from "../icons";
7
9
 
8
10
  export function BatchSend({
@@ -116,10 +118,17 @@ export function BatchSend({
116
118
  )}
117
119
  </p>
118
120
  <div className="actions">
119
- <button className="button approve" disabled={!!busy} onClick={send}>
120
- <Power className="ic" />{" "}
121
- {busy === "send" ? "Mandando…" : `Confirmar: ${commentOnly ? "responder" : "mandar"} ${onBehalf}`}
122
- </button>
121
+ <BenchoScope>
122
+ <SlideToConfirm
123
+ className="bn-sld-grow"
124
+ width="fill"
125
+ label={commentOnly ? "Deslize para responder" : "Deslize para mandar"}
126
+ busyLabel="Mandando…"
127
+ busy={busy === "send"}
128
+ disabled={!!busy}
129
+ onConfirm={send}
130
+ />
131
+ </BenchoScope>
123
132
  <button className="button" disabled={!!busy} onClick={() => setConfirm(false)}>
124
133
  <X className="ic" /> Cancelar
125
134
  </button>
@@ -244,9 +244,17 @@ export function OmniWidget({ menuCollapsed }: { menuCollapsed: boolean }) {
244
244
  const [autoCall, setAutoCall] = useState(false);
245
245
  const toggle = () => {
246
246
  if (open) return collapse();
247
- setOpen(true);
248
- if (live || !info) return;
249
- if (!info.config.enabled || unavailable || claudeMode) return greeting.play(info.config.gender);
247
+ /* a call already on, minimized in the card: the second click brings the dock */
248
+ if (live) return setOpen(true);
249
+ if (!info) return setOpen(true);
250
+ /* with no call to start (Omni off, unavailable, voice quota over) the dock opens and the greeting plays */
251
+ if (!info.config.enabled || unavailable || claudeMode) {
252
+ setOpen(true);
253
+ return greeting.play(info.config.gender);
254
+ }
255
+ /* the first click only activates the voice: the call starts minimized, living in the card (with the
256
+ menu collapsed there is no card to show it, so the dock opens) */
257
+ if (menuCollapsed) setOpen(true);
250
258
  setAutoCall(true);
251
259
  void conv.start(mode, undefined, { greet: info.config.gender, holdIdle: true });
252
260
  };
@@ -331,7 +339,7 @@ export function OmniWidget({ menuCollapsed }: { menuCollapsed: boolean }) {
331
339
  }, [autoCall, conv.status, userSpoke, holdIdle, discard]);
332
340
  /* "Oi, Omni" (the switch in the settings): starts the conversation the way opening the panel does (a
333
341
  silent call ends by itself); in Claude mode it starts the recording. With the menu expanded the call
334
- starts minimized, living in the sidebar card (its icon or a click opens the dock); with the menu
342
+ starts minimized, living in the sidebar card (a click opens the dock); with the menu
335
343
  collapsed there is no card to show it, so the dock opens. Claude mode always opens it: the
336
344
  recording's button is there. It sleeps while Omni talks. */
337
345
  const callByVoice = () => {
@@ -418,10 +426,11 @@ export function OmniWidget({ menuCollapsed }: { menuCollapsed: boolean }) {
418
426
  available,
419
427
  face: info ? face : null,
420
428
  open,
429
+ active: live,
421
430
  ring: bubble,
422
431
  glow,
423
432
  });
424
- }, [available, info, face, open, bubble, glow]);
433
+ }, [available, info, face, open, live, bubble, glow]);
425
434
  /* while the agent works without speaking, the name gives way to a shimmering "pensando…" */
426
435
  const omniName = bubble === "thinking" ? <b className="omni-name-thinking">Pensando…</b> : <b>Omni</b>;
427
436
  /* agent.enabled false: Omni is off, no button and no panel (a live conversation still ends normally) */
@@ -1,8 +1,11 @@
1
- /* An action Omni staged: nothing runs until the user presses Confirmar here, or says yes to Omni, who
2
- then presses it (confirm_action; the server only takes a card the user has spoken after). */
1
+ /* An action Omni staged: nothing runs until the user slides to confirm here, or says yes to Omni, who
2
+ then confirms it on the server (confirm_action; the server only takes a card the user has spoken after, and the
3
+ card leaves this list when it resolves, so the voice path never needs the slide). */
3
4
 
4
5
  import { useEffect, useRef, useState } from "react";
5
6
  import { CaretLeft, CaretRight } from "../icons";
7
+ import { BenchoScope } from "../bencho/BenchoScope";
8
+ import { SlideToConfirm } from "../bencho/SlideToConfirm";
6
9
  import type { AgentPendingAction } from "../../types/api";
7
10
 
8
11
  const KIND_TITLE: Record<AgentPendingAction["kind"], string> = {
@@ -34,6 +37,14 @@ const KIND_BUSY: Record<AgentPendingAction["kind"], string> = {
34
37
  omnichat_send: "Mandando…",
35
38
  };
36
39
 
40
+ /* the slide's words: the verb says what leaves the studio, since there is no second button to read it from */
41
+ const KIND_SLIDE: Partial<Record<AgentPendingAction["kind"], string>> = {
42
+ publish: "Deslize para publicar",
43
+ schedule: "Deslize para agendar",
44
+ omnichat: "Deslize para gravar",
45
+ omnichat_send: "Deslize para mandar",
46
+ };
47
+
37
48
  /* a summary past three lines folds, with a "Mostrar tudo" that opens it */
38
49
  function Summary({ text }: { text: string }) {
39
50
  const ref = useRef<HTMLParagraphElement>(null);
@@ -83,6 +94,7 @@ export function PendingActions({
83
94
  const r = await onResolve(a, confirm);
84
95
  setBusy(null);
85
96
  setErrors(e => ({ ...e, [a.id]: r.ok ? "" : (r.error ?? "falhou") }));
97
+ return r.ok;
86
98
  };
87
99
  /* mouse drag scrolls the carousel (touch scrolls natively); a drag never presses a button */
88
100
  const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
@@ -150,12 +162,19 @@ export function PendingActions({
150
162
  {errors[a.id] && <p className="response error">{errors[a.id]}</p>}
151
163
  </div>
152
164
  <div className="omni-pending-actions">
153
- <button type="button" className="button" disabled={busy === a.id} onClick={() => act(a, false)}>
165
+ <BenchoScope>
166
+ <SlideToConfirm
167
+ width="fill"
168
+ label={KIND_SLIDE[a.kind] ?? "Deslize para confirmar"}
169
+ busyLabel={KIND_BUSY[a.kind]}
170
+ busy={busy === a.id}
171
+ disabled={busy === a.id}
172
+ onConfirm={() => act(a, true)}
173
+ />
174
+ </BenchoScope>
175
+ <button type="button" className="button small" disabled={busy === a.id} onClick={() => act(a, false)}>
154
176
  Cancelar
155
177
  </button>
156
- <button type="button" className="button primary" disabled={busy === a.id} onClick={() => act(a, true)}>
157
- {busy === a.id ? KIND_BUSY[a.kind] : "Confirmar"}
158
- </button>
159
178
  </div>
160
179
  </div>
161
180
  ))}
@@ -7,6 +7,8 @@ import { useState } from "react";
7
7
  import type { RuleEditor } from "../../hooks/useRuleEditor";
8
8
  import type { OmnichatRuleDetail } from "../../types/api";
9
9
  import { ArrowCounterClockwise, ArrowLeft, ArrowRight, Check, FloppyDisk, Play, SlidersHorizontal, X } from "../icons";
10
+ import { BenchoScope } from "../bencho/BenchoScope";
11
+ import { SlideToConfirm } from "../bencho/SlideToConfirm";
10
12
  import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "../ui/accordion";
11
13
  import { STEPS, stepInfo, type StepId } from "./rule-steps";
12
14
  import { MessageStep } from "./steps/MessageStep";
@@ -111,9 +113,17 @@ export function RuleConfig({
111
113
  )}
112
114
  {confirmSave && enabled ? (
113
115
  <>
114
- <button className="button approve" disabled={!!busy} onClick={save}>
115
- <Check className="ic" /> {busy === "save" ? "Gravando…" : "Confirmar: gravar com a regra ligada"}
116
- </button>
116
+ <BenchoScope>
117
+ <SlideToConfirm
118
+ className="bn-sld-grow"
119
+ width="fill"
120
+ label="Deslize para gravar com a regra ligada"
121
+ busyLabel="Gravando…"
122
+ busy={busy === "save"}
123
+ disabled={!!busy}
124
+ onConfirm={save}
125
+ />
126
+ </BenchoScope>
117
127
  <button className="button" disabled={!!busy} onClick={() => setConfirmSave(false)}>
118
128
  <X className="ic" /> Cancelar
119
129
  </button>
@@ -3,7 +3,9 @@
3
3
  import { capitalize } from "../../lib/format";
4
4
  import type { RuleEditor } from "../../hooks/useRuleEditor";
5
5
  import type { OmnichatRuleDetail } from "../../types/api";
6
- import { Power, X } from "../icons";
6
+ import { BenchoScope } from "../bencho/BenchoScope";
7
+ import { SlideToConfirm } from "../bencho/SlideToConfirm";
8
+ import { X } from "../icons";
7
9
  import { triggerLabel } from "../RuleForm";
8
10
 
9
11
  export function RuleEnableConfirm({
@@ -30,9 +32,17 @@ export function RuleEnableConfirm({
30
32
  : ` ${capitalize(thePost)} ainda não subiu: começa quando ele for publicado.`}
31
33
  </p>
32
34
  <div className="actions">
33
- <button className="button approve" disabled={!!busy} onClick={() => enable(true)}>
34
- <Power className="ic" /> {busy === "enable" ? "Ligando…" : `Confirmar: responder ${onBehalf}`}
35
- </button>
35
+ <BenchoScope>
36
+ <SlideToConfirm
37
+ className="bn-sld-grow"
38
+ width="fill"
39
+ label="Deslize para ligar e responder"
40
+ busyLabel="Ligando…"
41
+ busy={busy === "enable"}
42
+ disabled={!!busy}
43
+ onConfirm={() => enable(true)}
44
+ />
45
+ </BenchoScope>
36
46
  <button className="button" disabled={!!busy} onClick={() => setConfirm(false)}>
37
47
  <X className="ic" /> Cancelar
38
48
  </button>
@@ -1,7 +1,16 @@
1
+ import { useEffect } from "react";
1
2
  import type { ActionResult } from "../lib/api";
3
+ import { isToastable, toastResult } from "../lib/toast";
2
4
 
3
- /* the output (ok) or the error of the last save */
4
- export const ResultMessage = ({ result }: { result: ActionResult }) =>
5
- result ? (
5
+ /* the output (ok) or the error of the last save: a toast, which goes away on its own. A long or multi-line text
6
+ (command output, a stack of failures) is still written here, since the reader may need to come back to it;
7
+ `inline` forces that for a result that must stay on the screen. */
8
+ export function ResultMessage({ result, inline }: { result: ActionResult; inline?: boolean }) {
9
+ const toasted = !!result && !inline && isToastable(result);
10
+ useEffect(() => {
11
+ if (toasted) toastResult(result);
12
+ }, [toasted, result]);
13
+ return result && !toasted ? (
6
14
  <p className={`response ${result.ok ? "ok" : "error"}`}>{result.ok ? result.output : result.error}</p>
7
15
  ) : null;
16
+ }
@@ -9,6 +9,8 @@
9
9
  import type { ReactNode } from "react";
10
10
  import { Link, useMatchRoute, useNavigate } from "@tanstack/react-router";
11
11
  import { useAdmin } from "../hooks/useAdmin";
12
+ import { BenchoScope } from "./bencho/BenchoScope";
13
+ import { CreateMenu, type CreateMenuItem } from "./bencho/CreateMenu";
12
14
  import { useRunner } from "../hooks/useRunner";
13
15
  import { omniLevel, toggleOmniPanel, useOmniPanel } from "../lib/omni-panel";
14
16
  import { OmniGlow } from "./Omni/OmniGlow";
@@ -22,9 +24,10 @@ import {
22
24
  Dashboard,
23
25
  Gear,
24
26
  ArrowExpand,
27
+ GridFour,
25
28
  Minus,
26
29
  Plug,
27
- Plus,
30
+ Play,
28
31
  Power,
29
32
  ShieldCheck,
30
33
  SidebarLeft,
@@ -152,6 +155,42 @@ function NavLink({
152
155
  );
153
156
  }
154
157
 
158
+ declare module "@tanstack/history" {
159
+ interface HistoryState {
160
+ /* /new: which deliverables come checked */
161
+ deliverables?: ("video" | "carousel")[];
162
+ /* /styles: the new-style form comes open */
163
+ creating?: boolean;
164
+ }
165
+ }
166
+
167
+ /* The Criar menu: what the studio can start. The video and the carousel are the same form (/new, the carousel with its
168
+ deliverable already checked); a new visual style opens the form at the top of Estilos visuais. The destination reads
169
+ the router's history state, so no route needs a search schema. */
170
+ function useCreateItems(): CreateMenuItem[] {
171
+ const navigate = useNavigate();
172
+ return [
173
+ {
174
+ id: "video",
175
+ label: "Vídeo",
176
+ icon: Play,
177
+ onSelect: () => navigate({ to: "/new", state: { deliverables: ["video"] } }),
178
+ },
179
+ {
180
+ id: "carousel",
181
+ label: "Carrossel",
182
+ icon: GridFour,
183
+ onSelect: () => navigate({ to: "/new", state: { deliverables: ["carousel"] } }),
184
+ },
185
+ {
186
+ id: "style",
187
+ label: "Estilo visual",
188
+ icon: Sparkle,
189
+ onSelect: () => navigate({ to: "/styles", state: { creating: true } }),
190
+ },
191
+ ];
192
+ }
193
+
155
194
  /* `missing`: the required setup steps still missing (Primeiros passos shows while there are any) */
156
195
  export function Sidebar({
157
196
  collapsed,
@@ -165,7 +204,7 @@ export function Sidebar({
165
204
  showGettingStarted: boolean;
166
205
  }) {
167
206
  const isActive = useIsActive();
168
- const navigate = useNavigate();
207
+ const createItems = useCreateItems();
169
208
  const { hosted } = useRunner();
170
209
  const { isAdmin } = useAdmin();
171
210
  const omni = useOmniPanel();
@@ -194,24 +233,19 @@ export function Sidebar({
194
233
  <span className="menu-group-name">{g.name}</span>
195
234
  {g.items.map(item =>
196
235
  item.id === "videos" ? (
197
- /* "Criar vídeo" lives inside the Conteúdos row: the + sits over the link, as a sibling
198
- (a button inside <a> is not valid HTML), and its click never reaches the link */
236
+ /* "Criar" lives inside the Conteúdos row: the pill sits over the link, as a sibling
237
+ (a button inside <a> is not valid HTML), and its menu hangs from its corner */
199
238
  <div key={item.id} className="menu-item-with-action">
200
239
  <NavLink item={item} collapsed={collapsed} active={isActive(item)} />
201
- <button
202
- type="button"
203
- className="menu-action"
204
- title="Criar vídeo"
205
- aria-label="Criar vídeo"
206
- onClick={e => {
207
- e.stopPropagation();
208
- e.preventDefault();
209
- navigate({ to: "/new" });
210
- }}
211
- >
212
- <Plus weight="bold" aria-hidden="true" />
213
- <span>Criar</span>
214
- </button>
240
+ <BenchoScope>
241
+ <CreateMenu
242
+ className="menu-create"
243
+ label="Criar"
244
+ items={createItems}
245
+ anchor="top-end"
246
+ pillHeight={28}
247
+ />
248
+ </BenchoScope>
215
249
  </div>
216
250
  ) : (
217
251
  <NavLink key={item.id} item={item} collapsed={collapsed} active={isActive(item)} />
@@ -249,18 +283,23 @@ export function Sidebar({
249
283
  className={`menu-omni ${omni.ring}${omni.open ? " open" : ""}`}
250
284
  onClick={toggleOmniPanel}
251
285
  aria-expanded={omni.open}
252
- aria-label={omni.open ? "Minimizar o Omni" : "Falar com Omni"}
253
- title={collapsed ? "Falar com Omni" : omni.open ? "Minimizar o Omni" : undefined}
286
+ aria-label={omni.open ? "Minimizar o Omni" : omni.active ? "Abrir o Omni" : "Falar com Omni"}
287
+ title={
288
+ omni.open ? "Minimizar o Omni" : omni.active ? "Abrir o Omni" : collapsed ? "Falar com Omni" : undefined
289
+ }
254
290
  >
255
291
  <VoiceAvatar seed={omni.face ?? OMNI_AVATAR} size={40} />
256
292
  {/* minimized in a call, the button says what Omni is doing */}
257
293
  <span className="menu-label">
258
294
  {omni.open ? "Falar com Omni" : (GLOW_LABEL[omni.glow] ?? "Falar com Omni")}
259
295
  </span>
260
- {/* the whole card opens the dock; the icon says so (and, with the dock open, that it minimizes) */}
261
- <span className="menu-omni-expand" aria-hidden="true">
262
- {omni.open ? <Minus /> : <ArrowExpand />}
263
- </span>
296
+ {/* the first click activates the voice (minimized in the card); only then the icon shows, and the
297
+ next click opens the dock (with it open, the icon says it minimizes) */}
298
+ {(omni.active || omni.open) && (
299
+ <span className="menu-omni-expand" aria-hidden="true">
300
+ {omni.open ? <Minus /> : <ArrowExpand />}
301
+ </span>
302
+ )}
264
303
  </button>
265
304
  </OmniGlow>
266
305
  )}
@@ -1,8 +1,12 @@
1
- /* The pipeline stepper: each stage with its state now. On the overview each step links to the
2
- stage on the video page; on the page, it picks the stage. */
1
+ /* The pipeline stepper: each stage as a Bencho action node card with its state now. On the overview each card links
2
+ to the stage on the video page; on the page, it picks the stage. The corner buttons are the actions the studio
3
+ already has for the stage (approve, build the export again, open its process); a stage with none shows none. */
3
4
 
5
+ import { type CSSProperties, useState } from "react";
4
6
  import { Link } from "@tanstack/react-router";
5
7
  import { useNow } from "../hooks/useNow";
8
+ import { soundRoutes } from "../hooks/useSound";
9
+ import { api, enc, send, type ActionResult } from "../lib/api";
6
10
  import {
7
11
  STAGES,
8
12
  getStageState,
@@ -12,7 +16,10 @@ import {
12
16
  type StageState,
13
17
  } from "../lib/stages";
14
18
  import type { Queue, Ticket } from "../types/api";
15
- import { ArrowClockwise, Check, X } from "./icons";
19
+ import { ActionNode, type NodeAction } from "./bencho/ActionNode";
20
+ import { BenchoScope } from "./bencho/BenchoScope";
21
+ import { ResultMessage } from "./ResultMessage";
22
+ import { ArrowClockwise, Check, SidebarRight, X } from "./icons";
16
23
 
17
24
  function stepMark(s: StageState, stage: StageDef) {
18
25
  switch (s.kind) {
@@ -33,13 +40,15 @@ function stepMark(s: StageState, stage: StageDef) {
33
40
  }
34
41
  }
35
42
 
36
- /* `stages`: the pipeline shown (the video's by default; the carousel's in PIPELINES) */
43
+ /* `stages`: the pipeline shown (the video's by default; the carousel's in PIPELINES).
44
+ `onProcess`: opens the stage's process (the page's side panel); without it that action is not offered. */
37
45
  export function StageStepper({
38
46
  video,
39
47
  queue,
40
48
  runs,
41
49
  selected,
42
50
  onSelect,
51
+ onProcess,
43
52
  compact = false,
44
53
  stages = STAGES,
45
54
  }: {
@@ -49,39 +58,101 @@ export function StageStepper({
49
58
  runs?: Ticket[];
50
59
  selected?: string;
51
60
  onSelect?: (id: string) => void;
61
+ onProcess?: (id: string) => void;
52
62
  compact?: boolean;
53
63
  }) {
54
64
  const now = useNow();
65
+ const [busy, setBusy] = useState<string | null>(null);
66
+ const [note, setNote] = useState<ActionResult>(null);
67
+ const param = { slug: enc(video.slug) };
68
+ const run = async (key: string, request: () => Promise<NonNullable<ActionResult>>) => {
69
+ setBusy(key);
70
+ setNote(null);
71
+ const r = await request();
72
+ setBusy(null);
73
+ setNote(r);
74
+ };
75
+
76
+ /* the buttons a stage's card carries: only what the studio already does, in the order of how often it is wanted */
77
+ const actionsOf = (stage: StageDef, s: StageState): NodeAction[] => {
78
+ const list: NodeAction[] = [];
79
+ if (s.kind === "user" && stage.id === "script")
80
+ list.push({
81
+ id: "approve",
82
+ label: "Aprovar o roteiro",
83
+ icon: Check,
84
+ disabled: !!busy,
85
+ onClick: () =>
86
+ run("approve", () => send(api.videos[":slug"].script.decide.$post({ param, json: { decision: "approve" } }))),
87
+ });
88
+ if (s.kind === "user" && stage.id === "motion_script")
89
+ list.push({
90
+ id: "approve",
91
+ label: "Aprovar todas as cenas",
92
+ icon: Check,
93
+ disabled: !!busy,
94
+ onClick: () => run("approve", () => send(api.videos[":slug"].scenes["approve-all"].$post({ param, json: {} }))),
95
+ });
96
+ /* the editor's "Renderizar" is the same call; it is offered here only when the export is out of date */
97
+ if (s.kind === "warning" && stage.id === "render" && video.status?.stages.find(x => x.id === "export")?.outdated)
98
+ list.push({
99
+ id: "reassemble",
100
+ label: "Montar o export de novo",
101
+ icon: ArrowClockwise,
102
+ disabled: !!busy,
103
+ onClick: () => run("reassemble", () => soundRoutes(video.slug).reassemble()),
104
+ });
105
+ if (onProcess && ["running", "queued", "redoing", "error", "warning"].includes(s.kind))
106
+ list.push({
107
+ id: "process",
108
+ label: "Abrir o processamento",
109
+ icon: SidebarRight,
110
+ onClick: () => onProcess(stage.id),
111
+ });
112
+ return list;
113
+ };
114
+
55
115
  return (
56
- <ol className={`stepper${compact ? " compact" : ""}`}>
57
- {stages.map(stage => {
58
- const s = getStageState(stage, video, queue, runs);
59
- const hint = `${stage.num} · ${stage.title}${s.kind === "warning" ? `: ${s.reason}` : ""}`;
60
- const content = (
61
- <>
62
- <span className="step-mark">{stepMark(s, stage)}</span>
63
- <span className="step-name">{stage.name}</span>
64
- {s.kind !== "done" && <span className="step-status">{stageStateDetail(s, now)}</span>}
65
- </>
66
- );
67
- return (
68
- <li key={stage.id} className={`step ${s.kind}${selected === stage.id ? " chosen" : ""}`}>
69
- {onSelect ? (
70
- <button
71
- onClick={() => onSelect(stage.id)}
72
- title={hint}
73
- aria-current={selected === stage.id ? "step" : undefined}
74
- >
75
- {content}
76
- </button>
77
- ) : (
78
- <Link to="/videos/$slug/$stage" params={{ slug: video.slug, stage: stage.id }} title={hint}>
79
- {content}
80
- </Link>
81
- )}
82
- </li>
83
- );
84
- })}
85
- </ol>
116
+ <>
117
+ <ol className={`stepper cards${compact ? " compact" : ""}`} style={{ "--steps": stages.length } as CSSProperties}>
118
+ {stages.map(stage => {
119
+ const s = getStageState(stage, video, queue, runs);
120
+ const hint = `${stage.num} · ${stage.title}${s.kind === "warning" ? `: ${s.reason}` : ""}`;
121
+ return (
122
+ <li key={stage.id} className={`step ${s.kind}${selected === stage.id ? " chosen" : ""}`}>
123
+ <BenchoScope>
124
+ <ActionNode
125
+ title={stage.name}
126
+ description={stageStateDetail(s, now) || "Ainda não feita"}
127
+ hint={hint}
128
+ mark={<span className="step-mark">{stepMark(s, stage)}</span>}
129
+ actions={actionsOf(stage, s)}
130
+ people={[]}
131
+ selected={selected === stage.id}
132
+ width="100%"
133
+ dense
134
+ corner={compact ? 12 : 14}
135
+ air={0}
136
+ fan="down"
137
+ render={props =>
138
+ onSelect ? (
139
+ <button
140
+ type="button"
141
+ {...props}
142
+ onClick={() => onSelect(stage.id)}
143
+ aria-current={selected === stage.id ? "step" : undefined}
144
+ />
145
+ ) : (
146
+ <Link to="/videos/$slug/$stage" params={{ slug: video.slug, stage: stage.id }} {...props} />
147
+ )
148
+ }
149
+ />
150
+ </BenchoScope>
151
+ </li>
152
+ );
153
+ })}
154
+ </ol>
155
+ <ResultMessage result={note} />
156
+ </>
86
157
  );
87
158
  }