Download apps/web/components/dynamic/DynamicReport.tsx from Sandy-sys/finai: direct link, hf CLI and curl.
- Browser
- Download file 14.2 kB
-
https://huggingface.co/spaces/Sandy-sys/finai/resolve/main/apps/web/components/dynamic/DynamicReport.tsx
- Command line
-
hf download hf://spaces/Sandy-sys/finai/apps/web/components/dynamic/DynamicReport.tsx
-
curl -L -o DynamicReport.tsx https://huggingface.co/spaces/Sandy-sys/finai/resolve/main/apps/web/components/dynamic/DynamicReport.tsx
14.2 kB
| "use client"; | |
| import { useState, useMemo } from "react"; | |
| import { motion, AnimatePresence } from "framer-motion"; | |
| import { Download, Copy, Check, Info, ChevronDown, ChevronUp, BarChart3, Eye, EyeOff, Maximize2 } from "lucide-react"; | |
| import { ChartRenderer } from "@/components/charts/registry"; | |
| import type { UIPlan, ChartComponent } from "./types"; | |
| import { CitationsRail } from "@/components/dynamic/CitationsRail"; | |
| import { renderRichText } from "./rich"; | |
| import { copyToClipboard, downloadFile, rowsToCsv, safeFilename } from "@/lib/export"; | |
| const MOOD_TINTS: Record<string, string> = { | |
| calm: "from-mb-paper via-white to-mb-amber-50/30", | |
| urgent: "from-rose-50 via-white to-rose-50/20", | |
| celebratory: "from-yellow-50 via-white to-amber-50/30", | |
| investigative: "from-sky-50/60 via-white to-sky-50/20", | |
| neutral: "from-mb-amber-50/60 via-white to-mb-paper", | |
| }; | |
| const PERSONA_ICONS: Record<string, string> = { | |
| analyst: "π", investigator: "π", executive: "π―", | |
| storyteller: "π", auditor: "β ", strategist: "π§", | |
| }; | |
| const DENSITY_GAP: Record<string, string> = { | |
| compact: "gap-3", comfortable: "gap-4", spacious: "gap-5", | |
| }; | |
| export function DynamicReport({ plan }: { plan: UIPlan }) { | |
| const [expandedCards, setExpandedCards] = useState<Set<string>>(new Set()); | |
| const [hiddenCards, setHiddenCards] = useState<Set<string>>(new Set()); | |
| const cellById = useMemo( | |
| () => new Map(plan.layout.grid.map((g) => [g.component, g])), | |
| [plan.layout.grid] | |
| ); | |
| const persona = plan.persona ?? { | |
| name: "analyst", accent: "#F47C2C", mood: "neutral", density: "comfortable", | |
| }; | |
| const accent = persona.accent ?? "#F47C2C"; | |
| const mood = MOOD_TINTS[persona.mood ?? "neutral"] ?? MOOD_TINTS.neutral; | |
| const gap = DENSITY_GAP[persona.density ?? "comfortable"] ?? DENSITY_GAP.comfortable; | |
| const icon = PERSONA_ICONS[persona.name ?? "analyst"] ?? "π"; | |
| // Group components by semantic role | |
| const { kpis, charts, tables, metas } = useMemo(() => { | |
| const k: ChartComponent[] = []; | |
| const c: ChartComponent[] = []; | |
| const t: ChartComponent[] = []; | |
| const m: ChartComponent[] = []; | |
| for (const comp of plan.components) { | |
| if (hiddenCards.has(comp.id)) continue; | |
| if (comp.type === "kpi") k.push(comp); | |
| else if (comp.type === "data-table") t.push(comp); | |
| else if (comp.type === "raw-sql" || comp.type === "callout" || comp.type === "narrative") m.push(comp); | |
| else c.push(comp); | |
| } | |
| return { kpis: k, charts: c, tables: t, metas: m }; | |
| }, [plan.components, hiddenCards]); | |
| const visibleCount = plan.components.length - hiddenCards.size; | |
| const toggleExpand = (id: string) => { | |
| setExpandedCards((prev) => { | |
| const next = new Set(prev); | |
| next.has(id) ? next.delete(id) : next.add(id); | |
| return next; | |
| }); | |
| }; | |
| const toggleHide = (id: string) => { | |
| setHiddenCards((prev) => { | |
| const next = new Set(prev); | |
| next.has(id) ? next.delete(id) : next.add(id); | |
| return next; | |
| }); | |
| }; | |
| return ( | |
| <div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_320px] overflow-x-hidden"> | |
| <div className="min-w-0 space-y-6"> | |
| {/* ββ Narrative Hero βββββββββββββββββββββββββββββββββββββββββ */} | |
| {plan.narrative && plan.narrative.trim().length > 20 ? ( | |
| <motion.div | |
| initial={{ opacity: 0, y: 8 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| transition={{ delay: 0.05, duration: 0.4 }} | |
| className={`rounded-2xl border bg-gradient-to-br ${mood} p-6 shadow-mb-card backdrop-blur-sm`} | |
| style={{ borderColor: `${accent}25` }} | |
| > | |
| <div className="mb-4 flex items-center gap-3"> | |
| <div | |
| className="flex h-8 w-8 items-center justify-center rounded-lg text-sm shadow-sm" | |
| style={{ backgroundColor: `${accent}15` }} | |
| > | |
| {icon} | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <span | |
| className="rounded-full px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-[0.15em] text-white shadow-sm" | |
| style={{ backgroundColor: accent }} | |
| > | |
| {persona.name} | |
| </span> | |
| <span className="text-[10px] uppercase tracking-[0.12em] text-mb-slate/45 font-medium"> | |
| {persona.mood} Β· {visibleCount} panels | |
| </span> | |
| </div> | |
| <div className="ml-auto flex h-1 flex-1 max-w-24 rounded-full overflow-hidden bg-mb-amber-100/60"> | |
| <div className="rounded-full" style={{ width: "100%", backgroundImage: `linear-gradient(90deg, ${accent}, #FFC72C)` }} /> | |
| </div> | |
| </div> | |
| <div className="font-serif text-[17px] leading-[1.7] text-mb-ink/90"> | |
| {renderRichText(plan.narrative, accent)} | |
| </div> | |
| {plan.components.some((c) => c.why) && ( | |
| <div className="mt-4 flex flex-wrap gap-1.5"> | |
| {plan.components | |
| .filter((c) => c.why) | |
| .slice(0, 4) | |
| .map((c) => ( | |
| <span key={c.id} className="rounded-full bg-white/80 px-2.5 py-1 text-[10px] text-mb-slate/70 border border-mb-amber-200/50 shadow-sm"> | |
| {c.why!.slice(0, 60)}{c.why!.length > 60 ? "β¦" : ""} | |
| </span> | |
| ))} | |
| </div> | |
| )} | |
| </motion.div> | |
| ) : null} | |
| {/* ββ KPI Strip βββββββββββββββββββββββββββββββββββββββββββ */} | |
| {kpis.length > 0 && ( | |
| <motion.div | |
| initial={{ opacity: 0 }} | |
| animate={{ opacity: 1 }} | |
| transition={{ delay: 0.1 }} | |
| className={`grid gap-3 ${ | |
| kpis.length === 1 ? "grid-cols-1" : | |
| kpis.length === 2 ? "grid-cols-1 sm:grid-cols-2" : | |
| kpis.length === 3 ? "grid-cols-1 sm:grid-cols-3" : | |
| "grid-cols-2 sm:grid-cols-2 lg:grid-cols-4" | |
| }`} | |
| > | |
| {kpis.slice(0, 8).map((c, i) => ( | |
| <motion.div key={c.id} initial={{ opacity: 0, scale: 0.97 }} animate={{ opacity: 1, scale: 1 }} | |
| transition={{ delay: 0.1 + i * 0.04 }}> | |
| <ChartRenderer component={c} accent={accent} /> | |
| </motion.div> | |
| ))} | |
| </motion.div> | |
| )} | |
| {/* ββ Charts Grid βββββββββββββββββββββββββββββββββββββββββ */} | |
| <div className={`grid grid-cols-12 ${gap}`}> | |
| <AnimatePresence mode="popLayout"> | |
| {charts.map((c, i) => { | |
| const cell = cellById.get(c.id); | |
| const isExpanded = expandedCards.has(c.id); | |
| const w = isExpanded ? 12 : (cell?.w ?? 6); | |
| return ( | |
| <motion.div | |
| key={c.id} | |
| layout | |
| initial={{ opacity: 0, y: 18, scale: 0.96 }} | |
| animate={{ opacity: 1, y: 0, scale: 1 }} | |
| exit={{ opacity: 0, scale: 0.92 }} | |
| transition={{ delay: 0.12 + i * 0.06, duration: 0.45, type: "spring", stiffness: 260, damping: 24, layout: { duration: 0.3 } }} | |
| className="group/card rounded-2xl border bg-white/95 shadow-mb-card backdrop-blur-sm transition-all duration-300 hover:shadow-lg hover:shadow-mb-orange/8" | |
| style={{ | |
| gridColumn: `span ${w} / span ${w}`, | |
| minHeight: (cell?.h ?? 5) * 64, | |
| borderColor: `${accent}18`, | |
| }} | |
| > | |
| <div className="p-4"> | |
| <CardHeader | |
| component={c} | |
| accent={accent} | |
| expanded={isExpanded} | |
| onToggleExpand={() => toggleExpand(c.id)} | |
| onToggleHide={() => toggleHide(c.id)} | |
| /> | |
| <ChartRenderer component={c} accent={accent} /> | |
| </div> | |
| </motion.div> | |
| ); | |
| })} | |
| </AnimatePresence> | |
| </div> | |
| {/* ββ Callouts / Metas ββββββββββββββββββββββββββββββββββββ */} | |
| {metas.length > 0 && ( | |
| <div className="space-y-3"> | |
| {metas.map((c, i) => ( | |
| <motion.div | |
| key={c.id} | |
| initial={{ opacity: 0, y: 8 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| transition={{ delay: 0.15 + i * 0.03 }} | |
| > | |
| <CardHeader component={c} accent={accent} /> | |
| <ChartRenderer component={c} accent={accent} /> | |
| </motion.div> | |
| ))} | |
| </div> | |
| )} | |
| {/* ββ Data Tables βββββββββββββββββββββββββββββββββββββββββ */} | |
| {tables.length > 0 && ( | |
| <div className="space-y-4"> | |
| <div className="flex items-center gap-2 text-[11px] uppercase tracking-[0.12em] text-mb-slate/50 font-semibold"> | |
| <BarChart3 className="h-3.5 w-3.5" /> | |
| Source Data | |
| </div> | |
| {tables.map((c, i) => ( | |
| <motion.div | |
| key={c.id} | |
| initial={{ opacity: 0 }} | |
| animate={{ opacity: 1 }} | |
| transition={{ delay: 0.2 + i * 0.03 }} | |
| className="group/card rounded-2xl border bg-white/90 p-4 shadow-sm" | |
| style={{ borderColor: `${accent}15` }} | |
| > | |
| <CardHeader component={c} accent={accent} /> | |
| <ChartRenderer component={c} accent={accent} /> | |
| </motion.div> | |
| ))} | |
| </div> | |
| )} | |
| {/* ββ Hidden panels recovery ββββββββββββββββββββββββββββββ */} | |
| {hiddenCards.size > 0 && ( | |
| <div className="flex items-center gap-2 text-[11px] text-mb-slate/50"> | |
| <EyeOff className="h-3 w-3" /> | |
| {hiddenCards.size} panel{hiddenCards.size > 1 ? "s" : ""} hidden Β·{" "} | |
| <button onClick={() => setHiddenCards(new Set())} className="underline hover:text-mb-amber-700"> | |
| Show all | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| <CitationsRail citations={plan.citations} accent={accent} /> | |
| </div> | |
| ); | |
| } | |
| function CardHeader({ | |
| component, accent, expanded, onToggleExpand, onToggleHide, | |
| }: { | |
| component: ChartComponent; | |
| accent: string; | |
| expanded?: boolean; | |
| onToggleExpand?: () => void; | |
| onToggleHide?: () => void; | |
| }) { | |
| const [copied, setCopied] = useState(false); | |
| const data = component.data; | |
| const isTabular = Array.isArray(data) && data.length > 0 && typeof data[0] === "object"; | |
| const isSql = component.type === "raw-sql"; | |
| async function onCopySql() { | |
| const sql = (data as any)?.sql ?? ""; | |
| if (!sql) return; | |
| if (await copyToClipboard(sql)) { | |
| setCopied(true); | |
| setTimeout(() => setCopied(false), 1500); | |
| } | |
| } | |
| function onDownloadCsv() { | |
| if (!isTabular) return; | |
| const csv = rowsToCsv(data as any[]); | |
| if (!csv) return; | |
| downloadFile(safeFilename(component.title || component.id, "csv"), csv); | |
| } | |
| if (!component.title && !component.subtitle && !component.why && !isTabular && !isSql) { | |
| return null; | |
| } | |
| return ( | |
| <div className="mb-3 flex items-start justify-between gap-2"> | |
| <div className="min-w-0 flex-1"> | |
| {component.title ? ( | |
| <h3 className="truncate text-[13px] font-semibold text-mb-ink" title={component.title}> | |
| <span | |
| className="mr-1.5 inline-block h-1.5 w-1.5 rounded-full align-middle" | |
| style={{ backgroundColor: accent }} | |
| /> | |
| {component.title} | |
| </h3> | |
| ) : null} | |
| {component.subtitle ? ( | |
| <p className="mt-0.5 truncate text-[11px] text-mb-slate/60">{component.subtitle}</p> | |
| ) : null} | |
| </div> | |
| <div className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity duration-200 group-hover/card:opacity-100"> | |
| {component.why && ( | |
| <span title={component.why} className="rounded-md p-1 text-mb-slate/50 hover:bg-mb-amber-50 hover:text-mb-amber-700 cursor-help"> | |
| <Info className="h-3.5 w-3.5" /> | |
| </span> | |
| )} | |
| {onToggleExpand && ( | |
| <button onClick={onToggleExpand} title={expanded ? "Collapse" : "Expand"} className="rounded-md p-1 text-mb-slate/50 hover:bg-mb-amber-50 hover:text-mb-amber-700"> | |
| <Maximize2 className="h-3.5 w-3.5" /> | |
| </button> | |
| )} | |
| {onToggleHide && ( | |
| <button onClick={onToggleHide} title="Hide panel" className="rounded-md p-1 text-mb-slate/50 hover:bg-mb-amber-50 hover:text-mb-amber-700"> | |
| <EyeOff className="h-3 w-3" /> | |
| </button> | |
| )} | |
| {isSql && ( | |
| <button onClick={onCopySql} title="Copy SQL" className="rounded-md p-1 text-mb-slate/50 hover:bg-mb-amber-50 hover:text-mb-amber-700"> | |
| {copied ? <Check className="h-3.5 w-3.5 text-emerald-600" /> : <Copy className="h-3.5 w-3.5" />} | |
| </button> | |
| )} | |
| {isTabular && ( | |
| <button onClick={onDownloadCsv} title="Download CSV" className="rounded-md p-1 text-mb-slate/50 hover:bg-mb-amber-50 hover:text-mb-amber-700"> | |
| <Download className="h-3.5 w-3.5" /> | |
| </button> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| } | |