feat: implement JSON Viewer page and enhance Home page layout
- Added a new JSON Viewer page for visualizing and querying JSON data using JSONPath. - Updated the Home page with a new header, main call-to-action, and feature highlights for the JSON Visualiser tool. - Removed unused state and components from the Home page to streamline the layout.
@@ -1,149 +1,52 @@
|
||||
import { useCallback, useMemo, useState } from "react"
|
||||
import jp from "jsonpath"
|
||||
import JsonTreeNode from "@/components/json-tree-node"
|
||||
import { Link } from "react-router-dom"
|
||||
|
||||
/**
|
||||
* Home page component that displays the main landing content.
|
||||
*/
|
||||
export default function Home() {
|
||||
const initialData = {
|
||||
centre_id: "LON-01",
|
||||
location: "London",
|
||||
is_active: true,
|
||||
staff_members: [
|
||||
{ id: 101, name: "Alice", roles: ["Admin", "Manager"] },
|
||||
{ id: 102, name: "Bob", roles: ["Developer"] },
|
||||
],
|
||||
config: {
|
||||
colour_scheme: "Dark Mode",
|
||||
retention_days: 30,
|
||||
},
|
||||
}
|
||||
|
||||
const [jsonInput, setJsonInput] = useState<string>(JSON.stringify(initialData, null, 2))
|
||||
const [query, setQuery] = useState<string>("$.staff_members[*].name")
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
// Compute matching results
|
||||
const result = useMemo(() => {
|
||||
let parsed
|
||||
try {
|
||||
parsed = JSON.parse(jsonInput)
|
||||
} catch (e) {
|
||||
return { parsed: null, matchedPaths: [], matchedValues: [], error: (e as Error).message, queryError: null }
|
||||
}
|
||||
|
||||
try {
|
||||
const nodes = jp.nodes(parsed, query)
|
||||
return {
|
||||
parsed,
|
||||
matchedPaths: nodes.map((n) => jp.stringify(n.path)),
|
||||
matchedValues: nodes.map((n) => n.value),
|
||||
error: null,
|
||||
queryError: null,
|
||||
}
|
||||
} catch (e) {
|
||||
// When JSONPath expression is invalid, still display the JSON tree but with no matches
|
||||
return { parsed, matchedPaths: [], matchedValues: [], error: null, queryError: (e as Error).message }
|
||||
}
|
||||
}, [jsonInput, query])
|
||||
|
||||
// Copy as CSV
|
||||
const copyAsCsv = useCallback(() => {
|
||||
if (result.matchedValues.length === 0) return
|
||||
|
||||
const escapeCsvValue = (val: unknown): string => {
|
||||
const str = typeof val === "object" ? JSON.stringify(val) : String(val)
|
||||
if (str.includes(",") || str.includes('"') || str.includes("\n")) {
|
||||
return `"${str.replace(/"/g, '""')}"`
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
||||
const header = query
|
||||
const rows = result.matchedValues.map(escapeCsvValue)
|
||||
const csv = [header, ...rows].join("\n")
|
||||
|
||||
navigator.clipboard.writeText(csv).then(() => {
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
})
|
||||
}, [query, result.matchedValues])
|
||||
|
||||
return (
|
||||
<div className="h-full flex gap-4 overflow-hidden">
|
||||
{/* Left panel - 30% */}
|
||||
<div className="w-[30%] flex flex-col gap-4 min-h-0">
|
||||
{/* JSON Source - fills remaining height */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden flex-1 flex flex-col min-h-0">
|
||||
<div className="bg-slate-50 px-4 py-2 border-b border-slate-200 shrink-0">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
JSON Source
|
||||
</span>
|
||||
</div>
|
||||
<textarea
|
||||
className="flex-1 w-full p-4 font-mono text-sm outline-none focus:ring-2 focus:ring-indigo-500/20 transition-all resize-none overflow-auto"
|
||||
value={jsonInput}
|
||||
onChange={(e) => setJsonInput(e.target.value)}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<div className="space-y-8 max-w-6xl mx-auto">
|
||||
{/* Page Header */}
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-bold text-gray-900 sm:text-4xl">JSON Visualiser</h1>
|
||||
<p className="mt-4 text-lg text-gray-600">
|
||||
A powerful, privacy-focused tool for debugging and visualising complex JSON data
|
||||
structures.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* JSONPath Expression - fixed height */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-4 shrink-0">
|
||||
<label className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider mb-2">
|
||||
<span className="text-slate-500">JSONPath Expression</span>
|
||||
{result.queryError && (
|
||||
<span className="text-red-500 normal-case">— Invalid syntax</span>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className={`w-full p-3 font-mono text-sm border rounded-lg focus:ring-2 outline-none transition-all shadow-sm ${
|
||||
result.queryError
|
||||
? "border-red-300 focus:ring-red-500 focus:border-red-500"
|
||||
: "border-slate-200 focus:ring-indigo-500 focus:border-indigo-500"
|
||||
}`}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="e.g. $..roles"
|
||||
/>
|
||||
</div>
|
||||
{/* Main CTA */}
|
||||
<div className="bg-white shadow rounded-lg p-8 border border-gray-100 text-center">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-4">Get Started</h2>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Start visualising and querying your JSON data with our intuitive JSONPath-based tool.
|
||||
</p>
|
||||
<Link
|
||||
to="/json-viewer"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colours font-medium">
|
||||
Open JSON Viewer
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Right visualisation panel - 70% */}
|
||||
<div className="w-[70%] bg-white rounded-xl shadow-sm border border-slate-200 flex flex-col overflow-hidden min-h-0">
|
||||
<div className="bg-slate-50 px-4 py-2 border-b border-slate-200 flex justify-between items-center shrink-0">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
Visualised Result
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium px-2 py-0.5 bg-indigo-100 text-indigo-700 rounded-full">
|
||||
{result.matchedPaths.length} matches
|
||||
</span>
|
||||
<button
|
||||
onClick={copyAsCsv}
|
||||
disabled={result.matchedValues.length === 0 || !!result.error}
|
||||
className="text-xs font-medium px-3 py-1 bg-emerald-500 text-white rounded-lg hover:bg-emerald-600 disabled:bg-slate-300 disabled:cursor-not-allowed transition-colours"
|
||||
>
|
||||
{copied ? "Copied!" : "Copy as CSV"}
|
||||
</button>
|
||||
{/* Features */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="bg-white shadow rounded-lg p-6 border border-gray-100">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">🔍 JSONPath Queries</h3>
|
||||
<p className="text-gray-600 text-sm">
|
||||
Use powerful JSONPath expressions to query and filter your JSON data structures.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white shadow rounded-lg p-6 border border-gray-100">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">🎨 Visual Highlighting</h3>
|
||||
<p className="text-gray-600 text-sm">
|
||||
See matching paths highlighted in real-time as you type your queries.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 p-6 overflow-auto font-mono text-sm leading-relaxed min-h-0">
|
||||
{result.error && (
|
||||
<div className="bg-red-50 text-red-600 p-4 rounded-lg border border-red-100 text-xs mb-4">
|
||||
<strong>Error:</strong> {result.error}
|
||||
</div>
|
||||
)}
|
||||
{result.parsed && (
|
||||
<JsonTreeNode
|
||||
data={result.parsed}
|
||||
path={["$"]}
|
||||
matchedPaths={result.matchedPaths}
|
||||
/>
|
||||
)}
|
||||
<div className="bg-white shadow rounded-lg p-6 border border-gray-100">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">🔒 Privacy First</h3>
|
||||
<p className="text-gray-600 text-sm">
|
||||
All processing happens locally in your browser. No data is ever sent to servers.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { useCallback, useMemo, useState } from "react"
|
||||
import jp from "jsonpath"
|
||||
import JsonTreeNode from "@/components/json-tree-node"
|
||||
|
||||
/**
|
||||
* JSON Viewer page component that displays the JSON Visualiser tool.
|
||||
*/
|
||||
export default function JsonViewer() {
|
||||
const initialData = {
|
||||
centre_id: "LON-01",
|
||||
location: "London",
|
||||
is_active: true,
|
||||
staff_members: [
|
||||
{ id: 101, name: "Alice", roles: ["Admin", "Manager"] },
|
||||
{ id: 102, name: "Bob", roles: ["Developer"] },
|
||||
],
|
||||
config: {
|
||||
colour_scheme: "Dark Mode",
|
||||
retention_days: 30,
|
||||
},
|
||||
}
|
||||
|
||||
const [jsonInput, setJsonInput] = useState<string>(JSON.stringify(initialData, null, 2))
|
||||
const [query, setQuery] = useState<string>("$.staff_members[*].name")
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
// Compute matching results
|
||||
const result = useMemo(() => {
|
||||
let parsed
|
||||
try {
|
||||
parsed = JSON.parse(jsonInput)
|
||||
} catch (e) {
|
||||
return { parsed: null, matchedPaths: [], matchedValues: [], error: (e as Error).message, queryError: null }
|
||||
}
|
||||
|
||||
try {
|
||||
const nodes = jp.nodes(parsed, query)
|
||||
return {
|
||||
parsed,
|
||||
matchedPaths: nodes.map((n) => jp.stringify(n.path)),
|
||||
matchedValues: nodes.map((n) => n.value),
|
||||
error: null,
|
||||
queryError: null,
|
||||
}
|
||||
} catch (e) {
|
||||
// When JSONPath expression is invalid, still display the JSON tree but with no matches
|
||||
return { parsed, matchedPaths: [], matchedValues: [], error: null, queryError: (e as Error).message }
|
||||
}
|
||||
}, [jsonInput, query])
|
||||
|
||||
// Copy as CSV
|
||||
const copyAsCsv = useCallback(() => {
|
||||
if (result.matchedValues.length === 0) return
|
||||
|
||||
const escapeCsvValue = (val: unknown): string => {
|
||||
const str = typeof val === "object" ? JSON.stringify(val) : String(val)
|
||||
if (str.includes(",") || str.includes('"') || str.includes("\n")) {
|
||||
return `"${str.replace(/"/g, '""')}"`
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
||||
const header = query
|
||||
const rows = result.matchedValues.map(escapeCsvValue)
|
||||
const csv = [header, ...rows].join("\n")
|
||||
|
||||
navigator.clipboard.writeText(csv).then(() => {
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
})
|
||||
}, [query, result.matchedValues])
|
||||
|
||||
return (
|
||||
<div className="h-full flex gap-4 overflow-hidden">
|
||||
{/* Left panel - 30% */}
|
||||
<div className="w-[30%] flex flex-col gap-4 min-h-0">
|
||||
{/* JSON Source - fills remaining height */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden flex-1 flex flex-col min-h-0">
|
||||
<div className="bg-slate-50 px-4 py-2 border-b border-slate-200 shrink-0">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
JSON Source
|
||||
</span>
|
||||
</div>
|
||||
<textarea
|
||||
className="flex-1 w-full p-4 font-mono text-sm outline-none focus:ring-2 focus:ring-indigo-500/20 transition-all resize-none overflow-auto"
|
||||
value={jsonInput}
|
||||
onChange={(e) => setJsonInput(e.target.value)}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* JSONPath Expression - fixed height */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-4 shrink-0">
|
||||
<label className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider mb-2">
|
||||
<span className="text-slate-500">JSONPath Expression</span>
|
||||
{result.queryError && (
|
||||
<span className="text-red-500 normal-case">— Invalid syntax</span>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className={`w-full p-3 font-mono text-sm border rounded-lg focus:ring-2 outline-none transition-all shadow-sm ${
|
||||
result.queryError
|
||||
? "border-red-300 focus:ring-red-500 focus:border-red-500"
|
||||
: "border-slate-200 focus:ring-indigo-500 focus:border-indigo-500"
|
||||
}`}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="e.g. $..roles"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right visualisation panel - 70% */}
|
||||
<div className="w-[70%] bg-white rounded-xl shadow-sm border border-slate-200 flex flex-col overflow-hidden min-h-0">
|
||||
<div className="bg-slate-50 px-4 py-2 border-b border-slate-200 flex justify-between items-center shrink-0">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
Visualised Result
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium px-2 py-0.5 bg-indigo-100 text-indigo-700 rounded-full">
|
||||
{result.matchedPaths.length} matches
|
||||
</span>
|
||||
<button
|
||||
onClick={copyAsCsv}
|
||||
disabled={result.matchedValues.length === 0 || !!result.error}
|
||||
className="text-xs font-medium px-3 py-1 bg-emerald-500 text-white rounded-lg hover:bg-emerald-600 disabled:bg-slate-300 disabled:cursor-not-allowed transition-colours"
|
||||
>
|
||||
{copied ? "Copied!" : "Copy as CSV"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 p-6 overflow-auto font-mono text-sm leading-relaxed min-h-0">
|
||||
{result.error && (
|
||||
<div className="bg-red-50 text-red-600 p-4 rounded-lg border border-red-100 text-xs mb-4">
|
||||
<strong>Error:</strong> {result.error}
|
||||
</div>
|
||||
)}
|
||||
{result.parsed && (
|
||||
<JsonTreeNode
|
||||
data={result.parsed}
|
||||
path={["$"]}
|
||||
matchedPaths={result.matchedPaths}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -27,6 +27,10 @@ const router = createBrowserRouter(
|
||||
index: true,
|
||||
lazy: lazy(() => import("@/page/home")),
|
||||
},
|
||||
{
|
||||
path: "json-viewer",
|
||||
lazy: lazy(() => import("@/page/json-viewer")),
|
||||
},
|
||||
{
|
||||
path: "about",
|
||||
lazy: lazy(() => import("@/page/about")),
|
||||
|
||||