mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-19 06:40:38 -07:00
Initialize voicebox project with backend, frontend, and Tauri setup. Added configuration files, dependencies, and basic structure for components, hooks, and utilities. Included README and setup documentation for guidance.
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# Voice profile management components
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Edit, Mic, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import type { VoiceProfileResponse } from '@/lib/api/types';
|
||||
import { useDeleteProfile } from '@/lib/hooks/useProfiles';
|
||||
import { formatDate } from '@/lib/utils/format';
|
||||
import { useUIStore } from '@/stores/uiStore';
|
||||
import { ProfileDetail } from './ProfileDetail';
|
||||
|
||||
interface ProfileCardProps {
|
||||
profile: VoiceProfileResponse;
|
||||
}
|
||||
|
||||
export function ProfileCard({ profile }: ProfileCardProps) {
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const deleteProfile = useDeleteProfile();
|
||||
const setEditingProfileId = useUIStore((state) => state.setEditingProfileId);
|
||||
const setProfileDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
|
||||
|
||||
const handleEdit = () => {
|
||||
setEditingProfileId(profile.id);
|
||||
setProfileDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (
|
||||
confirm(`Are you sure you want to delete "${profile.name}"? This action cannot be undone.`)
|
||||
) {
|
||||
deleteProfile.mutate(profile.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
className="cursor-pointer hover:shadow-lg transition-shadow"
|
||||
onClick={() => setDetailOpen(true)}
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2">
|
||||
<Mic className="h-5 w-5" />
|
||||
{profile.name}
|
||||
</span>
|
||||
<div className="flex gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
<Button variant="ghost" size="icon" onClick={handleEdit} aria-label="Edit profile">
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleDelete}
|
||||
disabled={deleteProfile.isPending}
|
||||
aria-label="Delete profile"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{profile.description && (
|
||||
<p className="text-sm text-muted-foreground mb-2">{profile.description}</p>
|
||||
)}
|
||||
<div className="flex gap-2 mb-2">
|
||||
<Badge variant="outline">{profile.language}</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Created {formatDate(profile.created_at)}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ProfileDetail profileId={profile.id} open={detailOpen} onOpenChange={setDetailOpen} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { useProfile } from '@/lib/hooks/useProfiles';
|
||||
import { formatDate } from '@/lib/utils/format';
|
||||
import { SampleList } from './SampleList';
|
||||
|
||||
interface ProfileDetailProps {
|
||||
profileId: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function ProfileDetail({ profileId, open, onOpenChange }: ProfileDetailProps) {
|
||||
const { data: profile, isLoading } = useProfile(profileId);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<div className="text-muted-foreground">Loading profile...</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
if (!profile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{profile.name}</DialogTitle>
|
||||
<DialogDescription>Manage samples and view profile details</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{profile.description && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-1">Description</h3>
|
||||
<p className="text-sm text-muted-foreground">{profile.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Badge variant="outline">{profile.language}</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Created {formatDate(profile.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<SampleList profileId={profileId} />
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import * as z from 'zod';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { useCreateProfile, useProfile, useUpdateProfile } from '@/lib/hooks/useProfiles';
|
||||
import { useUIStore } from '@/stores/uiStore';
|
||||
|
||||
const profileSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required').max(100),
|
||||
description: z.string().max(500).optional(),
|
||||
language: z.enum(['en', 'zh']),
|
||||
});
|
||||
|
||||
type ProfileFormValues = z.infer<typeof profileSchema>;
|
||||
|
||||
export function ProfileForm() {
|
||||
const open = useUIStore((state) => state.profileDialogOpen);
|
||||
const setOpen = useUIStore((state) => state.setProfileDialogOpen);
|
||||
const editingProfileId = useUIStore((state) => state.editingProfileId);
|
||||
const setEditingProfileId = useUIStore((state) => state.setEditingProfileId);
|
||||
const { data: editingProfile } = useProfile(editingProfileId || '');
|
||||
const createProfile = useCreateProfile();
|
||||
const updateProfile = useUpdateProfile();
|
||||
const { toast } = useToast();
|
||||
|
||||
const form = useForm<ProfileFormValues>({
|
||||
resolver: zodResolver(profileSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
description: '',
|
||||
language: 'en',
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (editingProfile) {
|
||||
form.reset({
|
||||
name: editingProfile.name,
|
||||
description: editingProfile.description || '',
|
||||
language: editingProfile.language as 'en' | 'zh',
|
||||
});
|
||||
} else {
|
||||
form.reset({
|
||||
name: '',
|
||||
description: '',
|
||||
language: 'en',
|
||||
});
|
||||
}
|
||||
}, [editingProfile, form]);
|
||||
|
||||
async function onSubmit(data: ProfileFormValues) {
|
||||
try {
|
||||
if (editingProfileId) {
|
||||
await updateProfile.mutateAsync({
|
||||
profileId: editingProfileId,
|
||||
data,
|
||||
});
|
||||
toast({
|
||||
title: 'Profile updated',
|
||||
description: `"${data.name}" has been updated successfully.`,
|
||||
});
|
||||
} else {
|
||||
await createProfile.mutateAsync(data);
|
||||
toast({
|
||||
title: 'Profile created',
|
||||
description: `"${data.name}" has been created successfully.`,
|
||||
});
|
||||
}
|
||||
|
||||
form.reset();
|
||||
setEditingProfileId(null);
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error instanceof Error ? error.message : 'Failed to save profile',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenChange(open: boolean) {
|
||||
setOpen(open);
|
||||
if (!open) {
|
||||
setEditingProfileId(null);
|
||||
form.reset();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingProfileId ? 'Edit Profile' : 'Create Voice Profile'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingProfileId
|
||||
? 'Update your voice profile details.'
|
||||
: 'Add a new voice profile with samples.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Voice" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea placeholder="Describe this voice..." {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="language"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Language</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="en">English</SelectItem>
|
||||
<SelectItem value="zh">Chinese</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button type="button" variant="outline" onClick={() => handleOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={createProfile.isPending || updateProfile.isPending}>
|
||||
{createProfile.isPending || updateProfile.isPending
|
||||
? 'Saving...'
|
||||
: editingProfileId
|
||||
? 'Update Profile'
|
||||
: 'Create Profile'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Mic, Plus } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { useDeleteProfile, useProfiles } from '@/lib/hooks/useProfiles';
|
||||
import { useUIStore } from '@/stores/uiStore';
|
||||
import { ProfileCard } from './ProfileCard';
|
||||
import { ProfileForm } from './ProfileForm';
|
||||
|
||||
export function ProfileList() {
|
||||
const { data: profiles, isLoading, error } = useProfiles();
|
||||
const _deleteProfile = useDeleteProfile();
|
||||
const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="text-muted-foreground">Loading profiles...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="text-destructive">Error loading profiles: {error.message}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold">Voice Profiles</h2>
|
||||
<Button onClick={() => setDialogOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New Profile
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{profiles && profiles.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Mic className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground mb-4">
|
||||
No voice profiles yet. Create your first profile to get started.
|
||||
</p>
|
||||
<Button onClick={() => setDialogOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Profile
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{profiles?.map((profile) => (
|
||||
<ProfileCard key={profile.id} profile={profile} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ProfileForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { useDeleteSample, useProfileSamples } from '@/lib/hooks/useProfiles';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
import { SampleUpload } from './SampleUpload';
|
||||
|
||||
interface SampleListProps {
|
||||
profileId: string;
|
||||
}
|
||||
|
||||
export function SampleList({ profileId }: SampleListProps) {
|
||||
const { data: samples, isLoading } = useProfileSamples(profileId);
|
||||
const deleteSample = useDeleteSample();
|
||||
const [uploadOpen, setUploadOpen] = useState(false);
|
||||
const { toast } = useToast();
|
||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||
|
||||
const handleDelete = (sampleId: string) => {
|
||||
if (confirm('Are you sure you want to delete this sample?')) {
|
||||
deleteSample.mutate(sampleId);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlay = (audioPath: string) => {
|
||||
const audioUrl = `${serverUrl}${audioPath}`;
|
||||
const audio = new Audio(audioUrl);
|
||||
audio.play().catch((_error) => {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to play audio',
|
||||
variant: 'destructive',
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-sm text-muted-foreground">Loading samples...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Audio Samples</h3>
|
||||
<Button size="sm" onClick={() => setUploadOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Sample
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{samples && samples.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground py-4">
|
||||
No samples yet. Add your first audio sample.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{samples?.map((sample) => (
|
||||
<div
|
||||
key={sample.id}
|
||||
className="flex items-center justify-between p-3 border rounded-lg"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">{sample.reference_text}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">{sample.audio_path}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => handlePlay(sample.audio_path)}>
|
||||
Play
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(sample.id)}
|
||||
disabled={deleteSample.isPending}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SampleUpload profileId={profileId} open={uploadOpen} onOpenChange={setUploadOpen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import * as z from 'zod';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { useAddSample } from '@/lib/hooks/useProfiles';
|
||||
|
||||
const sampleSchema = z.object({
|
||||
file: z.instanceof(File, { message: 'Please select an audio file' }),
|
||||
referenceText: z
|
||||
.string()
|
||||
.min(1, 'Reference text is required')
|
||||
.max(1000, 'Reference text must be less than 1000 characters'),
|
||||
});
|
||||
|
||||
type SampleFormValues = z.infer<typeof sampleSchema>;
|
||||
|
||||
interface SampleUploadProps {
|
||||
profileId: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProps) {
|
||||
const addSample = useAddSample();
|
||||
const { toast } = useToast();
|
||||
|
||||
const form = useForm<SampleFormValues>({
|
||||
resolver: zodResolver(sampleSchema),
|
||||
defaultValues: {
|
||||
referenceText: '',
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit(data: SampleFormValues) {
|
||||
try {
|
||||
await addSample.mutateAsync({
|
||||
profileId,
|
||||
file: data.file,
|
||||
referenceText: data.referenceText,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: 'Sample added',
|
||||
description: 'Audio sample has been added successfully.',
|
||||
});
|
||||
|
||||
form.reset();
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error instanceof Error ? error.message : 'Failed to add sample',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Audio Sample</DialogTitle>
|
||||
<DialogDescription>
|
||||
Upload an audio file and provide the reference text that matches the audio.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="file"
|
||||
render={({ field: { onChange, value, ...field } }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Audio File</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
onChange(file);
|
||||
}
|
||||
}}
|
||||
{...field}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>Supported formats: WAV, MP3, M4A</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="referenceText"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Reference Text</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Enter the exact text spoken in the audio..."
|
||||
className="min-h-[100px]"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
This should match exactly what is spoken in the audio file.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={addSample.isPending}>
|
||||
{addSample.isPending ? 'Uploading...' : 'Add Sample'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user