feat: add json grid page and update home layout

This commit is contained in:
2026-02-24 09:41:33 +08:00
parent 4cd227d1a1
commit 046c8ea01f
5 changed files with 214 additions and 33 deletions
+14 -3
View File
@@ -18,10 +18,12 @@
"title": "DevLab",
"description": "A collection of powerful, privacy-focused developer tools. All processing happens locally in your browser.",
"getStarted": "Get Started",
"getStartedDescription": "Start visualising and querying your JSON data with our intuitive JSONPath-based tool.",
"openJsonViewer": "Open JSON Viewer",
"openBmiCalculator": "Open BMI Calculator",
"jsonViewerDescription": "Start visualising and querying your JSON data with our intuitive JSONPath-based tool.",
"jsonViewer": "JSON Viewer",
"bmiCalculator": "BMI Calculator",
"jsonGrid": "JSON Grid",
"bmiCalculatorDescription": "Calculate your Body Mass Index (BMI) to assess your weight status and health. Get instant results with personalised advice based on your BMI category.",
"jsonGridDescription": "Convert JSON arrays into a clean table view for quick inspection and comparison.",
"features": {
"tools": {
"title": "🛠️ Developer Tools",
@@ -48,6 +50,15 @@
"copyAsCsv": "Copy as CSV",
"error": "Error:"
},
"jsonGrid": {
"jsonInput": "JSON Input",
"tableResult": "Table Result",
"rows": "rows",
"exportCsv": "Export CSV",
"empty": "No data to display.",
"parseError": "Invalid JSON:",
"arrayOnlyError": "Please provide a JSON array."
},
"about": {
"title": "About DevLab",
"description": "A powerful, privacy-focused tool for debugging and visualising complex JSON data structures.",
+14 -3
View File
@@ -18,10 +18,12 @@
"title": "DevLab",
"description": "一系列强大的、注重隐私的开发者工具集合。所有处理都在您的浏览器本地进行。",
"getStarted": "开始使用",
"getStartedDescription": "使用我们直观的基于 JSONPath 的工具开始可视化和查询您的 JSON 数据。",
"openJsonViewer": "打开 JSON 查看器",
"openBmiCalculator": "打开 BMI 计算器",
"jsonViewerDescription": "使用我们直观的基于 JSONPath 的工具开始可视化和查询您的 JSON 数据。",
"jsonViewer": "JSON 查看器",
"bmiCalculator": "BMI 计算器",
"jsonGrid": "JSON Grid",
"bmiCalculatorDescription": "计算您的身体质量指数(BMI)以评估您的体重状态和健康状况。根据您的 BMI 分类获得即时结果和个性化建议。",
"jsonGridDescription": "将 JSON 数组转换为清晰的表格视图,便于快速查看和对比。",
"features": {
"tools": {
"title": "🛠️ 开发者工具",
@@ -48,6 +50,15 @@
"copyAsCsv": "复制为 CSV",
"error": "错误:"
},
"jsonGrid": {
"jsonInput": "JSON 输入",
"tableResult": "表格结果",
"rows": "行",
"exportCsv": "导出 CSV",
"empty": "暂无可展示的数据。",
"parseError": "JSON 无效:",
"arrayOnlyError": "请输入 JSON 数组。"
},
"about": {
"title": "关于 DevLab",
"description": "一个强大的、注重隐私的工具,用于调试和可视化复杂的 JSON 数据结构。",
+30 -27
View File
@@ -12,34 +12,37 @@ export default function Home() {
{/* Page Header */}
<div className="text-center">
<h1 className="text-3xl font-bold text-gray-900 sm:text-4xl">{t("home.title")}</h1>
<p className="mt-4 text-lg text-gray-600">
{t("home.description")}
</p>
<p className="mt-4 text-lg text-gray-600">{t("home.description")}</p>
</div>
{/* Main CTA - Two columns */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<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">{t("home.getStarted")}</h2>
<p className="text-gray-600 mb-6">
{t("home.getStartedDescription")}
</p>
<h2 className="text-2xl font-semibold text-gray-900 mb-4">{t("home.jsonViewer")}</h2>
<p className="text-gray-600 mb-6">{t("home.jsonViewerDescription")}</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">
{t("home.openJsonViewer")}
{t("home.getStarted")}
</Link>
</div>
<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">{t("home.getStarted")}</h2>
<p className="text-gray-600 mb-6">
{t("home.bmiCalculatorDescription")}
</p>
<h2 className="text-2xl font-semibold text-gray-900 mb-4">{t("home.bmiCalculator")}</h2>
<p className="text-gray-600 mb-6">{t("home.bmiCalculatorDescription")}</p>
<Link
to="/bmi-calculator"
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">
{t("home.openBmiCalculator")}
{t("home.getStarted")}
</Link>
</div>
<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">{t("home.jsonGrid")}</h2>
<p className="text-gray-600 mb-6">{t("home.jsonGridDescription")}</p>
<Link
to="/json-grid"
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">
{t("home.getStarted")}
</Link>
</div>
</div>
@@ -47,22 +50,22 @@ export default function Home() {
{/* 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">{t("home.features.tools.title")}</h3>
<p className="text-gray-600 text-sm">
{t("home.features.tools.description")}
</p>
<h3 className="text-lg font-semibold text-gray-900 mb-2">
{t("home.features.tools.title")}
</h3>
<p className="text-gray-600 text-sm">{t("home.features.tools.description")}</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">{t("home.features.privacy.title")}</h3>
<p className="text-gray-600 text-sm">
{t("home.features.privacy.description")}
</p>
<h3 className="text-lg font-semibold text-gray-900 mb-2">
{t("home.features.privacy.title")}
</h3>
<p className="text-gray-600 text-sm">{t("home.features.privacy.description")}</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">{t("home.features.free.title")}</h3>
<p className="text-gray-600 text-sm">
{t("home.features.free.description")}
</p>
<h3 className="text-lg font-semibold text-gray-900 mb-2">
{t("home.features.free.title")}
</h3>
<p className="text-gray-600 text-sm">{t("home.features.free.description")}</p>
</div>
</div>
</div>
+152
View File
@@ -0,0 +1,152 @@
import { useCallback, useMemo, useState } from "react"
import { useTranslation } from "react-i18next"
type RowRecord = Record<string, unknown>
export default function JsonGrid() {
const { t } = useTranslation()
const initialData = [
{ id: 1, name: "Alice", role: "Developer", active: true },
{ id: 2, name: "Bob", role: "Designer", active: false },
{ id: 3, name: "Charlie", role: "Product Manager", active: true },
]
const [jsonInput, setJsonInput] = useState<string>(JSON.stringify(initialData, null, 2))
const result = useMemo(() => {
try {
const parsed = JSON.parse(jsonInput)
if (!Array.isArray(parsed)) {
return { rows: [] as RowRecord[], columns: [] as string[], error: t("jsonTable.arrayOnlyError") }
}
const rows: RowRecord[] = parsed.map((item) => {
if (item !== null && typeof item === "object" && !Array.isArray(item)) {
return item as RowRecord
}
return { value: item }
})
const columns = Array.from(new Set(rows.flatMap((row) => Object.keys(row))))
return { rows, columns, error: null }
} catch (e) {
return {
rows: [] as RowRecord[],
columns: [] as string[],
error: `${t("jsonTable.parseError")} ${(e as Error).message}`,
}
}
}, [jsonInput, t])
const formatCell = (value: unknown): string => {
if (value === null || value === undefined) return ""
if (typeof value === "object") return JSON.stringify(value)
return String(value)
}
const exportCsv = useCallback(() => {
if (result.error || result.rows.length === 0 || result.columns.length === 0) return
const escapeCsv = (value: string) => {
if (value.includes(",") || value.includes('"') || value.includes("\n")) {
return `"${value.replace(/"/g, '""')}"`
}
return value
}
const header = result.columns.map(escapeCsv).join(",")
const lines = result.rows.map((row) =>
result.columns
.map((column) => escapeCsv(formatCell(row[column])))
.join(",")
)
const csvContent = [header, ...lines].join("\n")
const blob = new Blob([`\uFEFF${csvContent}`], { type: "text/csv;charset=utf-8;" })
const url = URL.createObjectURL(blob)
const link = document.createElement("a")
link.href = url
link.setAttribute("download", "json-table.csv")
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}, [result, formatCell])
return (
<div className="h-full flex gap-4 overflow-hidden">
<div className="w-[35%] bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden 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">
{t("jsonTable.jsonInput")}
</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>
<div className="w-[65%] bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden flex flex-col 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">
{t("jsonTable.tableResult")}
</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.rows.length} {t("jsonTable.rows")}
</span>
<button
onClick={exportCsv}
disabled={!!result.error || result.rows.length === 0 || result.columns.length === 0}
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"
>
{t("jsonTable.exportCsv")}
</button>
</div>
</div>
<div className="flex-1 p-4 overflow-auto min-h-0">
{result.error ? (
<div className="bg-red-50 text-red-600 p-4 rounded-lg border border-red-100 text-sm">
{result.error}
</div>
) : result.rows.length === 0 ? (
<div className="text-slate-500 text-sm">{t("jsonTable.empty")}</div>
) : (
<table className="w-full border-collapse text-sm">
<thead>
<tr>
{result.columns.map((column) => (
<th
key={column}
className="text-left font-semibold text-slate-700 bg-slate-100 border border-slate-200 px-3 py-2"
>
{column}
</th>
))}
</tr>
</thead>
<tbody>
{result.rows.map((row, index) => (
<tr key={index} className="odd:bg-white even:bg-slate-50">
{result.columns.map((column) => (
<td key={column} className="border border-slate-200 px-3 py-2 align-top text-slate-700">
{formatCell(row[column])}
</td>
))}
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
</div>
)
}
+4
View File
@@ -31,6 +31,10 @@ const router = createBrowserRouter(
path: "json-viewer",
lazy: lazy(() => import("@/page/json-viewer")),
},
{
path: "json-grid",
lazy: lazy(() => import("@/page/json-grid")),
},
{
path: "bmi-calculator",
lazy: lazy(() => import("@/page/bmi-calculator")),