mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-10-03 00:55:14 -07:00
Add favorites, effects badge on profiles, UI polish
- Add is_favorited column with toggle endpoint and star button on history - Show sparkles icon on profile cards that have effects configured - Gold ring on selected profile cards - Smaller, gray action buttons with brighter hover - Clamp player time to duration to prevent runaway playback - Align profile card icon to top for wrapped names - Flush bottom corners on history card when versions expanded - Simplify .gitignore data/ rule
This commit is contained in:
+1
-4
@@ -35,10 +35,7 @@ target/
|
|||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
# Data (user-generated)
|
# Data (user-generated)
|
||||||
data/profiles/*
|
data/
|
||||||
data/generations/*
|
|
||||||
data/projects/*
|
|
||||||
data/voicebox.db
|
|
||||||
!data/.gitkeep
|
!data/.gitkeep
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ import { AnimatePresence, motion } from 'framer-motion';
|
|||||||
import {
|
import {
|
||||||
Download,
|
Download,
|
||||||
FileArchive,
|
FileArchive,
|
||||||
Layers,
|
GalleryVerticalEnd,
|
||||||
Loader2,
|
Loader2,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Play,
|
Play,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
|
Star,
|
||||||
Trash2,
|
Trash2,
|
||||||
Wand2,
|
Wand2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
@@ -238,6 +239,19 @@ export function HistoryTable() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleToggleFavorite = async (generationId: string) => {
|
||||||
|
try {
|
||||||
|
await apiClient.toggleFavorite(generationId);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['history'] });
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: 'Failed to update favorite',
|
||||||
|
description: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleApplyEffects = (generationId: string) => {
|
const handleApplyEffects = (generationId: string) => {
|
||||||
setEffectsTargetId(generationId);
|
setEffectsTargetId(generationId);
|
||||||
setEffectsChain([]);
|
setEffectsChain([]);
|
||||||
@@ -380,6 +394,7 @@ export function HistoryTable() {
|
|||||||
className={cn(
|
className={cn(
|
||||||
'flex items-stretch gap-4 h-26 p-3',
|
'flex items-stretch gap-4 h-26 p-3',
|
||||||
isPlayable && 'hover:bg-muted/70 cursor-pointer rounded-md',
|
isPlayable && 'hover:bg-muted/70 cursor-pointer rounded-md',
|
||||||
|
isVersionsExpanded && 'rounded-b-none',
|
||||||
)}
|
)}
|
||||||
aria-label={
|
aria-label={
|
||||||
isGenerating
|
isGenerating
|
||||||
@@ -457,7 +472,7 @@ export function HistoryTable() {
|
|||||||
|
|
||||||
{/* Far right - Actions */}
|
{/* Far right - Actions */}
|
||||||
<div
|
<div
|
||||||
className="shrink-0 flex flex-col justify-center items-center gap-1"
|
className="shrink-0 flex flex-col justify-center items-center gap-0.5"
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
@@ -465,11 +480,11 @@ export function HistoryTable() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8"
|
className="h-7 w-7 text-muted-foreground hover:bg-muted-foreground/20 hover:text-muted-foreground"
|
||||||
aria-label="Retry generation"
|
aria-label="Retry generation"
|
||||||
onClick={() => handleRetry(gen.id)}
|
onClick={() => handleRetry(gen.id)}
|
||||||
>
|
>
|
||||||
<RotateCcw className="h-4 w-4" />
|
<RotateCcw className="h-2 w-2" />
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -478,11 +493,11 @@ export function HistoryTable() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8"
|
className="h-7 w-7 text-muted-foreground hover:bg-muted-foreground/20 hover:text-muted-foreground"
|
||||||
aria-label="Actions"
|
aria-label="Actions"
|
||||||
disabled={isGenerating}
|
disabled={isGenerating}
|
||||||
>
|
>
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
<MoreHorizontal className="h-2 w-2" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
@@ -517,24 +532,42 @@ export function HistoryTable() {
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => handleDeleteClick(gen.id, gen.profile_name)}
|
onClick={() => handleDeleteClick(gen.id, gen.profile_name)}
|
||||||
disabled={deleteGeneration.isPending}
|
disabled={deleteGeneration.isPending}
|
||||||
className="text-destructive focus:text-destructive"
|
// className="text-destructive focus:text-destructive"
|
||||||
>
|
>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
Delete
|
Delete
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={cn(
|
||||||
|
'h-7 w-7 text-muted-foreground hover:bg-muted-foreground/20 hover:text-muted-foreground',
|
||||||
|
gen.is_favorited && 'text-accent hover:text-accent',
|
||||||
|
)}
|
||||||
|
aria-label={gen.is_favorited ? 'Unfavorite' : 'Favorite'}
|
||||||
|
onClick={() => handleToggleFavorite(gen.id)}
|
||||||
|
>
|
||||||
|
<Star
|
||||||
|
className="h-2 w-2"
|
||||||
|
fill={gen.is_favorited ? 'currentColor' : 'none'}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
{hasVersions && (
|
{hasVersions && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className={cn('h-8 w-8', isVersionsExpanded && 'text-accent')}
|
className={cn(
|
||||||
|
'h-7 w-7 text-muted-foreground hover:bg-muted-foreground/20 hover:text-muted-foreground',
|
||||||
|
isVersionsExpanded && 'text-accent hover:text-accent',
|
||||||
|
)}
|
||||||
aria-label="Toggle versions"
|
aria-label="Toggle versions"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setExpandedVersionsId(isVersionsExpanded ? null : gen.id)
|
setExpandedVersionsId(isVersionsExpanded ? null : gen.id)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Layers className="h-4 w-4" />
|
<GalleryVerticalEnd className="h-2 w-2" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Download, Edit, Mic, Trash2 } from 'lucide-react';
|
import { Download, Edit, Mic, Sparkles, 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 { Button } from '@/components/ui/button';
|
||||||
@@ -79,7 +79,7 @@ export function ProfileCard({ profile }: ProfileCardProps) {
|
|||||||
<Card
|
<Card
|
||||||
className={cn(
|
className={cn(
|
||||||
'cursor-pointer hover:shadow-md transition-all flex flex-col h-[162px]',
|
'cursor-pointer hover:shadow-md transition-all flex flex-col h-[162px]',
|
||||||
isSelected && 'ring-2 ring-primary shadow-md',
|
isSelected && 'ring-2 ring-accent shadow-md',
|
||||||
)}
|
)}
|
||||||
onClick={handleSelect}
|
onClick={handleSelect}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
@@ -89,8 +89,8 @@ export function ProfileCard({ profile }: ProfileCardProps) {
|
|||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
>
|
>
|
||||||
<CardHeader className="p-3 pb-2">
|
<CardHeader className="p-3 pb-2">
|
||||||
<CardTitle className="flex items-center gap-1.5 text-base font-medium">
|
<CardTitle className="flex items-start gap-1.5 text-base font-medium">
|
||||||
<div className="h-6 w-6 rounded-full bg-muted flex items-center justify-center shrink-0 overflow-hidden">
|
<div className="h-6 w-6 mt-[3px] rounded-full bg-muted flex items-center justify-center shrink-0 overflow-hidden">
|
||||||
{avatarUrl && !avatarError ? (
|
{avatarUrl && !avatarError ? (
|
||||||
<img
|
<img
|
||||||
src={avatarUrl}
|
src={avatarUrl}
|
||||||
@@ -112,10 +112,13 @@ export function ProfileCard({ profile }: ProfileCardProps) {
|
|||||||
<p className="text-xs text-muted-foreground mb-1.5 line-clamp-2 leading-relaxed">
|
<p className="text-xs text-muted-foreground mb-1.5 line-clamp-2 leading-relaxed">
|
||||||
{profile.description || 'No description'}
|
{profile.description || 'No description'}
|
||||||
</p>
|
</p>
|
||||||
<div className="mb-2">
|
<div className="mb-2 flex items-center gap-1.5">
|
||||||
<Badge variant="outline" className="text-xs h-5 px-1.5 text-muted-foreground">
|
<Badge variant="outline" className="text-xs h-5 px-1.5 text-muted-foreground">
|
||||||
{profile.language}
|
{profile.language}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{profile.effects_chain && profile.effects_chain.length > 0 && (
|
||||||
|
<Sparkles className="h-3.5 w-3.5 text-accent" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-0.5 justify-end items-end mt-auto">
|
<div className="flex gap-0.5 justify-end items-end mt-auto">
|
||||||
<CircleButton
|
<CircleButton
|
||||||
|
|||||||
@@ -218,6 +218,12 @@ class ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async toggleFavorite(generationId: string): Promise<{ is_favorited: boolean }> {
|
||||||
|
return this.request<{ is_favorited: boolean }>(`/history/${generationId}/favorite`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// History
|
// History
|
||||||
async listHistory(query?: HistoryQuery): Promise<HistoryListResponse> {
|
async listHistory(query?: HistoryQuery): Promise<HistoryListResponse> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ export interface GenerationResponse {
|
|||||||
model_size?: string;
|
model_size?: string;
|
||||||
status: 'generating' | 'completed' | 'failed';
|
status: 'generating' | 'completed' | 'failed';
|
||||||
error?: string;
|
error?: string;
|
||||||
|
is_favorited?: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
versions?: GenerationVersionResponse[];
|
versions?: GenerationVersionResponse[];
|
||||||
active_version_id?: string;
|
active_version_id?: string;
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ class Generation(Base):
|
|||||||
model_size = Column(String, nullable=True)
|
model_size = Column(String, nullable=True)
|
||||||
status = Column(String, default="completed") # generating, completed, failed
|
status = Column(String, default="completed") # generating, completed, failed
|
||||||
error = Column(Text, nullable=True)
|
error = Column(Text, nullable=True)
|
||||||
|
is_favorited = Column(Boolean, default=False)
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
@@ -375,6 +376,15 @@ def _run_migrations(engine):
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
print("Added sort_order column to effect_presets")
|
print("Added sort_order column to effect_presets")
|
||||||
|
|
||||||
|
if 'generations' in inspector.get_table_names():
|
||||||
|
columns = {col['name'] for col in inspector.get_columns('generations')}
|
||||||
|
if 'is_favorited' not in columns:
|
||||||
|
print("Migrating generations: adding is_favorited column")
|
||||||
|
with engine.connect() as conn:
|
||||||
|
conn.execute(text("ALTER TABLE generations ADD COLUMN is_favorited BOOLEAN DEFAULT 0"))
|
||||||
|
conn.commit()
|
||||||
|
print("Added is_favorited column to generations")
|
||||||
|
|
||||||
# Migration: Create generation_versions for existing generations
|
# Migration: Create generation_versions for existing generations
|
||||||
# (populate after tables are created, handled in init_db)
|
# (populate after tables are created, handled in init_db)
|
||||||
|
|
||||||
|
|||||||
@@ -222,6 +222,7 @@ async def list_generations(
|
|||||||
model_size=generation.model_size,
|
model_size=generation.model_size,
|
||||||
status=generation.status or "completed",
|
status=generation.status or "completed",
|
||||||
error=generation.error,
|
error=generation.error,
|
||||||
|
is_favorited=bool(generation.is_favorited),
|
||||||
created_at=generation.created_at,
|
created_at=generation.created_at,
|
||||||
versions=versions,
|
versions=versions,
|
||||||
active_version_id=active_version_id,
|
active_version_id=active_version_id,
|
||||||
|
|||||||
@@ -1314,6 +1314,20 @@ async def get_generation(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/history/{generation_id}/favorite")
|
||||||
|
async def toggle_favorite(
|
||||||
|
generation_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Toggle the favorite status of a generation."""
|
||||||
|
gen = db.query(DBGeneration).filter_by(id=generation_id).first()
|
||||||
|
if not gen:
|
||||||
|
raise HTTPException(status_code=404, detail="Generation not found")
|
||||||
|
gen.is_favorited = not gen.is_favorited
|
||||||
|
db.commit()
|
||||||
|
return {"is_favorited": gen.is_favorited}
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/history/{generation_id}")
|
@app.delete("/history/{generation_id}")
|
||||||
async def delete_generation(
|
async def delete_generation(
|
||||||
generation_id: str,
|
generation_id: str,
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ class GenerationResponse(BaseModel):
|
|||||||
model_size: Optional[str] = None
|
model_size: Optional[str] = None
|
||||||
status: str = "completed"
|
status: str = "completed"
|
||||||
error: Optional[str] = None
|
error: Optional[str] = None
|
||||||
|
is_favorited: bool = False
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
versions: Optional[List["GenerationVersionResponse"]] = None
|
versions: Optional[List["GenerationVersionResponse"]] = None
|
||||||
active_version_id: Optional[str] = None
|
active_version_id: Optional[str] = None
|
||||||
@@ -110,6 +111,7 @@ class HistoryResponse(BaseModel):
|
|||||||
model_size: Optional[str] = None
|
model_size: Optional[str] = None
|
||||||
status: str = "completed"
|
status: str = "completed"
|
||||||
error: Optional[str] = None
|
error: Optional[str] = None
|
||||||
|
is_favorited: bool = False
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
versions: Optional[List["GenerationVersionResponse"]] = None
|
versions: Optional[List["GenerationVersionResponse"]] = None
|
||||||
active_version_id: Optional[str] = None
|
active_version_id: Optional[str] = None
|
||||||
|
|||||||
Reference in New Issue
Block a user