From e8d54d52d306e55b06f7959e7da20d33686f156e Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Sat, 14 Mar 2026 09:10:56 -0700 Subject: [PATCH] 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 --- .gitignore | 5 +- app/src/components/History/HistoryTable.tsx | 51 +++++++++++++++---- .../components/VoiceProfiles/ProfileCard.tsx | 13 +++-- app/src/lib/api/client.ts | 6 +++ app/src/lib/api/types.ts | 1 + backend/database.py | 10 ++++ backend/history.py | 1 + backend/main.py | 14 +++++ backend/models.py | 2 + 9 files changed, 85 insertions(+), 18 deletions(-) diff --git a/.gitignore b/.gitignore index 05f7ef0d..118735ec 100644 --- a/.gitignore +++ b/.gitignore @@ -35,10 +35,7 @@ target/ Thumbs.db # Data (user-generated) -data/profiles/* -data/generations/* -data/projects/* -data/voicebox.db +data/ !data/.gitkeep # Logs diff --git a/app/src/components/History/HistoryTable.tsx b/app/src/components/History/HistoryTable.tsx index 7900cd44..404a1914 100644 --- a/app/src/components/History/HistoryTable.tsx +++ b/app/src/components/History/HistoryTable.tsx @@ -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 */}
e.stopPropagation()} onClick={(e) => e.stopPropagation()} > @@ -465,11 +480,11 @@ export function HistoryTable() { ) : ( <> @@ -478,11 +493,11 @@ export function HistoryTable() { @@ -517,24 +532,42 @@ export function HistoryTable() { handleDeleteClick(gen.id, gen.profile_name)} disabled={deleteGeneration.isPending} - className="text-destructive focus:text-destructive" + // className="text-destructive focus:text-destructive" > Delete + {hasVersions && ( )} diff --git a/app/src/components/VoiceProfiles/ProfileCard.tsx b/app/src/components/VoiceProfiles/ProfileCard.tsx index 5cf4c931..77bb7c61 100644 --- a/app/src/components/VoiceProfiles/ProfileCard.tsx +++ b/app/src/components/VoiceProfiles/ProfileCard.tsx @@ -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) { - -
+ +
{avatarUrl && !avatarError ? ( {profile.description || 'No description'}

-
+
{profile.language} + {profile.effects_chain && profile.effects_chain.length > 0 && ( + + )}
{ + return this.request<{ is_favorited: boolean }>(`/history/${generationId}/favorite`, { + method: 'POST', + }); + } + // History async listHistory(query?: HistoryQuery): Promise { const params = new URLSearchParams(); diff --git a/app/src/lib/api/types.ts b/app/src/lib/api/types.ts index 95dc0f68..5a00c2f7 100644 --- a/app/src/lib/api/types.ts +++ b/app/src/lib/api/types.ts @@ -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; diff --git a/backend/database.py b/backend/database.py index 78afef9b..92332d71 100644 --- a/backend/database.py +++ b/backend/database.py @@ -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) diff --git a/backend/history.py b/backend/history.py index 8747206e..78da51ca 100644 --- a/backend/history.py +++ b/backend/history.py @@ -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, diff --git a/backend/main.py b/backend/main.py index 31ca5ad9..98970e51 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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, diff --git a/backend/models.py b/backend/models.py index 428f3803..b411000d 100644 --- a/backend/models.py +++ b/backend/models.py @@ -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