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
+28 -25
View File
@@ -14,7 +14,7 @@ import { isTauri, startServer, setupWindowCloseHandler } from '@/lib/tauri';
let serverStarting = false; let serverStarting = false;
function App() { function App() {
const [activeTab, setActiveTab] = useState('profiles'); const [activeTab, setActiveTab] = useState('main');
const [serverReady, setServerReady] = useState(false); const [serverReady, setServerReady] = useState(false);
// Setup window close handler and auto-start server when running in Tauri (production only) // Setup window close handler and auto-start server when running in Tauri (production only)
@@ -84,38 +84,41 @@ function App() {
} }
return ( return (
<div className="min-h-screen bg-background flex flex-col"> <div className="h-screen bg-background flex flex-col overflow-hidden">
<div className="flex flex-1"> <div className="flex flex-1 min-h-0 overflow-hidden">
<Sidebar activeTab={activeTab} onTabChange={setActiveTab} /> <Sidebar activeTab={activeTab} onTabChange={setActiveTab} />
<main className="flex-1 ml-20 pb-20"> <main className="flex-1 ml-20 overflow-hidden flex flex-col">
<div className="container mx-auto px-8 py-8 max-w-7xl"> <div className="container mx-auto px-8 py-8 max-w-[1800px] h-full overflow-hidden flex flex-col">
{activeTab === 'profiles' && ( {activeTab === 'settings' ? (
<div className="space-y-4"> <div className="space-y-4 overflow-y-auto">
<ProfileList />
</div>
)}
{activeTab === 'generate' && (
<div className="space-y-4">
<GenerationForm />
</div>
)}
{activeTab === 'history' && (
<div className="space-y-4">
<HistoryTable />
</div>
)}
{activeTab === 'settings' && (
<div className="space-y-4">
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<ConnectionForm /> <ConnectionForm />
<ServerStatus /> <ServerStatus />
</div> </div>
<ModelManagement /> <ModelManagement />
</div> </div>
) : (
// Main view: Profiles top left, Generator bottom left, History right
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 h-full min-h-0 overflow-hidden">
{/* Left Column */}
<div className="flex flex-col gap-6 min-h-0 overflow-y-auto pb-32">
{/* Profiles - Top Left */}
<div className="shrink-0 flex flex-col">
<ProfileList />
</div>
{/* Generator - Bottom Left */}
<div className="shrink-0">
<GenerationForm />
</div>
</div>
{/* Right Column - History */}
<div className="flex flex-col min-h-0 overflow-hidden">
<HistoryTable />
</div>
</div>
)} )}
</div> </div>
</main> </main>
@@ -1,7 +1,8 @@
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { Loader2 } from 'lucide-react'; import { Loader2, Mic } from 'lucide-react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import * as z from 'zod'; import * as z from 'zod';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { import {
@@ -24,10 +25,10 @@ import {
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { useToast } from '@/components/ui/use-toast'; import { useToast } from '@/components/ui/use-toast';
import { useGeneration } from '@/lib/hooks/useGeneration'; 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({ const generationSchema = z.object({
profileId: z.string().min(1, 'Please select a voice profile'),
text: z.string().min(1, 'Text is required').max(5000), text: z.string().min(1, 'Text is required').max(5000),
language: z.enum(['en', 'zh']), language: z.enum(['en', 'zh']),
seed: z.number().int().optional(), seed: z.number().int().optional(),
@@ -37,14 +38,14 @@ const generationSchema = z.object({
type GenerationFormValues = z.infer<typeof generationSchema>; type GenerationFormValues = z.infer<typeof generationSchema>;
export function GenerationForm() { export function GenerationForm() {
const { data: profiles } = useProfiles(); const selectedProfileId = useUIStore((state) => state.selectedProfileId);
const { data: selectedProfile } = useProfile(selectedProfileId || '');
const generation = useGeneration(); const generation = useGeneration();
const { toast } = useToast(); const { toast } = useToast();
const form = useForm<GenerationFormValues>({ const form = useForm<GenerationFormValues>({
resolver: zodResolver(generationSchema), resolver: zodResolver(generationSchema),
defaultValues: { defaultValues: {
profileId: '',
text: '', text: '',
language: 'en', language: 'en',
seed: undefined, seed: undefined,
@@ -53,9 +54,18 @@ export function GenerationForm() {
}); });
async function onSubmit(data: GenerationFormValues) { 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 { try {
const result = await generation.mutateAsync({ const result = await generation.mutateAsync({
profile_id: data.profileId, profile_id: selectedProfileId,
text: data.text, text: data.text,
language: data.language, language: data.language,
seed: data.seed, seed: data.seed,
@@ -85,30 +95,20 @@ export function GenerationForm() {
<CardContent> <CardContent>
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField <div>
control={form.control} <FormLabel>Voice Profile</FormLabel>
name="profileId" {selectedProfile ? (
render={({ field }) => ( <div className="mt-2 p-3 border rounded-md bg-muted/50 flex items-center gap-2">
<FormItem> <Mic className="h-4 w-4 text-muted-foreground" />
<FormLabel>Voice Profile</FormLabel> <span className="font-medium">{selectedProfile.name}</span>
<Select onValueChange={field.onChange} defaultValue={field.value}> <Badge variant="outline">{selectedProfile.language}</Badge>
<FormControl> </div>
<SelectTrigger> ) : (
<SelectValue placeholder="Select a voice" /> <div className="mt-2 p-3 border border-dashed rounded-md text-sm text-muted-foreground">
</SelectTrigger> Click on a profile card above to select a voice profile
</FormControl> </div>
<SelectContent>
{profiles?.map((profile) => (
<SelectItem key={profile.id} value={profile.id}>
{profile.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)} )}
/> </div>
<FormField <FormField
control={form.control} control={form.control}
@@ -198,7 +198,11 @@ export function GenerationForm() {
/> />
</div> </div>
<Button type="submit" className="w-full" disabled={generation.isPending}> <Button
type="submit"
className="w-full"
disabled={generation.isPending || !selectedProfileId}
>
{generation.isPending ? ( {generation.isPending ? (
<> <>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+64 -64
View File
@@ -10,7 +10,6 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from '@/components/ui/table'; } from '@/components/ui/table';
import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client'; import { apiClient } from '@/lib/api/client';
import { useDeleteGeneration, useHistory } from '@/lib/hooks/useHistory'; import { useDeleteGeneration, useHistory } from '@/lib/hooks/useHistory';
import { formatDate, formatDuration } from '@/lib/utils/format'; import { formatDate, formatDuration } from '@/lib/utils/format';
@@ -19,7 +18,6 @@ import { usePlayerStore } from '@/stores/playerStore';
export function HistoryTable() { export function HistoryTable() {
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const limit = 20; const limit = 20;
const { toast } = useToast();
const { data: historyData, isLoading } = useHistory({ const { data: historyData, isLoading } = useHistory({
limit, limit,
@@ -61,74 +59,76 @@ export function HistoryTable() {
const hasMore = history.length === limit && (page + 1) * limit < total; const hasMore = history.length === limit && (page + 1) * limit < total;
return ( return (
<div className="space-y-4"> <div className="flex flex-col h-full min-h-0">
<h2 className="text-2xl font-bold">Generation History</h2> <h2 className="text-2xl font-bold mb-4 shrink-0">Generation History</h2>
{history.length === 0 ? ( {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. No generation history yet. Generate your first audio to see it here.
</div> </div>
) : ( ) : (
<> <>
<Table> <div className="flex-1 min-h-0 overflow-y-auto border rounded-md max-h-[calc(100vh-280px)]">
<TableHeader> <Table>
<TableRow> <TableHeader className="sticky top-0 bg-background z-10">
<TableHead>Text</TableHead> <TableRow>
<TableHead>Profile</TableHead> <TableHead>Text</TableHead>
<TableHead>Language</TableHead> <TableHead>Profile</TableHead>
<TableHead>Duration</TableHead> <TableHead>Language</TableHead>
<TableHead>Created</TableHead> <TableHead>Duration</TableHead>
<TableHead className="text-right">Actions</TableHead> <TableHead>Created</TableHead>
</TableRow> <TableHead className="text-right">Actions</TableHead>
</TableHeader> </TableRow>
<TableBody> </TableHeader>
{history.map((gen) => ( <TableBody>
<TableRow key={gen.id}> {history.map((gen) => (
<TableCell className="max-w-[300px] truncate">{gen.text}</TableCell> <TableRow key={gen.id}>
<TableCell>{gen.profile_name}</TableCell> <TableCell className="max-w-[200px] truncate">{gen.text}</TableCell>
<TableCell> <TableCell>{gen.profile_name}</TableCell>
<Badge variant="outline">{gen.language}</Badge> <TableCell>
</TableCell> <Badge variant="outline">{gen.language}</Badge>
<TableCell>{formatDuration(gen.duration)}</TableCell> </TableCell>
<TableCell>{formatDate(gen.created_at)}</TableCell> <TableCell>{formatDuration(gen.duration)}</TableCell>
<TableCell className="text-right"> <TableCell className="text-sm">{formatDate(gen.created_at)}</TableCell>
<div className="flex justify-end gap-2"> <TableCell className="text-right">
<Button <div className="flex justify-end gap-2">
variant="ghost" <Button
size="icon" variant="ghost"
onClick={() => handlePlay(gen.id, gen.text)} size="icon"
aria-label="Play audio" onClick={() => handlePlay(gen.id, gen.text)}
className={ aria-label="Play audio"
currentAudioId === gen.id && isPlaying ? 'text-primary' : '' className={
} currentAudioId === gen.id && isPlaying ? 'text-primary' : ''
> }
<Play className="h-4 w-4" /> >
</Button> <Play className="h-4 w-4" />
<Button </Button>
variant="ghost" <Button
size="icon" variant="ghost"
onClick={() => handleDownload(gen.id, gen.text)} size="icon"
aria-label="Download audio" onClick={() => handleDownload(gen.id, gen.text)}
> aria-label="Download audio"
<Download className="h-4 w-4" /> >
</Button> <Download className="h-4 w-4" />
<Button </Button>
variant="ghost" <Button
size="icon" variant="ghost"
onClick={() => deleteGeneration.mutate(gen.id)} size="icon"
disabled={deleteGeneration.isPending} onClick={() => deleteGeneration.mutate(gen.id)}
aria-label="Delete generation" disabled={deleteGeneration.isPending}
> aria-label="Delete generation"
<Trash2 className="h-4 w-4 text-destructive" /> >
</Button> <Trash2 className="h-4 w-4 text-destructive" />
</div> </Button>
</TableCell> </div>
</TableRow> </TableCell>
))} </TableRow>
</TableBody> ))}
</Table> </TableBody>
</Table>
</div>
<div className="flex justify-between"> <div className="flex justify-between items-center mt-4 shrink-0">
<Button <Button
variant="outline" variant="outline"
onClick={() => setPage((p) => Math.max(0, p - 1))} onClick={() => setPage((p) => Math.max(0, p - 1))}
@@ -136,7 +136,7 @@ export function HistoryTable() {
> >
Previous Previous
</Button> </Button>
<div className="text-sm text-muted-foreground flex items-center"> <div className="text-sm text-muted-foreground">
Page {page + 1} • {total} total Page {page + 1} • {total} total
</div> </div>
<Button variant="outline" onClick={() => setPage((p) => p + 1)} disabled={!hasMore}> <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'; import { cn } from '@/lib/utils/cn';
interface SidebarProps { interface SidebarProps {
@@ -7,9 +7,7 @@ interface SidebarProps {
} }
const tabs = [ const tabs = [
{ id: 'profiles', icon: Mic, label: 'Profiles' }, { id: 'main', icon: Home, label: 'Main' },
{ id: 'generate', icon: Sparkles, label: 'Generate' },
{ id: 'history', icon: History, label: 'History' },
{ id: 'settings', icon: Settings, label: 'Settings' }, { 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 { useState } from 'react';
import { Badge } from '@/components/ui/badge'; 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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { cn } from '@/lib/utils/cn';
import type { VoiceProfileResponse } from '@/lib/api/types'; import type { VoiceProfileResponse } from '@/lib/api/types';
import { useDeleteProfile } from '@/lib/hooks/useProfiles'; import { useDeleteProfile } from '@/lib/hooks/useProfiles';
import { formatDate } from '@/lib/utils/format'; import { formatDate } from '@/lib/utils/format';
@@ -18,6 +19,14 @@ export function ProfileCard({ profile }: ProfileCardProps) {
const deleteProfile = useDeleteProfile(); const deleteProfile = useDeleteProfile();
const setEditingProfileId = useUIStore((state) => state.setEditingProfileId); const setEditingProfileId = useUIStore((state) => state.setEditingProfileId);
const setProfileDialogOpen = useUIStore((state) => state.setProfileDialogOpen); 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 = () => { const handleEdit = () => {
setEditingProfileId(profile.id); setEditingProfileId(profile.id);
@@ -35,39 +44,51 @@ export function ProfileCard({ profile }: ProfileCardProps) {
return ( return (
<> <>
<Card <Card
className="cursor-pointer hover:shadow-lg transition-shadow" className={cn(
onClick={() => setDetailOpen(true)} "cursor-pointer hover:shadow-md transition-all",
isSelected && "ring-2 ring-primary shadow-md"
)}
onClick={handleSelect}
> >
<CardHeader> <CardHeader className="p-3 pb-2">
<CardTitle className="flex items-center justify-between"> <CardTitle className="flex items-center justify-between gap-2 text-base font-medium">
<span className="flex items-center gap-2"> <span className="flex items-center gap-1.5 min-w-0 flex-1">
<Mic className="h-5 w-5" /> <Mic className="h-4 w-4 shrink-0 text-muted-foreground" />
{profile.name} <span className="truncate">{profile.name}</span>
</span> </span>
<div className="flex gap-1" onClick={(e) => e.stopPropagation()}> <div className="flex gap-0.5 shrink-0">
<Button variant="ghost" size="icon" onClick={handleEdit} aria-label="Edit profile"> <CircleButton
<Edit className="h-4 w-4" /> icon={Eye}
</Button> onClick={(e) => {
<Button e.stopPropagation();
variant="ghost" setDetailOpen(true);
size="icon" }}
aria-label="View details"
/>
<CircleButton
icon={Edit}
onClick={handleEdit}
aria-label="Edit profile"
/>
<CircleButton
icon={Trash2}
onClick={handleDelete} onClick={handleDelete}
disabled={deleteProfile.isPending} disabled={deleteProfile.isPending}
aria-label="Delete profile" aria-label="Delete profile"
> />
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div> </div>
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="p-3 pt-0">
{profile.description && ( <p className="text-xs text-muted-foreground mb-1.5 line-clamp-2 leading-relaxed">
<p className="text-sm text-muted-foreground mb-2">{profile.description}</p> {profile.description || 'No description'}
)} </p>
<div className="flex gap-2 mb-2"> <div className="flex items-center justify-between gap-2 mb-1">
<Badge variant="outline">{profile.language}</Badge> <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> </div>
<p className="text-xs text-muted-foreground">Created {formatDate(profile.created_at)}</p>
</CardContent> </CardContent>
</Card> </Card>
@@ -26,9 +26,11 @@ export function ProfileList() {
); );
} }
const allProfiles = profiles || [];
return ( return (
<div className="space-y-4"> <div className="flex flex-col">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between mb-4 shrink-0">
<h2 className="text-2xl font-bold">Voice Profiles</h2> <h2 className="text-2xl font-bold">Voice Profiles</h2>
<Button onClick={() => setDialogOpen(true)}> <Button onClick={() => setDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
@@ -36,26 +38,28 @@ export function ProfileList() {
</Button> </Button>
</div> </div>
{profiles && profiles.length === 0 ? ( <div className="min-h-[280px] shrink-0">
<Card> {allProfiles.length === 0 ? (
<CardContent className="flex flex-col items-center justify-center py-12"> <Card>
<Mic className="h-12 w-12 text-muted-foreground mb-4" /> <CardContent className="flex flex-col items-center justify-center py-12">
<p className="text-muted-foreground mb-4"> <Mic className="h-12 w-12 text-muted-foreground mb-4" />
No voice profiles yet. Create your first profile to get started. <p className="text-muted-foreground mb-4">
</p> No voice profiles yet. Create your first profile to get started.
<Button onClick={() => setDialogOpen(true)}> </p>
<Plus className="mr-2 h-4 w-4" /> <Button onClick={() => setDialogOpen(true)}>
Create Profile <Plus className="mr-2 h-4 w-4" />
</Button> Create Profile
</CardContent> </Button>
</Card> </CardContent>
) : ( </Card>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> ) : (
{profiles?.map((profile) => ( <div className="grid gap-4 grid-cols-3 auto-rows-fr">
<ProfileCard key={profile.id} profile={profile} /> {allProfiles.map((profile) => (
))} <ProfileCard key={profile.id} profile={profile} />
</div> ))}
)} </div>
)}
</div>
<ProfileForm /> <ProfileForm />
</div> </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 };
+4
View File
@@ -102,6 +102,10 @@
* { * {
@apply border-border; @apply border-border;
} }
html,
body {
@apply overflow-hidden;
}
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
+7
View File
@@ -14,6 +14,10 @@ interface UIStore {
generationDialogOpen: boolean; generationDialogOpen: boolean;
setGenerationDialogOpen: (open: boolean) => void; setGenerationDialogOpen: (open: boolean) => void;
// Selected profile for generation
selectedProfileId: string | null;
setSelectedProfileId: (id: string | null) => void;
// Theme // Theme
theme: 'light' | 'dark'; theme: 'light' | 'dark';
setTheme: (theme: 'light' | 'dark') => void; setTheme: (theme: 'light' | 'dark') => void;
@@ -31,6 +35,9 @@ export const useUIStore = create<UIStore>((set) => ({
generationDialogOpen: false, generationDialogOpen: false,
setGenerationDialogOpen: (open) => set({ generationDialogOpen: open }), setGenerationDialogOpen: (open) => set({ generationDialogOpen: open }),
selectedProfileId: null,
setSelectedProfileId: (id) => set({ selectedProfileId: id }),
theme: 'light', theme: 'light',
setTheme: (theme) => { setTheme: (theme) => {
set({ theme }); set({ theme });
+49 -19
View File
@@ -7,8 +7,8 @@ import { Hero } from '@/components/ui/hero';
import { Section, SectionTitle } from '@/components/ui/section'; import { Section, SectionTitle } from '@/components/ui/section';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { FeatureCard } from '@/components/ui/feature-card'; import { FeatureCard } from '@/components/ui/feature-card';
import { DownloadSection } from '@/components/DownloadSection'; import { GITHUB_REPO, DOWNLOAD_LINKS } from '@/lib/constants';
import { GITHUB_REPO } from '@/lib/constants'; import { AppleIcon, WindowsIcon, LinuxIcon } from '@/components/PlatformIcons';
export default function Home() { export default function Home() {
const features = [ const features = [
@@ -33,8 +33,8 @@ export default function Home() {
icon: <Cloud className="h-6 w-6" />, icon: <Cloud className="h-6 w-6" />,
}, },
{ {
title: 'Production Ready', title: 'Audio Transcription',
description: 'Type-safe, modular architecture. Desktop-first experience with native performance.', description: 'Powered by Whisper for accurate speech-to-text. Extract reference text from voice samples automatically.',
icon: <Shield className="h-6 w-6" />, icon: <Shield className="h-6 w-6" />,
}, },
{ {
@@ -51,23 +51,61 @@ export default function Home() {
title="voicebox" title="voicebox"
description="Professional voice cloning powered by Qwen3-TTS. Create natural-sounding speech from text with near-perfect voice replication." description="Professional voice cloning powered by Qwen3-TTS. Create natural-sounding speech from text with near-perfect voice replication."
actions={ actions={
<> <div className="space-y-4 w-full lg:w-auto">
<Button asChild size="lg"> <div>
<a href="#download"> <h2 className="text-2xl sm:text-3xl font-bold mb-1">Download</h2>
Download Now <p className="text-sm text-muted-foreground">Choose your platform</p>
</a> </div>
</Button> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3 w-full max-w-2xl">
<Button asChild size="lg" className="w-full px-0">
<a href={DOWNLOAD_LINKS.macArm} download className="flex items-center w-full relative">
<div className="flex items-center gap-2 flex-shrink-0 pl-4">
<AppleIcon className="h-5 w-5" />
<div className="h-5 w-px bg-border" />
</div>
<span className="flex-1 text-center px-4">macOS (ARM)</span>
</a>
</Button>
<Button asChild size="lg" className="w-full px-0">
<a href={DOWNLOAD_LINKS.macIntel} download className="flex items-center w-full relative">
<div className="flex items-center gap-2 flex-shrink-0 pl-4">
<AppleIcon className="h-5 w-5" />
<div className="h-5 w-px bg-border" />
</div>
<span className="flex-1 text-center px-4">macOS (Intel)</span>
</a>
</Button>
<Button asChild size="lg" className="w-full px-0">
<a href={DOWNLOAD_LINKS.windows} download className="flex items-center w-full relative">
<div className="flex items-center gap-2 flex-shrink-0 pl-4">
<WindowsIcon className="h-5 w-5" />
<div className="h-5 w-px bg-border" />
</div>
<span className="flex-1 text-center px-4">Windows</span>
</a>
</Button>
<Button asChild size="lg" className="w-full px-0">
<a href={DOWNLOAD_LINKS.linux} download className="flex items-center w-full relative">
<div className="flex items-center gap-2 flex-shrink-0 pl-4">
<LinuxIcon className="h-5 w-5" />
<div className="h-5 w-px bg-border" />
</div>
<span className="flex-1 text-center px-4">Linux</span>
</a>
</Button>
</div>
<Button <Button
variant="outline" variant="outline"
size="lg" size="lg"
asChild asChild
className="w-full"
> >
<a href={GITHUB_REPO} target="_blank" rel="noopener noreferrer"> <a href={GITHUB_REPO} target="_blank" rel="noopener noreferrer">
<Github className="h-4 w-4 mr-2" /> <Github className="h-4 w-4 mr-2" />
View on GitHub View on GitHub
</a> </a>
</Button> </Button>
</> </div>
} }
/> />
@@ -89,14 +127,6 @@ export default function Home() {
</div> </div>
</Section> </Section>
{/* Download Section */}
<Section id="download">
<SectionTitle className="mb-4 text-center">Download</SectionTitle>
<p className="text-sm text-muted-foreground mb-8 text-center max-w-2xl mx-auto">
Available for macOS, Windows, and Linux. Choose your platform below.
</p>
<DownloadSection />
</Section>
</div> </div>
); );
} }
+1 -1
View File
@@ -35,7 +35,7 @@ export function DownloadSection() {
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 sm:gap-6"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4 sm:gap-6">
{downloads.map(({ platform, icon: Icon, link, description }) => ( {downloads.map(({ platform, icon: Icon, link, description }) => (
<Card key={platform} className="hover:border-primary/50 hover:shadow-xl hover:shadow-primary/10 transition-all duration-300 hover:-translate-y-1"> <Card key={platform} className="hover:border-primary/20 hover:shadow-lg hover:shadow-primary/3 transition-all duration-200 hover:-translate-y-0.5">
<CardContent className="p-6"> <CardContent className="p-6">
<div className="flex flex-col items-center text-center space-y-4"> <div className="flex flex-col items-center text-center space-y-4">
<div className="p-3 rounded-xl bg-muted/50 backdrop-blur-sm border border-border"> <div className="p-3 rounded-xl bg-muted/50 backdrop-blur-sm border border-border">
+23
View File
@@ -0,0 +1,23 @@
export function AppleIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M17.05 20.28c-.98.95-2.05.88-3.08.4-1.09-.5-2.08-.48-3.24 0-1.44.62-2.2.44-3.06-.4C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09l.01-.01zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"/>
</svg>
);
}
export function WindowsIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M3 12V6.75l6-1.32v6.48L3 12zm17-9v8.75l-10 .15V5.21L20 3zM3 13l6 .09v7.81l-6-1.15V13zm17 .25V22l-10-1.8v-7.15l10 .15z"/>
</svg>
);
}
export function LinuxIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M12.504 0c-.155 0-.315.008-.48.021-4.226.333-3.105 4.807-3.17 6.298-.076 1.092-.3 1.953-1.05 3.02-.885 1.051-2.127 2.75-2.716 4.521-.278.832-.41 1.684-.287 2.489a.424.424 0 00-.11.135c-.26.26-.195.69-.133 1.001.054.27.112.553.077.784-.12.794-.3 1.593-.3 2.406 0 .599.18 1.193.3 1.791.12.599.3 1.193.3 1.792 0 .812.18 1.611.3 2.405.035.23-.023.514-.077.783-.062.312-.127.742.133 1.002a.424.424 0 00.11.135c-.123.805.01 1.657.287 2.489.589 1.771 1.831 3.47 2.716 4.521.75 1.067 0.974 1.928 1.05 3.02.065 1.491-1.056 5.965 3.17 6.298.165.013.325.021.48.021.155 0 .315-.008.48-.021 4.226-.333 3.105-4.807 3.17-6.298.076-1.092.3-1.953 1.05-3.02.885-1.051 2.127-2.75 2.716-4.521.278-.832.41-1.684.287-2.489a.424.424 0 00.11-.135c.26-.26.195-.69.133-1.001-.054-.27-.112-.553-.077-.784.12-.794.3-1.593.3-2.406 0-.599-.18-1.193-.3-1.791-.12-.599-.3-1.193-.3-1.792 0-.812-.18-1.611-.3-2.405-.035-.23.023-.514.077-.783.062-.312.127-.742-.133-1.002a.424.424 0 00-.11-.135c.123-.805-.01-1.657-.287-2.489-.589-1.771-1.831-3.47-2.716-4.521-.75-1.067-.974-1.928-1.05-3.02-.065-1.491 1.056-5.965-3.17-6.298C12.819.008 12.659 0 12.504 0z"/>
</svg>
);
}
+4 -4
View File
@@ -8,14 +8,14 @@ const buttonVariants = cva(
{ {
variants: { variants: {
variant: { variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90 shadow-lg shadow-black/20 active:scale-[0.98]', default: 'bg-primary/10 backdrop-blur-sm border border-border text-primary hover:bg-primary/11 hover:border-primary/15 shadow-lg shadow-black/20 active:scale-[0.99]',
destructive: destructive:
'bg-destructive text-destructive-foreground hover:bg-destructive/90 shadow-lg shadow-destructive/20 active:scale-[0.98]', 'bg-destructive text-destructive-foreground hover:bg-destructive/90 shadow-lg shadow-destructive/20 active:scale-[0.98]',
outline: outline:
'border border-border bg-background/50 backdrop-blur-sm hover:bg-accent/50 hover:border-border transition-all active:scale-[0.98]', 'border border-border bg-background/50 backdrop-blur-sm hover:bg-foreground/5 transition-all active:scale-[0.99]',
secondary: secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80 backdrop-blur-sm active:scale-[0.98]', 'bg-secondary text-secondary-foreground hover:bg-secondary/70 backdrop-blur-sm active:scale-[0.99]',
ghost: 'hover:bg-accent/50 hover:text-accent-foreground backdrop-blur-sm active:scale-[0.98]', ghost: 'hover:bg-accent/30 hover:text-accent-foreground backdrop-blur-sm active:scale-[0.99]',
link: 'text-primary underline-offset-4 hover:underline', link: 'text-primary underline-offset-4 hover:underline',
}, },
size: { size: {
+1 -1
View File
@@ -11,7 +11,7 @@ interface FeatureCardProps {
export function FeatureCard({ title, description, icon, className }: FeatureCardProps) { export function FeatureCard({ title, description, icon, className }: FeatureCardProps) {
return ( return (
<Card className={cn('text-center hover:border-primary/30 hover:shadow-xl hover:shadow-primary/5 transition-all duration-300 hover:-translate-y-1', className)}> <Card className={cn('text-center hover:border-primary/20 hover:shadow-lg hover:shadow-primary/3 transition-all duration-200 hover:-translate-y-0.5', className)}>
<CardHeader> <CardHeader>
{icon && ( {icon && (
<div className="flex justify-center mb-3"> <div className="flex justify-center mb-3">
+28 -25
View File
@@ -14,31 +14,34 @@ interface HeroProps {
export function Hero({ title, description, actions, className, showLogo = true }: HeroProps) { export function Hero({ title, description, actions, className, showLogo = true }: HeroProps) {
return ( return (
<section className={cn('relative text-center pt-8 sm:pt-12 md:pt-14 -mb-6 sm:-mb-8 md:-mb-10 overflow-hidden -mx-4 sm:-mx-6 md:-mx-4 -mt-4 sm:mt-0 md:mt-0', className)}> <section className={cn('relative py-12 sm:py-16 md:py-20 lg:py-24', className)}>
<div className="relative z-10 px-4"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-12 items-center">
{showLogo && ( {/* Left side - Content */}
<div className="flex justify-center mb-4 sm:mb-6"> <div className="space-y-6 lg:pr-8">
<Image {showLogo && (
src="/voicebox-logo.png" <div className="flex lg:justify-start justify-center mb-6">
alt="Voicebox Logo" <Image
width={1024} src="/voicebox-logo.png"
height={1024} alt="Voicebox Logo"
className="w-32 sm:w-40 md:w-48 h-auto" width={1024}
priority height={1024}
/> className="w-32 sm:w-40 md:w-48 h-auto"
</div> priority
)} />
<h1 className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-bold mb-4 sm:mb-6"> </div>
{title} )}
</h1> <h1 className="text-5xl sm:text-6xl md:text-7xl lg:text-8xl font-bold leading-tight text-left">
<p className="text-base sm:text-lg md:text-xl text-foreground/60 mb-6 sm:mb-8 max-w-2xl mx-auto px-2"> {title}
{description} </h1>
</p> <p className="text-lg sm:text-xl md:text-2xl text-foreground/70 max-w-xl text-left">
{actions && ( {description}
<div className="flex flex-col sm:flex-row gap-3 sm:gap-4 justify-center px-2"> </p>
{actions} </div>
</div>
)} {/* Right side - Actions */}
<div className="flex flex-col items-start lg:items-end gap-4">
{actions}
</div>
</div> </div>
</section> </section>
); );
+4 -3
View File
@@ -3,9 +3,10 @@
export const LATEST_VERSION = 'v0.1.0'; export const LATEST_VERSION = 'v0.1.0';
export const DOWNLOAD_LINKS = { export const DOWNLOAD_LINKS = {
mac: 'https://github.com/USERNAME/voicebox/releases/download/v0.1.0/voicebox-macos-universal.dmg', macArm: 'https://github.com/USERNAME/voicebox/releases/download/v0.1.0/voicebox_aarch64-apple-darwin.dmg',
windows: 'https://github.com/USERNAME/voicebox/releases/download/v0.1.0/voicebox-windows-x64.msi', macIntel: 'https://github.com/USERNAME/voicebox/releases/download/v0.1.0/voicebox_x86_64-apple-darwin.dmg',
linux: 'https://github.com/USERNAME/voicebox/releases/download/v0.1.0/voicebox-linux-x86_64.AppImage', windows: 'https://github.com/USERNAME/voicebox/releases/download/v0.1.0/voicebox_x86_64-pc-windows-msvc.msi',
linux: 'https://github.com/USERNAME/voicebox/releases/download/v0.1.0/voicebox_x86_64-unknown-linux-gnu.AppImage',
} as const; } as const;
export const GITHUB_REPO = 'https://github.com/USERNAME/voicebox'; export const GITHUB_REPO = 'https://github.com/USERNAME/voicebox';