@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
@@ -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
  }
@@ -0,0 +1,25 @@
1
+ /* The studio's toasts (lib/toast.ts), drawn once in the app shell: Bencho's stack in the bottom-right corner. */
2
+
3
+ import { useEffect } from "react";
4
+ import { toast, useToasts } from "../lib/toast";
5
+ import { BenchoScope } from "./bencho/BenchoScope";
6
+ import { ToastStack } from "./bencho/ToastStack";
7
+
8
+ export function Toaster() {
9
+ const toasts = useToasts();
10
+ /* a stack open when the shell goes away must not leave every timer held */
11
+ useEffect(() => () => toast.resume(), []);
12
+ return (
13
+ <BenchoScope>
14
+ <div className="toaster">
15
+ <ToastStack
16
+ toasts={toasts}
17
+ onDismiss={toast.dismiss}
18
+ onOpenChange={open => (open ? toast.pause() : toast.resume())}
19
+ align="end"
20
+ floating
21
+ />
22
+ </div>
23
+ </BenchoScope>
24
+ );
25
+ }
@@ -1,8 +1,9 @@
1
1
  /* The carousel (Configurações → Voz). The center voice is the selected one; the neighbors are
2
- smaller and lighter. From there it becomes the active voice, or opens to edit. The carousel's
3
- last item adds a voice. */
2
+ smaller and lighter. From there it becomes the active voice, or opens to edit. The add ball is
3
+ not a carousel step: it stays in the slot before the center (centered while there is no voice),
4
+ and the voices that go past the center slide under it. */
4
5
 
5
- import { useEffect, useRef, useState } from "react";
6
+ import { type CSSProperties, useEffect, useRef, useState } from "react";
6
7
  import { useAvatarAudioReactive } from "../hooks/useAvatarAudioReactive";
7
8
  import { useVoiceProfilePreview } from "../hooks/useVoiceProfilePreview";
8
9
  import type { ActionResult } from "../lib/api";
@@ -26,7 +27,7 @@ export function VoiceCarousel({
26
27
  onEdit: (id: string) => void;
27
28
  onAdd: () => void;
28
29
  }) {
29
- const total = profiles.length; /* after the last voice comes the "+": it opens the modal, it is not a carousel step */
30
+ const total = profiles.length;
30
31
  const last = Math.max(0, total - 1);
31
32
  const initial = Math.max(
32
33
  0,
@@ -97,7 +98,8 @@ export function VoiceCarousel({
97
98
  <button
98
99
  key={p.id}
99
100
  type="button"
100
- className={`carousel-item d${Math.min(Math.abs(i - index), 3)}${p.id === defaultId ? " active" : ""}`}
101
+ className={`carousel-item d${Math.min(Math.abs(i - index), 3)}${p.id === defaultId ? " active" : ""}${i < index - 1 ? " under" : ""}`}
102
+ style={{ "--shift": `calc(${Math.max(0, index - 1 - i)} * var(--slot))` } as CSSProperties}
101
103
  onClick={() => go(i)}
102
104
  aria-label={p.name}
103
105
  aria-current={i === index}
@@ -106,18 +108,19 @@ export function VoiceCarousel({
106
108
  <VoiceAvatar seed={p.avatar || p.id} size={160} />
107
109
  </button>
108
110
  ))}
109
- <button
110
- type="button"
111
- className={`carousel-item more d${Math.min(Math.abs(total - index), 3)}`}
112
- onClick={onAdd}
113
- aria-label="Adicionar voz"
114
- title="Adicionar voz"
115
- >
116
- <span className="avatar-more large">
117
- <Plus />
118
- </span>
119
- </button>
120
111
  </div>
112
+ <button
113
+ type="button"
114
+ className={`carousel-add ${total ? "d1" : "d0"}${index > 0 ? " covering" : ""}`}
115
+ style={{ "--add-x": total ? -1.5 : -0.5 } as CSSProperties}
116
+ onClick={onAdd}
117
+ aria-label="Adicionar voz"
118
+ title="Adicionar voz"
119
+ >
120
+ <span className="avatar-more large">
121
+ <Plus />
122
+ </span>
123
+ </button>
121
124
  </div>
122
125
  {profile && (
123
126
  <div className="carousel-actions">
@@ -13,6 +13,7 @@
13
13
 
14
14
  import { useEffect, useState, type ReactNode } from "react";
15
15
  import { api, send } from "../lib/api";
16
+ import { toastResult } from "../lib/toast";
16
17
  import type { Settings } from "../types/api";
17
18
  import { CloneVoiceDialog } from "./CloneVoiceDialog";
18
19
  import { EditVoiceDialog } from "./EditVoiceDialog";
@@ -36,8 +37,12 @@ export function VoiceProfilesSettings({
36
37
  /* the id of the voice being redone: opens the clone modal with its recording attached */
37
38
  const [redoing, setRedoing] = useState<string | null>(null);
38
39
  const [focus, setFocus] = useState<string | null>(null);
39
- /* what the removal did on each provider; stays under the carousel until the next action */
40
+ /* what the removal did on each provider: a toast when it is one short line; a longer text stays under the carousel
41
+ until the next action */
40
42
  const [message, setMessage] = useState<string | null>(null);
43
+ const report = (output: string) => {
44
+ if (!toastResult({ ok: true, output })) setMessage(output);
45
+ };
41
46
  /* the clone creates the voice by itself: when the new profile shows up, it comes to the center.
42
47
  If some provider failed, the voice opens in edit with the warning, to create again only there */
43
48
  const [cloned, setCloned] = useState<{ before: string[]; warnings: string[] } | null>(null);
@@ -108,7 +113,7 @@ export function VoiceProfilesSettings({
108
113
  onRedone={output => {
109
114
  setRedoing(null);
110
115
  setFocus(profile.id);
111
- setMessage(output);
116
+ report(output);
112
117
  reload();
113
118
  }}
114
119
  />
@@ -140,7 +145,7 @@ export function VoiceProfilesSettings({
140
145
  }}
141
146
  onRemove={output => {
142
147
  setEditing(null);
143
- setMessage(output);
148
+ report(output);
144
149
  }}
145
150
  />
146
151
  );