| 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; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| export const LiveVisualPanel = ({ stats: statsProp }: Props) => { |
| const [stats, setStats] = useState<LiveStatsResponse | null>(statsProp); |
| const [tickerItems, setTickerItems] = useState<LiveItem[]>([]); |
|
|
| |
| 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 { |
| |
| } |
| }; |
| tick(); |
| const t = window.setInterval(tick, 15000); |
| return () => { |
| cancelled = true; |
| window.clearInterval(t); |
| }; |
| }, []); |
|
|
| |
| |
| 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, |
| ]); |
|
|
| |
| 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> |
| ); |
| }; |
|
|