"use client"; import { useEffect, useState, useCallback } from "react"; import { useTranslations, useLocale } from "next-intl"; import { motion, AnimatePresence } from "framer-motion"; import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { useSSE } from "@/hooks/use-sse"; interface FeedItem { id: string; type: "task" | "online" | "offline" | "registered"; clawName: string; city?: string; country?: string; summary?: string; durationMs?: number; timestamp: number; } export function ClawFeed() { const t = useTranslations("clawFeed"); const locale = useLocale(); const [items, setItems] = useState([]); const handleEvent = useCallback((event: { type: string; data: Record }) => { if (event.type === "task" || event.type === "online" || event.type === "offline" || event.type === "registered") { const newItem: FeedItem = { id: `${Date.now()}-${Math.random().toString(36).slice(2)}`, type: event.type as FeedItem["type"], clawName: (event.data.clawName as string) ?? "Unknown", city: event.data.city as string | undefined, country: event.data.country as string | undefined, summary: event.data.summary as string | undefined, durationMs: event.data.durationMs as number | undefined, timestamp: Date.now(), }; setItems((prev) => [newItem, ...prev].slice(0, 50)); } }, []); useSSE({ url: "/api/v1/stream", onEvent: handleEvent, }); // Load initial recent tasks + newest registrations useEffect(() => { const fetchRecent = async () => { try { const [taskRes, newestRes] = await Promise.all([ fetch("/api/v1/claws?limit=10"), fetch("/api/v1/claws?sort=newest&limit=5"), ]); const feedItems: FeedItem[] = []; if (taskRes.ok) { const data = await taskRes.json(); const taskItems: FeedItem[] = (data.claws ?? []) .filter((l: Record) => l.lastTask) .map((l: Record) => { const task = l.lastTask as Record; return { id: `init-task-${l.id}`, type: "task" as const, clawName: l.name as string, city: l.city as string, country: l.country as string, summary: task.summary as string, durationMs: task.durationMs as number, timestamp: new Date(task.timestamp as string).getTime(), }; }); feedItems.push(...taskItems); } if (newestRes.ok) { const data = await newestRes.json(); const regItems: FeedItem[] = (data.claws ?? []) .filter((l: Record) => l.createdAt) .map((l: Record) => ({ id: `init-reg-${l.id}`, type: "registered" as const, clawName: l.name as string, city: l.city as string, country: l.country as string, timestamp: new Date(l.createdAt as string).getTime(), })); feedItems.push(...regItems); } // Deduplicate by clawName+type, sort by timestamp desc const seen = new Set(); const unique = feedItems.filter((item) => { const key = `${item.clawName}-${item.type}`; if (seen.has(key)) return false; seen.add(key); return true; }); unique.sort((a, b) => b.timestamp - a.timestamp); setItems(unique); } catch { // will populate via SSE } }; fetchRecent(); }, []); const formatDuration = (ms?: number) => { if (!ms) return ""; if (ms < 1000) return `${ms}ms`; return `${(ms / 1000).toFixed(1)}s`; }; const getIcon = (type: string) => { switch (type) { case "task": return "⚡"; case "online": return "🟢"; case "offline": return "⭕"; case "registered": return "🦞"; default: return "🦞"; } }; const getDescription = (item: FeedItem) => { if (item.type === "registered" && item.city && item.country) { return t("joinedFrom", { city: item.city, country: item.country }); } return item.summary; }; return ( {t("title")} {items.length === 0 ? (

{t("waiting")}

) : ( items.map((item) => (
{getIcon(item.type)}
{item.clawName} {item.city && item.type !== "registered" && ( {item.city}, {item.country} )}
{getDescription(item) && (

{getDescription(item)}

)}
{item.durationMs && ( {formatDuration(item.durationMs)} )} {new Date(item.timestamp).toLocaleTimeString(locale)}
)) )}
); }