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:
Jamie Pine
2026-03-14 09:10:56 -07:00
parent 00c5b75ffb
commit e8d54d52d3
9 changed files with 85 additions and 18 deletions
+1 -4
View File
@@ -35,10 +35,7 @@ target/
Thumbs.db
# Data (user-generated)
data/profiles/*
data/generations/*
data/projects/*
data/voicebox.db
data/
!data/.gitkeep
# Logs
+42 -9
View File
@@ -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
+6
View File
@@ -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();
+1
View File
@@ -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;
+10
View File
@@ -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)
+1
View File
@@ -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,
+14
View File
@@ -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,
+2
View File
@@ -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