Spaces:
Build error
Build error
| "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> | |
| ); | |
| } | |