Refactor App layout and enhance UI components for better usability

- Changed the default active tab in the App component from 'profiles' to 'main'.
- Improved the layout of the main content area to better accommodate different views, including profiles, generation forms, and history.
- Updated Sidebar component to reflect the new tab structure with a 'main' tab.
- Enhanced the GenerationForm to utilize the selected profile from the UI store, improving user feedback when no profile is selected.
- Added a new CircleButton component for better icon button interactions.
- Adjusted styles in various components for improved responsiveness and visual consistency.
This commit is contained in:
Jamie Pine
2026-01-25 14:44:42 -08:00
parent 05036dfef6
commit 98cfe2dc5a
16 changed files with 352 additions and 224 deletions
@@ -1,7 +1,8 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { Loader2 } from 'lucide-react';
import { Loader2, Mic } from 'lucide-react';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
@@ -24,10 +25,10 @@ import {
import { Textarea } from '@/components/ui/textarea';
import { useToast } from '@/components/ui/use-toast';
import { useGeneration } from '@/lib/hooks/useGeneration';
import { useProfiles } from '@/lib/hooks/useProfiles';
import { useProfile } from '@/lib/hooks/useProfiles';
import { useUIStore } from '@/stores/uiStore';
const generationSchema = z.object({
profileId: z.string().min(1, 'Please select a voice profile'),
text: z.string().min(1, 'Text is required').max(5000),
language: z.enum(['en', 'zh']),
seed: z.number().int().optional(),
@@ -37,14 +38,14 @@ const generationSchema = z.object({
type GenerationFormValues = z.infer<typeof generationSchema>;
export function GenerationForm() {
const { data: profiles } = useProfiles();
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
const { data: selectedProfile } = useProfile(selectedProfileId || '');
const generation = useGeneration();
const { toast } = useToast();
const form = useForm<GenerationFormValues>({
resolver: zodResolver(generationSchema),
defaultValues: {
profileId: '',
text: '',
language: 'en',
seed: undefined,
@@ -53,9 +54,18 @@ export function GenerationForm() {
});
async function onSubmit(data: GenerationFormValues) {
if (!selectedProfileId) {
toast({
title: 'No profile selected',
description: 'Please select a voice profile from the cards above.',
variant: 'destructive',
});
return;
}
try {
const result = await generation.mutateAsync({
profile_id: data.profileId,
profile_id: selectedProfileId,
text: data.text,
language: data.language,
seed: data.seed,
@@ -85,30 +95,20 @@ export function GenerationForm() {
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="profileId"
render={({ field }) => (
<FormItem>
<FormLabel>Voice Profile</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a voice" />
</SelectTrigger>
</FormControl>
<SelectContent>
{profiles?.map((profile) => (
<SelectItem key={profile.id} value={profile.id}>
{profile.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
<div>
<FormLabel>Voice Profile</FormLabel>
{selectedProfile ? (
<div className="mt-2 p-3 border rounded-md bg-muted/50 flex items-center gap-2">
<Mic className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{selectedProfile.name}</span>
<Badge variant="outline">{selectedProfile.language}</Badge>
</div>
) : (
<div className="mt-2 p-3 border border-dashed rounded-md text-sm text-muted-foreground">
Click on a profile card above to select a voice profile
</div>
)}
/>
</div>
<FormField
control={form.control}
@@ -198,7 +198,11 @@ export function GenerationForm() {
/>
</div>
<Button type="submit" className="w-full" disabled={generation.isPending}>
<Button
type="submit"
className="w-full"
disabled={generation.isPending || !selectedProfileId}
>
{generation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
+64 -64
View File
@@ -10,7 +10,6 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client';
import { useDeleteGeneration, useHistory } from '@/lib/hooks/useHistory';
import { formatDate, formatDuration } from '@/lib/utils/format';
@@ -19,7 +18,6 @@ import { usePlayerStore } from '@/stores/playerStore';
export function HistoryTable() {
const [page, setPage] = useState(0);
const limit = 20;
const { toast } = useToast();
const { data: historyData, isLoading } = useHistory({
limit,
@@ -61,74 +59,76 @@ export function HistoryTable() {
const hasMore = history.length === limit && (page + 1) * limit < total;
return (
<div className="space-y-4">
<h2 className="text-2xl font-bold">Generation History</h2>
<div className="flex flex-col h-full min-h-0">
<h2 className="text-2xl font-bold mb-4 shrink-0">Generation History</h2>
{history.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<div className="text-center py-12 text-muted-foreground flex-1 flex items-center justify-center">
No generation history yet. Generate your first audio to see it here.
</div>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>Text</TableHead>
<TableHead>Profile</TableHead>
<TableHead>Language</TableHead>
<TableHead>Duration</TableHead>
<TableHead>Created</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{history.map((gen) => (
<TableRow key={gen.id}>
<TableCell className="max-w-[300px] truncate">{gen.text}</TableCell>
<TableCell>{gen.profile_name}</TableCell>
<TableCell>
<Badge variant="outline">{gen.language}</Badge>
</TableCell>
<TableCell>{formatDuration(gen.duration)}</TableCell>
<TableCell>{formatDate(gen.created_at)}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => handlePlay(gen.id, gen.text)}
aria-label="Play audio"
className={
currentAudioId === gen.id && isPlaying ? 'text-primary' : ''
}
>
<Play className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDownload(gen.id, gen.text)}
aria-label="Download audio"
>
<Download className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => deleteGeneration.mutate(gen.id)}
disabled={deleteGeneration.isPending}
aria-label="Delete generation"
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="flex-1 min-h-0 overflow-y-auto border rounded-md max-h-[calc(100vh-280px)]">
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow>
<TableHead>Text</TableHead>
<TableHead>Profile</TableHead>
<TableHead>Language</TableHead>
<TableHead>Duration</TableHead>
<TableHead>Created</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{history.map((gen) => (
<TableRow key={gen.id}>
<TableCell className="max-w-[200px] truncate">{gen.text}</TableCell>
<TableCell>{gen.profile_name}</TableCell>
<TableCell>
<Badge variant="outline">{gen.language}</Badge>
</TableCell>
<TableCell>{formatDuration(gen.duration)}</TableCell>
<TableCell className="text-sm">{formatDate(gen.created_at)}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => handlePlay(gen.id, gen.text)}
aria-label="Play audio"
className={
currentAudioId === gen.id && isPlaying ? 'text-primary' : ''
}
>
<Play className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDownload(gen.id, gen.text)}
aria-label="Download audio"
>
<Download className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => deleteGeneration.mutate(gen.id)}
disabled={deleteGeneration.isPending}
aria-label="Delete generation"
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="flex justify-between">
<div className="flex justify-between items-center mt-4 shrink-0">
<Button
variant="outline"
onClick={() => setPage((p) => Math.max(0, p - 1))}
@@ -136,7 +136,7 @@ export function HistoryTable() {
>
Previous
</Button>
<div className="text-sm text-muted-foreground flex items-center">
<div className="text-sm text-muted-foreground">
Page {page + 1} {total} total
</div>
<Button variant="outline" onClick={() => setPage((p) => p + 1)} disabled={!hasMore}>
+2 -4
View File
@@ -1,4 +1,4 @@
import { History, Mic, Settings, Sparkles } from 'lucide-react';
import { Home, Settings } from 'lucide-react';
import { cn } from '@/lib/utils/cn';
interface SidebarProps {
@@ -7,9 +7,7 @@ interface SidebarProps {
}
const tabs = [
{ id: 'profiles', icon: Mic, label: 'Profiles' },
{ id: 'generate', icon: Sparkles, label: 'Generate' },
{ id: 'history', icon: History, label: 'History' },
{ id: 'main', icon: Home, label: 'Main' },
{ id: 'settings', icon: Settings, label: 'Settings' },
];
@@ -1,8 +1,9 @@
import { Edit, Mic, Trash2 } from 'lucide-react';
import { Edit, Eye, Mic, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { CircleButton } from '@/components/ui/circle-button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { cn } from '@/lib/utils/cn';
import type { VoiceProfileResponse } from '@/lib/api/types';
import { useDeleteProfile } from '@/lib/hooks/useProfiles';
import { formatDate } from '@/lib/utils/format';
@@ -18,6 +19,14 @@ export function ProfileCard({ profile }: ProfileCardProps) {
const deleteProfile = useDeleteProfile();
const setEditingProfileId = useUIStore((state) => state.setEditingProfileId);
const setProfileDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
const setSelectedProfileId = useUIStore((state) => state.setSelectedProfileId);
const isSelected = selectedProfileId === profile.id;
const handleSelect = () => {
setSelectedProfileId(isSelected ? null : profile.id);
};
const handleEdit = () => {
setEditingProfileId(profile.id);
@@ -35,39 +44,51 @@ export function ProfileCard({ profile }: ProfileCardProps) {
return (
<>
<Card
className="cursor-pointer hover:shadow-lg transition-shadow"
onClick={() => setDetailOpen(true)}
className={cn(
"cursor-pointer hover:shadow-md transition-all",
isSelected && "ring-2 ring-primary shadow-md"
)}
onClick={handleSelect}
>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span className="flex items-center gap-2">
<Mic className="h-5 w-5" />
{profile.name}
<CardHeader className="p-3 pb-2">
<CardTitle className="flex items-center justify-between gap-2 text-base font-medium">
<span className="flex items-center gap-1.5 min-w-0 flex-1">
<Mic className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate">{profile.name}</span>
</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"
<div className="flex gap-0.5 shrink-0">
<CircleButton
icon={Eye}
onClick={(e) => {
e.stopPropagation();
setDetailOpen(true);
}}
aria-label="View details"
/>
<CircleButton
icon={Edit}
onClick={handleEdit}
aria-label="Edit profile"
/>
<CircleButton
icon={Trash2}
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>
<CardContent className="p-3 pt-0">
<p className="text-xs text-muted-foreground mb-1.5 line-clamp-2 leading-relaxed">
{profile.description || 'No description'}
</p>
<div className="flex items-center justify-between gap-2 mb-1">
<Badge variant="outline" className="text-xs h-5 px-1.5">
{profile.language}
</Badge>
<p className="text-xs text-muted-foreground/60 text-right">{formatDate(profile.created_at)}</p>
</div>
<p className="text-xs text-muted-foreground">Created {formatDate(profile.created_at)}</p>
</CardContent>
</Card>
@@ -26,9 +26,11 @@ export function ProfileList() {
);
}
const allProfiles = profiles || [];
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex flex-col">
<div className="flex items-center justify-between mb-4 shrink-0">
<h2 className="text-2xl font-bold">Voice Profiles</h2>
<Button onClick={() => setDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
@@ -36,26 +38,28 @@ export function ProfileList() {
</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>
)}
<div className="min-h-[280px] shrink-0">
{allProfiles.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 grid-cols-3 auto-rows-fr">
{allProfiles.map((profile) => (
<ProfileCard key={profile.id} profile={profile} />
))}
</div>
)}
</div>
<ProfileForm />
</div>
+30
View File
@@ -0,0 +1,30 @@
import * as React from 'react';
import { cn } from '@/lib/utils/cn';
export interface CircleButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
icon: React.ComponentType<{ className?: string }>;
}
const CircleButton = React.forwardRef<HTMLButtonElement, CircleButtonProps>(
({ className, icon: Icon, ...props }, ref) => {
return (
<button
ref={ref}
className={cn(
'h-7 w-7 rounded-full flex items-center justify-center',
'hover:bg-accent transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'disabled:pointer-events-none disabled:opacity-50',
className
)}
{...props}
>
<Icon className="h-3.5 w-3.5 text-muted-foreground/60" />
</button>
);
}
);
CircleButton.displayName = 'CircleButton';
export { CircleButton };