feat(db): mesh data model — meshes, members, invites, audit log
- pgSchema "mesh" with 4 tables isolating the peer mesh domain - Enums: visibility, transport, tier, role - audit_log is metadata-only (E2E encryption enforced at broker/client) - Cascade on mesh delete, soft-delete via archivedAt/revokedAt Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
66
apps/web/src/modules/tts/composer/hooks/use-audio.tsx
Normal file
66
apps/web/src/modules/tts/composer/hooks/use-audio.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export const useAudio = (url?: string) => {
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!audioRef.current) {
|
||||
audioRef.current = new Audio(url);
|
||||
} else if (url) {
|
||||
audioRef.current.src = url;
|
||||
}
|
||||
}, [url]);
|
||||
|
||||
useEffect(() => {
|
||||
const audioElement = audioRef.current;
|
||||
if (!audioElement) return;
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
const currentProgress =
|
||||
(audioElement.currentTime / audioElement.duration) * 100;
|
||||
setProgress(currentProgress);
|
||||
};
|
||||
|
||||
const handleEnded = () => {
|
||||
setPlaying(false);
|
||||
setProgress(0);
|
||||
};
|
||||
|
||||
audioElement.addEventListener("timeupdate", handleTimeUpdate);
|
||||
audioElement.addEventListener("ended", handleEnded);
|
||||
|
||||
return () => {
|
||||
audioElement.removeEventListener("timeupdate", handleTimeUpdate);
|
||||
audioElement.removeEventListener("ended", handleEnded);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const play = () => {
|
||||
if (audioRef.current) {
|
||||
void audioRef.current.play();
|
||||
setPlaying(true);
|
||||
}
|
||||
};
|
||||
|
||||
const pause = () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
setPlaying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const scroll = (seconds: number) => {
|
||||
const audioElement = audioRef.current;
|
||||
if (!audioElement) return;
|
||||
|
||||
const newTime = Math.max(
|
||||
0,
|
||||
Math.min(audioElement.duration, audioElement.currentTime + seconds),
|
||||
);
|
||||
audioElement.currentTime = newTime;
|
||||
};
|
||||
|
||||
return { play, pause, playing, progress, scroll };
|
||||
};
|
||||
137
apps/web/src/modules/tts/composer/hooks/use-composer.tsx
Normal file
137
apps/web/src/modules/tts/composer/hooks/use-composer.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useForm, useFormContext } from "react-hook-form";
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
import { MODELS } from "@turbostarter/ai/tts/constants";
|
||||
import { ttsSchema } from "@turbostarter/ai/tts/schema";
|
||||
import { useDebounceCallback } from "@turbostarter/shared/hooks";
|
||||
|
||||
import { useTts } from "~/modules/tts/use-tts";
|
||||
|
||||
import type {
|
||||
TtsOptionsPayload,
|
||||
TtsPayload,
|
||||
} from "@turbostarter/ai/tts/schema";
|
||||
import type { Voice } from "@turbostarter/ai/tts/types";
|
||||
import type { WatchObserver } from "react-hook-form";
|
||||
|
||||
interface TtsComposerState {
|
||||
text: string;
|
||||
options: TtsOptionsPayload;
|
||||
setText: (text: string) => void;
|
||||
setOptions: (options: Partial<TtsOptionsPayload>) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
const DEFAULT_OPTIONS = {
|
||||
model: MODELS[0].id,
|
||||
voice: {
|
||||
id: "",
|
||||
speed: 1,
|
||||
stability: 0.5,
|
||||
similarity: 0.75,
|
||||
boost: false,
|
||||
},
|
||||
};
|
||||
|
||||
const useTtsComposerStore = create<TtsComposerState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
text: "",
|
||||
options: DEFAULT_OPTIONS,
|
||||
setText: (text) => set({ text }),
|
||||
setOptions: (options) =>
|
||||
set((state) => ({
|
||||
options: { ...state.options, ...options },
|
||||
})),
|
||||
reset: () =>
|
||||
set({
|
||||
text: "",
|
||||
options: DEFAULT_OPTIONS,
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: "tts-options",
|
||||
partialize: (state) => ({ options: state.options }),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
interface UseComposerProps {
|
||||
voices: Voice[];
|
||||
}
|
||||
|
||||
export const useComposer = ({ voices }: UseComposerProps) => {
|
||||
const { speak } = useTts();
|
||||
const { options, reset, setOptions, setText } = useTtsComposerStore();
|
||||
|
||||
const newForm = useForm({
|
||||
resolver: zodResolver(ttsSchema),
|
||||
defaultValues: {
|
||||
text: "",
|
||||
options,
|
||||
},
|
||||
});
|
||||
const contextForm = useFormContext<TtsPayload>();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
const form = contextForm ?? newForm;
|
||||
|
||||
useEffect(() => {
|
||||
if (voices.length && !options.voice.id) {
|
||||
const newOptions = {
|
||||
...options,
|
||||
voice: {
|
||||
...options.voice,
|
||||
id: voices[0]?.id ?? "",
|
||||
},
|
||||
};
|
||||
setOptions(newOptions);
|
||||
form.setValue("options", newOptions);
|
||||
}
|
||||
}, [voices, options, setOptions, form]);
|
||||
|
||||
const sync: WatchObserver<TtsPayload> = useCallback(
|
||||
(values) => {
|
||||
setText(values.text ?? "");
|
||||
setOptions({
|
||||
...(values.options ?? DEFAULT_OPTIONS),
|
||||
voice: {
|
||||
...(values.options?.voice ?? DEFAULT_OPTIONS.voice),
|
||||
id: values.options?.voice?.id ?? "",
|
||||
},
|
||||
});
|
||||
},
|
||||
[setText, setOptions],
|
||||
);
|
||||
|
||||
const debouncedSync = useDebounceCallback(sync, 500);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = form.watch(debouncedSync);
|
||||
return () => subscription.unsubscribe();
|
||||
}, [form, debouncedSync]);
|
||||
|
||||
const onSubmit = (input: TtsPayload) => {
|
||||
form.resetField("text");
|
||||
speak.mutate(input);
|
||||
};
|
||||
|
||||
const resetVoiceSettings = () => {
|
||||
form.setValue("options.voice", {
|
||||
...DEFAULT_OPTIONS.voice,
|
||||
id: options.voice.id,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
form,
|
||||
setText,
|
||||
onSubmit,
|
||||
reset,
|
||||
resetVoiceSettings,
|
||||
};
|
||||
};
|
||||
104
apps/web/src/modules/tts/composer/index.tsx
Normal file
104
apps/web/src/modules/tts/composer/index.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
|
||||
import { MODELS } from "@turbostarter/ai/tts/constants";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
} from "@turbostarter/ui-web/form";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import { ScrollArea } from "@turbostarter/ui-web/scroll-area";
|
||||
|
||||
import { Composer } from "~/modules/common/ai/composer";
|
||||
import { ModelSelector } from "~/modules/common/ai/composer/model-selector";
|
||||
|
||||
import { useComposer } from "./hooks/use-composer";
|
||||
import { Settings } from "./settings";
|
||||
import { VoiceSelector } from "./voice-selector";
|
||||
|
||||
import type { UIVoice } from "~/modules/tts/utils/types";
|
||||
|
||||
interface TtsComposerProps {
|
||||
voices: UIVoice[];
|
||||
}
|
||||
|
||||
export const TtsComposer = memo<TtsComposerProps>(({ voices }) => {
|
||||
const { t } = useTranslation(["common", "ai"]);
|
||||
const { onSubmit, form, resetVoiceSettings } = useComposer({ voices });
|
||||
|
||||
return (
|
||||
<div className="h-full w-full overflow-hidden pt-12 md:pt-14">
|
||||
<Form {...form}>
|
||||
<Composer.Form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex h-full w-full flex-col"
|
||||
>
|
||||
<ScrollArea className="h-full min-h-0 w-full px-5">
|
||||
<VoiceSelector
|
||||
control={form.control}
|
||||
name="options.voice.id"
|
||||
options={voices}
|
||||
/>
|
||||
</ScrollArea>
|
||||
<div className="relative z-20 mx-auto w-full px-3 pb-3 shadow-[0_-72px_52px_-16px_var(--background)]">
|
||||
<Composer.Input className="mx-auto pb-12">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="text"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Composer.Textarea
|
||||
{...field}
|
||||
placeholder={t("tts.composer.placeholder")}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
|
||||
return form.handleSubmit(onSubmit)();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="absolute inset-x-0 bottom-0 flex w-full gap-1.5 overflow-hidden border-2 border-transparent p-2 @[480px]/input:p-3">
|
||||
<Settings
|
||||
control={form.control}
|
||||
path="options.voice"
|
||||
onReset={resetVoiceSettings}
|
||||
/>
|
||||
|
||||
<div className="ml-auto flex w-full items-center justify-end gap-1.5">
|
||||
<ModelSelector
|
||||
control={form.control}
|
||||
name="options.model"
|
||||
options={MODELS}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="shrink-0 rounded-full"
|
||||
disabled={!form.formState.isValid}
|
||||
size="icon"
|
||||
type="submit"
|
||||
>
|
||||
<Icons.AudioLines className="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Composer.Input>
|
||||
</div>
|
||||
</Composer.Form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
TtsComposer.displayName = "TtsComposer";
|
||||
243
apps/web/src/modules/tts/composer/settings.tsx
Normal file
243
apps/web/src/modules/tts/composer/settings.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { useBreakpoint } from "@turbostarter/ui-web";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerTrigger,
|
||||
} from "@turbostarter/ui-web/drawer";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
} from "@turbostarter/ui-web/form";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverPortal,
|
||||
PopoverTrigger,
|
||||
} from "@turbostarter/ui-web/popover";
|
||||
import { Slider } from "@turbostarter/ui-web/slider";
|
||||
import { Switch } from "@turbostarter/ui-web/switch";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@turbostarter/ui-web/tooltip";
|
||||
|
||||
import type { Control, FieldValues, Path } from "react-hook-form";
|
||||
|
||||
const SettingLabel = ({
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
}) => {
|
||||
return (
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<FormLabel className="decoration-border hover:decoration-foreground w-fit cursor-pointer underline decoration-dashed underline-offset-2 transition-colors">
|
||||
{title}
|
||||
</FormLabel>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4} className="max-w-sm">
|
||||
{description}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
interface SettingsProps<T extends FieldValues> {
|
||||
control: Control<T>;
|
||||
path: Path<T>;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
export const Settings = <T extends FieldValues>({
|
||||
control,
|
||||
path,
|
||||
onReset,
|
||||
}: SettingsProps<T>) => {
|
||||
const { t } = useTranslation(["common", "ai"]);
|
||||
const isDesktop = useBreakpoint("md");
|
||||
|
||||
const renderTrigger = () => (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="text-muted-foreground shrink-0 rounded-full"
|
||||
>
|
||||
<Icons.Settings className="size-4" />
|
||||
<span className="sr-only">{t("settings")}</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
const renderContent = () => (
|
||||
<div className="grid gap-5">
|
||||
<FormField
|
||||
control={control}
|
||||
name={`${path}.speed` as Path<T>}
|
||||
render={({ field }) => (
|
||||
<FormItem className="gap-1.5">
|
||||
<SettingLabel
|
||||
title={t("speed")}
|
||||
description={t("tts.composer.settings.voice.speed.description")}
|
||||
/>
|
||||
<div className="text-muted-foreground mt-1.5 flex items-center justify-between text-xs">
|
||||
<span>{t("slower")}</span>
|
||||
<span>{t("faster")}</span>
|
||||
</div>
|
||||
<FormControl>
|
||||
<div className="flex items-center gap-3">
|
||||
<Slider
|
||||
id="speed"
|
||||
min={0.7}
|
||||
max={1.2}
|
||||
step={0.01}
|
||||
value={[field.value]}
|
||||
onValueChange={(vals) => {
|
||||
field.onChange(vals[0]);
|
||||
}}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-muted-foreground w-12 text-right text-sm tabular-nums">
|
||||
{/* eslint-disable-next-line i18next/no-literal-string */}
|
||||
{Number(field.value).toFixed(2)}x
|
||||
</span>
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name={`${path}.stability` as Path<T>}
|
||||
render={({ field }) => (
|
||||
<FormItem className="gap-1.5">
|
||||
<SettingLabel
|
||||
title={t("stability")}
|
||||
description={t(
|
||||
"tts.composer.settings.voice.stability.description",
|
||||
)}
|
||||
/>
|
||||
<div className="text-muted-foreground mt-1.5 flex items-center justify-between text-xs">
|
||||
<span>{t("moreVariable")}</span>
|
||||
<span>{t("moreStable")}</span>
|
||||
</div>
|
||||
<FormControl>
|
||||
<div className="flex items-center gap-3">
|
||||
<Slider
|
||||
id="stability"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={[field.value]}
|
||||
onValueChange={(vals) => {
|
||||
field.onChange(vals[0]);
|
||||
}}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-muted-foreground w-12 text-right text-sm tabular-nums">
|
||||
{(field.value * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name={`${path}.similarity` as Path<T>}
|
||||
render={({ field }) => (
|
||||
<FormItem className="gap-1.5">
|
||||
<SettingLabel
|
||||
title={t("similarity")}
|
||||
description={t(
|
||||
"tts.composer.settings.voice.similarity.description",
|
||||
)}
|
||||
/>
|
||||
<div className="text-muted-foreground mt-1.5 flex items-center justify-between text-xs">
|
||||
<span>{t("low")}</span>
|
||||
<span>{t("high")}</span>
|
||||
</div>
|
||||
<FormControl>
|
||||
<div className="flex items-center gap-3">
|
||||
<Slider
|
||||
id="similarity"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={[field.value]}
|
||||
onValueChange={(vals) => {
|
||||
field.onChange(vals[0]);
|
||||
}}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-muted-foreground w-12 text-right text-sm tabular-nums">
|
||||
{(field.value * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="mt-1 flex items-center justify-between">
|
||||
<FormField
|
||||
control={control}
|
||||
name={`${path}.boost` as Path<T>}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center gap-2.5 space-y-0">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<SettingLabel
|
||||
title={t("speakerBoost")}
|
||||
description={t(
|
||||
"tts.composer.settings.voice.speakerBoost.description",
|
||||
)}
|
||||
/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button variant="outline" className="gap-2" onClick={onReset}>
|
||||
<Icons.Undo2 className="size-4" />
|
||||
{t("reset")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isDesktop) {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>{renderTrigger()}</PopoverTrigger>
|
||||
<PopoverPortal>
|
||||
<PopoverContent className="w-fit min-w-96 px-5">
|
||||
{renderContent()}
|
||||
</PopoverContent>
|
||||
</PopoverPortal>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer>
|
||||
<DrawerTrigger asChild>{renderTrigger()}</DrawerTrigger>
|
||||
<DrawerContent className="gap-4 px-5 pb-6">
|
||||
{renderContent()}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
248
apps/web/src/modules/tts/composer/voice-selector.tsx
Normal file
248
apps/web/src/modules/tts/composer/voice-selector.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import { memo } from "react";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@turbostarter/ui-web/avatar";
|
||||
import { Badge } from "@turbostarter/ui-web/badge";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardContent,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
} from "@turbostarter/ui-web/card";
|
||||
import { FormControl, FormField, FormItem } from "@turbostarter/ui-web/form";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import { Label } from "@turbostarter/ui-web/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@turbostarter/ui-web/radio-group";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@turbostarter/ui-web/tooltip";
|
||||
|
||||
import { useAudio } from "./hooks/use-audio";
|
||||
|
||||
import type { UIVoice } from "../utils/types";
|
||||
import type { FieldValues, Path } from "react-hook-form";
|
||||
import type { Control } from "react-hook-form";
|
||||
|
||||
const Voice = ({ voice, selected }: { voice: UIVoice; selected: boolean }) => {
|
||||
const { t } = useTranslation("common");
|
||||
const { play, pause, playing, progress, scroll } = useAudio(voice.previewUrl);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={voice.id}
|
||||
className={cn(
|
||||
"dark:bg-background flex h-full flex-col overflow-hidden rounded-2xl transition-all",
|
||||
selected ? "border-primary" : "hover:border-input border",
|
||||
)}
|
||||
>
|
||||
<Label
|
||||
htmlFor={voice.id}
|
||||
className="flex grow cursor-pointer flex-col gap-0"
|
||||
>
|
||||
<CardHeader className="flex w-full flex-row items-center justify-between gap-3.5 px-5 pt-4 pb-3">
|
||||
<Avatar className="size-10">
|
||||
<AvatarFallback>{voice.name.charAt(0)}</AvatarFallback>
|
||||
<AvatarImage src={voice.avatar?.src} style={voice.avatar?.style} />
|
||||
</Avatar>
|
||||
|
||||
<div className="mr-auto min-w-0">
|
||||
<CardTitle className="truncate text-lg leading-tight">
|
||||
{voice.name}
|
||||
</CardTitle>
|
||||
<CardDescription className="truncate leading-tight capitalize">
|
||||
{voice.category}
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
<RadioGroupItem
|
||||
value={voice.id}
|
||||
id={voice.id}
|
||||
className="mt-0.5 self-start"
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="flex w-full grow flex-col justify-between gap-3 pb-4">
|
||||
<div className="-ml-0.5 flex w-full flex-wrap gap-1.5">
|
||||
{voice.details.map((detail) => (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
key={detail}
|
||||
className="font-normal lowercase"
|
||||
>
|
||||
{detail.replace(/_/g, " ")}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-muted-foreground flex items-center justify-between gap-3 text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Icons.TextSearch className="size-3.5" />
|
||||
<span>
|
||||
{new Date(voice.createdAt).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Icons.UsersRound className="size-3.5" />
|
||||
<span>
|
||||
{new Intl.NumberFormat("en", {
|
||||
notation: "compact",
|
||||
compactDisplay: "short",
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
}).format(voice.usage.cloned)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Icons.AudioWaveform className="size-3.5" />
|
||||
|
||||
{new Intl.NumberFormat("en", {
|
||||
notation: "compact",
|
||||
compactDisplay: "short",
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
}).format(voice.usage.character)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Label>
|
||||
|
||||
<CardFooter className="bg-muted/50 relative flex justify-center overflow-hidden border-t px-3 py-1">
|
||||
<div
|
||||
className="bg-muted absolute top-0 left-0 h-full w-full transition-all duration-300 ease-linear"
|
||||
style={{
|
||||
transform: `scaleX(${progress / 100})`,
|
||||
transformOrigin: "left",
|
||||
}}
|
||||
/>
|
||||
<div className="relative z-10 flex justify-center">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground size-8 rounded-full"
|
||||
onClick={() => scroll(-5)}
|
||||
>
|
||||
<Icons.Undo className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
-{t("second", { count: 5 })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground size-8 rounded-full"
|
||||
onClick={() => {
|
||||
if (playing) {
|
||||
pause();
|
||||
} else {
|
||||
play();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{playing ? (
|
||||
<Icons.Pause className="size-3.5" />
|
||||
) : (
|
||||
<Icons.Play className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
{playing ? t("pause") : t("play")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground size-8 rounded-full"
|
||||
onClick={() => scroll(5)}
|
||||
>
|
||||
<Icons.Redo className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
+{t("second", { count: 5 })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
interface VoiceSelectorProps<T extends FieldValues> {
|
||||
readonly control: Control<T>;
|
||||
readonly name: Path<T>;
|
||||
readonly options: UIVoice[];
|
||||
}
|
||||
|
||||
type VoiceSelectorComponent = <T extends FieldValues>(
|
||||
props: VoiceSelectorProps<T>,
|
||||
) => React.ReactNode;
|
||||
|
||||
export const VoiceSelector: VoiceSelectorComponent = memo(
|
||||
({ control, name, options }) => {
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
className="grid w-full min-w-0 grid-cols-[repeat(auto-fill,minmax(min(20rem,100%),1fr))] gap-4 pb-6"
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
>
|
||||
{options.map((voice, index) => (
|
||||
<motion.div
|
||||
key={voice.id}
|
||||
initial={{ opacity: 0, filter: "blur(4px)" }}
|
||||
animate={{ opacity: 1, filter: "blur(0px)" }}
|
||||
transition={{ duration: 0.2, delay: index * 0.1 }}
|
||||
>
|
||||
<Voice
|
||||
key={voice.id}
|
||||
voice={voice}
|
||||
selected={field.value === voice.id}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user