diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 671404c..f73e80c 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -325,118 +325,6 @@ } } -/* --- Stats ---------------------------------------------------------------- */ - -.stats { - display: flex; - width: 100%; - overflow-x: auto; - border: 1px solid var(--color-border); - border-radius: 0.75rem; - background-color: var(--color-muted); -} - -.stat { - display: inline-flex; - flex: 1 1 0; - flex-direction: column; - gap: 0.25rem; - padding: 1rem; - border-inline-start: 1px solid var(--color-border); -} - -.stat:first-child { - border-inline-start-width: 0; -} - -.stat-title { - font-size: 0.6875rem; - font-weight: 600; - letter-spacing: 0.05em; - text-transform: uppercase; - color: var(--color-brand-offwhite-muted); - line-height: 1.3; -} - -.stat-value { - font-size: 1.375rem; - font-weight: 700; - font-variant-numeric: tabular-nums; - line-height: 1.1; - color: var(--color-brand-offwhite); -} - -.stat-desc { - font-size: 0.75rem; - color: var(--color-brand-offwhite-muted); -} - -.stat-figure { - display: inline-flex; - align-items: center; - justify-content: center; -} - -@media (max-width: 640px) { - .stats { - flex-direction: column; - } - .stat { - border-inline-start-width: 0; - border-top: 1px solid var(--color-border); - } - .stat:first-child { - border-top-width: 0; - } -} - -/* --- Radial progress ------------------------------------------------------ */ - -.radial-progress { - position: relative; - display: inline-grid; - place-content: center; - width: var(--size, 5rem); - height: var(--size, 5rem); - border-radius: 9999px; - background: - radial-gradient( - farthest-side, - var(--color-brand-charcoal-panel) calc(100% - var(--thickness, 0.5rem) - 1px), - transparent calc(100% - var(--thickness, 0.5rem)) - ), - conic-gradient( - currentColor calc(var(--value, 0) * 1%), - rgba(255, 255, 255, 0.08) 0 - ); - color: var(--color-brand-offwhite); -} - -/* --- Divider --------------------------------------------------------------- */ - -.divider { - display: flex; - align-items: center; - gap: 1rem; - margin: 0.75rem 0; - font-size: 0.625rem; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--color-brand-offwhite-muted); -} - -.divider::before, -.divider::after { - content: ""; - flex: 1 1 0; - height: 1px; - background-color: var(--color-border); -} - -.divider-start::before { display: none; } -.divider-end::after { display: none; } - /* --- Window mockup --------------------------------------------------------- */ .mockup-window { diff --git a/frontend/components/best-practices/AnalysisApproach.tsx b/frontend/components/best-practices/AnalysisApproach.tsx index 1a22a46..ee97082 100644 --- a/frontend/components/best-practices/AnalysisApproach.tsx +++ b/frontend/components/best-practices/AnalysisApproach.tsx @@ -1,15 +1,16 @@ "use client"; import { renderMarkdown } from "@/lib/markdown"; -import { AnalysisSection } from "./AnalysisSection"; +import { AnalysisLabel } from "./AnalysisLabel"; export function AnalysisApproach({ approach }: { approach: string }) { return ( - +
+ Approach
- +
); -} +} \ No newline at end of file diff --git a/frontend/components/best-practices/AnalysisComplexity.tsx b/frontend/components/best-practices/AnalysisComplexity.tsx index 144bc9e..8e8fabb 100644 --- a/frontend/components/best-practices/AnalysisComplexity.tsx +++ b/frontend/components/best-practices/AnalysisComplexity.tsx @@ -1,7 +1,6 @@ "use client"; import { ComplexityBadge } from "./ComplexityBadge"; -import { ComplexityScale } from "./ComplexityScale"; export function AnalysisComplexity({ time, @@ -11,12 +10,9 @@ export function AnalysisComplexity({ space: string; }) { return ( -
-
- - -
- +
+ +
); -} +} \ No newline at end of file diff --git a/frontend/components/best-practices/AnalysisFooter.tsx b/frontend/components/best-practices/AnalysisFooter.tsx deleted file mode 100644 index b2c4f28..0000000 --- a/frontend/components/best-practices/AnalysisFooter.tsx +++ /dev/null @@ -1,19 +0,0 @@ -"use client"; - -import { MessageSquare } from "lucide-react"; - -export function AnalysisFooter({ onOpenChat }: { onOpenChat: () => void }) { - return ( -
-
Have a question?
- -
- ); -} diff --git a/frontend/components/best-practices/AnalysisHydration.tsx b/frontend/components/best-practices/AnalysisHydration.tsx index e64f406..84b961e 100644 --- a/frontend/components/best-practices/AnalysisHydration.tsx +++ b/frontend/components/best-practices/AnalysisHydration.tsx @@ -5,6 +5,7 @@ import type { SolutionExplanation } from "@/lib/types"; import { AnalysisSummary } from "./AnalysisSummary"; import { AnalysisApproach } from "./AnalysisApproach"; import { AnalysisScorecard } from "./AnalysisScorecard"; +import { AnalysisComplexity } from "./AnalysisComplexity"; function Shimmer({ className }: { className?: string }) { return
; @@ -16,7 +17,7 @@ export function AnalysisHydration({ partial: Partial | null; }) { return ( -
+
Generating analysis @@ -32,7 +33,7 @@ export function AnalysisHydration({ ) : (
- +
@@ -41,27 +42,27 @@ export function AnalysisHydration({ {partial?.quality_score != null ? ( ) : ( -
-
- - -
-
- - -
-
+ )} {partial?.approach ? ( ) : (
- +
)} + + {partial?.time_complexity && partial?.space_complexity ? ( + + ) : ( + + )}
); -} +} \ No newline at end of file diff --git a/frontend/components/best-practices/AnalysisLabel.tsx b/frontend/components/best-practices/AnalysisLabel.tsx new file mode 100644 index 0000000..e0a0258 --- /dev/null +++ b/frontend/components/best-practices/AnalysisLabel.tsx @@ -0,0 +1,9 @@ +"use client"; + +export function AnalysisLabel({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} \ No newline at end of file diff --git a/frontend/components/best-practices/AnalysisPoints.tsx b/frontend/components/best-practices/AnalysisPoints.tsx index 8146a95..0229137 100644 --- a/frontend/components/best-practices/AnalysisPoints.tsx +++ b/frontend/components/best-practices/AnalysisPoints.tsx @@ -1,6 +1,6 @@ "use client"; -import { AnalysisSection } from "./AnalysisSection"; +import { AnalysisLabel } from "./AnalysisLabel"; export function AnalysisPoints({ strengths, @@ -11,37 +11,33 @@ export function AnalysisPoints({ }) { if (!strengths.length && !improvements.length) return null; return ( -
+
{strengths.length > 0 && ( - -
    +
    + Strengths +

    {strengths.map((s, i) => ( -

  • - - {s} -
  • + + + {s} + ))} -
-
+

+
)} {improvements.length > 0 && ( - -
    +
    + Improvements +

    {improvements.map((s, i) => ( -

  • - - {s} -
  • + + + {s} + ))} -
-
+

+
)}
); -} +} \ No newline at end of file diff --git a/frontend/components/best-practices/AnalysisScorecard.tsx b/frontend/components/best-practices/AnalysisScorecard.tsx index ee79779..cfcefa1 100644 --- a/frontend/components/best-practices/AnalysisScorecard.tsx +++ b/frontend/components/best-practices/AnalysisScorecard.tsx @@ -1,64 +1,71 @@ "use client"; -import { Eye, ShieldCheck, ThumbsUp, Zap } from "lucide-react"; +import type { CSSProperties } from "react"; +import { Progress } from "@/components/ui/progress"; import type { SolutionExplanation } from "@/lib/types"; -import { gradeScore, QualityGauge } from "./QualityGauge"; -import { ScoreRadar } from "./ScoreRadar"; -import { MetricTile } from "./MetricTile"; + +export function gradeScore(score: number): { label: string; color: string } { + if (score >= 85) return { label: "Excellent", color: "#10b981" }; + if (score >= 70) return { label: "Good", color: "#14b8a6" }; + if (score >= 50) return { label: "Fair", color: "#f59e0b" }; + return { label: "Needs work", color: "#f43f5e" }; +} + +const DIMENSIONS: Array<{ + label: string; + score: (e: SolutionExplanation) => number; +}> = [ + { label: "Efficiency", score: (e) => e.efficiency_score }, + { label: "Readability", score: (e) => e.readability_score }, + { label: "Correctness", score: (e) => e.correctness_score }, + { label: "Best practices", score: (e) => e.best_practices_score }, +]; export function AnalysisScorecard({ explanation, }: { explanation: SolutionExplanation; }) { - const hasSubScores = - (explanation.efficiency_score ?? 0) + - (explanation.readability_score ?? 0) + - (explanation.correctness_score ?? 0) + - (explanation.best_practices_score ?? 0) > - 0; + const overall = gradeScore(explanation.quality_score); return ( -
-
- - +
+ + {explanation.quality_score} + + + {overall.label} + +
- - {hasSubScores && ( -
- - - - -
- )} +
+ {DIMENSIONS.map((d) => { + const g = gradeScore(d.score(explanation)); + return ( + + + {d.label} + + {d.score(explanation)} + + + ); + })} +
); -} +} \ No newline at end of file diff --git a/frontend/components/best-practices/AnalysisSection.tsx b/frontend/components/best-practices/AnalysisSection.tsx deleted file mode 100644 index 0a21ef7..0000000 --- a/frontend/components/best-practices/AnalysisSection.tsx +++ /dev/null @@ -1,18 +0,0 @@ -"use client"; - -export function AnalysisSection({ - label, - children, -}: { - label: string; - children: React.ReactNode; -}) { - return ( -
-

- {label} -

- {children} -
- ); -} diff --git a/frontend/components/best-practices/AnalysisSummary.tsx b/frontend/components/best-practices/AnalysisSummary.tsx index 1986f2b..5909d5c 100644 --- a/frontend/components/best-practices/AnalysisSummary.tsx +++ b/frontend/components/best-practices/AnalysisSummary.tsx @@ -1,11 +1,12 @@ "use client"; -import { AnalysisSection } from "./AnalysisSection"; +import { AnalysisLabel } from "./AnalysisLabel"; export function AnalysisSummary({ summary }: { summary: string }) { return ( - +
+ Summary

{summary}

- +
); -} +} \ No newline at end of file diff --git a/frontend/components/best-practices/AnalysisTechniques.tsx b/frontend/components/best-practices/AnalysisTechniques.tsx index 5892d42..0fc32c1 100644 --- a/frontend/components/best-practices/AnalysisTechniques.tsx +++ b/frontend/components/best-practices/AnalysisTechniques.tsx @@ -1,11 +1,12 @@ "use client"; -import { AnalysisSection } from "./AnalysisSection"; +import { AnalysisLabel } from "./AnalysisLabel"; export function AnalysisTechniques({ techniques }: { techniques: string[] }) { if (!techniques.length) return null; return ( - +
+ Key techniques
{techniques.map((t, i) => ( ))}
- +
); -} +} \ No newline at end of file diff --git a/frontend/components/best-practices/ComplexityScale.tsx b/frontend/components/best-practices/ComplexityScale.tsx deleted file mode 100644 index 2b2d130..0000000 --- a/frontend/components/best-practices/ComplexityScale.tsx +++ /dev/null @@ -1,51 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { ChevronDown } from "lucide-react"; -import { cn } from "@/lib/utils"; - -const TIERS: Array<{ label: string; desc: string; width: string; barCls: string }> = [ - { label: "O(1)", desc: "Constant — one operation, regardless of input", width: "8%", barCls: "bg-emerald-500" }, - { label: "O(log n)", desc: "Logarithmic — halves the input each step (binary search)", width: "20%", barCls: "bg-teal-500" }, - { label: "O(n)", desc: "Linear — one pass over the input", width: "45%", barCls: "bg-amber-500" }, - { label: "O(n log n)", desc: "Linearithmic — efficient sorts (merge sort)", width: "62%", barCls: "bg-orange-500" }, - { label: "O(n²)", desc: "Quadratic — nested loops over the input", width: "78%", barCls: "bg-orange-500" }, - { label: "O(2ⁿ)", desc: "Exponential — doubles per extra element", width: "90%", barCls: "bg-rose-500" }, - { label: "O(n!)", desc: "Factorial — permutations of the input", width: "100%", barCls: "bg-rose-500" }, -]; - -export function ComplexityScale() { - const [open, setOpen] = useState(false); - - return ( -
- - - {open && ( -
- {TIERS.map((t) => ( -
- - {t.label} - -
-
-
- - {t.desc} - -
- ))} -
- )} -
- ); -} diff --git a/frontend/components/best-practices/ExplainPanel.tsx b/frontend/components/best-practices/ExplainPanel.tsx index 8f5d8e2..5bee05b 100644 --- a/frontend/components/best-practices/ExplainPanel.tsx +++ b/frontend/components/best-practices/ExplainPanel.tsx @@ -1,14 +1,20 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; -import { Bot, Sparkles } from "lucide-react"; +import { + Bot, + Check, + CheckCircle2, + Copy, + MessageSquare, + Sparkles, +} from "lucide-react"; import type { CommunitySolution, SolutionExplanation } from "@/lib/types"; import { explainSolutionStream, explainSolutionChatStream, } from "@/lib/api"; import { toast } from "@/lib/toast"; -import { AnalysisHeader } from "./AnalysisHeader"; import { AnalysisHydration } from "./AnalysisHydration"; import { AnalysisError, type ExplainErrorInfo } from "./AnalysisError"; import { AnalysisSummary } from "./AnalysisSummary"; @@ -17,7 +23,6 @@ import { AnalysisApproach } from "./AnalysisApproach"; import { AnalysisComplexity } from "./AnalysisComplexity"; import { AnalysisTechniques } from "./AnalysisTechniques"; import { AnalysisPoints } from "./AnalysisPoints"; -import { AnalysisFooter } from "./AnalysisFooter"; import { FollowUpDrawer } from "./FollowUpDrawer"; import type { ChatMessage } from "./chat/types"; @@ -108,6 +113,8 @@ export function ExplainPanel({ const [error, setError] = useState(null); const [partial, setPartial] = useState | null>(null); const rawRef = useRef(""); + const [copied, setCopied] = useState(false); + const copyTimer = useRef | null>(null); const [chatOpen, setChatOpen] = useState(false); const [messages, setMessages] = useState([]); @@ -116,6 +123,13 @@ export function ExplainPanel({ const [streamingAnswer, setStreamingAnswer] = useState(null); const pendingAnswerRef = useRef(""); + useEffect( + () => () => { + if (copyTimer.current) clearTimeout(copyTimer.current); + }, + [], + ); + const loadExplanation = useCallback(async () => { if (explanation || loading) return; setLoading(true); @@ -176,15 +190,17 @@ export function ExplainPanel({ }, }); - if (pendingAnswerRef.current) { - setMessages((prev) => [ - ...prev, - { role: "ai", content: pendingAnswerRef.current }, - ]); - } + // Snapshot the accumulated text before clearing the ref. Reading the ref + // inside the setMessages updater would see "" instead: React batches these + // state updates and runs the updater after this synchronous block, by which + // point pendingAnswerRef has already been reset. + const finalAnswer = pendingAnswerRef.current; pendingAnswerRef.current = ""; setStreamingAnswer(null); setChatLoading(false); + if (finalAnswer) { + setMessages((prev) => [...prev, { role: "ai", content: finalAnswer }]); + } }; const copyAnalysis = async () => { @@ -212,6 +228,9 @@ export function ExplainPanel({ } try { await navigator.clipboard.writeText(sections.join("\n")); + setCopied(true); + if (copyTimer.current) clearTimeout(copyTimer.current); + copyTimer.current = setTimeout(() => setCopied(false), 1500); } catch { toast.error("Could not copy analysis"); } @@ -251,11 +270,49 @@ export function ExplainPanel({ )} {explanation && ( -
- +
+
+
+ + AI Analysis + + NIM + +
+ {cached && ( + + + cached + + )} +
+ + +
+
+ - + - setChatOpen(true)} />
)} @@ -282,4 +338,4 @@ export function ExplainPanel({ />
); -} +} \ No newline at end of file diff --git a/frontend/components/best-practices/MetricTile.tsx b/frontend/components/best-practices/MetricTile.tsx deleted file mode 100644 index a25c247..0000000 --- a/frontend/components/best-practices/MetricTile.tsx +++ /dev/null @@ -1,35 +0,0 @@ -"use client"; - -import { LucideIcon } from "lucide-react"; - -export function MetricTile({ - icon: Icon, - label, - value, - sublabel, - tone = "default", -}: { - icon: LucideIcon; - label: string; - value: string; - sublabel?: string; - tone?: "default" | "gold" | "emerald"; -}) { - const toneClasses = - tone === "gold" - ? "text-amber-400" - : tone === "emerald" - ? "text-emerald-400" - : "text-purple-300"; - - return ( -
-
- - {label} -
-
{value}
- {sublabel &&
{sublabel}
} -
- ); -} diff --git a/frontend/components/best-practices/QualityGauge.tsx b/frontend/components/best-practices/QualityGauge.tsx deleted file mode 100644 index 89e7830..0000000 --- a/frontend/components/best-practices/QualityGauge.tsx +++ /dev/null @@ -1,52 +0,0 @@ -"use client"; - -import type { CSSProperties } from "react"; -import { RatingBadge } from "@/components/ui/rating-badge"; - -export function gradeScore(score: number): { label: string; color: string } { - if (score >= 85) return { label: "Excellent", color: "#10b981" }; - if (score >= 70) return { label: "Good", color: "#14b8a6" }; - if (score >= 50) return { label: "Fair", color: "#f59e0b" }; - return { label: "Needs work", color: "#f43f5e" }; -} - -export function QualityGauge({ score, label }: { score: number; label: string }) { - const g = gradeScore(score); - const stars = Math.round((score / 100) * 5 * 2) / 2; - - return ( -
-
-
- - {score} - - - {g.label} - -
-
-
- - - {label} - -
-
- ); -} diff --git a/frontend/components/best-practices/ScoreRadar.tsx b/frontend/components/best-practices/ScoreRadar.tsx deleted file mode 100644 index 2cee8bb..0000000 --- a/frontend/components/best-practices/ScoreRadar.tsx +++ /dev/null @@ -1,53 +0,0 @@ -"use client"; - -import { - RadarChart, - PolarGrid, - PolarAngleAxis, - Radar, - ResponsiveContainer, -} from "recharts"; - -export function ScoreRadar({ - efficiency, - readability, - correctness, - bestPractices, -}: { - efficiency: number; - readability: number; - correctness: number; - bestPractices: number; -}) { - const data = [ - { subject: "Efficiency", score: efficiency }, - { subject: "Readability", score: readability }, - { subject: "Correctness", score: correctness }, - { subject: "Best practices", score: bestPractices }, - ]; - - return ( -
-
- Skill breakdown -
- - - - - - - -
- ); -}