In this chapter, we're going to build the entire dedicated voices page, where users can browse, search, and manage all available voices. We're going to build the full browsing experience, meaning voice cards with avatars, flag emojis, category badges, plus a debounced URL-based search. We're then going to add the create voice dialog where users will either be able to upload via drag and drop or record and see the audio feedback in a waveform preview in real time using WaveSurfer and record RTC libraries. We're going to wrap it all up by enabling users to delete any custom voices and doing a cache invalidation after that. By the end, users will have a complete CRUD functionality for custom voices.
So let's get started by building the voices page. So make sure you are on the main branch. In the previous bonus chapter I almost forgot that we have to check out, do git checkout main and then git pull origin main. So in case you didn't do that, make sure you do this so your main is up to date with whatever we have merged. Then make sure you have NPM run dev running.
And let's get started by building the voices page. So I'm going to go ahead inside of the source folder here, app dashboard. And I'm going to create a new folder called voices. In here I'm going to need a page.tsx and I'm going to need a layout.tsx. So let's go ahead and add a very simple VoicesPage function.
So inside of page here, so VoicesPage, we're going to export the default asynchronous function VoicesPage. And in here let's just return a div VoicesPage. Then let's go ahead and export the layout as well. So layout.tsx is going to have a singular children prop, which are a type of React node. And the layout is going to return a component VoicesLayout, which we don't have yet.
So let's go ahead and build it since it's very simple. I'm gonna go inside of features and I'm gonna create a new folder called voices. We actually have it already, my apologies, but we only have data inside, that's why I forgot. And inside let's create views. And in here let's create VoicesLayout.tsx.
And this will be a very simple component. Our VoicesLayout will be a normal export function, So no default export here because this is a component. It will accept the children prop. It will have a div, flex, height full, min height zero, flex column and overflow hidden. And it's going to reuse our page header shared component and render the children beneath it.
Then let's go back inside of the actual app dashboard voices layout and let's import voices layout from the respective features, voices, views, voices layout. By now you should be able to go to localhost 3000 and you should be able to click on the explore voices which should redirect you to forward slash voices, make sure there is no typo and you should see the voices header where you can open the sidebar on mobile and on desktop it should be the same thing except optimized for desktop and you should see a text which says Voices page. Brilliant. Now that we have that, let's go ahead and focus on building the voices page since right now this is just a text here. So I'm going to go back inside of the features, voices, views and in here I'll create a new file voicesview.tsx.
I'm going to go ahead and mark this as useClient and I'm going to export a function called VoicesView Inside of here I'm going to go ahead and return a container with some classes, which will mostly be some spacing. So flex1, space y 10, overflow y auto, padding 3, and enlarge padding 6. And now, in here, we have to render the voices toolbar which will hold the search component and we have to render the voices content which will render different voice lists, all right? So in order to build the toolbar component, we need to add a package called nooks, because our search will be controlled via URL, right? So in order to make this search functionalities box work, we need to modify the URL.
So let's go ahead and install NUX. And while we are here, let's also install useDebounce So we can properly debounce whatever the user searches without overloading our TRPC procedure and server. Now that we have NUX installed, I will quickly show you the version. So NUX 2.8.8, again you don't have to have the same version. I'm just showing this so you are aware of my versions and use the bounds 10.1.0.
Now I'm going to go ahead inside of the voices folder here and I'm going to create a new folder called lib. And in here I'm going to prepare the params that users will be able to control. So those params are going to be the query params. So let's import createSearchParamsCache and the parseAsString from nux forward slash server. And in here we're going to export voices search params and give it one property query and that's going to be a type of string and it's going to have this property called with default So whenever the URL resets to this, it will clear the URL, right?
I will show you what I mean once we actually have the functionality for that. And let's do export const voicesSearchParamsCache, create SearchParamsCache, and pass in the voicesSearchParams. So by using Nuke's server package, we can now do Nuke's functionality both in client components but also in React server components which we are going to need in order to prefetch if a URL has a query. So we have to think about that as well as all the other things, okay? So now I'm gonna go ahead and connect these params to our page here in Voices.
So find VoicesPage.tsx and let's go ahead and do the following. We're going to add some imports now. I'm going to add metadata from Next and search params from Nuke's server. Both are importing a type, alright? Then let's go ahead and define the metadata to be Voices.
So now when you are on your Voices page, take a look at the tab, it should say voices resonance, exactly what we want. Then let's go ahead and extend the props here. So the props will be search params and the type of search params will be a promise and then search params inside. Like that. And then what we have to do is we have to somehow extract the query from the search params.
So make sure the function is asynchronous. And then let's do query await voicesSearchParamsCache which we can import from features.voices.lib.params and parse the search params. So this will actually parse and see if the query string is valid. And if it's valid, we can then do prefetching to voices get all. So we need to import prefetch from trpc server and we need to import trpc from trpc server as well.
And you can see there's no errors happening because if you command click onto Voices getAll, we already took care of that. So our get all procedure for voices has always had the ability to be searchable, right? And we are now using that in this prefetch function. So this React server component will prefetch all voices for this page but in case this page is not loaded on localhost 3000 voices this is easy we just have to prefetch get all but what if it's prefetched with something like this right in this case we should only prefetch for this query. That's why we needed to do this.
All right. Now that we are here, let's also add hydrate client. And then let's go ahead and replace this with hydrate client. And in here we're gonna do voices view. And you can import voices view from features voices views and finally voices view.
Alright. So now I'm gonna go ahead and go back inside of the VoicesView component here. Right now, it's just an empty div, so you can add a paragraph to do list of voice cards and then you should get a text list of voice cards so I'm gonna go ahead and develop a voice card component so let's go inside of voices open a new folder Components and in here I'm gonna create VoiceCard.tsx Let's go ahead and import Link from NextLink and Microphone, MoreHorizontal, Pause and Play icons from Lucid React Now let's add the components button, drop down menu, drop down content, drop down menu item and drop down menu trigger. Let's continue by adding spinner from components UI spinner. Let's go ahead and add voice avatar from components voice avatar Then let's go ahead and add import type inferRouterOutputs from trpcServer.
Let's go ahead and import the entire AppRouterType from trpcrouters underscore app. And let's go ahead and import voice category labels from features, voices, data, voice categories. So it's basically a remapping of our Prisma enum to human readable strings. We already used this for seeding. Now we need to define a singular voice item type by using inferRouterOutputs, passing in the entire app router, and then targeting the voices router, specifically getAllProcedure, and then we already did this somewhere else, we can choose between custom or system and then just pick a single item from that array.
Because getAllProcedure returns custom and system voices. So in order to have fully type safe type, we need to have this info, what's being returned. And that's what we just got right here. All right. And then we can safely create an interface for the card.
Voice is a voice item type. Now let's go ahead and prepare region names using the INTL API. Display names, English in array, type region. Then I'm gonna go ahead and create a function parse language parse language function will accept the locale and it's going to split the locale with a dash So let me try and find what I mean here. It is inside of voices data, voices scoping maybe, voices, let me try voice categories.
Okay, I know where it is. It's in the seed script. This. It's going to look for this. It's going to find English US, English IN, English GB, and it will basically separate by the little dash here, and then it will get the country in the second arguments.
That's why we skip over the first. In case we cannot find that argument, it means the locale is broken. So we're just gonna return an empty flag and the region locale. Then we're going to try and create a flag emoji. You can use an npm package for that but looks like it can be done by using this specific code right here.
In case you don't want to type it out, feel free to go inside of the source code and just copy this function. But yes, basically it will accept the following and using this it will generate an emoji of that country. So that's what this function is doing. I'm going to leave it to you to pause the screen and copy it. And now let's go ahead and develop the actual voice card.
So function voiceCard has a voice right here. Let's go ahead and extract flag and region using parse language, voice.language. Let's go ahead and define the audio source to go to API, Voices, and then encode URI component, VoiceID. So this will now go to a function which I don't think we yet have. We will create it later.
But it's going to be identical to audio. So it will basically just stream the R2 object key we have of the voice sample. So very similar to this route. It will just return the response of assigned audio. Alright.
Great. Now that we have that, let's go ahead and let's return some code so we can actually see this. So, we're gonna go ahead and add a div with flex, item center, gap 1, overflow hidden, rounded, extra large, border, PR3 and large PR6. Then in here, another div, relative, height 24, width 20, shrink 0, large height of 30 and large width 24. Then in here we're gonna go ahead and just add a self-closing div because this will serve as a nice little decorator which will kind of halve the card.
You're going to see how that looks like. Absolute left 0, top 0, height 24, width 10, border right, and background muted of 50. Enlarge height is 30 and enlarge width is 12. Then let's go ahead and create a container, which is going to hold our voice avatar. So that's going to have absolute, instance 0, flex, item center and justify center.
In here, let's go ahead and render the voice avatar and let's give the voice avatar a class name, size 14, border 1.5 pixels, border white, and shadow extra small, large size 18. Great. Then we have to go outside of this div here and we have to create the container which is going to hold the voice name and all the other things. So flex minimum width of zero, flex one, flex call, gap 1.5 on large gap three. Then let's open another div here.
Flex item center, gap 1.5, line clamp one, Text Small, Font Medium and Tracking Tight. And this is where we render the voice name. Next to the voice name, we're going to go ahead and add a self-closing span, which will serve as a decorator. So Size 1, Shrink 0, rounded full, BG muted foreground 50. And then next to it, we're going to render the voice category inside of a span.
This span will have this specific text color and it will look through voice category labels to find a match here for a human readable category using voice.category. Great. Outside of this div, we're going to render a voice description in a paragraph with line clamp one text extra small and text muted foreground then let's go ahead and render a flag and the region in another paragraph flex item center gap one text extra small span shrink zero which will render the flag emoji and span truncate font medium which will render the region text. Outside of this div we're gonna go ahead and add the container which will hold the actions to play or delete something. Right, so this and this.
And let's go ahead and start with a normal button. Now, I'm going to go ahead and add a few variables here, which we don't yet have. So const is loading will be false, const is playing will be false as well. And I think those are the only ones we need at the moment. So let's just have them here because later we're going to create a hook that we will be able to use to extract the properties from.
So this button here is going to have the following attributes, variant outline, size icon small, and class name rounded full. It's gonna be disabled if we are loading it and on click for now is just gonna be an empty arrow function. I'm just gonna add to do change to toggle play because we don't yet have this. And then inside of the button, depending on the state of is loading or is playing, we're gonna either display a spinner, pause or play, and all of them will have the same class name here. Great, and then the last thing we have to do is we have to add a dropdown menu, which will serve as the options button where we are gonna be able to delete the voice from.
So the dropdown menu will have the dropdown menu trigger, which has an as child property, which will render a button with a variant of outline, size icon small and class name rounded full. And that button will simply have a more horizontal icon with a class name of size four. Great. Outside of the dropdown menu trigger, we're going to add drop-down menu content with the line end option, then drop-down menu item as child, and in here we're going to render a link href text to speech with predefined voice ID of voice ID with a microphone icon of size 4 and text foreground and span use this voice. So later this is where we're going to add the delete option as well.
Now let's go ahead and build the VoicesList component which will be used to render the newly created voice card. So VoicesList.tsx is going to have audio lines, microphone and volume 2 icons and it's going to use voice card from voice card and the type voice item from voice card. So make sure you are exporting the type voice item and make sure you are exporting function voice card. Okay. Now in here we're going to create an interface for the voice list props and let's go ahead and export a function VoicesList.
First thing we're gonna do is empty state. So in case there are no voices, we're gonna go ahead and display a placeholder here. Let's make sure to return it actually. And the placeholder will actually be super similar to voice preview panel. So it's going to basically be, oh not voice preview, voice preview placeholder.
So basically this idea of three icons next to each other. So that's what we're going to be building now if you want to visualize it. So inside of this space y4 we're gonna add a heading which will render the title of the current list. Then beneath it we're going to add a container flex flex call item center justify center gap 3 and padding y 12. Then inside we're going to add a div relative flex height 14 with 32 item center and justify center and then in here we're going to render the three icons.
So the first icon is going to be volume 2 icon within absolute left 0 minus rotate 30 rounded full background muted padding 4. The Volume 2 will have a size 5 and text muted foreground. And then we're going to have two more items here. So the first one is this one, the microphone icon, which has relative z-index of 10, rounded full, bg foreground and padding 4. Microphone has a text of background and bg-of-foreground for the container.
So the reverse of what we had here where it's bg-muted and text-muted-foreground. And the last one is identical to the first one with a different icon. And then what we're going to do is outside of this div here, just render a paragraph saying no voices found, and then beneath it, what voices will appear here using the title. Be mindful of the classes, so maximum width, medium, text center, text small, text needed foreground, text large, font semi-bold, the tracking tight, and text foreground. So that's for a scenario of no voices.
But for a scenario of voices existing, we're gonna go ahead and do a much simpler code. So space y4, then an h3 element, rendering the title. And then we're gonna have a very simple grid. Grid columns one on mobile, on large grid columns two, and gap four between each item. And we're simply going to iterate over the voices using voice.map and render a voice card and give each card a key and the entire voice property.
The voice comes from the data right here. Perfect. So now that we have that, we can go ahead and try and render it in the voices view page. So why can we already render it? Well, because we are already prefetching it, right?
Instead of app dashboard voices page.tsx, we prefetch the voices. So now, instead of this voices view right here, we can go ahead and prefetch them. So how do we do that? Well, by creating something called VoicesContent. So right here I'm just going to separate it for clarity's sake, a new component called VoicesContent And we're going to start by adding TRPC here.
So let's import TRPC. Then we're going to go ahead and import the data. Well, not import the data, extract the data from useSuspenseQuery from tanstack react query. And the useSuspenseQuery will simply accept TRPC voices get all. And for now, Let's just make query options be empty so data will have custom and system voices now And then what we have to do is we have to return two voices list.
One voice list for team voices which will render data.custom and one for this built-in voices, data.system. Make sure you've imported voices list. And then simply render the voices content. So I'm gonna go ahead and replace this with the voices content. Like this.
So let's go ahead and refresh and just like that you should be able to see that there are no team voices but there are many many default voices here which are built in and by clicking on use this voice you should have it preselected in the text to speech. Great. So what's missing right now is the preview button. We currently cannot play it and we cannot search through our voices. So Let's add that.
Let's go ahead and let's add the ability to search through available system voices. For that, we're going to go inside of source app folder layout file and we're going to go ahead and import Nux Adapter from Nux Adapter's next app. And then let's go ahead and wrap our children inside of the Nuke's adapter. This will then allow us to revisit our Voices view page, but this time we will be able to actually query. So we're going to go back inside of the features, Voices, Views, Voices view right here and let's go ahead and add a query state from NUX right here and let's go ahead and track it So const query comes from useQueryState which will accept query key in the first parameter and use VoicesSearchParams from libParams which we've created here.
So make sure you're not using the cache. That's for the React server component. This one is for the client component. And now we can keep track of the query from the URL. So inside of the query options here we can pass along the query just like that.
So already in theory this should work. If I modify my URL to search for Aaron, so I appended a query Aaron here, only Aaron is available. Great! Now we have to connect that to UI. So in order to do that, we have to modify the voices view to also render the Voices Toolbar, which is currently not something that exists, so we have to go ahead and build it.
So we're going to go inside of Components and we're going to create VoicesToolbar.tsx We're going to import useState, useQueryState, useDebouncedCallback, search and sparkles. For the components, we're going to import button, inputGroup, inputGroupInput and inputGroupAddon. And let's also import VoicesSearchParams. Let's go ahead and export function VoicesToolbar. And in here, we're going to add our query.
So query and set query from use query state which uses the query key inside of voices.searchparams.query. So from here, we can also control it, not just look at it. We also need a local query so we can immediately show what the user is typing but only debounce into set query after a certain time has passed. So use the bounced callback so that set query is only called after 300 milliseconds has passed and user hasn't typed anything new. Meaning that we can consider this the end of typing.
Because if you search for every keystroke, that can very easily be tough on the database. Or even if it isn't tough on the database, it's just unoptimized. So let's go ahead and add a container here. Let's add a div. And in here, let's go ahead and add an h2 element with text large.
On large devices, text to Excel, font semi-bold and tracking tight. And then beneath it, let's go ahead and add a paragraph. Discover your voices or make your own. Then outside of this div let's add a flex flex call gap 3. Then let's add a flex items center gap 3.
And in here we're gonna add the input group with class name onLargeMaxWidthSmall. Let's go ahead and add InputGroupAddon which will render this search icon with class name Size4. And then beneath it, or should I say next to it an input group input with placeholder search voices value of local query and on change set local query immediately but debounce set query from NUX so it's not immediately queried to the database. And then next to the input group, let's add a div mlauto which is hidden on mobile and only visible on desktop. And in here we're gonna render a button size small, sparkles, custom voice.
And then let's do the opposite outside of the div. So only visible on desktop, let's do the same thing. The reason we are separating it now is partially because of the class name, but also because it's going to have different wrappers around this button once we actually implement the dialog and the drawer for it. So now we can import Voices Toolbar from Components -> Voices Toolbar and just like that you should be able, you can see it's already pre-selected here and if you search for Andy after 300 milliseconds it will change. You can see that while I'm typing nothing is happening, but if I stop typing, only then does the database input happen.
And you can see that right now the query is this, but if I delete it, the query is completely removed from the URL so it's clear. Great! Beautiful, beautiful job. So let's go ahead and see what else we have to do. We have to implement the ability to play this and we have to add placeholder, dialog and drawer for this button right here.
So let's create the proxy so that we can play audio samples. So we're going to go inside of source, app, API and in here we're going to create voices. Inside of voices We're going to create voice ID and then inside of voice ID. We're going to create a route. Dot DS in here.
Let's go ahead and import out Prisma and get signed audio URL. Let's go ahead and export a get request, which skips over the first argument and only looks for params with voice ID. Make sure the voice ID isn't misspelled, otherwise this will be undefined. Let's go ahead and check if we are allowed to look at this request if we have an organization should I say then let's extract the voice ID from params let's attempt to find the voice and then let's go ahead and do some checks if the voice doesn't exist and then if we aren't allowed to listen to this voice or if the voice simply is orphaned and we don't have the equivalent storage object key. Otherwise let's create a signed URL and let's create an audio response by using fetch.
If fetching the audio response fails, we also have to throw an error. Otherwise, let's go ahead and prepare the content type, which we can either get by looking at the audio response headers content type or fall back to audio wav and then let's go ahead and return new response audio response.body with the headers content type content type and cache control right here and by doing that you should be able to well you can't play it yet because we didn't implement the hook to play that. So now we have to add that hook inside of here. Hooks. Let's go ahead and do useAudioPlayback.ts and this will mostly be repeating what we already have in the voice preview mobile.
So we already kind of did a bunch of things here and it's going to be no different than that. So it's not really much of a learning experience. So what I would highly suggest is that you look inside of my source code, go inside of source, hooks, find use audio playback and simply copy the entire thing here. So we mark it as used client, we import all of these hooks, we set up the audio ref, isPlaying and isLoading, we go ahead and do a cleanup here, we add a toggle play button, we go ahead and return isPlayingLoadingAndTogglePlay. So as I said, we already did something like this a few times, so it really makes no sense for you to write it by hand once more.
If you want to, here is the entire code of course but it's a very simple is playing is loading and toggle play now let's go inside of voice card right here so we can replace these fake ones with a real hook so the way we're going to do that is by removing these entirely. Make sure you have audio source and then go ahead and import use audio playback from hooks use audio playback. And let's go ahead and use toggle play here in the to-do which we have assigned it to. Great so now you should be able to play this so try playing Aaron for example. Alright so I tested it and it works and now let's go ahead and implement the VoiceCreate dialog so we can prepare for adding custom voices.
So we're gonna build this inside of Features, Voices, Components, VoiceCreateDialog.tsx. Let's go ahead and add all the imports, use client, everything from dialogue and everything from drawer and also use is mobile hook. Then let's go ahead and create the interface, VoiceCreateDialogProps, which has optional children, optional open boolean, and optional onOpenChange. Let's go ahead and export function, VoiceCreateDialog with the props assigned. And let's start by checking if we are on mobile or not.
So if we are on mobile we're going to use a drawer otherwise we're going to use a dialogue. So let's go ahead and let's return a drawer. Let me go ahead and end the return here. Make sure you pass in the open and unopen change properties to the drawer here. And if we pass the children along make sure to render it inside of a drawer trigger with as child prop, like that.
And then in here, we're just going to add a mock composition. So drawer content, drawer header, drawer title, and drawer description. As simple as that. And we're gonna just do the equivalent for desktop mode. So in here, let's go ahead and return dialogue with open on open change.
If we have children, render the dialogue trigger with as child and the composition is exactly the same except in here we're using dialogue header, dialogue title, dialogue description with a class name text left. Right, you can see it's exactly the same but different components the composition is the same and in here we have a slight text left. Great, and now that we have this, let's go ahead inside of the voice toolbar and let's go ahead and encapsulate each. So I'm going to encapsulate this one with voice create dialog and I'm going to encapsulate this one with voice create dialogue and I'm going to encapsulate this one with voice create dialogue as well. Let's go ahead and import voice create dialogue.
Let me show you from ./.voice create dialogue because they are in the same folder. So this is Voices Toolbar and this is Voice Created Dialog. Let me go ahead and fix the indentation. And now, let me go ahead and click on custom voice. You can see on mobile you have a drawer, but on desktop you have a dialog.
Brilliant. So that was step one here. We did the Voices page, Browse, Search, Cards with avatars and flags, and now we have to develop the file upload and voice recorder, and finally, voice deletion. In order to create the upload functionality, we have to install some packages. React Dropzone, Locale Codes, and music metadata to help us define the duration of the audio file content type basically all things we need to validate if it's a valid input or not Then let's go inside of source app API and inside of voices.
Let's create a create folder with the route.tss inside and the route should be a file. So route.ts. Let's go ahead and add all the imports that we are going to need I'm going to expand this, auth, parse buffer, Zod, Prisma, upload audio, voice categories and type voice category from Prisma Client. So all the things we already have and have worked with. Let's create the voice schema using ZObject.
Required name, required category which has to be an enum of one of the categories we support. Language, which needs to be required, it needs to be a language code. And description, which can be optional. Let's go ahead and define some other limits. For example, maximum upload size will be 20 megabytes.
We don't want this to be too long. And minimum audio duration will be 10 seconds. Then let's go ahead and export this POST request. And the reason we are doing this inside of an APA route and not TRPC is again because of buffers and how you upload files. So we need to do it instead of a normal route for this specific functionality.
Let's extract user ID and organization ID from await out. Let's go ahead and throw an error if it's missing. Then let's go ahead and parse the URL. And let's go ahead and see if we have all the valid fields from this schema here. So why are we parsing the URL?
Why not request JSON of the body? Because we're going to be using a file upload. So when you do file upload, you can't pass along the body. Well you can, but then you have to change the type of upload and you have to look for the compatibility and all those other things. It's simpler to simply append to search params, right?
So let's go ahead and use create a voice schema to initiate safe parse over URL search params for name, category, language and description. So make sure you don't misspell any of these fields here. And then if the validation has not been a success, let's throw an error, invalid input, and we can pass along all the issues that happened during parsing with a status of 400 remember to add an exclamation point here so this is if it fails otherwise we are safe to extract that data so we now have proper types for each of these fields here because we have parsed them and thrown errors otherwise. Because of the current request which I was explaining earlier, we can now do request array buffer and we can get the exact file buffer that we are trying to upload. First, let's check if it exists.
If file buffer byte length doesn't exist, this isn't an audio file. So let's go, I mean this isn't a valid file, so let's throw an error. Next, let's check if it's too big. If the byte length is above maximum upload size, which we've defined to be 20 megabytes, let's throw an error once again. Then let's go ahead and extract the content type from request headers get content type.
And if the content type is missing, let's throw an error as well. Then let's go ahead and normalize the content type simply because it can have some additional properties here. So we're just going to fall back to this or we can just trim it here. All right. Then let's go ahead and let's validate the audio format and the duration.
So we're gonna go ahead and define the duration to be a number and then we're gonna go ahead and open a try and catch block here. Inside of try, we're gonna go ahead and define the metadata to be a weight parse buffer passing the new unit 8 array with the file buffer inside, give it a min type of content type which we have and duration to be true. And then to this duration let we're gonna assign metadata format duration or fallback to zero. And in catch we're gonna throw an error file is not a valid audio file. And then using that duration we can go ahead and throw if it's not long enough so if duration is less than minimum audio duration in seconds let's return response JSON audio is too short let's display how long it is Minimum duration is minimum audio duration in seconds.
Alright, then let's go ahead and define a created voice ID and let's go ahead and open a try and catch block for creating the voice ID. So we are going to create a new voice using Prisma Voice Create, pass along name, variant custom, pass along the org ID, description, category language, and let's only select back the ID. Then we can immediately assign created voice ID to be voice ID and using that we can create the R2 object key voices organizations organization ID voice ID. So so far we've only had Voices system. If you look inside of the seed script, somewhere here you will find our key which was Voices system.
So when an organization is creating their voice we're going to store that under voices organizations and then organization id now that we have the r2 object key we can go ahead and upload audio passing the buffer r2 object key and content type to be normalized content type and finally once that is uploaded we can go ahead and update that voice and pass along the R2 object key. Now in the catch method, we have to do some cleanup. So if something failed, let's go ahead and check. If we have created a voice ID let's delete it simply because that would mean something went wrong and let's go ahead and return response json failed to create voice please retry otherwise we're going to go ahead and return response JSON with the status of 201, pass along the name of the voice and message, voice created successfully. Great!
Now let's go ahead and create the UI. So let's go inside of features, voices, components and let's create a new file voice create form dot TSX in here let's go ahead and let's add use client, use state Zod, Toast, use form, use dropzone from our new package use mutation and use query client Then let's go ahead and let's add all the icons which we're gonna use and there's a lot of them. Audio lines, folder open, X all the way to a line left so pause and add all of these icons. Then we're going to add locales from locale codes. Then let's go ahead and import CN from libutils But let's also add one more function to libutils here.
So go inside of source libutils where we only have CN and let's go ahead and add a function format file size which accepts the bytes and if it's less than 1024 it will return this in that format. If it's less than twice that it will return it in kilobytes, otherwise in megabytes. So a very simple function to display format file size. And then once we have that, we can go ahead and import that from here as well. So format file size.
Then let's go ahead and import our previously created use audio playback. Let's go ahead and import use DRPC. For the components we're gonna add the following ones. Button, input, text area, field, field error, tabs, tabs list, tabs trigger and tabs content. Besides that we're also going to have select and all the elements inside of select.
Then we're going to have popover and these three elements from popover after that we're going to have command and all of the elements from command and last we're gonna have voice categories and voice category labels from features, voices, data, voice categories great! Now let's go ahead and define the language options for the dropdown. That's gonna use the locales.all filter for each locale, find a tag and find if the tag includes a dash and then display locale name. After we've filtered that let's map over each locale and give it a object of value and label. Value will be its tag and label will look for location and then render the name and location in parentheses otherwise fall back to just the name.
So that's how our language options will look like. Like this. It's going to be an array of value and label. And we're using locales from our locale codes package. Great.
Let's go ahead and let's create voice create form schema which is a Z object which accepts a required name, a file, which has to be an instance of file, an audio file is required, and we just add nullable and refine here to make sure it's required additionally. We add category, language, and description. The only optional thing here is description. Great. So now let's go ahead and let's build the Voice Create form.
So for that we're going to need to add Voice Create form props. Optional scrollable, optional footer, and optional onError here. So let's go ahead and export function, VoiceCreateForm with those props. Let's go ahead and add use DRPC and query client from use query client. Then let's go ahead and do a create mutation.
So what happens when we actually hit upload right in here. Let's go ahead and add a mutation function. Let me go ahead and do this. So this mutation function is going to accept Name, File, Category, Language, and Description. We can go ahead and create the equivalent types for all of that.
Here they are. The only optional one is Description and be mindful of the File to be the File property. And what we're going to do is we're going to append those things to params. So params, new URL search params and pass in name, category and language. And only if description exists, pass along the description to.
So this is what we're doing. We're appending those form elements to params. So later in create.route, we can destructure it from the params. So that's why we are constructing the URL element here and then parsing it against the voice schema. So name, category, language, and description is what we're adding here.
Name, category, language, and description. So that's what that is. And then what we can do is we can create a fetch request to that endpoint. Alright, so I'm just going to pull this down. So await fetch, open parenthesis, open backdigs, API voices create and attach the params.
And we have to pass along a method to be post method and headers, we have to make that content dash type, file dot type that was uploaded and body file. Then let's go ahead and check if the response is okay or not. If it's not we're gonna go ahead and await response JSON and throw new error body dot error or fall back to failed to create voice and last but not least let's go ahead and return response JSON great so that is our mutation here Now let's go ahead and define the hook for form. It's gonna have default values of empty name, file, null as file, all fall back to null. Category, so null as file or null, that's what I meant to say.
Category is gonna be general as a string. Why general? Well, because inside of the data for voices, for not voice scoping, voices data, voice categories has general here and we need to use the key rather than the value. So the ones that are inside of Prisma schema. So inside of your schema here You have an enum of categories and we're going to put general as the fallback.
The default language will be English US and description will be empty. Now let's add some validators which will trigger onSubmit. We're going to call voice create form schema from above. And then in here, we have to open the onSubmit function. The onSubmit function will have access to values, so make sure you destructure that, make sure it's an asynchronous function.
And let's go ahead and open a try block and a catch block. Inside of the try block we're going to call await createMutation mutateAsync so we're gonna use this mutation we've just created above and in here we're gonna go ahead and simply pass along all the values. Name will be value.name, file will be value.file, and you can add an exclamation point here at the end because we know we're going to have it at this point. Category, language and description which can be undefined otherwise. Then let's go ahead and add a toast that we have created a voice successfully, if that passes.
And let's use query client, invalidate queries and invalidate by a key, trpc voices get all query key. So when you create a new voice, these voices will be invalidated and refetched so you will have a new voice appear here instantly. Alright. And after that, let's make sure to reset the form. Inside of the error here, let's go ahead and create the error message, which we can either do by reading error message, if the error is an instance of error.
Otherwise, let's fall back to a string like this. And then we're just gonna go ahead and either call an error callback or simply throw a toast error. So if we have onError prop, we're going to pass along the message otherwise toast.error. Great. Now let's go ahead and build the actual form.
So the form element is going to have onSubmit, which prevents default, and calls form handleSubmit, and the class name, which is going to have a CNUtil for className flex flex call and if it's scrollable it will change the CSS to minimum height 0, flex 1, otherwise gap 6. Then let's go ahead and do the same thing for the inner div when it comes to class names like this. So if it's scrollable, We're gonna use no scroll bar, class name flex flex column gap6 overflow y auto px4 otherwise a much simpler class. And let's go ahead and add some fields here. So the first form.field which will have a name of file will render the following element.
First things first, let's get if it's empty or not. I mean, if it's invalid or not. So field state meta is touched and not field state meta is valid. We're gonna use that for some ARIA attributes. Let's go ahead and, oh, something's wrong here.
I have to reverse these or not. Let me just see what's wrong with the... Alright, I need double curly brackets. In the return here, let's render the field element data.invalid is invalid. And then in here, we're going to render the tabs with the default value of upload.
So our tabs will have a tabs list with the following class names and two options. The first tab trigger will have a value of upload, an icon of upload and a text of upload. And the second one, which for now is going to be disabled will be the value record with a microphone icon and record label and then in the tabs content outside of the tabs list here for the value upload let's add a to-do file upload like that. And if it's invalid, let's go ahead and render an error here. So if it's invalid, simply render a field error like that.
All right, so that's the first element. And now we have to add all the other fields. But before we continue developing this, I think it would be a good idea to actually render this. So let's go inside of Voice Create dialog because this is where this is going to be rendered. And let's first do the desktop mode.
So desktop is quite easy. After the dialog header, simply add voice create form like this and import it from voice create form. The mobile one will be slightly different. So after drawer header render voice create form but we're going to do it with some props. So it's gonna have a scrollable prop, and it's gonna have a footer prop.
And the footer is gonna use drawer footer, drawer close from components UI drawer, button from components UI button. And I think that's it. Let me just check if I imported all of them correctly Footer Close And Button. Make sure there are no imports from base UI or erratics. So it needs to be all from components UI.
Great. That looks good. Let's keep it like that for now. And if you go ahead and click on custom voice, you should start to see the results here. You can see we now have to do file upload and a hidden record tab.
So if I go to desktop, it's the same but on desktop. So at least now we can see what we are developing. So let's go back to create form and let's go ahead and implement the second element, which is going to be the name of the field. So after we ended this form field, Let's go ahead and open a new fourth field, which is the name of the audio file. Inside of this, let's go ahead and render a field.
Let's go ahead and define what's the invalid state as usual and let's go ahead and return. Inside of the return, we're going to go ahead and render a field element with the data is invalid for the invalid prop above. Let's add a div with relative flex item center. Then a div with pointer events none, absolute left zero flex height full with 11 items center and justify center and within we simply render a tag element like this. Then let's go ahead and render an input down here after that tag which has an ID of field name, placeholder, voice label is invalid, value, field state, value, onChange, field, handleChange, event, target, value, onBlur, field, handleBlur and class name PL10 to leave space for this little tag right here.
:58 Great, so we now have a voice label at the bottom, beautiful. And let's go ahead and make sure we can display an error if it happens. So identical to how we displayed it here. Okay, now let's go ahead and do category field. So form field for category.
:19 We're going to start in an identical way as we did above. So you can copy and paste the is invalid state because it's the same. And now let's go ahead and return a field once again. And inside of the field it's actually gonna be the same div, so you can copy this too. Right?
:48 And let's add it here. And we're gonna need to add a closing div here instead of the tag icon it's gonna be layers alright and then outside of this div we're going to render a select component select is gonna have a value field state value and on value change field handle change. In here let's add a select trigger. The select trigger will have a class name with full and PL 10 and select value inside will have a placeholder select a category Outside of the select trigger we have to render the select content with all the options Select content will iterate over voice categories and for each category is going to display a select item with the value of that category and then mapping the voice category label to each category. So audiobook becomes this, customer service becomes this.
:46 You can go ahead and try it out to see if it works. And there we go. You have all of the options right here. Great. Now let's go ahead and make sure that we have an isError field here.
:01 And let's wrap it up before we start building some custom fields with a description component. So for the description component you can copy the entire form field for the name because it's very similar. So I'm going to copy this and I'm going to go to the end of the category here, paste this. I'm gonna change this to be description. I'm gonna change the icon here to be align left.
:26 And instead of using an input here, I'm gonna be using a text area. The placeholder is not going to be voice label. It's going to be described this voice. On change will be identical, field will be identical, but class name will be different and along with class name, we're also going to add rows three. Now let's go ahead and let's add a submit button.
:53 So again, after we end with this form.field right here, we're going to open a form.subscribe. Form.subscribe is gonna have a selector like this. And then in here, let's go ahead and use that selector. And then let's go ahead and render a submit button inside of a constant. So submit button will be a button with type submit.
:19 Disabled is submitting. If it's submitting, we're gonna display creating, otherwise create voice. And then we're gonna choose, if we have a prop called footer, then we're going to, whoops, we have to do this outside of the submit button constant. If we have a footer, we're going to render a footer and then submit button inside, otherwise submit button solo. So you can see how that looks here.
:46 We have a cancel button for the drawer. That's why we need this solution. All right. But for desktop, it's much simpler. We don't need cancel here because we can cancel in other ways.
:57 All right. So we now have that. And now we need to create a language combo box which is basically a component for choosing languages. So I'm going to go here at the top and after we Before we create the voice form props, let's go ahead and create a function, language combo box. Make sure you add the following props, value on change and is invalid.
:28 Let's go ahead and start with a open and set open hook. Let's go ahead and choose a selected label using the language options. So from that language option, if we find a valid value, we're gonna pick its label, otherwise fall back to an empty string. And for that we're going to use a popover and command component. So let's go ahead and return a popover with open and on open change, calling open and set open respectively.
:00 Let's go ahead and render popover trigger with as child property and inside of here we're going to render a button. Now this button here is gonna have type of button, variant outline, role combo box. This type button is super important so you don't accidentally submit the form with it. After these area elements we're going to add a class name using CN. Height 9, width full, justify between, font normal and if there's no value Let's go ahead and make it muted.
:32 Then inside of this button here, we're going to go ahead and render a div. Flex, item center, gap 2 and truncate. And we're going to render a globe icon with size 4, shrink 0 and text midi foreground. It's a self-closing tag. If we have a selected voice, I mean language, we're going to display the select label which is calculated up here if we can find it in the language options array.
:57 Otherwise, select a language. Then let's go ahead outside of this div and render chevrons up down with size 4, shrink 0 and opacity 50. And then let's go ahead and go outside of the popover trigger and let's render the popover content. The popover content has this specific radix class name so it fixes the width of the trigger and padding 0. In here we are gonna do command composition.
:27 So let's add the command input so we can search for a language. Command input is a self-closing tag then we render a command list if there's nothing to be found within the list we're going to add command empty and inside of the command group right here we're gonna go ahead and iterate over the language options Inside of these language options we're going to go ahead and render the command item. Each command item will have a key, value and on select. Let me just go ahead and see what I need to fix here. Just a second.
:11 Like that. So key is language value. Value is language label. All right? And on change, we send language dot value and we call set open post to close this instead of the command item, we are rendering language label and for each label that's selected we're gonna render a check icon.
:31 So by default, ML auto and size 4, but only if the value is equal to language value, we check the opacity to 100 and opacity to 0 otherwise. Alright, so that is the language combo box component. Now we have to render the language combo box component before we render the description. So find category, find description and between those two go ahead and add form field name language, go ahead and open your usual field property, your usual is invalid state, go ahead and return the field property and instead of rendering text area or anything like that, we render language combo box with value, field state value on change field handle change is invalid prop and the usual is invalid. So identical to what we've been doing in description, but even simpler because inside of the field, we directly render our new language combo box.
:37 So go ahead and save that file. And now in here, you can see that you can select a voice and you can even search for, for example, Croatian voice right here. Beautiful, beautiful work. Now it's time to add the drop zone so we can actually upload a file. So we're gonna go above our language component here, above function language combo box, and in here we're gonna develop function file drop zone.
:07 File drop zone component will have prop file on file change and is invalid prop. Then we're gonna use our use audio playback hook and pass along the file prop to extract is playing and toggle play and then we get to the big hook we can now use use drop zone we use use drop zone from the package we installed react drop zone right react drop zone right here and from this package we can go ahead and extract a bunch of things but we first have to define how this drop zone is going to work so it's going to accept audio files only we're going to calculate the maximum size to be 20 MB on the front end as well. We're gonna disable uploading multiple files. And we're going to specifically handle onDrop this way. So accepted files and onFileChange only the first one from the accepted files so we will never accept multiple files and then once you have done that you can go ahead and extract get root props get input props is drag active and is drag reject all right Now inside of this function here we will have two renders.
:21 The first one is if we have a file. So if we have a file we're gonna display one thing, if we don't we're gonna display the other thing. So in case we have a file we have to go ahead and display a container. Then we have to render an icon within another container here. So the outer container is flex item center gap 3 rounded Excel border and padding 4.
:45 The inner one is flex size 10 item center just if I sent their rounded large and background muted and inside a file audio of size 5 and text muted foreground. Then next to it, we're going to go ahead and add some file information. So minimum width of zero and flex one in the first paragraph, we're going to render a file name inside of truncate, text small and font medium class names. And then we're going to use format file size function. So in the paragraph beneath it we render the file size with extra small text and text muted foreground.
:20 Alright, then we're going to add a button to listen to what we just uploaded. Type of button is super important so you don't accidentally submit something and on click toggle play. Let's go ahead and add if it's playing pause icon otherwise play icon. And another thing we need to do is a reset button. So if we want to reset what we just uploaded again a type of button and on click on file change to null with an X icon.
:50 Great. And then the last thing we have to do for this component, outside of this curly bracket. So the normal return is the usual drop zone. So let's go ahead and add this. And we'll just prepare this empty div and we're going to go ahead and pass all the props which arrive from a function get root props.
:15 So we extract that from useDropZone here and then we have to open the dynamic class name here inside of the dynamic class names let's first add the default ones so the default class names will look like this flexCursorPointer, flexCall, itemCenter, justifyCenter, gapFor, overflowHidden, roundedToExcel, border, px6, py10 and transitionColors Then we're going to do a nested ternary in the second argument. If is drag reject or if is invalid we're going to add border destructive so the user knows you can't drag and drop that file. Otherwise we're going to check if the drag is active and we're going to use border primary or simply fall back to nothing. Then we have to render a native input element with get input props spread inside. Beneath it we're going to go ahead and render audio lines icon within a container flex size 12 item center justify center rounded excel and background muted.
:20 Then beneath that we're going to add some text. So flex flex call item center gap 1.5. Let's go ahead and render a paragraph, upload your audio files with the following class names. Beneath it, another paragraph. Supports all audio formats, maximum file size 20 MB with those class names.
:40 So some more informational content. And then outside of this div, a button. Again, type button, super important. Variant outline, size small, width folder, open icon and upload file text. Great!
:56 We are ready to render file drop zone. So let's go ahead and go inside of voice create form and this one will be a little bit different so we have to find the tabs here and in here tabs content for the value upload we're gonna go ahead and remove the placeholder and render a file drop zone just like this. So now when you click here, you should be able to upload your file. And now you can try it out. So I'm going to go ahead and try and upload something here.
:27 If you click on the play button, you will hear audio feedback of what is played. You can of course remove it too. And now let's go ahead and try and upload this. So I'm going to call this custom voice number one. I will pick narrative.
:42 I will leave this as English custom voice test and I will click create voice. Looks like audio is too short. So there we go. Our validation works. Let's try with a proper voice.
:53 And after you pick a voice, which is long enough, for me that was Madison, in case you're interested, you will have your custom voice right here. There we go. And you should be able to use it here as well. And you can see how it's separated into team voices. So you can actually try and record yourself and upload that file.
:16 And you should be able to clone your own voice right now. So what we have to do next here is we have to enable you to record your voice directly from here rather than just uploading a file. Also a quick tip, if you're working with larger files like up to 20 megabytes, you actually have to go instead of nextconfig.ts and in here you have to open experimental and then proxy client max body size and change it to 20 megabytes. Otherwise you will get errors in the console. But since we are really looking for small files here, this will almost never be a problem.
:55 We only need 10 seconds. And this could maybe trigger you to restart your server. So let's just go ahead and do that and see if everything works just fine with that. There we go, looks great. In order to build the recording functionality we have to install some packages.
:13 So install recordrtc and types for recordrtc. Then let's go ahead and let's build the component voice, sorry, we're going to build a hook, useAudioRecorder first. So let's go inside of features of voices and that will be a hook specifically for this, so hooks, and inside of here, useAudioRecorder.ts. All right. And inside of this hook, we're gonna go ahead and import the following packages.
:50 So I'm going to expand this as much as I can. Use state, use ref, use callback, use effect, import type, record RTC type from record RTC, wave surfer and record plugin from wave surfer. Great. Let's go ahead and export function useAudioRecorder. Let's go ahead and all the state fields we need.
:08 So isRecording, elapsedTime, audioBlob and error. Be mindful of the types. Boolean, number, blob or null, string or null. Then let's go ahead and add all the refs that we're gonna need. We're gonna need a recorder ref, which is a type of record RTC type, stream ref, which is a type of media stream, timer ref, which is a return type of set interval, container ref, which is a div element, vs ref which is a wave surfer instance and microphone stream ref which will simply be a custom object with on destroy function here.
:40 All are initialized as null in the default. Let's go ahead and create a destroy wave surfer function. Then let's go ahead and implement a cleanup function which will reset everything. So timer ref.currentReset, recorder ref.currentDestroy, stream ref.currentGetTracks for each track stop a track and destroy wave surfer which is a function we've created up here. Okay, now that we have that let's go ahead and create a use effect.
:21 Inside of here, let's go ahead and see if we can early return. We can do that if it's not recording, if we don't have a container, or if we don't have a stream ref container. Then let's go ahead and initialize WaveSurfer. We've already done this before. WaveSurfer create, pass in the container, wave color and then some configuration properties.
:42 Let's go ahead and assign the WaveSurfer instance to its ref. And Then let's go ahead and add a record plugin. So record vs register plugin, record plugin create, scrolling waveform set to true so we get the desired effect. Then let's go ahead and listen to the microphone. So handle record, render microphone stream using stream ref dot current and go ahead and assign that to the ref as well and when it out mounts make sure to destroy the wave surfer and in the dependency array we need two items is recording and destroy wave surfer Then let's go ahead and let's develop the startRecording useCallback.
:26 Let's go ahead and open a try and catch block. Let me go ahead and properly close this. There we go. In the try block here, first of all, let's reset our elements, set error, set audio blob and set elapsed time to zero. Then let's go ahead and create a stream by awaiting navigator, which is a built-in browser API, get the media devices and then get user media and search for audio media.
:50 So audio set to true. And then let's go ahead and assign that to a ref. So stream ref becomes the stream. And then let's go ahead and import record RTC dynamically. So we are awaiting import record RTC and we go ahead and extract recordRTC and stereo audio recorder from here.
:12 Once we have dynamically imported this, we can go ahead and create an instance of recorder using new record RTC, pass along the stream and pass along the other options it requires. Then let's go ahead and assign that to a ref. Let's start the recording and set isRecording set to true. Now to keep track of time, we also need to define a start time and then set an interval to timerRef to modify the elapsed time every 100 milliseconds. Let's go ahead and catch an error if anything goes wrong inside of the initialization here.
:50 So I'm just gonna go ahead and check what type of error happens here. So if error is an instance of DOMException and error name is not allowed error, we're gonna set the error to microphone access denied, please allow microphone access in your browser settings. Else, fail to access microphone, please check your device. And the only thing we need in this dependency array is a cleanup function. And let me just see.
:17 We also, of course, need to call cleanup in the catch method. My apologies. There we go. And now let's go ahead and implement stopRecording function, which is simpler. StopRecording simply accepts onBlob function, which is optional.
:32 It attempts to find the recorder ref. If it cannot find it, it cannot stop it. Otherwise, it calls stopRecording() function. It gets the blob of the recorder, setsAudioBlob, setIsRecording to false, does the cleanup and calls onBlob. This basically means user has finished recording and wants to see the results.
:49 That's why we need blob. So this isn't a cleanup function. This is a on change function rather. Now this is a cleanup function, a reset recording function. Calls cleanup, set is recording to false, set elapsed time to zero, set audio blob to null, set error to null with the cleanup dependency key and This hook will return is recording, elapsed time, audio blob, container ref, error, start recording, stop recording and reset recording.
:17 Now let's build the UI. So we're going to build this component in the same place as the voice create form. So I'm going to go ahead and do voice create, my apologies, voice recorder. That's going to be the name, Voice Recorder. And let's go ahead and add the icons, microphone square, rotate X, file audio play and pause.
:39 And then let's go ahead and add all the other elements. We're gonna need CN, format file size, button, use audio playback, use audio recorder, which we've just created. Let's go ahead and add a custom function called formatTime, which will simply take in the seconds from the audio recorder and format it in a human readable way. Feel free to copy this function from the source code as this text is kind of hard to look at or you can just pause the screen and copy it yourself. Then let's go ahead and define a function voice recorder.
:12 The voice recorder function will accept file on file change and is invalid. Is invalid is optional so be mindful of that. Now let's go ahead and reuse our old use audio playback and accept the file and pass along is playing and toggle play here. Then from use audio recorder we can extract all of the things that we are exporting from here. IsRecording, ElapsedTime, AudioBlob, ContainerRef, Error, StartRecording, StopRecording, and ResetRecording.
:40 Now let's go ahead and develop a very simple handleStop method. This will stop recording as we expect and you can access the blob from there. And then it's going to create a new file instance from that blob, name it recording.app and it will call on file change as if this file was uploaded. So that's how that's going to work. Let's go ahead and also enable a handle re-record to change the file to null and reset the recording.
:06 Now if an error happens we need to have a screen for that. So let's go ahead and return the following. A div with flex flex call item center gap for rounded to excel border border destructive with 50% opacity and background destructive with 5% opacity px6 py10 a paragraph describing the error inside of text center text small text destructive and the button again type button, super important, variant outline size small and on click, reset recording. Now let's go ahead and do an instance if we have a file. So if we have a file, meaning we finished recording, we have to display it in a similar manner to what we did in...
:51 Let me go ahead and find it... Voice Create 4. In fact, the file drop zone might be identical to what we need. So let's actually copy this. I think it's almost identical.
:06 I'm going to copy it here and I'm going to paste the entire thing. And now I'm just going to compare if that's what we need. So file audio definitely stays the same, file name stays the same. The only thing we have to do is after format file size, we also care about how long this duration of this is. So we have that info here, but we don't have that info when we upload a file.
:31 So we can use audio blob and if elapsed time is above 0, we can go ahead and add this dot and then use format time function which we have here it is, which we have defined above to format seconds into human readable format. There we go. Then in here let's go ahead and see what we should do. So type is button, variant is goat, icon is small, onclick, toggle play. Let's set the title is playing to be pause and play.
:02 Reverse icons and beneath it let's go ahead and do the following. So we should have some different buttons here. Let's go ahead and add one more button. So before we do the X button, let's add a rotate CCV button which calls handle re-record. Make sure it's a type of button.
:26 And then we're going to do the exact same thing for the X button. So it should also click handle rerecord. All right. Now let's go ahead and render if is recording state. So if it's recording, we have to go ahead and render the beautiful waveform, right?
:46 So for that we're going to go ahead and render a flex, flex call, overflow hidden, rounded to excel and border and then we're going to render a container inside with a class name width full this container is where the actual waveform will be rendered Then flex item center justify between border top and padding for and beneath it we're going to format the elapsed time to see how long has it passed. So you know you have passed 10 seconds. Text is 28 pixels font semi bold leading 1.2 and tracking is tight. And then beneath this paragraph, we're going to go ahead and render a button which simply has let me go ahead and indent this properly. So this button has a square icon and stop and on click handle stop.
:35 You can go ahead and look at the props like this. So it's cleaner. All right. And now we just have to build the final UI here, which is to present this component. So let's go ahead and start with the parent div which uses the dynamic class name flex cursor pointer flex call item center justify center gap four and these classes as well and if it's invalid It's going to be border destructive.
:02 We then have to render the icon, which is a microphone icon within a div flex size 12 items center justify center rounded Excel and background muted. Then let's go ahead and let's render a div flex flex call item center gap 1.5 a paragraph record your voice. Another paragraph click to record. Sorry, click to start capturing audio. Be mindful of the class names of course, text base, font semi-bold, tracking tight, text center, text small and text muted foreground.
:35 And then outside of this div, let's go ahead and render a button to actually trigger the recording. So this button, type of button, variant outline, size small, on click, start recording, microphone 3.5 and record. Now that this component is finished we can go back inside of voice create form and we can go ahead and find our tabs. So let's scroll down here until we find the actual voice create form and in here we should find the tabs, perfect, and for we currently don't have tabs content at all for record so let's create it. Tabs content with value record just beneath the tabs content for upload and import the voice recorder like this ./.voicerecorder.
:22 Pass along the file on file change and is invalid. Let's go ahead and see that now when I click on record which is disabled so I have to enable it remove the disabled attribute here And there we go. Let's go ahead and allow the microphone. So allow. And you can see that as I speak, I get a waveform here.
:46 So I can exactly see if my audio is being captured or not and I can also see the seconds here. When I click this I can go ahead and listen to my voice. You can see the seconds repeat here as well and now I'm going to try and upload this. And just like that, voice was created successfully and here it is, my custom Antonio cloned voice. I can now use this voice and I can tell a silly joke in my voice.
:15 Now let's go ahead and wrap it up with an ability to delete custom voices because currently we can only listen to them but we cannot delete them. So for that we have to go back inside of the voice card component which is located in source features components voice card. And in here let's go ahead and let's add the following imports. Toast from Sonar, useMutation and useQueryClient. And then let's also go ahead and add alert, action, cancelContent, description, footer, header and title.
:48 We already have drop down. We are missing use the RPC. So let's add that here at the bottom. Use the RPC from the RPC client. Perfect.
:58 Now let's go ahead and let's create some mutations here. So once we define audio source, once we have this, let's add use TRPC, use query client. Let's go ahead and create a delete mutation here. And in here, we're simply going to call TRPC voices delete mutation options On success, toast voice deleted successfully and invalidate trpc.voices.getAllQuery and on error, simply toast error failed to delete voice. We already have trpcvoices.deleteOrganizationProcedure which allows us to only delete custom voices for that organization that the user is currently logged in.
:42 So we already have that implemented. Perfect. And now we have to add the ability to... For this action to appear in the drop-down menu. Because right now we only have one drop-down menu item.
:54 So what we're gonna do here is we're gonna go ahead and add another drop-down menu item. And we're going to import Trash2Icon from Lucid React. It's gonna have the exact same class name except text destructive. So not the exact same, sorry. Text destructive and focus text destructive.
:11 Trash2 icon and span delete voice. And this will also be text destructive. Now we also have to maintain a state for this set show delete dialog. So let's go ahead and add that here. So set show delete dialog.
:31 Use state. So import use state from react right here. And now let's go ahead down and actually use it here. So after a drop down menu, we're going to go ahead and check if voice variant is custom once again and only then We're gonna go ahead and render an alert dialogue for them with open show deleted dialogue and on open change set show deleted dialogue In here, we're gonna go ahead and do the usual alert dialog composition. So that's going to be alert dialog content, header, title, some description with the voice name we are trying to delete.
:12 And then once we close the header, we're going to go ahead and open the alert dialogue footer. Instead of the alert dialogue footer, we're going to go ahead and add a cancel button which will be disabled if the mutation is already pending. And then we're going to add the alert dialogue action. The alert dialogue action will have a variant of destructive, it's also going to be disabled if the delete mutation is pending and on click it will prevent default and call the delete mutation with the property of ID, the voice ID of this card. On success it's going to close this dialogue and it's going to show a different text depending on what's currently happening.
:48 Let's try it out. So you can see I have delete voice on my custom theme voices but I don't have it for built-in voices. So I'm going to try and delete this old one here. Delete voice, Delete and let's wait for a second. There we go.
:03 Voice is deleted. Amazing, amazing job. You just finished the largest chapter of this entire project. You can now upload, you can record, you can delete. You built the entire voice management.
:17 We have cache invalidation, live form visualization, in-browser audio recording, drag and drop file upload, and we can search through voices. We implemented so so much in this chapter. Now it's time to commit it. Chapter 8, Voice Management. As always, let's go ahead and just make sure everything is fine.
:36 So npm run build, npm run lint to make sure there are no errors and then we're going to go ahead and commit. Great so both of my functions have passed, so now I'm going to git checkout b08 and this is a voice management Management like that git add git commit 08 voice management and then git push u-u origin 08 voice management perfect Then let's go ahead and open a pull request and let's merge it. Brilliant. So let's open this pull request. I'm going to go ahead and immediately merge it.
:27 We know that CICD is going to work because we just checked the npm run lint. And once we've done that, we can go ahead and do git checkout main, git pull origin main to make sure we are up to date with what we've just merged. And Beautiful. I think this is just a temporary TypeScript error. There we go.
:49 It resolved itself. We are now on the main branch. And here I can see voice management being merged into the main branch. Brilliant. So in a few seconds, you should also see all of these changes on your railway production instance.
:04 So go ahead and keep track of it here. You can see it's currently deploying and once it deploys, you will be able to try voice recording on your live instance. Amazing, amazing job and see you in the next chapter.