AIvoices/frontend-nextjs/app/components/CreateCharacter/BuildDashboard.tsx

452 lines
No EOL
16 KiB
TypeScript

"use client";
import React, { useState } from "react";
import { updateUser } from "@/db/users";
import { createClient } from "@/utils/supabase/client";
import HomePageSubtitles from "./../HomePageSubtitles";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { ArrowLeft, ArrowRight, Check, Mic, Volume2 } from "lucide-react";
import Twemoji from "react-twemoji";
import { createPersonality } from "@/db/personalities";
import { v4 as uuidv4 } from 'uuid';
import { toast } from "@/components/ui/use-toast";
import { useRouter } from "next/navigation";
import { z } from "zod";
import { emotionOptions, r2UrlAudio, voices } from "@/lib/data";
import EmojiComponent from "./EmojiComponent";
interface SettingsDashboardProps {
selectedUser: IUser;
allLanguages: ILanguage[];
}
const formSchema = z.object({
title: z.string().min(2, "Minimum 2 characters").max(50, "Maximum 50 characters"),
description: z.string().min(50, "Minimum 50 characters").max(200, "Maximum 200 characters"),
prompt: z.string().min(100, "Minimum 100 characters").max(1000, "Maximum 1000 characters"),
voice: z.string().min(1, "Voice selection is required"),
voiceCharacteristics: z.object({
features: z.string().min(10, "Minimum 10 characters").max(150, "Maximum 150 characters"),
emotion: z.string()
})
});
type FormData = z.infer<typeof formSchema>;
const SettingsDashboard: React.FC<SettingsDashboardProps> = ({
selectedUser,
allLanguages,
}) => {
const supabase = createClient();
const router = useRouter();
const [currentStep, setCurrentStep] = useState<'personality' | 'voice'>('personality');
const [isSubmitting, setIsSubmitting] = useState(false);
const [languageState, setLanguageState] = useState<string>(
selectedUser.language_code! // Initial value from props
);
const [formData, setFormData] = useState({
title: '',
description: '',
prompt: '',
voice: '',
voiceCharacteristics: {
features: '',
emotion: 'neutral'
}
});
const [touchedFields, setTouchedFields] = useState<Record<string, boolean>>({});
const [formErrors, setFormErrors] = useState<Partial<Record<keyof FormData | 'features', string>>>({});
const [previewingVoice, setPreviewingVoice] = useState<string | null>(null);
const handleBlur = (field: keyof FormData | 'features') => {
// Mark the field as touched
setTouchedFields(prev => ({ ...prev, [field]: true }));
// Validate the field
if (field === 'features') {
validateField(field, formData.voiceCharacteristics.features);
} else {
validateField(field, formData[field] as string);
}
};
const validateField = (field: keyof FormData | 'features', value: string) => {
try {
if (field === 'features') {
formSchema.shape.voiceCharacteristics.shape.features.parse(value);
} else if (field === 'voiceCharacteristics') {
formSchema.shape.voiceCharacteristics.parse(value);
} else {
formSchema.shape[field].parse(value);
}
// Clear error if validation passes
setFormErrors(prev => ({ ...prev, [field]: undefined }));
} catch (error: unknown) {
if (error instanceof z.ZodError) {
const zodError = error as z.ZodError;
setFormErrors(prev => ({ ...prev, [field]: zodError.errors[0].message }));
}
}
};
const handleInputChange = (field: keyof FormData, value: string) => {
const newFormData = { ...formData, [field]: value };
setFormData(newFormData);
// Only validate if the field has been touched before
if (touchedFields[field]) {
validateField(field, value);
}
};
const handleVoiceCharacteristicChange = (characteristic: 'features' | 'emotion', value: string) => {
const newVoiceCharacteristics = {
...formData.voiceCharacteristics,
[characteristic]: value
};
// Validate just this nested field
try {
formSchema.shape.voiceCharacteristics.shape[characteristic].parse(value);
// Clear error if validation passes
setFormErrors(prev => ({ ...prev, [characteristic]: undefined }));
} catch (error: unknown) {
if (error instanceof z.ZodError) {
// Type assertion is needed here
const zodError = error as z.ZodError;
setFormErrors(prev => ({ ...prev, [characteristic]: zodError.errors[0].message }));
}
}
setFormData({
...formData,
voiceCharacteristics: newVoiceCharacteristics
});
};
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
// Set submitting state to true
setIsSubmitting(true);
// Validate the entire form
const result = formSchema.safeParse(formData);
console.log(result);
if (!result.success) {
// Extract and set all validation errors
const errors: Partial<Record<keyof FormData | 'features', string>> = {};
result.error.errors.forEach(err => {
const path = err.path.join('.');
if (path === 'voiceCharacteristics.features') {
errors['features'] = err.message;
} else {
errors[err.path[0] as keyof FormData] = err.message;
}
});
setFormErrors(errors);
setIsSubmitting(false); // Reset submitting state
return;
}
try {
const personality = await createPersonality(supabase, selectedUser.user_id, {
title: formData.title,
subtitle: "",
character_prompt: formData.prompt,
oai_voice: formData.voice as OaiVoice,
voice_prompt: formData.voiceCharacteristics.features + "\nThe voice should be " + formData.voiceCharacteristics.emotion,
is_doctor: false,
is_child_voice: false,
is_story: false,
key: formData.title.toLowerCase().replace(/ /g, '_') + "_" + uuidv4(),
creator_id: selectedUser.user_id,
short_description: formData.description
});
if (personality) {
toast({
title: "New AI Character created",
description: "Your character has been created!",
duration: 3000,
});
router.push(`/home`);
}
} catch (error) {
console.error("Error creating personality:", error);
toast({
title: "Error",
description: "Failed to create your character. Please try again.",
variant: "destructive",
duration: 3000,
});
} finally {
setIsSubmitting(false); // Reset submitting state
}
};
const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(null);
const previewVoice = (voiceId: string) => {
// Stop any currently playing preview
if (audioElement) {
audioElement.pause();
audioElement.currentTime = 0;
}
const audioSampleUrl = `${r2UrlAudio}/${voiceId}.wav`;
setPreviewingVoice(voiceId);
// Create and play audio element
const audio = new Audio(audioSampleUrl);
setAudioElement(audio);
// Play the audio
audio.play().catch(error => {
console.error("Error playing audio:", error);
setPreviewingVoice(null);
});
// Reset the previewing state when audio ends
audio.onended = () => {
setPreviewingVoice(null);
};
// Fallback in case audio doesn't trigger onended
setTimeout(() => {
if (previewingVoice === voiceId) {
setPreviewingVoice(null);
}
}, 10000); // 10 second fallback
};
const Heading = () => {
return (
<div className="flex flex-col gap-2">
<div className="flex flex-row gap-4 items-center sm:justify-normal justify-between max-w-screen-sm">
<div className="flex flex-row gap-4 items-center justify-between w-full">
<h1 className="text-3xl font-normal">Create your AI Character</h1>
</div>
</div>
<HomePageSubtitles user={selectedUser} page="create" />
</div>
);
};
return (
<div className="overflow-hidden pb-2 w-full flex-auto flex flex-col pl-1 max-w-screen-sm">
<Heading />
<form onSubmit={handleSubmit} className="space-y-6 mt-8 w-full pr-1">
{currentStep === 'personality' ? <div className="space-y-4">
<h2 className="text-lg font-semibold border-b border-gray-200 pb-2">
Character Details
</h2>
<div className="space-y-2">
<Label htmlFor="title">Title</Label>
<Input
id="title"
placeholder="E.g., 'Storytelling Assistant'"
value={formData.title}
onChange={(e) => handleInputChange('title', e.target.value)}
onBlur={() => handleBlur('title')}
/>
<p className="text-sm flex justify-between">
<span className={formErrors.title ? "text-red-500" : "text-gray-500"}>
{formErrors.title || "Give your AI character a name or title."}
</span>
<span className="text-gray-500">{formData.title.length}/50</span>
</p>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
placeholder="Describe what your AI character does and its personality..."
rows={2}
value={formData.description}
onChange={(e) => handleInputChange('description', e.target.value)}
onBlur={() => handleBlur('description')} />
<p className="text-sm flex justify-between">
<span className={formErrors.description ? "text-red-500" : "text-gray-500"}>
{formErrors.description || "Briefly describe your character's purpose and personality."}
</span>
<span className="text-gray-500">{formData.description.length}/200</span>
</p>
</div>
<div className="space-y-2">
<Label htmlFor="prompt">Prompt</Label>
<Textarea
id="prompt"
placeholder="Enter specific instructions for how your AI should respond..."
rows={4}
value={formData.prompt}
onChange={(e) => handleInputChange('prompt', e.target.value)}
onBlur={() => handleBlur('prompt')}
/>
<p className="text-sm flex justify-between">
<span className={formErrors.prompt ? "text-red-500" : "text-gray-500"}>
{formErrors.prompt || "Detailed instructions that define how your AI responds to users."}
</span>
<span className="text-gray-500">{formData.prompt.length}/1000</span>
</p>
</div>
</div> :
<div className="space-y-4">
<h2 className="text-lg font-semibold border-b border-gray-200 pb-2">
Voice Details
</h2>
<div className="space-y-2">
<Label htmlFor="voice">Pick a voice</Label>
<p className="text-sm text-gray-500">
Click a voice to preview how it sounds. Select one for your character.
</p>
<div className="grid grid-cols-3 gap-3">
{voices.map((voice) => (
<div
key={voice.id}
className={`
rounded-lg border p-3 transition-all relative
${formData.voice === voice.id
? 'border-2 border-blue-500 shadow-sm ' + voice.color
: 'border-gray-200 hover:border-gray-300 cursor-pointer'
}
`}
onClick={() => {
handleInputChange('voice', voice.id);
previewVoice(voice.id);
}}
>
<div className="flex flex-col">
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-3">
<div className="text-2xl mt-0.5">
<EmojiComponent emoji={voice.emoji} />
</div>
<div className="flex flex-col text-center sm:text-left">
<span className="font-medium">{voice.name}</span>
<span className="text-xs text-gray-600">{voice.description}</span>
</div>
</div>
{previewingVoice === voice.id && (
<div className="absolute top-2 right-2">
<div className="animate-pulse text-blue-500">
<Volume2 size={20} />
</div>
</div>
)}
</div>
</div>
))}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="voiceCharacteristics">Characteristics</Label>
<Textarea
id="voiceCharacteristics"
placeholder="e.g., Medium pitch, Normal speed, Clear voice"
className="w-full min-h-16"
rows={2}
value={formData.voiceCharacteristics.features}
onChange={(e) => {
const value = e.target.value;
setFormData(prev => ({
...prev,
voiceCharacteristics: {
...prev.voiceCharacteristics,
features: value
}
}));
// Only validate if touched
if (touchedFields['features']) {
validateField('features', value);
}
}}
onBlur={() => handleBlur('features')}
/>
<p className="text-sm flex justify-between">
<span className={formErrors.features ? "text-red-500" : "text-gray-500"}>
{formErrors.features || "Describe the voice characteristics."}
</span>
<span className="text-gray-500">{formData.voiceCharacteristics.features.length}/150</span>
</p>
</div>
<div className="space-y-3">
<Label className="block mb-2">Emotional Tone</Label>
<div className="grid grid-cols-3 gap-3">
{emotionOptions.map((emotion) => (
<div
key={emotion.value}
className={`
rounded-lg border p-3 cursor-pointer transition-all
${formData.voiceCharacteristics.emotion === emotion.value
? 'border-2 border-blue-500 shadow-sm ' + emotion.color
: 'border-gray-200 hover:border-gray-300'
}
`}
onClick={() => handleVoiceCharacteristicChange('emotion', emotion.value)}
>
<div className="flex flex-col items-center text-center">
<EmojiComponent emoji={emotion.icon} />
<span className="text-sm font-medium">{emotion.label}</span>
</div>
</div>
))}
</div>
</div>
</div>}
{currentStep === 'personality' ? (
<Button
onClick={() => setCurrentStep('voice')}
className="ml-auto flex flex-row gap-2 items-center"
>
Add Voice Features <ArrowRight className="w-4 h-4" />
</Button>
) : (
<div className="w-full flex justify-between">
<Button
variant="outline"
className="flex flex-row gap-2 items-center"
onClick={() => setCurrentStep('personality')}
>
<ArrowLeft className="w-4 h-4" /> Back
</Button>
<Button
variant="default"
className="flex flex-row gap-2 items-center"
type="submit"
disabled={
isSubmitting ||
formData.title === '' ||
formData.description === '' ||
formData.prompt === '' ||
formData.voice === '' ||
formData.voiceCharacteristics.features === ''
}
>
{isSubmitting ? "Creating..." : "Create"} {!isSubmitting && <Check className="w-4 h-4" />}
</Button>
</div>
)}
</form>
</div>
);
};
export default SettingsDashboard;