mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-15 04:40:40 -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
|
||||
|
||||
# Data (user-generated)
|
||||
data/profiles/*
|
||||
data/generations/*
|
||||
data/projects/*
|
||||
data/voicebox.db
|
||||
data/
|
||||
!data/.gitkeep
|
||||
|
||||
# Logs
|
||||
|
||||
@@ -3,11 +3,12 @@ import { AnimatePresence, motion } from 'framer-motion';
|
||||
import {
|
||||
Download,
|
||||
FileArchive,
|
||||
Layers,
|
||||
GalleryVerticalEnd,
|
||||
Loader2,
|
||||
MoreHorizontal,
|
||||
Play,
|
||||
RotateCcw,
|
||||
Star,
|
||||
Trash2,
|
||||
Wand2,
|
||||
} 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) => {
|
||||
setEffectsTargetId(generationId);
|
||||
setEffectsChain([]);
|
||||
@@ -380,6 +394,7 @@ export function HistoryTable() {
|
||||
className={cn(
|
||||
'flex items-stretch gap-4 h-26 p-3',
|
||||
isPlayable && 'hover:bg-muted/70 cursor-pointer rounded-md',
|
||||
isVersionsExpanded && 'rounded-b-none',
|
||||
)}
|
||||
aria-label={
|
||||
isGenerating
|
||||
@@ -457,7 +472,7 @@ export function HistoryTable() {
|
||||
|
||||
{/* Far right - Actions */}
|
||||
<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()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
@@ -465,11 +480,11 @@ export function HistoryTable() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
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"
|
||||
onClick={() => handleRetry(gen.id)}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
<RotateCcw className="h-2 w-2" />
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
@@ -478,11 +493,11 @@ export function HistoryTable() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
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"
|
||||
disabled={isGenerating}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<MoreHorizontal className="h-2 w-2" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
@@ -517,24 +532,42 @@ export function HistoryTable() {
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDeleteClick(gen.id, gen.profile_name)}
|
||||
disabled={deleteGeneration.isPending}
|
||||
className="text-destructive focus:text-destructive"
|
||||
// className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</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 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
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"
|
||||
onClick={() =>
|
||||
setExpandedVersionsId(isVersionsExpanded ? null : gen.id)
|
||||
}
|
||||
>
|
||||
<Layers className="h-4 w-4" />
|
||||
<GalleryVerticalEnd className="h-2 w-2" />
|
||||
</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 { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -79,7 +79,7 @@ export function ProfileCard({ profile }: ProfileCardProps) {
|
||||
<Card
|
||||
className={cn(
|
||||
'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}
|
||||
tabIndex={0}
|
||||
@@ -89,8 +89,8 @@ export function ProfileCard({ profile }: ProfileCardProps) {
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<CardHeader className="p-3 pb-2">
|
||||
<CardTitle className="flex items-center 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">
|
||||
<CardTitle className="flex items-start gap-1.5 text-base font-medium">
|
||||
<div className="h-6 w-6 mt-[3px] rounded-full bg-muted flex items-center justify-center shrink-0 overflow-hidden">
|
||||
{avatarUrl && !avatarError ? (
|
||||
<img
|
||||
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">
|
||||
{profile.description || 'No description'}
|
||||
</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">
|
||||
{profile.language}
|
||||
</Badge>
|
||||
{profile.effects_chain && profile.effects_chain.length > 0 && (
|
||||
<Sparkles className="h-3.5 w-3.5 text-accent" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-0.5 justify-end items-end mt-auto">
|
||||
<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
|
||||
async listHistory(query?: HistoryQuery): Promise<HistoryListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
@@ -72,6 +72,7 @@ export interface GenerationResponse {
|
||||
model_size?: string;
|
||||
status: 'generating' | 'completed' | 'failed';
|
||||
error?: string;
|
||||
is_favorited?: boolean;
|
||||
created_at: string;
|
||||
versions?: GenerationVersionResponse[];
|
||||
active_version_id?: string;
|
||||
|
||||
@@ -54,6 +54,7 @@ class Generation(Base):
|
||||
model_size = Column(String, nullable=True)
|
||||
status = Column(String, default="completed") # generating, completed, failed
|
||||
error = Column(Text, nullable=True)
|
||||
is_favorited = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
@@ -375,6 +376,15 @@ def _run_migrations(engine):
|
||||
conn.commit()
|
||||
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
|
||||
# (populate after tables are created, handled in init_db)
|
||||
|
||||
|
||||
@@ -222,6 +222,7 @@ async def list_generations(
|
||||
model_size=generation.model_size,
|
||||
status=generation.status or "completed",
|
||||
error=generation.error,
|
||||
is_favorited=bool(generation.is_favorited),
|
||||
created_at=generation.created_at,
|
||||
versions=versions,
|
||||
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}")
|
||||
async def delete_generation(
|
||||
generation_id: str,
|
||||
|
||||
@@ -79,6 +79,7 @@ class GenerationResponse(BaseModel):
|
||||
model_size: Optional[str] = None
|
||||
status: str = "completed"
|
||||
error: Optional[str] = None
|
||||
is_favorited: bool = False
|
||||
created_at: datetime
|
||||
versions: Optional[List["GenerationVersionResponse"]] = None
|
||||
active_version_id: Optional[str] = None
|
||||
@@ -110,6 +111,7 @@ class HistoryResponse(BaseModel):
|
||||
model_size: Optional[str] = None
|
||||
status: str = "completed"
|
||||
error: Optional[str] = None
|
||||
is_favorited: bool = False
|
||||
created_at: datetime
|
||||
versions: Optional[List["GenerationVersionResponse"]] = None
|
||||
active_version_id: Optional[str] = None
|
||||
|
||||
Reference in New Issue
Block a user