From 1cfc6787590166223534abbe78160334feca05e9 Mon Sep 17 00:00:00 2001 From: Janin Nicole Manalili Date: Sun, 5 Apr 2026 19:51:56 +0800 Subject: [PATCH] add: validation; edit: ui --- frontend/app/globals.css | 194 ++++++++- frontend/app/page.tsx | 567 ++++++-------------------- frontend/components/AnalysisPanel.tsx | 56 ++- frontend/components/FlowDiagram.tsx | 30 +- frontend/components/QueryEditor.tsx | 64 ++- frontend/package-lock.json | 97 ++++- frontend/package.json | 2 + 7 files changed, 512 insertions(+), 498 deletions(-) diff --git a/frontend/app/globals.css b/frontend/app/globals.css index f3bde4f..d79d00b 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -14,21 +14,46 @@ --border-bright: #3a3a42; --text: #e4e4e7; --text-dim: #71717a; - --accent: #7c6af7; - --accent-dim: #4a3fa0; + --accent: #10b981; + --accent-dim: #059669; --node-scan: #3b82f6; --node-filter: #10b981; --node-join: #f59e0b; - --node-aggregate: #8b5cf6; + --node-aggregate: #059669; --node-subquery: #ef4444; --node-sort: #06b6d4; --node-output: #6b7280; - --node-groupby: #ec4899; + --node-groupby: #047857; --cost-low: #10b981; --cost-medium: #f59e0b; --cost-high: #ef4444; } +[data-theme="light"] { + --bg: #ffffff; + --surface-1: #f8f9fa; + --surface-2: #e9ecef; + --surface-3: #dee2e6; + --surface-4: #ced4da; + --border: #ced4da; + --border-bright: #adb5bd; + --text: #212529; + --text-dim: #6c757d; + --accent: #10b981; + --accent-dim: #059669; + --node-scan: #0d6efd; + --node-filter: #10b981; + --node-join: #fd7e14; + --node-aggregate: #059669; + --node-subquery: #dc3545; + --node-sort: #0dcaf0; + --node-output: #6c757d; + --node-groupby: #047857; + --cost-low: #10b981; + --cost-medium: #fd7e14; + --cost-high: #dc3545; +} + * { box-sizing: border-box; margin: 0; @@ -121,3 +146,164 @@ body { color: var(--text); font-weight: 500; } + +/* App layout */ +.app-shell { + min-height: 100vh; + background: var(--bg); + display: flex; + flex-direction: column; +} + +.app-header { + border-bottom: 1px solid var(--border); + padding: 0 24px; + height: 52px; + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; +} + +.brand { + font-weight: 600; + font-size: 15px; + letter-spacing: -0.02em; + color: var(--text); +} + +.brand-accent { + color: var(--accent); +} + +.mode-toggle { + display: flex; + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: 6px; + padding: 3px; + gap: 2px; +} + +.toggle-button { + padding: 5px 14px; + border-radius: 4px; + border: none; + cursor: pointer; + font-size: 12px; + font-weight: 500; + font-family: "DM Sans", sans-serif; + background: transparent; + color: var(--text-dim); + transition: all 0.15s; +} + +.toggle-button.active { + background: var(--accent); + color: white; +} + +.theme-toggle { + background: var(--accent); + color: white; + border: none; + border-radius: 5px; + padding: 5px 14px; + font-size: 12px; + font-weight: 500; + cursor: pointer; + font-family: "DM Sans", sans-serif; + transition: all 0.15s; +} + +.theme-toggle:hover:not(:disabled) { + background: var(--accent-dim); +} + +.theme-toggle:disabled { + opacity: 0.7; + cursor: not-allowed; +} + +.main-grid { + flex: 1; + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-rows: 1fr 1fr; + gap: 1px; + background: var(--border); + overflow: hidden; + height: calc(100vh - 52px); +} + +.panel-card { + background: var(--surface-1); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.panel-card.full-height { + height: 100%; +} + +.panel-header { + padding: 10px 14px; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; +} + +.panel-title { + font-size: 11px; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.08em; + font-weight: 600; +} + +.panel-body { + flex: 1; + overflow: hidden; +} + +.panel-scroll { + padding: 16px; + overflow-y: auto; +} + +.panel-warning { + display: flex; + gap: 8px; + margin-bottom: 10px; + padding: 10px 12px; + background: var(--surface-2); + border-radius: 5px; + border-left: 3px solid var(--cost-medium); + font-size: 12px; + color: var(--text-dim); + line-height: 1.5; +} + +.empty-state { + color: var(--text-dim); + font-size: 13px; +} + +.status-chip { + font-size: 10px; + padding: 2px 8px; + border-radius: 3px; + font-weight: 500; + background: var(--cost-low) 20; + color: var(--cost-low); +} +/* Error message styling */ +.analysis-content p:first-child { + font-size: 14px; + font-weight: 500; + color: var(--cost-high); + margin-bottom: 1rem; +} diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index c57370b..7254097 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -1,5 +1,5 @@ "use client"; -import { useState, useCallback } from "react"; +import { useState, useCallback, useEffect } from "react"; import dynamic from "next/dynamic"; import AnalysisPanel from "@/components/AnalysisPanel"; @@ -33,6 +33,8 @@ WHERE id IN ( type Mode = "single" | "compare"; +type Theme = "dark" | "light"; + interface Structure { nodes: any[]; edges: any[]; @@ -42,6 +44,23 @@ interface Structure { export default function Home() { const [mode, setMode] = useState("single"); + const [theme, setTheme] = useState("dark"); + + useEffect(() => { + const saved = window.localStorage.getItem("sqtip-theme"); + if (saved === "light" || saved === "dark") { + setTheme(saved); + } + }, []); + + useEffect(() => { + document.documentElement.dataset.theme = theme; + window.localStorage.setItem("sqtip-theme", theme); + }, [theme]); + + const toggleTheme = useCallback(() => { + setTheme((current) => (current === "dark" ? "light" : "dark")); + }, []); // Single mode const [sql, setSql] = useState(SAMPLE_SINGLE); @@ -67,10 +86,16 @@ export default function Home() { body: JSON.stringify({ sql }), }); const data = await res.json(); - setStructure(data.structure); - setAnalysis(data.analysis); + if (!res.ok) { + setAnalysis(`❌ ${data.error}`); + setStructure(null); + } else { + setStructure(data.structure); + setAnalysis(data.analysis); + } } catch (e) { setAnalysis("Error connecting to backend. Make sure Django is running."); + setStructure(null); } setLoading(false); }, [sql]); @@ -85,233 +110,84 @@ export default function Home() { body: JSON.stringify({ sql1, sql2 }), }); const data = await res.json(); - setStructureA(data.query_a.structure); - setStructureB(data.query_b.structure); - setComparison(data.comparison); + if (!res.ok) { + setComparison(`❌ ${data.error}`); + setStructureA(null); + setStructureB(null); + } else { + setStructureA(data.query_a.structure); + setStructureB(data.query_b.structure); + setComparison(data.comparison); + } } catch (e) { setComparison( "Error connecting to backend. Make sure Django is running.", ); + setStructureA(null); + setStructureB(null); } setLoadingCompare(false); }, [sql1, sql2]); return ( -
- {/* Header */} -
-
-
- - - - -
- - SQTip - +
+
+
+ sqtip
- {/* Mode toggle */} -
+
{(["single", "compare"] as Mode[]).map((m) => ( ))}
-
+
- {/* Single Mode */} {mode === "single" && ( -
- {/* Editor */} -
-
- - SQL Query - +
+
+
+ SQL query
-
+
- {/* Flow diagram */} -
-
- - Execution Flow - +
+
+ Execution flow {structure && ( - + {structure.estimated_cost} cost )}
-
+
- {/* Warnings */} -
-
- - Static Warnings - +
+
+ Static warnings
-
+
{structure?.warnings?.length ? ( structure.warnings.map((w, i) => ( -
- - {w} - +
+ {w}
)) ) : ( -

+

{structure ? "No warnings detected" : "Run a query to see warnings"} @@ -382,232 +216,71 @@ export default function Home() {

- {/* Analysis */} -
-
- - AI Analysis - +
+
+ AI analysis
-
+
)} - {/* Compare Mode */} {mode === "compare" && ( -
- {/* Top: two editors */} -
- {[ - { label: "Query A", value: sql1, onChange: setSql1 }, - { label: "Query B", value: sql2, onChange: setSql2 }, - ].map(({ label, value, onChange }) => ( -
-
- - {label} - - {label === "Query B" && ( - - )} -
-
- -
-
- ))} +
+
+
+ Query A +
+
+ +
- {/* Middle: two diagrams */} -
- {[ - { label: "Flow A", structure: structureA }, - { label: "Flow B", structure: structureB }, - ].map(({ label, structure }) => ( -
+
+ Query B +
- ))} + {loadingCompare ? "Comparing..." : "Compare →"} + +
+
+ +
- {/* Bottom: comparison analysis */} -
-
- - Comparison Analysis - +
+
+ Comparison +
+
+

+ {comparison || "Run comparison to see the diff"} +

+
+
+ +
+
+ Analysis
-
+
diff --git a/frontend/components/AnalysisPanel.tsx b/frontend/components/AnalysisPanel.tsx index dca9ccd..58db797 100644 --- a/frontend/components/AnalysisPanel.tsx +++ b/frontend/components/AnalysisPanel.tsx @@ -1,41 +1,63 @@ -'use client' -import ReactMarkdown from 'react-markdown' +"use client"; +import ReactMarkdown from "react-markdown"; interface Props { - analysis: string - loading: boolean + analysis: string; + loading: boolean; } export default function AnalysisPanel({ analysis, loading }: Props) { if (loading) { return ( -
+
{[80, 60, 90, 50, 70].map((w, i) => ( -
+
))}
- ) + ); } if (!analysis) { return ( -
-

Analysis will appear here

+
+

+ Analysis will appear here +

- ) + ); } return ( -
+
{analysis}
- ) + ); } diff --git a/frontend/components/FlowDiagram.tsx b/frontend/components/FlowDiagram.tsx index d804c10..267432a 100644 --- a/frontend/components/FlowDiagram.tsx +++ b/frontend/components/FlowDiagram.tsx @@ -13,29 +13,29 @@ import "reactflow/dist/style.css"; import { useEffect } from "react"; const NODE_COLORS: Record = { - scan: "#3b82f6", - filter: "#10b981", - join: "#f59e0b", - aggregate: "#8b5cf6", - subquery: "#ef4444", - sort: "#06b6d4", - output: "#6b7280", - groupby: "#ec4899", + scan: "var(--node-scan)", + filter: "var(--node-filter)", + join: "var(--node-join)", + aggregate: "var(--node-aggregate)", + subquery: "var(--node-subquery)", + sort: "var(--node-sort)", + output: "var(--node-output)", + groupby: "var(--node-groupby)", }; const COST_COLORS: Record = { - low: "#10b981", - medium: "#f59e0b", - high: "#ef4444", + low: "var(--cost-low)", + medium: "var(--cost-medium)", + high: "var(--cost-high)", }; function SqlNode({ data }: { data: any }) { - const color = NODE_COLORS[data.type] || "#6b7280"; - const costColor = COST_COLORS[data.cost] || "#6b7280"; + const color = NODE_COLORS[data.type] || "var(--node-output)"; + const costColor = COST_COLORS[data.cost] || "var(--node-output)"; return (
void; placeholder?: string; + theme?: "dark" | "light"; } -export default function QueryEditor({ value, onChange, placeholder }: Props) { +export default function QueryEditor({ + value, + onChange, + placeholder, + theme = "dark", +}: Props) { return (