src/components/TTSPage.tsx (215 lines of code) (raw):
"use client";
import React, { useState } from "react";
import {
getRandomLibrarySet,
getRandomVoice,
LIBRARY,
VOICES,
} from "../lib/library";
import { Block } from "./ui/Block";
import { Footer } from "./ui/Footer";
import { Header } from "./ui/Header";
import { DevMode } from "./ui/DevMode";
import { Regenerate, Shuffle, Star } from "./ui/Icons";
import { useBodyScrollable } from "@/hooks/useBodyScrollable";
import { Button, ButtonLED } from "./ui/Button";
import { appStore } from "@/lib/store";
import BrowserNotSupported from "./ui/BrowserNotSupported";
const EXPRESSIVE_VOICES = ["ash", "ballad", "coral", "sage", "verse"];
export default function TtsPage() {
const [devMode, setDevMode] = useState(false);
const isScrollable = useBodyScrollable();
return (
<div
data-scrollable={isScrollable}
className="flex flex-col gap-x-3 min-h-screen px-5 pt-6 pb-32 md:pb-24 selection:bg-primary/20"
>
<Header devMode={devMode} setDevMode={setDevMode} />
{devMode ? <DevMode /> : <Board />}
<Footer devMode={devMode} />
</div>
);
}
const Board = () => {
const voice = appStore.useState((state) => state.voice);
const input = appStore.useState((state) => state.input);
const inputDirty = appStore.useState((state) => state.inputDirty);
const prompt = appStore.useState((state) => state.prompt);
const selectedEntry = appStore.useState((state) => state.selectedEntry);
const librarySet = appStore.useState((state) => state.librarySet);
const browserNotSupported = appStore.useState(
() => !("serviceWorker" in navigator)
);
const handleRefreshLibrarySet = () => {
const nextSet = getRandomLibrarySet();
appStore.setState((draft) => {
draft.librarySet = nextSet;
// When the user has changes, don't update the script.
if (!draft.inputDirty) {
draft.input = nextSet[0].input;
}
draft.prompt = nextSet[0].prompt;
draft.selectedEntry = nextSet[0];
draft.latestAudioUrl = null;
});
};
const handlePresetSelect = (name: string) => {
const entry = LIBRARY[name];
appStore.setState((draft) => {
// When the user has changes, don't update the script.
if (!inputDirty) {
draft.input = entry.input;
}
draft.prompt = entry.prompt;
draft.selectedEntry = entry;
draft.latestAudioUrl = null;
});
};
return (
<main className="flex-1 flex flex-col gap-x-3 w-full max-w-(--page-max-width) mx-auto">
{browserNotSupported && (
<BrowserNotSupported
open={browserNotSupported}
onOpenChange={() => {}}
/>
)}
<div className="flex flex-row">
<Block title="Voice">
<div className="grid grid-cols-12 gap-3">
{VOICES.map((newVoice) => (
<div
key={newVoice}
className="col-span-4 sm:col-span-3 md:col-span-2 xl:col-span-1 relative"
>
<Button
block
color="default"
onClick={() => {
appStore.setState((draft) => {
draft.voice = newVoice;
draft.latestAudioUrl = null;
});
}}
selected={newVoice === voice}
className="aspect-4/3 sm:aspect-2/1 lg:aspect-2.5/1 xl:aspect-square min-h-[60px] max-h-[100px] flex-col items-start justify-between relative"
>
<span>
{newVoice[0].toUpperCase()}
{newVoice.substring(1)}
</span>
<div className="absolute left-[0.93rem] bottom-[0.93rem]">
<ButtonLED />
</div>
{EXPRESSIVE_VOICES.includes(newVoice) && (
<div className="absolute right-[13px] bottom-[10.5px]">
<Star className="w-[12px] h-[12px]" />
</div>
)}
</Button>
</div>
))}
<div className="col-span-4 sm:col-span-3 md:col-span-2 xl:col-span-1">
<Button
block
color="neutral"
onClick={() => {
const randomVoice = getRandomVoice(voice);
appStore.setState((draft) => {
draft.voice = randomVoice;
draft.latestAudioUrl = null;
});
}}
className="aspect-4/3 sm:aspect-2/1 lg:aspect-2.5/1 xl:aspect-square max-h-[100px]"
aria-label="Select random voice"
>
<Shuffle />
</Button>
</div>
</div>
</Block>
</div>
<div className="flex flex-col md:flex-row gap-3">
<Block title="Vibe">
<div className="flex flex-col gap-3">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-2 lg:grid-cols-3 gap-3">
{librarySet.map((entry) => (
<Button
key={entry.name}
block
color="default"
onClick={() => handlePresetSelect(entry.name)}
selected={selectedEntry?.name === entry.name}
className="aspect-4/3 sm:aspect-2/1 lg:aspect-2.5/1 min-h-[60px] max-h-[100px] flex-col items-start justify-between relative"
>
<span className="break-words pr-1">{entry.name}</span>
<div className="absolute left-[0.93rem] bottom-[0.93rem]">
<ButtonLED />
</div>
</Button>
))}
<Button
block
color="neutral"
onClick={handleRefreshLibrarySet}
className="aspect-4/3 sm:aspect-2/1 lg:aspect-2.5/1 min-h-[60px] max-h-[100px]"
aria-label="Generate new list of vibes"
>
<Regenerate />
</Button>
</div>
<textarea
id="input"
rows={8}
maxLength={999}
className="w-full resize-none outline-none focus:outline-none bg-screen p-4 rounded-lg shadow-textarea text-[16px] md:text-[14px]"
value={prompt}
onChange={({ target }) => {
appStore.setState((draft) => {
draft.selectedEntry = null;
draft.prompt = target.value;
draft.latestAudioUrl = null;
});
}}
required
/>
</div>
</Block>
<Block title="Script">
<div className="relative flex flex-col h-full w-full">
<textarea
id="prompt"
rows={8}
maxLength={999}
className="w-full h-full min-h-[220px] resize-none outline-none focus:outline-none bg-screen p-4 rounded-lg shadow-textarea text-[16px] md:text-[14px]"
value={input}
onChange={({ target }) => {
const nextValue = target.value;
appStore.setState((draft) => {
draft.inputDirty =
!!nextValue && selectedEntry?.input !== nextValue;
draft.input = nextValue;
draft.latestAudioUrl = null;
});
}}
/>
{inputDirty && (
<span
className="absolute bottom-[-27px] sm:bottom-3 left-4 z-10 cursor-pointer uppercase hover:text-current/70 transition-colors"
onClick={() => {
appStore.setState((draft) => {
draft.inputDirty = false;
draft.input = selectedEntry?.input ?? input;
draft.latestAudioUrl = null;
});
}}
>
Reset
</span>
)}
<span className="absolute bottom-3 right-4 z-10 opacity-30 hidden sm:block">
{input.length}
</span>
</div>
</Block>
</div>
</main>
);
};