AudioForge / frontend /src /components /generation-card.tsx
AudioForge Deploy
chore: pre-deployment polish & fixes
5bf2d26
"use client";
import { useState, useEffect } from "react";
import { formatDistanceToNow } from "date-fns";
import { Loader2, CheckCircle2, XCircle } from "lucide-react";
import { type GenerationResponse } from "@/lib/api";
import { Progress } from "@/components/ui/progress";
import { AudioPlayer } from "@/components/audio-player";
import { cn } from "@/lib/utils";
import { useGenerationWebSocket } from "@/hooks/use-websocket";
interface GenerationCardProps {
generation: GenerationResponse;
}
export function GenerationCard({ generation: initialGeneration }: GenerationCardProps) {
const [generation, setGeneration] = useState(initialGeneration);
const [isPlaying, setIsPlaying] = useState(false);
// Sync prop changes to state (e.g. from list polling refetch)
useEffect(() => {
// Only update if the prop is "newer" or different, but generally we want to trust the prop
// However, if we have a WS connection, that might be more up to date.
// For simplicity, we'll let the WS override if active, but if the prop changes to completed, take it.
if (initialGeneration.status === 'completed' && generation.status !== 'completed') {
setGeneration(initialGeneration);
}
}, [initialGeneration, generation.status]);
const isProcessing = generation.status === "processing" || generation.status === "pending";
// WebSocket Integration
const { lastMessage } = useGenerationWebSocket(
generation.id,
isProcessing
);
useEffect(() => {
if (lastMessage) {
setGeneration(prev => ({
...prev,
status: lastMessage.status,
audio_path: lastMessage.audio_url || prev.audio_path, // Backend sends audio_url on complete
error_message: lastMessage.error || prev.error_message,
}));
}
}, [lastMessage]);
const statusConfig = {
pending: {
icon: Loader2,
label: "Queued",
color: "text-muted-foreground",
bgColor: "bg-muted",
},
processing: {
icon: Loader2,
label: lastMessage?.message || "Generating...",
color: "text-primary",
bgColor: "bg-primary/10",
},
completed: {
icon: CheckCircle2,
label: "Completed",
color: "text-green-600",
bgColor: "bg-green-100 dark:bg-green-900/20",
},
failed: {
icon: XCircle,
label: "Failed",
color: "text-destructive",
bgColor: "bg-destructive/10",
},
};
const config = statusConfig[generation.status] || statusConfig.pending;
const StatusIcon = config.icon;
const getAudioUrl = () => {
if (!generation.audio_path) return "";
// Use the same API base URL as the rest of the app
const apiBase = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
// If audio_path already contains the full URL (from WS), use it, otherwise build it
return generation.audio_path.startsWith('http') || generation.audio_path.startsWith('/')
? (generation.audio_path.startsWith('/') ? `${apiBase}${generation.audio_path}` : generation.audio_path)
: `${apiBase}/api/v1/generations/${generation.id}/audio`;
};
return (
<div
className={cn(
"bg-card border rounded-lg p-6 shadow-sm hover:shadow-lg transition-all duration-300 group/card",
isPlaying ? "border-primary ring-1 ring-primary shadow-[0_0_15px_rgba(var(--primary),0.2)] scale-[1.02]" : "hover:scale-[1.01]"
)}
>
<div className="flex flex-col gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<div className={cn("p-1.5 rounded-full", config.bgColor)}>
<StatusIcon
className={cn("h-4 w-4", config.color, {
"animate-spin": isProcessing,
})}
/>
</div>
<span className={cn("text-sm font-medium", config.color)}>
{config.label}
</span>
{generation.created_at && (
<span className="text-xs text-muted-foreground ml-auto">
{formatDistanceToNow(new Date(generation.created_at), {
addSuffix: true,
})}
</span>
)}
</div>
<p className="text-sm text-muted-foreground mb-3 line-clamp-2">
{generation.prompt ||
generation.metadata?.analysis?.original_prompt ||
generation.metadata?.prompt ||
"No prompt available"}
</p>
{generation.metadata?.analysis && (
<div className="flex flex-wrap gap-2 mb-3">
{generation.metadata.analysis.style && (
<span className="px-3 py-1 text-xs bg-gradient-to-r from-primary/10 to-purple-500/10 border border-primary/20 rounded-full font-medium hover:scale-105 transition-transform">
🎸 {generation.metadata.analysis.style}
</span>
)}
{generation.metadata.analysis.tempo != null && (
<span className="px-3 py-1 text-xs bg-gradient-to-r from-blue-500/10 to-cyan-500/10 border border-blue-500/20 rounded-full font-medium hover:scale-105 transition-transform">
⚡ {generation.metadata.analysis.tempo} BPM
</span>
)}
{generation.metadata.analysis.mood && (
<span className="px-3 py-1 text-xs bg-gradient-to-r from-purple-500/10 to-pink-500/10 border border-purple-500/20 rounded-full font-medium hover:scale-105 transition-transform">
✨ {generation.metadata.analysis.mood}
</span>
)}
</div>
)}
{isProcessing && (
<div className="mt-3 space-y-1">
<Progress value={lastMessage?.progress} className="h-1" />
<div className="flex justify-between text-[10px] text-muted-foreground">
<span>{lastMessage?.stage?.replace('_', ' ') || 'Initializing'}</span>
<span>{lastMessage?.progress || 0}%</span>
</div>
</div>
)}
{generation.error_message && (
<p className="text-sm text-destructive mt-2">
{generation.error_message}
</p>
)}
{generation.processing_time_seconds && (
<p className="text-xs text-muted-foreground mt-2">
⚡ Processed in {generation.processing_time_seconds.toFixed(1)}s
</p>
)}
</div>
{generation.status === "completed" && generation.audio_path && (
<div className="mt-2 pt-2 border-t">
<AudioPlayer
src={getAudioUrl()}
onPlayStateChange={setIsPlaying}
/>
</div>
)}
</div>
</div>
);
}