96 lines
3.2 KiB
TypeScript
96 lines
3.2 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
|
|
import { useAuth } from "@/auth/clerk";
|
|
import { customFetch } from "@/api/mutator";
|
|
|
|
interface TickerItem {
|
|
id: string;
|
|
source: string;
|
|
message: string;
|
|
created_at: string;
|
|
}
|
|
|
|
function fmtRelative(isoString: string): string {
|
|
const diffMs = Date.now() - new Date(isoString).getTime();
|
|
const s = Math.round(diffMs / 1000);
|
|
if (s < 60) return `${s}s ago`;
|
|
const m = Math.floor(s / 60);
|
|
if (m < 60) return `${m}m ago`;
|
|
const h = Math.floor(m / 60);
|
|
if (h < 24) return `${h}h ago`;
|
|
return `${Math.floor(h / 24)}d ago`;
|
|
}
|
|
|
|
async function fetchTickerItems(limit = 20): Promise<TickerItem[]> {
|
|
const res = await customFetch<{ data: TickerItem[]; status: number }>(
|
|
`/api/v1/activity/ticker?limit=${limit}`,
|
|
{ method: "GET" },
|
|
);
|
|
if (res.status === 200) return res.data;
|
|
return [];
|
|
}
|
|
|
|
export function AgentActivityTicker() {
|
|
const { isSignedIn } = useAuth();
|
|
const [items, setItems] = useState<TickerItem[]>([]);
|
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
const data = await fetchTickerItems(20);
|
|
if (data.length > 0) setItems(data);
|
|
} catch {
|
|
// Silent — ticker is non-critical
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!isSignedIn) return;
|
|
void load();
|
|
intervalRef.current = setInterval(() => void load(), 30_000);
|
|
return () => {
|
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
|
};
|
|
}, [isSignedIn, load]);
|
|
|
|
if (items.length === 0) return null;
|
|
|
|
// Duplicate items for a seamless loop (animate-ticker moves -50%)
|
|
const display = [...items, ...items];
|
|
|
|
return (
|
|
<div className="border-t border-[color:var(--border)] bg-[color:var(--surface-muted)] overflow-hidden h-8 flex items-center">
|
|
<span className="shrink-0 px-3 select-none border-r border-[color:var(--border)] h-full flex items-center">
|
|
<span className="inline-flex items-center gap-1.5 bg-red-600 text-white text-[10px] font-black tracking-[0.2em] uppercase px-2 py-0.5 rounded-sm shadow-sm shadow-red-900/40">
|
|
<span className="animate-live-pulse w-1.5 h-1.5 rounded-full bg-white" />
|
|
Live
|
|
</span>
|
|
</span>
|
|
<div className="flex-1 overflow-hidden h-full flex items-center">
|
|
<div className="flex whitespace-nowrap animate-ticker">
|
|
{display.map((item, idx) => (
|
|
<span
|
|
key={`${item.id}-${idx}`}
|
|
className="inline-flex items-center gap-2 px-6 text-[11px]"
|
|
>
|
|
<span className="font-semibold text-[color:var(--accent)]">
|
|
{item.source}
|
|
</span>
|
|
<span className="text-[color:var(--text-muted)]">·</span>
|
|
<span className="text-[color:var(--text)]">{item.message}</span>
|
|
<span className="text-[color:var(--text-quiet)] tabular-nums ml-1">
|
|
{fmtRelative(item.created_at)}
|
|
</span>
|
|
<span className="mx-5 text-[color:var(--border)] select-none">
|
|
│
|
|
</span>
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|