import { useEffect, useMemo, useState } from "react"; import { Activity, AlertTriangle, Globe2, Loader2, Shield, ShieldAlert, TrendingUp, } from "lucide-react"; import { getLiveFeed, getLiveStats, type LiveItem, type LiveStatsResponse, } from "@/lib/rasad-api"; const RISK_ORDER = ["verified", "suspicious", "risky", "neutral"] as const; type Risk = (typeof RISK_ORDER)[number]; const RISK_TONES: Record = { verified: "hsl(var(--verified))", suspicious: "hsl(var(--warning))", risky: "hsl(var(--primary))", neutral: "hsl(var(--muted-foreground))", }; const RISK_LABEL: Record = { verified: "موثوق", suspicious: "مشكوك", risky: "خطر", neutral: "قيد التصنيف", }; const RISK_ICON: Record = { verified: Shield, suspicious: AlertTriangle, risky: ShieldAlert, neutral: Loader2, }; interface Props { stats: LiveStatsResponse | null; } /** * Self-refreshing live-page visualization panel. * - Real donut of risk distribution (graceful when mostly neutral). * - Top sources with real counts + activity bars. * - Pulse stats showing the live cycle counts. * - Marquee of latest headlines from /api/v1/live/feed. */ export const LiveVisualPanel = ({ stats: statsProp }: Props) => { const [stats, setStats] = useState(statsProp); const [tickerItems, setTickerItems] = useState([]); // Sync with parent prop AND fetch independently so the panel always has data. useEffect(() => { if (statsProp) setStats(statsProp); }, [statsProp]); useEffect(() => { let cancelled = false; const tick = async () => { try { const [s, data] = await Promise.all([getLiveStats(), getLiveFeed({ limit: 12 })]); if (cancelled) return; setStats(s); setTickerItems(data.items); } catch { /* noop */ } }; tick(); const t = window.setInterval(tick, 15000); return () => { cancelled = true; window.clearInterval(t); }; }, []); // Normalize counts — ensure every risk key has a number so the donut always // renders the full ring (the legacy data only returned populated keys). const counts = useMemo(() => { const out: Record = { verified: 0, suspicious: 0, risky: 0, neutral: 0 }; for (const k of RISK_ORDER) out[k] = stats?.by_risk?.[k] ?? 0; return out; }, [stats?.by_risk]); const total = useMemo(() => counts.verified + counts.suspicious + counts.risky + counts.neutral, [ counts, ]); // Classified = anything that isn't still "neutral" / pending. const classified = counts.verified + counts.suspicious + counts.risky; const danger = counts.suspicious + counts.risky; const dangerPctOfClassified = classified > 0 ? Math.round((danger / classified) * 100) : 0; const sourceEntries = useMemo( () => Object.entries(stats?.by_source ?? {}) .sort((a, b) => b[1] - a[1]) .slice(0, 6), [stats?.by_source], ); const sourceMax = sourceEntries[0]?.[1] || 1; return (
{/* RISK DONUT */}

توزيع المخاطر

{total} عنصر
    {RISK_ORDER.map((k) => { const v = counts[k]; const pct = total > 0 ? Math.round((v / total) * 100) : 0; const Icon = RISK_ICON[k]; return (
  • {RISK_LABEL[k]} {v} {pct}%
  • ); })}
{/* TOP SOURCES */}

أكثر المصادر نشاطاً

آخر تحديث
    {sourceEntries.length === 0 && (
  • لم تصل بيانات بعد — البث المباشر يبدأ خلال دقائق.
  • )} {sourceEntries.map(([name, count], idx) => { const pct = Math.round((count / sourceMax) * 100); return (
  • {String(idx + 1).padStart(2, "0")} {name} {count}
  • ); })}
{/* PULSE */}

نبض الموقع

{/* MARQUEE TICKER */}
LIVE
{tickerItems.length === 0 && ( جاري سحب الأخبار من المصادر… )} {[...tickerItems, ...tickerItems].map((it, i) => { const tone = RISK_TONES[(it.risk_label as Risk) ?? "neutral"] ?? RISK_TONES.neutral; return ( {it.source_name} {it.title} ); })}
); }; const PulseStat = ({ Icon, label, value, tone, spin, }: { Icon: typeof Activity; label: string; value: number; tone: string; spin?: boolean; }) => (
{label} {value}
); const RiskDonut = ({ counts, total, centerValue, classified, dangerPctOfClassified, }: { counts: Record; total: number; centerValue: number; classified: number; dangerPctOfClassified: number; }) => { const size = 160; const r = 64; const stroke = 18; const c = 2 * Math.PI * r; const center = size / 2; let acc = 0; const segments: Array<{ k: Risk; pct: number; offset: number }> = []; for (const k of RISK_ORDER) { const v = counts[k]; const pct = total > 0 ? v / total : 0; segments.push({ k, pct, offset: acc }); acc += pct; } const empty = total === 0; return (
{!empty && segments .filter((s) => s.pct > 0) .map((s) => ( ))}
{empty ? (
جاري التحميل
) : classified === 0 ? (
قيد التصنيف
{counts.neutral}
عنصر جديد
) : (
خطورة
{dangerPctOfClassified}%
{centerValue}/{classified} مصنّفة
)}
); };