feat: add json grid page and update home layout
@@ -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.",
|
||||
|
||||
@@ -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 数据结构。",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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")),
|
||||
|
||||