RASAD-HU / frontend-src /src /components /rasad /LiveVisualPanel.tsx
aboodhaymouni
feat: full RASAD platform deploy — latest version
ed3a9a0
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<Risk, string> = {
verified: "hsl(var(--verified))",
suspicious: "hsl(var(--warning))",
risky: "hsl(var(--primary))",
neutral: "hsl(var(--muted-foreground))",
};
const RISK_LABEL: Record<Risk, string> = {
verified: "موثوق",
suspicious: "مشكوك",
risky: "خطر",
neutral: "قيد التصنيف",
};
const RISK_ICON: Record<Risk, typeof Shield> = {
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<LiveStatsResponse | null>(statsProp);
const [tickerItems, setTickerItems] = useState<LiveItem[]>([]);
// 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<Risk, number> = { 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 (
<div className="grid gap-4 lg:grid-cols-12">
{/* RISK DONUT */}
<div className="surface-card relative overflow-hidden rounded-2xl p-5 lg:col-span-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-bold text-foreground">توزيع المخاطر</h3>
<span className="mono text-[10px] uppercase tracking-widest text-muted-foreground">
{total} عنصر
</span>
</div>
<RiskDonut counts={counts} total={total} centerValue={danger} classified={classified} dangerPctOfClassified={dangerPctOfClassified} />
<ul className="mt-4 space-y-1.5">
{RISK_ORDER.map((k) => {
const v = counts[k];
const pct = total > 0 ? Math.round((v / total) * 100) : 0;
const Icon = RISK_ICON[k];
return (
<li
key={k}
className="flex items-center justify-between rounded-md border border-border px-2.5 py-1.5 text-[11px]"
style={{ background: "hsl(var(--surface-elev-2))" }}
>
<span className="inline-flex items-center gap-2">
<Icon
className={`h-3 w-3 ${k === "neutral" ? "animate-spin-slow" : ""}`}
style={{ color: RISK_TONES[k] }}
/>
<span className="font-semibold text-foreground/90">{RISK_LABEL[k]}</span>
</span>
<span className="flex items-center gap-2">
<span className="mono text-[10px] text-muted-foreground">{v}</span>
<span className="mono w-9 text-end font-bold" style={{ color: RISK_TONES[k] }}>
{pct}%
</span>
</span>
</li>
);
})}
</ul>
</div>
{/* TOP SOURCES */}
<div className="surface-card relative overflow-hidden rounded-2xl p-5 lg:col-span-5">
<div className="mb-3 flex items-center justify-between">
<h3 className="inline-flex items-center gap-2 text-sm font-bold text-foreground">
<Globe2 className="h-4 w-4 text-primary" />
أكثر المصادر نشاطاً
</h3>
<span className="mono text-[10px] uppercase tracking-widest text-muted-foreground">
آخر تحديث
</span>
</div>
<ul className="space-y-2.5">
{sourceEntries.length === 0 && (
<li className="rounded-lg border border-dashed border-border p-4 text-center text-xs text-muted-foreground">
لم تصل بيانات بعد — البث المباشر يبدأ خلال دقائق.
</li>
)}
{sourceEntries.map(([name, count], idx) => {
const pct = Math.round((count / sourceMax) * 100);
return (
<li key={name} className="space-y-1">
<div className="flex items-center justify-between text-[11px]">
<span className="inline-flex items-center gap-1.5 font-semibold text-foreground/90">
<span className="mono w-5 text-[9px] text-muted-foreground">
{String(idx + 1).padStart(2, "0")}
</span>
{name}
</span>
<span className="mono font-bold text-primary">{count}</span>
</div>
<div
className="relative h-1.5 overflow-hidden rounded-full"
style={{ background: "hsl(var(--surface-elev-2))" }}
>
<div
className="meter-bar absolute inset-y-0 right-0 rounded-full"
style={{
["--fill" as never]: `${Math.max(4, pct)}%`,
width: `${Math.max(4, pct)}%`,
background: `linear-gradient(90deg, hsl(var(--primary)), hsl(var(--primary-glow)))`,
}}
/>
</div>
</li>
);
})}
</ul>
</div>
{/* PULSE */}
<div className="surface-card relative overflow-hidden rounded-2xl p-5 lg:col-span-3">
<div className="mb-2 flex items-center justify-between">
<h3 className="inline-flex items-center gap-2 text-sm font-bold text-foreground">
<TrendingUp className="h-4 w-4 text-primary" />
نبض الموقع
</h3>
</div>
<div className="space-y-2">
<PulseStat
Icon={Activity}
label="دورات مكتملة"
value={stats?.cycles_completed ?? 0}
tone="hsl(var(--info))"
/>
<PulseStat
Icon={Shield}
label="موثوق"
value={counts.verified}
tone="hsl(var(--verified))"
/>
<PulseStat
Icon={AlertTriangle}
label="مشكوك"
value={counts.suspicious}
tone="hsl(var(--warning))"
/>
<PulseStat
Icon={ShieldAlert}
label="عالي الخطورة"
value={counts.risky}
tone="hsl(var(--primary))"
/>
<PulseStat
Icon={Loader2}
label="قيد التصنيف"
value={counts.neutral}
tone="hsl(var(--muted-foreground))"
spin
/>
</div>
</div>
{/* MARQUEE TICKER */}
<div className="surface-card relative overflow-hidden rounded-2xl py-2 lg:col-span-12">
<div
className="pointer-events-none absolute inset-y-0 right-0 z-10 w-32"
style={{
background:
"linear-gradient(to left, hsl(var(--surface-elev)), hsl(var(--surface-elev) / 0))",
}}
/>
<div
className="pointer-events-none absolute inset-y-0 left-0 z-10 w-32"
style={{
background:
"linear-gradient(to right, hsl(var(--surface-elev)), hsl(var(--surface-elev) / 0))",
}}
/>
<div className="absolute right-3 top-1/2 z-20 -translate-y-1/2">
<span className="inline-flex items-center gap-1.5 rounded-full border border-primary/40 bg-background/85 px-2.5 py-1 text-[10px] font-bold uppercase tracking-widest text-primary backdrop-blur">
<span className="relative inline-flex h-1.5 w-1.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-75" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-primary" />
</span>
LIVE
</span>
</div>
<div className="overflow-hidden">
<div className="ticker-marquee flex w-max gap-6 px-32 py-2">
{tickerItems.length === 0 && (
<span className="mono text-[11px] text-muted-foreground">
جاري سحب الأخبار من المصادر…
</span>
)}
{[...tickerItems, ...tickerItems].map((it, i) => {
const tone = RISK_TONES[(it.risk_label as Risk) ?? "neutral"] ?? RISK_TONES.neutral;
return (
<span
key={`${it.id}-${i}`}
className="inline-flex shrink-0 items-center gap-2 text-[12px] text-foreground/85"
dir="auto"
>
<span
className="h-1.5 w-1.5 shrink-0 rounded-full"
style={{ background: tone }}
/>
<span className="font-bold text-muted-foreground">{it.source_name}</span>
<span aria-hidden className="text-muted-foreground/60"></span>
<span className="max-w-[480px] truncate">{it.title}</span>
</span>
);
})}
</div>
</div>
</div>
</div>
);
};
const PulseStat = ({
Icon,
label,
value,
tone,
spin,
}: {
Icon: typeof Activity;
label: string;
value: number;
tone: string;
spin?: boolean;
}) => (
<div
className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2"
style={{ background: "hsl(var(--surface-elev-2))" }}
>
<span className="inline-flex items-center gap-2 text-[11px] font-semibold text-foreground/85">
<Icon
className={`h-3.5 w-3.5 ${spin ? "animate-spin-slow" : ""}`}
style={{ color: tone }}
/>
{label}
</span>
<span className="mono text-lg font-extrabold" style={{ color: tone }}>{value}</span>
</div>
);
const RiskDonut = ({
counts,
total,
centerValue,
classified,
dangerPctOfClassified,
}: {
counts: Record<Risk, number>;
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 (
<div className="relative mx-auto grid place-items-center" style={{ width: size, height: size }}>
<svg width={size} height={size} className="-rotate-90">
<circle
cx={center}
cy={center}
r={r}
fill="none"
stroke="hsl(var(--surface-elev-2))"
strokeWidth={stroke}
/>
{!empty &&
segments
.filter((s) => s.pct > 0)
.map((s) => (
<circle
key={s.k}
cx={center}
cy={center}
r={r}
fill="none"
stroke={RISK_TONES[s.k]}
strokeWidth={stroke}
strokeDasharray={`${c * s.pct} ${c}`}
strokeDashoffset={-c * s.offset}
strokeLinecap="butt"
style={{ transition: "stroke-dasharray 700ms ease" }}
/>
))}
</svg>
<div className="absolute inset-0 grid place-items-center text-center">
{empty ? (
<div>
<div className="mono text-[10px] uppercase tracking-widest text-muted-foreground">جاري التحميل</div>
<Loader2 className="mx-auto mt-1 h-5 w-5 animate-spin text-primary" />
</div>
) : classified === 0 ? (
<div>
<div className="mono text-[9px] uppercase tracking-widest text-muted-foreground">
قيد التصنيف
</div>
<div className="mono mt-0.5 text-2xl font-extrabold text-foreground leading-none">
{counts.neutral}
</div>
<div className="mt-1 text-[9px] text-muted-foreground">عنصر جديد</div>
</div>
) : (
<div>
<div className="mono text-[10px] uppercase tracking-widest text-muted-foreground">
خطورة
</div>
<div
className="mono text-3xl font-extrabold leading-none"
style={{ color: "hsl(var(--primary))" }}
>
{dangerPctOfClassified}%
</div>
<div className="mt-1 text-[10px] text-muted-foreground">
{centerValue}/{classified} مصنّفة
</div>
</div>
)}
</div>
</div>
);
};