Spaces:
Runtime error
Runtime error
Commit ·
93dfca2
1
Parent(s): 36ef247
LETS GOOOOOOOOOOOOOOOOOOOOOOOOO
Browse files- frontend/src/pages/VoiceProfile.tsx +202 -109
- frontend/src/styles/pages.css +134 -0
frontend/src/pages/VoiceProfile.tsx
CHANGED
|
@@ -4,6 +4,7 @@ import type { Delivery, Job, LibraryVoice, Quota, Voice, VoiceProfileData, Voice
|
|
| 4 |
import { DocPicker } from "../components/DocPicker";
|
| 5 |
import { ConfirmButton, errorMessage, formatDate, JobProgress, Loading, Modal, PageHeader } from "../components/ui";
|
| 6 |
import { useJob } from "../hooks/useJob";
|
|
|
|
| 7 |
|
| 8 |
type Host = "host_a" | "host_b";
|
| 9 |
|
|
@@ -460,11 +461,22 @@ function CloneModal({ state, onClose, onDone }: { state: VoiceState; onClose: ()
|
|
| 460 |
);
|
| 461 |
}
|
| 462 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 463 |
export default function VoiceProfile() {
|
| 464 |
const [state, setState] = useState<VoiceState | null>(null);
|
| 465 |
const [draft, setDraft] = useState<VoiceProfileData | null>(null);
|
| 466 |
const [voices, setVoices] = useState<Voice[]>([]);
|
| 467 |
const [quota, setQuota] = useState<Quota>({});
|
|
|
|
| 468 |
const [picking, setPicking] = useState(false);
|
| 469 |
const [samples, setSamples] = useState<string[]>([]);
|
| 470 |
const [buildJob, setBuildJob] = useState<Job | null>(null);
|
|
@@ -474,12 +486,14 @@ export default function VoiceProfile() {
|
|
| 474 |
const [saved, setSaved] = useState(false);
|
| 475 |
const [error, setError] = useState<string | null>(null);
|
| 476 |
const { play, playing } = usePlayer();
|
|
|
|
| 477 |
|
| 478 |
const load = () =>
|
| 479 |
voiceApi.get().then((s) => {
|
| 480 |
setState(s);
|
| 481 |
setDraft(s.profile);
|
| 482 |
setSamples(s.sample_doc_ids);
|
|
|
|
| 483 |
});
|
| 484 |
const loadVoices = () => voiceApi.voices().then(setVoices, () => setVoices([]));
|
| 485 |
|
|
@@ -495,9 +509,25 @@ export default function VoiceProfile() {
|
|
| 495 |
loadVoices();
|
| 496 |
});
|
| 497 |
|
| 498 |
-
if (!state) return <Loading />;
|
| 499 |
const building = build && (build.status === "queued" || build.status === "running");
|
| 500 |
const cloneRunning = clone && (clone.status === "queued" || clone.status === "running");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 501 |
|
| 502 |
const saveProfile = async () => {
|
| 503 |
setError(null);
|
|
@@ -510,143 +540,206 @@ export default function VoiceProfile() {
|
|
| 510 |
}
|
| 511 |
};
|
| 512 |
|
| 513 |
-
const used = quota.character_count ?? 0;
|
| 514 |
-
const limit = quota.character_limit ?? 0;
|
| 515 |
-
|
| 516 |
return (
|
| 517 |
-
<div className="page-wrap">
|
| 518 |
-
<PageHeader eyebrow="Voice profile" title="How you sound, on the page and out loud">
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 524 |
{error && <p className="error-text">{error}</p>}
|
| 525 |
|
| 526 |
-
|
| 527 |
-
<section className="card stack">
|
| 528 |
-
<
|
| 529 |
-
<
|
| 530 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 531 |
{draft && (
|
| 532 |
<>
|
| 533 |
<label className="field">
|
| 534 |
-
<span>
|
| 535 |
<textarea className="textarea" rows={3} value={draft.summary} onChange={(e) => setDraft({ ...draft, summary: e.target.value })} />
|
| 536 |
</label>
|
| 537 |
-
<
|
| 538 |
-
<
|
| 539 |
-
<
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
<label key={f.key} className="field">
|
| 547 |
-
<span>{f.label}</span>
|
| 548 |
-
<input
|
| 549 |
-
className="input input-sm"
|
| 550 |
-
placeholder={f.hint}
|
| 551 |
-
value={(draft[f.key] as string[]).join(", ")}
|
| 552 |
-
onChange={(e) => setDraft({ ...draft, [f.key]: e.target.value.split(",").map((x) => x.trim()).filter(Boolean) })}
|
| 553 |
-
/>
|
| 554 |
</label>
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 561 |
{saved ? "Saved" : "Save changes"}
|
| 562 |
</button>
|
|
|
|
|
|
|
|
|
|
| 563 |
</div>
|
| 564 |
</>
|
| 565 |
)}
|
| 566 |
</section>
|
|
|
|
| 567 |
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
</div>
|
| 578 |
-
{!state.tts_configured && <p className="muted">Set ELEVENLABS_API_KEY in .env to generate audio.</p>}
|
| 579 |
-
<p className="muted small">
|
| 580 |
-
Audio overviews use both hosts; videos are narrated by Host A. Model: {state.models[state.model] ?? state.model}.
|
| 581 |
-
</p>
|
| 582 |
{(["host_a", "host_b"] as Host[]).map((h) => (
|
| 583 |
<HostCard key={h} host={h} state={state} voices={voices} onSaved={setState} onBrowse={setBrowsing} />
|
| 584 |
))}
|
| 585 |
-
</
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 599 |
<button className="btn btn-primary" onClick={() => setCloning(true)} disabled={!state.tts_configured}>
|
| 600 |
Clone my voice
|
| 601 |
</button>
|
| 602 |
-
</>
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
)}
|
| 615 |
-
<button className="btn btn-small" onClick={() => setCloning(true)}>
|
| 616 |
-
Record again
|
| 617 |
</button>
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
>
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 633 |
|
| 634 |
{picking && (
|
| 635 |
<Modal title="Pick 5 to 10 posts that sound most like you" onClose={() => setPicking(false)} wide>
|
| 636 |
<DocPicker selected={samples} onChange={setSamples} max={10} />
|
| 637 |
<p className="muted small">Leave empty to let Notestack pick your longest posts.</p>
|
| 638 |
<div className="row end">
|
| 639 |
-
<button
|
| 640 |
-
className="btn btn-primary"
|
| 641 |
-
onClick={async () => {
|
| 642 |
-
setPicking(false);
|
| 643 |
-
try {
|
| 644 |
-
setBuildJob(await voiceApi.build(samples));
|
| 645 |
-
} catch (e) {
|
| 646 |
-
setError(errorMessage(e));
|
| 647 |
-
}
|
| 648 |
-
}}
|
| 649 |
-
>
|
| 650 |
Build profile
|
| 651 |
</button>
|
| 652 |
</div>
|
|
|
|
| 4 |
import { DocPicker } from "../components/DocPicker";
|
| 5 |
import { ConfirmButton, errorMessage, formatDate, JobProgress, Loading, Modal, PageHeader } from "../components/ui";
|
| 6 |
import { useJob } from "../hooks/useJob";
|
| 7 |
+
import { useUpgrade } from "../hooks/useUpgrade";
|
| 8 |
|
| 9 |
type Host = "host_a" | "host_b";
|
| 10 |
|
|
|
|
| 461 |
);
|
| 462 |
}
|
| 463 |
|
| 464 |
+
// The page: a three step flow. Writing voice first, then the two hosts, and cloning last.
|
| 465 |
+
|
| 466 |
+
type Step = "writing" | "hosts" | "clone";
|
| 467 |
+
|
| 468 |
+
const STEPS: { id: Step; label: string }[] = [
|
| 469 |
+
{ id: "writing", label: "Writing voice" },
|
| 470 |
+
{ id: "hosts", label: "Speaking voices" },
|
| 471 |
+
{ id: "clone", label: "Your own voice" },
|
| 472 |
+
];
|
| 473 |
+
|
| 474 |
export default function VoiceProfile() {
|
| 475 |
const [state, setState] = useState<VoiceState | null>(null);
|
| 476 |
const [draft, setDraft] = useState<VoiceProfileData | null>(null);
|
| 477 |
const [voices, setVoices] = useState<Voice[]>([]);
|
| 478 |
const [quota, setQuota] = useState<Quota>({});
|
| 479 |
+
const [step, setStep] = useState<Step | null>(null);
|
| 480 |
const [picking, setPicking] = useState(false);
|
| 481 |
const [samples, setSamples] = useState<string[]>([]);
|
| 482 |
const [buildJob, setBuildJob] = useState<Job | null>(null);
|
|
|
|
| 486 |
const [saved, setSaved] = useState(false);
|
| 487 |
const [error, setError] = useState<string | null>(null);
|
| 488 |
const { play, playing } = usePlayer();
|
| 489 |
+
const { openUpgrade } = useUpgrade();
|
| 490 |
|
| 491 |
const load = () =>
|
| 492 |
voiceApi.get().then((s) => {
|
| 493 |
setState(s);
|
| 494 |
setDraft(s.profile);
|
| 495 |
setSamples(s.sample_doc_ids);
|
| 496 |
+
setStep((cur) => cur ?? (s.profile ? "hosts" : "writing"));
|
| 497 |
});
|
| 498 |
const loadVoices = () => voiceApi.voices().then(setVoices, () => setVoices([]));
|
| 499 |
|
|
|
|
| 509 |
loadVoices();
|
| 510 |
});
|
| 511 |
|
| 512 |
+
if (!state || !step) return <Loading />;
|
| 513 |
const building = build && (build.status === "queued" || build.status === "running");
|
| 514 |
const cloneRunning = clone && (clone.status === "queued" || clone.status === "running");
|
| 515 |
+
const stepIndex = STEPS.findIndex((s) => s.id === step);
|
| 516 |
+
const done: Record<Step, boolean> = {
|
| 517 |
+
writing: Boolean(state.profile),
|
| 518 |
+
hosts: Boolean(state.profile) && stepIndex > 1,
|
| 519 |
+
clone: state.clone.status === "ready",
|
| 520 |
+
};
|
| 521 |
+
|
| 522 |
+
const startBuild = async (ids: string[]) => {
|
| 523 |
+
setPicking(false);
|
| 524 |
+
setError(null);
|
| 525 |
+
try {
|
| 526 |
+
setBuildJob(await voiceApi.build(ids));
|
| 527 |
+
} catch (e) {
|
| 528 |
+
setError(errorMessage(e));
|
| 529 |
+
}
|
| 530 |
+
};
|
| 531 |
|
| 532 |
const saveProfile = async () => {
|
| 533 |
setError(null);
|
|
|
|
| 540 |
}
|
| 541 |
};
|
| 542 |
|
|
|
|
|
|
|
|
|
|
| 543 |
return (
|
| 544 |
+
<div className="page-wrap vp">
|
| 545 |
+
<PageHeader eyebrow="Voice profile" title="How you sound, on the page and out loud" />
|
| 546 |
+
|
| 547 |
+
<ol className="vp-steps mono" aria-label="Voice profile steps">
|
| 548 |
+
{STEPS.map((s, i) => (
|
| 549 |
+
<li key={s.id} className={s.id === step ? "current" : done[s.id] ? "done" : ""}>
|
| 550 |
+
<button type="button" onClick={() => setStep(s.id)} aria-current={s.id === step ? "step" : undefined}>
|
| 551 |
+
<span className="vp-dot" />
|
| 552 |
+
<span>
|
| 553 |
+
{i + 1}. {s.label}
|
| 554 |
+
{done[s.id] && s.id !== step ? " ✓" : ""}
|
| 555 |
+
</span>
|
| 556 |
+
</button>
|
| 557 |
+
</li>
|
| 558 |
+
))}
|
| 559 |
+
</ol>
|
| 560 |
{error && <p className="error-text">{error}</p>}
|
| 561 |
|
| 562 |
+
{step === "writing" && (
|
| 563 |
+
<section className="card stack vp-card">
|
| 564 |
+
<p className="eyebrow">Step 1 of 3</p>
|
| 565 |
+
<h2>Teach Notestack how you write</h2>
|
| 566 |
+
<p className="muted">Launch Kits, carousels, hooks and scripts are all written in this voice, so they read like you wrote them.</p>
|
| 567 |
+
{build && (building || build.status === "failed") && <JobProgress job={build} />}
|
| 568 |
+
|
| 569 |
+
{!draft && !building && (
|
| 570 |
+
<div className="vp-choices">
|
| 571 |
+
<button className="vp-choice" onClick={() => startBuild([])}>
|
| 572 |
+
<strong>Let Notestack choose</strong>
|
| 573 |
+
<span className="muted small">We read your longest posts. One click, about a minute.</span>
|
| 574 |
+
</button>
|
| 575 |
+
<button className="vp-choice" onClick={() => setPicking(true)}>
|
| 576 |
+
<strong>I'll pick my posts</strong>
|
| 577 |
+
<span className="muted small">Choose 5 to 10 posts that sound most like you.</span>
|
| 578 |
+
</button>
|
| 579 |
+
</div>
|
| 580 |
+
)}
|
| 581 |
+
|
| 582 |
{draft && (
|
| 583 |
<>
|
| 584 |
<label className="field">
|
| 585 |
+
<span>In a sentence, you sound like this</span>
|
| 586 |
<textarea className="textarea" rows={3} value={draft.summary} onChange={(e) => setDraft({ ...draft, summary: e.target.value })} />
|
| 587 |
</label>
|
| 588 |
+
<details className="delivery">
|
| 589 |
+
<summary className="mono muted small">Fine-tune tone, phrases and habits</summary>
|
| 590 |
+
<label className="field">
|
| 591 |
+
<span>Sentence length</span>
|
| 592 |
+
<select className="input input-sm" value={draft.sentence_length} onChange={(e) => setDraft({ ...draft, sentence_length: e.target.value as VoiceProfileData["sentence_length"] })}>
|
| 593 |
+
{["short", "medium", "long", "varied"].map((v) => (
|
| 594 |
+
<option key={v}>{v}</option>
|
| 595 |
+
))}
|
| 596 |
+
</select>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 597 |
</label>
|
| 598 |
+
{LIST_FIELDS.map((f) => (
|
| 599 |
+
<label key={f.key} className="field">
|
| 600 |
+
<span>{f.label}</span>
|
| 601 |
+
<input
|
| 602 |
+
className="input input-sm"
|
| 603 |
+
placeholder={f.hint}
|
| 604 |
+
value={(draft[f.key] as string[]).join(", ")}
|
| 605 |
+
onChange={(e) => setDraft({ ...draft, [f.key]: e.target.value.split(",").map((x) => x.trim()).filter(Boolean) })}
|
| 606 |
+
/>
|
| 607 |
+
</label>
|
| 608 |
+
))}
|
| 609 |
+
</details>
|
| 610 |
+
<p className="mono muted small">
|
| 611 |
+
Built {formatDate(state.updated_at, true)} from {state.sample_doc_ids.length} posts ·{" "}
|
| 612 |
+
<button className="link-btn small" disabled={Boolean(building)} onClick={() => setPicking(true)}>
|
| 613 |
+
Rebuild from other posts
|
| 614 |
+
</button>
|
| 615 |
+
</p>
|
| 616 |
+
<div className="row end">
|
| 617 |
+
<button className="btn btn-small" onClick={saveProfile}>
|
| 618 |
{saved ? "Saved" : "Save changes"}
|
| 619 |
</button>
|
| 620 |
+
<button className="btn btn-primary" onClick={() => setStep("hosts")}>
|
| 621 |
+
Next: pick your hosts
|
| 622 |
+
</button>
|
| 623 |
</div>
|
| 624 |
</>
|
| 625 |
)}
|
| 626 |
</section>
|
| 627 |
+
)}
|
| 628 |
|
| 629 |
+
{step === "hosts" && (
|
| 630 |
+
<section className="card stack vp-card">
|
| 631 |
+
<p className="eyebrow">Step 2 of 3</p>
|
| 632 |
+
<h2>Pick who reads your work aloud</h2>
|
| 633 |
+
<p className="muted">
|
| 634 |
+
Audio overviews are a conversation between two hosts. Videos are narrated by Host A. Press Preview to hear each one, or browse the library for more voices.
|
| 635 |
+
</p>
|
| 636 |
+
{!state.tts_configured && <p className="muted">Set ELEVENLABS_API_KEY in .env to generate audio.</p>}
|
| 637 |
+
<div className="vp-hosts">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 638 |
{(["host_a", "host_b"] as Host[]).map((h) => (
|
| 639 |
<HostCard key={h} host={h} state={state} voices={voices} onSaved={setState} onBrowse={setBrowsing} />
|
| 640 |
))}
|
| 641 |
+
</div>
|
| 642 |
+
<div className="row between">
|
| 643 |
+
<button className="btn btn-small" onClick={() => setStep("writing")}>
|
| 644 |
+
Back
|
| 645 |
+
</button>
|
| 646 |
+
<button className="btn btn-primary" onClick={() => setStep("clone")}>
|
| 647 |
+
Last step: use your own voice
|
| 648 |
+
</button>
|
| 649 |
+
</div>
|
| 650 |
+
</section>
|
| 651 |
+
)}
|
| 652 |
+
|
| 653 |
+
{step === "clone" && (
|
| 654 |
+
<section className="card stack vp-card">
|
| 655 |
+
<p className="eyebrow">Step 3 of 3</p>
|
| 656 |
+
<h2>Make Host A sound exactly like you</h2>
|
| 657 |
+
|
| 658 |
+
{!state.clone.allowed && (
|
| 659 |
+
<>
|
| 660 |
+
<p className="muted">Clone your voice and every audio overview and video you make is narrated by you, not a stock voice.</p>
|
| 661 |
+
<ol className="vp-how">
|
| 662 |
+
<li>
|
| 663 |
+
<strong>Read for 1 to 3 minutes</strong>
|
| 664 |
+
<span className="muted small">We give you a passage from your own writing.</span>
|
| 665 |
+
</li>
|
| 666 |
+
<li>
|
| 667 |
+
<strong>We build your voice</strong>
|
| 668 |
+
<span className="muted small">Takes a few minutes. Only you can use it.</span>
|
| 669 |
+
</li>
|
| 670 |
+
<li>
|
| 671 |
+
<strong>Everything sounds like you</strong>
|
| 672 |
+
<span className="muted small">Your voice becomes Host A across overviews and videos.</span>
|
| 673 |
+
</li>
|
| 674 |
+
</ol>
|
| 675 |
+
<div className="vp-upgrade">
|
| 676 |
+
<div>
|
| 677 |
+
<strong>Voice cloning is on the Writer and Studio plans</strong>
|
| 678 |
+
<p className="muted small">Your writing voice and hosts are all set. Upgrade whenever you want to add your own.</p>
|
| 679 |
+
</div>
|
| 680 |
+
<button className="btn btn-primary" onClick={() => openUpgrade("writer")}>
|
| 681 |
+
Upgrade to clone my voice
|
| 682 |
+
</button>
|
| 683 |
+
</div>
|
| 684 |
+
</>
|
| 685 |
+
)}
|
| 686 |
+
|
| 687 |
+
{state.clone.allowed && quota.can_use_instant_voice_cloning === false && (
|
| 688 |
+
<p className="muted small">Your ElevenLabs plan does not include instant voice cloning. Upgrade it at elevenlabs.io.</p>
|
| 689 |
+
)}
|
| 690 |
+
{state.clone.allowed && (state.clone.status === "none" || state.clone.status === "failed") && !cloneRunning && (
|
| 691 |
+
<>
|
| 692 |
+
<p className="muted">
|
| 693 |
+
Record one to three minutes of your own writing, read aloud. Your voice becomes Host A, so audio overviews and videos sound like you.
|
| 694 |
+
</p>
|
| 695 |
+
{state.clone.status === "failed" && state.clone.error && <p className="error-text">{state.clone.error}</p>}
|
| 696 |
+
<div className="row">
|
| 697 |
<button className="btn btn-primary" onClick={() => setCloning(true)} disabled={!state.tts_configured}>
|
| 698 |
Clone my voice
|
| 699 |
</button>
|
| 700 |
+
</div>
|
| 701 |
+
</>
|
| 702 |
+
)}
|
| 703 |
+
{clone && (cloneRunning || clone.status === "failed") && <JobProgress job={clone} />}
|
| 704 |
+
{state.clone.status === "processing" && !clone && <p className="muted">Your voice is being created.</p>}
|
| 705 |
+
{state.clone.status === "ready" && (
|
| 706 |
+
<>
|
| 707 |
+
<p className="muted">Your voice is live and set as Host A. Adjust its delivery in step 2.</p>
|
| 708 |
+
<div className="row">
|
| 709 |
+
{state.clone.preview_url && (
|
| 710 |
+
<button className="btn btn-small" onClick={() => play(state.clone.preview_url!, "clone")}>
|
| 711 |
+
{playing === "clone" ? "Stop" : "Hear my voice"}
|
|
|
|
|
|
|
|
|
|
| 712 |
</button>
|
| 713 |
+
)}
|
| 714 |
+
<button className="btn btn-small" onClick={() => setCloning(true)}>
|
| 715 |
+
Record again
|
| 716 |
+
</button>
|
| 717 |
+
<ConfirmButton
|
| 718 |
+
confirmLabel="Revoke and delete my voice?"
|
| 719 |
+
onConfirm={async () => {
|
| 720 |
+
setState(await voiceApi.revoke());
|
| 721 |
+
loadVoices();
|
| 722 |
+
}}
|
| 723 |
+
>
|
| 724 |
+
Revoke consent
|
| 725 |
+
</ConfirmButton>
|
| 726 |
+
</div>
|
| 727 |
+
</>
|
| 728 |
+
)}
|
| 729 |
+
<div className="row">
|
| 730 |
+
<button className="btn btn-small" onClick={() => setStep("hosts")}>
|
| 731 |
+
Back
|
| 732 |
+
</button>
|
| 733 |
+
</div>
|
| 734 |
+
</section>
|
| 735 |
+
)}
|
| 736 |
|
| 737 |
{picking && (
|
| 738 |
<Modal title="Pick 5 to 10 posts that sound most like you" onClose={() => setPicking(false)} wide>
|
| 739 |
<DocPicker selected={samples} onChange={setSamples} max={10} />
|
| 740 |
<p className="muted small">Leave empty to let Notestack pick your longest posts.</p>
|
| 741 |
<div className="row end">
|
| 742 |
+
<button className="btn btn-primary" onClick={() => startBuild(samples)}>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 743 |
Build profile
|
| 744 |
</button>
|
| 745 |
</div>
|
frontend/src/styles/pages.css
CHANGED
|
@@ -1867,8 +1867,16 @@ select.input option {
|
|
| 1867 |
color: var(--white);
|
| 1868 |
}
|
| 1869 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1870 |
.map-controls .icon-btn.mono {
|
| 1871 |
font-size: 0.65rem;
|
|
|
|
|
|
|
| 1872 |
}
|
| 1873 |
|
| 1874 |
.map-hint {
|
|
@@ -1881,6 +1889,132 @@ select.input option {
|
|
| 1881 |
}
|
| 1882 |
|
| 1883 |
/* Voice page */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1884 |
.host-card {
|
| 1885 |
display: grid;
|
| 1886 |
gap: 10px;
|
|
|
|
| 1867 |
color: var(--white);
|
| 1868 |
}
|
| 1869 |
|
| 1870 |
+
.map-controls .icon-btn {
|
| 1871 |
+
padding: 0;
|
| 1872 |
+
line-height: 1;
|
| 1873 |
+
}
|
| 1874 |
+
|
| 1875 |
+
/* Trailing letter-spacing from .mono pushes "1:1" off centre in the round button. */
|
| 1876 |
.map-controls .icon-btn.mono {
|
| 1877 |
font-size: 0.65rem;
|
| 1878 |
+
letter-spacing: 0;
|
| 1879 |
+
font-variant-numeric: tabular-nums;
|
| 1880 |
}
|
| 1881 |
|
| 1882 |
.map-hint {
|
|
|
|
| 1889 |
}
|
| 1890 |
|
| 1891 |
/* Voice page */
|
| 1892 |
+
.vp .vp-card {
|
| 1893 |
+
max-width: 760px;
|
| 1894 |
+
animation: welcome-in 0.35s ease both;
|
| 1895 |
+
}
|
| 1896 |
+
|
| 1897 |
+
.vp-steps {
|
| 1898 |
+
list-style: none;
|
| 1899 |
+
margin: 0 0 20px;
|
| 1900 |
+
padding: 0;
|
| 1901 |
+
max-width: 760px;
|
| 1902 |
+
display: grid;
|
| 1903 |
+
grid-template-columns: repeat(3, 1fr);
|
| 1904 |
+
gap: 8px;
|
| 1905 |
+
font-size: 0.75rem;
|
| 1906 |
+
}
|
| 1907 |
+
|
| 1908 |
+
.vp-steps button {
|
| 1909 |
+
display: grid;
|
| 1910 |
+
gap: 8px;
|
| 1911 |
+
width: 100%;
|
| 1912 |
+
padding: 0;
|
| 1913 |
+
border: 0;
|
| 1914 |
+
background: none;
|
| 1915 |
+
color: var(--text-3);
|
| 1916 |
+
font: inherit;
|
| 1917 |
+
text-align: left;
|
| 1918 |
+
cursor: pointer;
|
| 1919 |
+
}
|
| 1920 |
+
|
| 1921 |
+
.vp-steps li.done button {
|
| 1922 |
+
color: var(--text-2);
|
| 1923 |
+
}
|
| 1924 |
+
|
| 1925 |
+
.vp-steps li.current button {
|
| 1926 |
+
color: var(--white);
|
| 1927 |
+
}
|
| 1928 |
+
|
| 1929 |
+
.vp-dot {
|
| 1930 |
+
height: 3px;
|
| 1931 |
+
border-radius: 999px;
|
| 1932 |
+
background: var(--line-strong);
|
| 1933 |
+
transition: background 0.3s, box-shadow 0.3s;
|
| 1934 |
+
}
|
| 1935 |
+
|
| 1936 |
+
.vp-steps li.done .vp-dot {
|
| 1937 |
+
background: var(--blue-60);
|
| 1938 |
+
}
|
| 1939 |
+
|
| 1940 |
+
.vp-steps li.current .vp-dot {
|
| 1941 |
+
background: var(--blue);
|
| 1942 |
+
box-shadow: var(--glow-sm);
|
| 1943 |
+
}
|
| 1944 |
+
|
| 1945 |
+
.vp-choices {
|
| 1946 |
+
display: grid;
|
| 1947 |
+
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
| 1948 |
+
gap: 12px;
|
| 1949 |
+
}
|
| 1950 |
+
|
| 1951 |
+
.vp-choice {
|
| 1952 |
+
display: grid;
|
| 1953 |
+
gap: 6px;
|
| 1954 |
+
padding: 18px;
|
| 1955 |
+
border: 1px solid var(--line-strong);
|
| 1956 |
+
border-radius: var(--radius-sm);
|
| 1957 |
+
background: transparent;
|
| 1958 |
+
color: var(--white);
|
| 1959 |
+
font: inherit;
|
| 1960 |
+
text-align: left;
|
| 1961 |
+
cursor: pointer;
|
| 1962 |
+
transition: border-color 0.2s, box-shadow 0.2s;
|
| 1963 |
+
}
|
| 1964 |
+
|
| 1965 |
+
.vp-choice:hover,
|
| 1966 |
+
.vp-choice:focus-visible {
|
| 1967 |
+
border-color: var(--blue-60);
|
| 1968 |
+
box-shadow: var(--glow-sm);
|
| 1969 |
+
}
|
| 1970 |
+
|
| 1971 |
+
.vp-hosts {
|
| 1972 |
+
display: grid;
|
| 1973 |
+
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
| 1974 |
+
gap: 12px;
|
| 1975 |
+
}
|
| 1976 |
+
|
| 1977 |
+
.vp-how {
|
| 1978 |
+
list-style: none;
|
| 1979 |
+
margin: 0;
|
| 1980 |
+
padding: 0;
|
| 1981 |
+
display: grid;
|
| 1982 |
+
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
| 1983 |
+
gap: 12px;
|
| 1984 |
+
counter-reset: vp-how;
|
| 1985 |
+
}
|
| 1986 |
+
|
| 1987 |
+
.vp-how li {
|
| 1988 |
+
display: grid;
|
| 1989 |
+
gap: 4px;
|
| 1990 |
+
padding: 14px;
|
| 1991 |
+
border: 1px solid var(--line);
|
| 1992 |
+
border-radius: var(--radius-sm);
|
| 1993 |
+
counter-increment: vp-how;
|
| 1994 |
+
}
|
| 1995 |
+
|
| 1996 |
+
.vp-how li::before {
|
| 1997 |
+
content: counter(vp-how);
|
| 1998 |
+
font: 600 0.75rem var(--font-mono);
|
| 1999 |
+
color: var(--blue);
|
| 2000 |
+
}
|
| 2001 |
+
|
| 2002 |
+
.vp-upgrade {
|
| 2003 |
+
display: flex;
|
| 2004 |
+
flex-wrap: wrap;
|
| 2005 |
+
align-items: center;
|
| 2006 |
+
justify-content: space-between;
|
| 2007 |
+
gap: 12px;
|
| 2008 |
+
padding: 16px;
|
| 2009 |
+
border: 1px solid var(--blue-35);
|
| 2010 |
+
border-radius: var(--radius-sm);
|
| 2011 |
+
background: var(--blue-05);
|
| 2012 |
+
}
|
| 2013 |
+
|
| 2014 |
+
.vp-upgrade p {
|
| 2015 |
+
margin: 4px 0 0;
|
| 2016 |
+
}
|
| 2017 |
+
|
| 2018 |
.host-card {
|
| 2019 |
display: grid;
|
| 2020 |
gap: 10px;
|