Merge branch 'main' into develop
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"chat.tools.terminal.autoApprove": {
|
||||
"pnpm": true
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ A sophisticated, TypeScript-powered tool designed to parse JSON data and visuali
|
||||
## Access the App
|
||||
|
||||
You can use the live version of the application directly in your browser:
|
||||
👉 **[https://dev-hub.onixbyte.dev](https://dev-hub.onixbyte.dev)**
|
||||
👉 **[https://dev-lab.onixbyte.dev](https://dev-lab.onixbyte.dev)**
|
||||
|
||||
## New Features
|
||||
|
||||
|
||||
@@ -397,66 +397,79 @@ packages:
|
||||
resolution: {integrity: sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.55.1':
|
||||
resolution: {integrity: sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.55.1':
|
||||
resolution: {integrity: sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.55.1':
|
||||
resolution: {integrity: sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.55.1':
|
||||
resolution: {integrity: sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-loong64-musl@4.55.1':
|
||||
resolution: {integrity: sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.55.1':
|
||||
resolution: {integrity: sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-musl@4.55.1':
|
||||
resolution: {integrity: sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.55.1':
|
||||
resolution: {integrity: sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.55.1':
|
||||
resolution: {integrity: sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.55.1':
|
||||
resolution: {integrity: sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.55.1':
|
||||
resolution: {integrity: sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.55.1':
|
||||
resolution: {integrity: sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-openbsd-x64@4.55.1':
|
||||
resolution: {integrity: sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==}
|
||||
@@ -532,24 +545,28 @@ packages:
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tailwindcss/oxide-linux-arm64-musl@4.1.18':
|
||||
resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-gnu@4.1.18':
|
||||
resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-musl@4.1.18':
|
||||
resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tailwindcss/oxide-wasm32-wasi@4.1.18':
|
||||
resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==}
|
||||
@@ -887,24 +904,28 @@ packages:
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-arm64-musl@1.30.2:
|
||||
resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-linux-x64-gnu@1.30.2:
|
||||
resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-x64-musl@1.30.2:
|
||||
resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-win32-arm64-msvc@1.30.2:
|
||||
resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==}
|
||||
|
||||
@@ -4,50 +4,48 @@
|
||||
<!-- Homepage -->
|
||||
<url>
|
||||
<loc>https://dev-lab.onixbyte.dev/</loc>
|
||||
<lastmod>2024-01-01</lastmod>
|
||||
<lastmod>2026-02-24</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>1.0</priority>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://dev-lab.onixbyte.dev/" />
|
||||
<xhtml:link rel="alternate" hreflang="zh" href="https://dev-lab.onixbyte.dev/?lang=zh-CN" />
|
||||
</url>
|
||||
|
||||
<!-- JSON Viewer -->
|
||||
<url>
|
||||
<loc>https://dev-lab.onixbyte.dev/json-viewer</loc>
|
||||
<lastmod>2024-01-01</lastmod>
|
||||
<lastmod>2026-02-24</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.9</priority>
|
||||
</url>
|
||||
|
||||
<!-- JSON Grid -->
|
||||
<url>
|
||||
<loc>https://dev-lab.onixbyte.dev/json-grid</loc>
|
||||
<lastmod>2026-02-24</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.9</priority>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://dev-lab.onixbyte.dev/json-viewer" />
|
||||
<xhtml:link rel="alternate" hreflang="zh" href="https://dev-lab.onixbyte.dev/json-viewer?lang=zh-CN" />
|
||||
</url>
|
||||
|
||||
<!-- BMI Calculator -->
|
||||
<url>
|
||||
<loc>https://dev-lab.onixbyte.dev/bmi-calculator</loc>
|
||||
<lastmod>2024-01-01</lastmod>
|
||||
<lastmod>2026-02-24</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.9</priority>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://dev-lab.onixbyte.dev/bmi-calculator" />
|
||||
<xhtml:link rel="alternate" hreflang="zh" href="https://dev-lab.onixbyte.dev/bmi-calculator?lang=zh-CN" />
|
||||
</url>
|
||||
|
||||
<!-- About -->
|
||||
<url>
|
||||
<loc>https://dev-lab.onixbyte.dev/about</loc>
|
||||
<lastmod>2024-01-01</lastmod>
|
||||
<lastmod>2026-02-24</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://dev-lab.onixbyte.dev/about" />
|
||||
<xhtml:link rel="alternate" hreflang="zh" href="https://dev-lab.onixbyte.dev/about?lang=zh-CN" />
|
||||
</url>
|
||||
|
||||
<!-- Contact -->
|
||||
<url>
|
||||
<loc>https://dev-lab.onixbyte.dev/contact</loc>
|
||||
<lastmod>2024-01-01</lastmod>
|
||||
<lastmod>2026-02-24</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://dev-lab.onixbyte.dev/contact" />
|
||||
<xhtml:link rel="alternate" hreflang="zh" href="https://dev-lab.onixbyte.dev/contact?lang=zh-CN" />
|
||||
</url>
|
||||
</urlset>
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useMemo, useRef } from "react"
|
||||
|
||||
type JsonCodeEditorProps = {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
const tokenRegex = /"(?:\\u[a-fA-F0-9]{4}|\\[^u]|[^\\"])*"\s*:?|\btrue\b|\bfalse\b|\bnull\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/g
|
||||
|
||||
function escapeHtml(input: string): string {
|
||||
return input
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
}
|
||||
|
||||
function getTokenClass(token: string): string {
|
||||
if (token.startsWith('"')) {
|
||||
return token.endsWith(":") ? "text-indigo-600" : "text-emerald-600"
|
||||
}
|
||||
if (token === "true" || token === "false") {
|
||||
return "text-violet-600"
|
||||
}
|
||||
if (token === "null") {
|
||||
return "text-slate-500 italic"
|
||||
}
|
||||
return "text-amber-600"
|
||||
}
|
||||
|
||||
function highlightJson(input: string): string {
|
||||
let result = ""
|
||||
let lastIndex = 0
|
||||
|
||||
for (const match of input.matchAll(tokenRegex)) {
|
||||
const token = match[0]
|
||||
const index = match.index ?? 0
|
||||
|
||||
result += escapeHtml(input.slice(lastIndex, index))
|
||||
result += `<span class="${getTokenClass(token)}">${escapeHtml(token)}</span>`
|
||||
lastIndex = index + token.length
|
||||
}
|
||||
|
||||
result += escapeHtml(input.slice(lastIndex))
|
||||
return result || " "
|
||||
}
|
||||
|
||||
export default function JsonCodeEditor({ value, onChange }: JsonCodeEditorProps) {
|
||||
const highlighted = useMemo(() => highlightJson(value), [value])
|
||||
const preRef = useRef<HTMLPreElement>(null)
|
||||
|
||||
const syncScroll = (event: React.UIEvent<HTMLTextAreaElement>) => {
|
||||
if (!preRef.current) return
|
||||
preRef.current.scrollTop = event.currentTarget.scrollTop
|
||||
preRef.current.scrollLeft = event.currentTarget.scrollLeft
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex-1 min-h-0">
|
||||
<pre
|
||||
ref={preRef}
|
||||
aria-hidden
|
||||
className="absolute inset-0 m-0 p-4 font-mono text-sm leading-6 overflow-auto whitespace-pre-wrap wrap-break-word"
|
||||
>
|
||||
<code dangerouslySetInnerHTML={{ __html: highlighted }} />
|
||||
</pre>
|
||||
<textarea
|
||||
className="absolute inset-0 w-full h-full p-4 font-mono text-sm leading-6 resize-none overflow-auto bg-transparent text-transparent caret-slate-900 outline-none focus:ring-2 focus:ring-indigo-500/20 transition-all"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onScroll={syncScroll}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useEffect } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
type SeoProps = {
|
||||
title: string
|
||||
description: string
|
||||
path: string
|
||||
}
|
||||
|
||||
const SITE_URL = "https://dev-lab.onixbyte.dev"
|
||||
const DEFAULT_IMAGE = `${SITE_URL}/onixbyte.svg`
|
||||
|
||||
function setMetaTag(selector: string, attr: string, value: string) {
|
||||
let element = document.querySelector<HTMLMetaElement>(selector)
|
||||
if (!element) {
|
||||
element = document.createElement("meta")
|
||||
const [key, keyValue] = selector.replace("meta[", "").replace("]", "").split("=")
|
||||
element.setAttribute(key, keyValue.replace(/"/g, ""))
|
||||
document.head.appendChild(element)
|
||||
}
|
||||
element.setAttribute(attr, value)
|
||||
}
|
||||
|
||||
function setLink(rel: string, href: string) {
|
||||
let element = document.querySelector<HTMLLinkElement>(`link[rel="${rel}"]`)
|
||||
if (!element) {
|
||||
element = document.createElement("link")
|
||||
element.rel = rel
|
||||
document.head.appendChild(element)
|
||||
}
|
||||
element.href = href
|
||||
}
|
||||
|
||||
function setJsonLd(id: string, payload: Record<string, unknown>) {
|
||||
let element = document.querySelector<HTMLScriptElement>(`script[data-seo="${id}"]`)
|
||||
if (!element) {
|
||||
element = document.createElement("script")
|
||||
element.type = "application/ld+json"
|
||||
element.setAttribute("data-seo", id)
|
||||
document.head.appendChild(element)
|
||||
}
|
||||
element.text = JSON.stringify(payload)
|
||||
}
|
||||
|
||||
export default function Seo({ title, description, path }: SeoProps) {
|
||||
const { i18n } = useTranslation()
|
||||
const url = `${SITE_URL}${path}`
|
||||
const locale = i18n.language === "zh" ? "zh-CN" : "en-GB"
|
||||
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
name: title,
|
||||
description,
|
||||
url,
|
||||
inLanguage: locale,
|
||||
isPartOf: {
|
||||
"@type": "WebSite",
|
||||
name: "DevLab",
|
||||
url: SITE_URL,
|
||||
},
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
document.title = title
|
||||
setMetaTag("meta[name=\"title\"]", "content", title)
|
||||
setMetaTag("meta[name=\"description\"]", "content", description)
|
||||
setLink("canonical", url)
|
||||
|
||||
setMetaTag("meta[property=\"og:type\"]", "content", "website")
|
||||
setMetaTag("meta[property=\"og:url\"]", "content", url)
|
||||
setMetaTag("meta[property=\"og:title\"]", "content", title)
|
||||
setMetaTag("meta[property=\"og:description\"]", "content", description)
|
||||
setMetaTag("meta[property=\"og:image\"]", "content", DEFAULT_IMAGE)
|
||||
setMetaTag("meta[property=\"og:locale\"]", "content", locale)
|
||||
setMetaTag("meta[property=\"og:site_name\"]", "content", "DevLab")
|
||||
|
||||
setMetaTag("meta[property=\"twitter:card\"]", "content", "summary_large_image")
|
||||
setMetaTag("meta[property=\"twitter:url\"]", "content", url)
|
||||
setMetaTag("meta[property=\"twitter:title\"]", "content", title)
|
||||
setMetaTag("meta[property=\"twitter:description\"]", "content", description)
|
||||
setMetaTag("meta[property=\"twitter:image\"]", "content", DEFAULT_IMAGE)
|
||||
|
||||
setJsonLd("webpage", jsonLd)
|
||||
}, [title, description, url, locale, jsonLd])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -7,21 +7,59 @@
|
||||
"navigation": {
|
||||
"home": "Home",
|
||||
"about": "About",
|
||||
"contact": "Contact"
|
||||
"contact": "Contact",
|
||||
"changelog": "Changelog",
|
||||
"tools": "Tools",
|
||||
"jsonProcessing": "JSON Processing",
|
||||
"dailyTools": "Daily Tools",
|
||||
"expandToolsMenu": "Expand tools menu",
|
||||
"collapseToolsMenu": "Collapse tools menu"
|
||||
},
|
||||
"language": {
|
||||
"switch": "Switch Language",
|
||||
"english": "English (Great Britain)",
|
||||
"chinese": "简体中文"
|
||||
},
|
||||
"seo": {
|
||||
"home": {
|
||||
"title": "DevLab - Free Developer Tools",
|
||||
"description": "A collection of powerful, privacy-focused developer tools. JSON Viewer with JSONPath queries, JSON Grid, BMI Calculator, and more."
|
||||
},
|
||||
"jsonViewer": {
|
||||
"title": "JSON Viewer - JSONPath Queries",
|
||||
"description": "Visualise JSON and query data using JSONPath. Inspect structures, filter results, and export CSV locally in your browser."
|
||||
},
|
||||
"jsonGrid": {
|
||||
"title": "JSON Grid - JSON Array to Table",
|
||||
"description": "Convert JSON arrays into a clean, sortable table view and export as CSV. All processing happens locally."
|
||||
},
|
||||
"bmi": {
|
||||
"title": "BMI Calculator - Instant Results",
|
||||
"description": "Calculate your Body Mass Index (BMI) with instant feedback and category guidance."
|
||||
},
|
||||
"about": {
|
||||
"title": "About DevLab",
|
||||
"description": "Learn about DevLab, an open source, privacy-focused toolkit for developers."
|
||||
},
|
||||
"contact": {
|
||||
"title": "Contact DevLab",
|
||||
"description": "Get in touch via GitHub Issues for support, bug reports, or feature requests."
|
||||
},
|
||||
"changelog": {
|
||||
"title": "Changelog",
|
||||
"description": "A complete history of updates and improvements to DevLab."
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
"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",
|
||||
@@ -45,9 +83,19 @@
|
||||
"visualisedResult": "Visualised Result",
|
||||
"matches": "matches",
|
||||
"copied": "Copied!",
|
||||
"copyRawJson": "Copy selected JSON",
|
||||
"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.",
|
||||
@@ -127,5 +175,13 @@
|
||||
"scale": {
|
||||
"title": "BMI Categories"
|
||||
}
|
||||
},
|
||||
"changelog": {
|
||||
"title": "Changelog",
|
||||
"description": "All notable changes to DevLab are documented here.",
|
||||
"featureType": "Feature",
|
||||
"fixType": "Fix",
|
||||
"refactorType": "Refactor",
|
||||
"choreType": "Chore"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,21 +7,59 @@
|
||||
"navigation": {
|
||||
"home": "首页",
|
||||
"about": "关于",
|
||||
"contact": "联系"
|
||||
"contact": "联系",
|
||||
"changelog": "更新日志",
|
||||
"tools": "工具",
|
||||
"jsonProcessing": "JSON 处理",
|
||||
"dailyTools": "日常小工具",
|
||||
"expandToolsMenu": "展开工具菜单",
|
||||
"collapseToolsMenu": "收起工具菜单"
|
||||
},
|
||||
"language": {
|
||||
"switch": "切换语言",
|
||||
"english": "English (Great Britain)",
|
||||
"chinese": "简体中文"
|
||||
},
|
||||
"seo": {
|
||||
"home": {
|
||||
"title": "DevLab - 免费开发者工具",
|
||||
"description": "一系列强大的、注重隐私的开发者工具集合,包含 JSON 查看器、json-grid、BMI 计算器等。"
|
||||
},
|
||||
"jsonViewer": {
|
||||
"title": "JSON 查看器 - JSONPath 查询",
|
||||
"description": "使用 JSONPath 可视化并查询 JSON 数据,筛选结果并导出 CSV,全程在浏览器本地完成。"
|
||||
},
|
||||
"jsonGrid": {
|
||||
"title": "JSON Grid - JSON 数组转表格",
|
||||
"description": "将 JSON 数组转换为清晰的表格视图并导出 CSV,所有处理均在本地完成。"
|
||||
},
|
||||
"bmi": {
|
||||
"title": "BMI 计算器 - 即时结果",
|
||||
"description": "快速计算 BMI,并获得分类与建议。"
|
||||
},
|
||||
"about": {
|
||||
"title": "关于 DevLab",
|
||||
"description": "了解 DevLab:一个开源且注重隐私的开发者工具集合。"
|
||||
},
|
||||
"contact": {
|
||||
"title": "联系 DevLab",
|
||||
"description": "通过 GitHub Issues 反馈问题或提交功能需求。"
|
||||
},
|
||||
"changelog": {
|
||||
"title": "更新日志",
|
||||
"description": "DevLab 完整的更新和改进历史记录。"
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
"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": "🛠️ 开发者工具",
|
||||
@@ -45,9 +83,19 @@
|
||||
"visualisedResult": "可视化结果",
|
||||
"matches": "个匹配",
|
||||
"copied": "已复制!",
|
||||
"copyRawJson": "复制选中 JSON",
|
||||
"copyAsCsv": "复制为 CSV",
|
||||
"error": "错误:"
|
||||
},
|
||||
"jsonGrid": {
|
||||
"jsonInput": "JSON 输入",
|
||||
"tableResult": "表格结果",
|
||||
"rows": "行",
|
||||
"exportCsv": "导出 CSV",
|
||||
"empty": "暂无可展示的数据。",
|
||||
"parseError": "JSON 无效:",
|
||||
"arrayOnlyError": "请输入 JSON 数组。"
|
||||
},
|
||||
"about": {
|
||||
"title": "关于 DevLab",
|
||||
"description": "一个强大的、注重隐私的工具,用于调试和可视化复杂的 JSON 数据结构。",
|
||||
@@ -127,5 +175,13 @@
|
||||
"scale": {
|
||||
"title": "BMI 分类"
|
||||
}
|
||||
},
|
||||
"changelog": {
|
||||
"title": "更新日志",
|
||||
"description": "DevLab 所有值得关注的变更都记录在此。",
|
||||
"featureType": "功能",
|
||||
"fixType": "修复",
|
||||
"refactorType": "重构",
|
||||
"choreType": "维护"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,12 @@ export default function HeroLayout() {
|
||||
>
|
||||
{t("navigation.contact")}
|
||||
</Link>
|
||||
<Link
|
||||
to="/changelog"
|
||||
className="text-gray-500 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium"
|
||||
>
|
||||
{t("navigation.changelog")}
|
||||
</Link>
|
||||
</nav>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useMemo, useState } from "react"
|
||||
import { Link, NavLink, Outlet } from "react-router-dom"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import dayjs from "dayjs"
|
||||
import LanguageSwitcher from "@/components/language-switcher"
|
||||
|
||||
export default function ToolsLayout() {
|
||||
const today = useMemo(() => dayjs(), [])
|
||||
const { t } = useTranslation()
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
|
||||
const toolGroups = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: t("navigation.jsonProcessing"),
|
||||
items: [
|
||||
{ to: "/json-viewer", label: t("home.jsonViewer"), shortLabel: "JV" },
|
||||
{ to: "/json-grid", label: t("home.jsonGrid"), shortLabel: "JG" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t("navigation.dailyTools"),
|
||||
items: [{ to: "/bmi-calculator", label: t("home.bmiCalculator"), shortLabel: "BMI" }],
|
||||
},
|
||||
],
|
||||
[t]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="h-screen bg-gray-50 flex flex-col overflow-hidden">
|
||||
<header className="bg-white shadow-sm border-b">
|
||||
<div className="px-4">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
<div className="flex items-center">
|
||||
<h1 className="text-xl font-semibold text-gray-900">{t("app.title")}</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<nav className="flex space-x-8">
|
||||
<Link
|
||||
to="/"
|
||||
className="text-gray-500 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">
|
||||
{t("navigation.home")}
|
||||
</Link>
|
||||
<Link
|
||||
to="/about"
|
||||
className="text-gray-500 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">
|
||||
{t("navigation.about")}
|
||||
</Link>
|
||||
<Link
|
||||
to="/contact"
|
||||
className="text-gray-500 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">
|
||||
{t("navigation.contact")}
|
||||
</Link>
|
||||
<Link
|
||||
to="/changelog"
|
||||
className="text-gray-500 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">
|
||||
{t("navigation.changelog")}
|
||||
</Link>
|
||||
</nav>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 p-4 overflow-hidden min-h-0">
|
||||
<div className="h-full flex gap-4 overflow-hidden">
|
||||
<aside
|
||||
className={`bg-white rounded-xl shadow-sm border border-slate-200 flex flex-col transition-all duration-200 ${
|
||||
collapsed ? "w-16" : "w-56"
|
||||
}`}>
|
||||
<div className="px-3 py-3 border-b border-slate-200 flex items-center justify-between">
|
||||
{!collapsed && (
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
{t("navigation.tools")}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed((value) => !value)}
|
||||
className="w-8 h-8 rounded-md border border-slate-200 text-slate-600 hover:bg-slate-50 transition-colours text-sm"
|
||||
aria-label={
|
||||
collapsed ? t("navigation.expandToolsMenu") : t("navigation.collapseToolsMenu")
|
||||
}>
|
||||
{collapsed ? "»" : "«"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="p-2 flex-1 overflow-auto">
|
||||
{toolGroups.map((group) => (
|
||||
<div key={group.title} className="mb-3 last:mb-0">
|
||||
{!collapsed && (
|
||||
<div className="px-3 py-1 text-[11px] font-semibold uppercase tracking-wider text-slate-400">
|
||||
{group.title}
|
||||
</div>
|
||||
)}
|
||||
{group.items.map((tool) => (
|
||||
<NavLink
|
||||
key={tool.to}
|
||||
to={tool.to}
|
||||
className={({ isActive }) =>
|
||||
`w-full flex items-center ${collapsed ? "justify-center" : "justify-start"} rounded-lg px-3 py-2 text-sm font-medium mb-1 transition-colours ${
|
||||
isActive
|
||||
? "bg-indigo-100 text-indigo-700"
|
||||
: "text-slate-600 hover:bg-slate-100 hover:text-slate-900"
|
||||
}`
|
||||
}
|
||||
title={collapsed ? `${group.title} · ${tool.label}` : tool.label}>
|
||||
{collapsed ? tool.shortLabel : tool.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<section className="flex-1 overflow-hidden min-h-0">
|
||||
<Outlet />
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer className="bg-white border-t shrink-0">
|
||||
<div className="max-w-7xl mx-auto py-4 px-4 sm:px-6 lg:px-8">
|
||||
<p className="text-center text-sm text-gray-500">
|
||||
{t("app.copyright", { year: today.year() })}
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useTranslation } from "react-i18next"
|
||||
import Seo from "@/components/seo"
|
||||
|
||||
/**
|
||||
* About page component that displays information about the DevLab application.
|
||||
@@ -8,6 +9,11 @@ export default function About() {
|
||||
|
||||
return (
|
||||
<div className="space-y-8 max-w-6xl mx-auto">
|
||||
<Seo
|
||||
title={t("seo.about.title")}
|
||||
description={t("seo.about.description")}
|
||||
path="/about"
|
||||
/>
|
||||
{/* Page Header */}
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-bold text-gray-900 sm:text-4xl">{t("about.title")}</h1>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import Seo from "@/components/seo"
|
||||
|
||||
/**
|
||||
* BMI Calculator page component that displays the BMI calculator tool.
|
||||
@@ -72,6 +73,11 @@ export default function BmiCalculator() {
|
||||
|
||||
return (
|
||||
<div className="h-full flex gap-4 overflow-hidden">
|
||||
<Seo
|
||||
title={t("seo.bmi.title")}
|
||||
description={t("seo.bmi.description")}
|
||||
path="/bmi-calculator"
|
||||
/>
|
||||
{/* Left panel - 30% */}
|
||||
<div className="w-[30%] flex flex-col gap-4 min-h-0">
|
||||
{/* Input Form - fills remaining height */}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useMemo } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import Seo from "@/components/seo"
|
||||
|
||||
interface ChangeEntry {
|
||||
type: "feat" | "fix" | "refactor" | "chore"
|
||||
title: string
|
||||
description?: string
|
||||
date?: string
|
||||
}
|
||||
|
||||
interface ChangelogVersion {
|
||||
version: string
|
||||
date: string
|
||||
entries: ChangeEntry[]
|
||||
}
|
||||
|
||||
const CHANGELOG_DATA: ChangelogVersion[] = [
|
||||
{
|
||||
version: "1.1.0",
|
||||
date: "2026-02-24",
|
||||
entries: [
|
||||
{
|
||||
type: "feat",
|
||||
title: "JSON Grid",
|
||||
description: "Convert JSON arrays into sortable table view with CSV export functionality"
|
||||
},
|
||||
{
|
||||
type: "feat",
|
||||
title: "Tools Layout with Collapsible Menu",
|
||||
description: "Added a dedicated tools layout with categorised sidebar menu for quick tool switching",
|
||||
},
|
||||
{
|
||||
type: "feat",
|
||||
title: "Copy Selected JSON Feature",
|
||||
description: "Added ability to copy JSONPath selected data as raw JSON with one-click action",
|
||||
},
|
||||
{
|
||||
type: "feat",
|
||||
title: "Tool Categories",
|
||||
description: "Organised tools into JSON Processing (JSON Viewer, JSON Grid) and Daily Tools (BMI Calculator)",
|
||||
},
|
||||
{
|
||||
type: "fix",
|
||||
title: "Live Application Link",
|
||||
description: "Updated live application link in README",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "1.0.0",
|
||||
date: "2026-01-19",
|
||||
entries: [
|
||||
{
|
||||
type: "feat",
|
||||
title: "JSON Viewer with JSONPath",
|
||||
description: "Full-featured JSON viewer with JSONPath query support and CSV export",
|
||||
},
|
||||
{
|
||||
type: "feat",
|
||||
title: "BMI Calculator",
|
||||
description: "Calculate Body Mass Index with category guidance and health advice",
|
||||
},
|
||||
{
|
||||
type: "feat",
|
||||
title: "Internationalization",
|
||||
description: "Support for English (GB) and Simplified Chinese with language switcher",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
type ChangeTypeKey = "feat" | "fix" | "refactor" | "chore"
|
||||
|
||||
export default function Changelog() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const changeTypeLabels = useMemo(() => {
|
||||
const labels: Record<ChangeTypeKey, { label: string; colour: string }> = {
|
||||
feat: { label: t("changelog.featureType"), colour: "bg-emerald-100 text-emerald-700" },
|
||||
fix: { label: t("changelog.fixType"), colour: "bg-blue-100 text-blue-700" },
|
||||
refactor: { label: t("changelog.refactorType"), colour: "bg-purple-100 text-purple-700" },
|
||||
chore: { label: t("changelog.choreType"), colour: "bg-slate-100 text-slate-700" },
|
||||
}
|
||||
return labels
|
||||
}, [t])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Seo
|
||||
title={t("seo.changelog.title")}
|
||||
description={t("seo.changelog.description")}
|
||||
path="/changelog"
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
<div className="bg-white shadow-sm border-b">
|
||||
<div className="max-w-4xl mx-auto px-4 py-12 sm:px-6 lg:px-8">
|
||||
<h1 className="text-4xl font-bold text-gray-900">{t("changelog.title")}</h1>
|
||||
<p className="mt-2 text-lg text-gray-600">{t("changelog.description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="max-w-4xl mx-auto px-4 py-12 sm:px-6 lg:px-8">
|
||||
<div className="space-y-8">
|
||||
{CHANGELOG_DATA.map((changelog) => (
|
||||
<div key={changelog.version} className="relative">
|
||||
{/* Version header */}
|
||||
<div className="flex items-baseline gap-4 mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900">v{changelog.version}</h2>
|
||||
<time className="text-sm text-gray-500">{changelog.date}</time>
|
||||
</div>
|
||||
|
||||
{/* Changes list */}
|
||||
<div className="space-y-4">
|
||||
{changelog.entries.map((entry, idx) => {
|
||||
const typeInfo = changeTypeLabels[entry.type as ChangeTypeKey]
|
||||
return (
|
||||
<div key={`${changelog.version}-${idx}`} className="bg-white rounded-lg border border-slate-200 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className={`inline-block px-2.5 py-0.5 rounded text-xs font-semibold ${typeInfo.colour}`}>
|
||||
{typeInfo.label}
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-base font-semibold text-gray-900">{entry.title}</h3>
|
||||
{entry.description && (
|
||||
<p className="mt-1 text-sm text-gray-600">{entry.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import Seo from "@/components/seo"
|
||||
|
||||
/**
|
||||
* Contact page component that encourages manual GitHub Issue submission.
|
||||
@@ -38,6 +39,11 @@ ${message}
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto py-12 px-4 sm:px-6">
|
||||
<Seo
|
||||
title={t("seo.contact.title")}
|
||||
description={t("seo.contact.description")}
|
||||
path="/contact"
|
||||
/>
|
||||
{/* Header */}
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-3xl font-bold text-gray-900 sm:text-4xl">{t("contact.title")}</h1>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Link } from "react-router-dom"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import Seo from "@/components/seo"
|
||||
|
||||
/**
|
||||
* Home page component that displays the main landing content.
|
||||
@@ -9,37 +10,45 @@ export default function Home() {
|
||||
|
||||
return (
|
||||
<div className="space-y-8 max-w-6xl mx-auto">
|
||||
<Seo
|
||||
title={t("seo.home.title")}
|
||||
description={t("seo.home.description")}
|
||||
path="/"
|
||||
/>
|
||||
{/* 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 +56,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,155 @@
|
||||
import { useCallback, useMemo, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import JsonCodeEditor from "@/components/json-code-editor"
|
||||
import Seo from "@/components/seo"
|
||||
|
||||
type RowRecord = Record<string, unknown>
|
||||
|
||||
export default function JsonGrid() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const initialData = [
|
||||
{ id: 0, name: "TTY", role: "CEO", active: true },
|
||||
{ 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("jsonGrid.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("jsonGrid.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">
|
||||
<Seo
|
||||
title={t("seo.jsonGrid.title")}
|
||||
description={t("seo.jsonGrid.description")}
|
||||
path="/json-grid"
|
||||
/>
|
||||
<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("jsonGrid.jsonInput")}
|
||||
</span>
|
||||
</div>
|
||||
<JsonCodeEditor value={jsonInput} onChange={setJsonInput} />
|
||||
</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("jsonGrid.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("jsonGrid.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("jsonGrid.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("jsonGrid.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>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { useCallback, useMemo, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import jp from "jsonpath"
|
||||
import JsonTreeNode from "@/components/json-tree-node"
|
||||
import JsonCodeEditor from "@/components/json-code-editor"
|
||||
import Seo from "@/components/seo"
|
||||
|
||||
/**
|
||||
* JSON Viewer page component that displays the JSON visualisation tool in DevLab.
|
||||
@@ -13,6 +15,7 @@ export default function JsonViewer() {
|
||||
location: "London",
|
||||
is_active: true,
|
||||
staff_members: [
|
||||
{ id: 100, name: "TTY", roles: ["CEO"] },
|
||||
{ id: 101, name: "Alice", roles: ["Admin", "Manager"] },
|
||||
{ id: 102, name: "Bob", roles: ["Developer"] },
|
||||
],
|
||||
@@ -24,7 +27,12 @@ export default function JsonViewer() {
|
||||
|
||||
const [jsonInput, setJsonInput] = useState<string>(JSON.stringify(initialData, null, 2))
|
||||
const [query, setQuery] = useState<string>("$.staff_members[*].name")
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [copiedCsv, setCopiedCsv] = useState(false)
|
||||
const [copiedRawJson, setCopiedRawJson] = useState(false)
|
||||
|
||||
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
}
|
||||
|
||||
// Compute matching results
|
||||
const result = useMemo(() => {
|
||||
@@ -32,7 +40,13 @@ export default function JsonViewer() {
|
||||
try {
|
||||
parsed = JSON.parse(jsonInput)
|
||||
} catch (e) {
|
||||
return { parsed: null, matchedPaths: [], matchedValues: [], error: (e as Error).message, queryError: null }
|
||||
return {
|
||||
parsed: null,
|
||||
matchedPaths: [],
|
||||
matchedValues: [],
|
||||
error: (e as Error).message,
|
||||
queryError: null,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -46,7 +60,13 @@ export default function JsonViewer() {
|
||||
}
|
||||
} 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 }
|
||||
return {
|
||||
parsed,
|
||||
matchedPaths: [],
|
||||
matchedValues: [],
|
||||
error: null,
|
||||
queryError: (e as Error).message,
|
||||
}
|
||||
}
|
||||
}, [jsonInput, query])
|
||||
|
||||
@@ -62,18 +82,51 @@ export default function JsonViewer() {
|
||||
return str
|
||||
}
|
||||
|
||||
const header = query
|
||||
const rows = result.matchedValues.map(escapeCsvValue)
|
||||
const csv = [header, ...rows].join("\n")
|
||||
const objectMatches = result.matchedValues.filter(isPlainObject)
|
||||
const isObjectTable =
|
||||
objectMatches.length > 0 && objectMatches.length === result.matchedValues.length
|
||||
|
||||
const csv = isObjectTable
|
||||
? (() => {
|
||||
const columns = Array.from(new Set(objectMatches.flatMap((item) => Object.keys(item))))
|
||||
const headerRow = columns.map(escapeCsvValue).join(",")
|
||||
const valueRows = objectMatches.map((item) =>
|
||||
columns.map((column) => escapeCsvValue(item[column])).join(",")
|
||||
)
|
||||
return [headerRow, ...valueRows].join("\n")
|
||||
})()
|
||||
: (() => {
|
||||
const header = query
|
||||
const rows = result.matchedValues.map(escapeCsvValue)
|
||||
return [header, ...rows].join("\n")
|
||||
})()
|
||||
|
||||
navigator.clipboard.writeText(csv).then(() => {
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
setCopiedCsv(true)
|
||||
setTimeout(() => setCopiedCsv(false), 2000)
|
||||
})
|
||||
}, [query, result.matchedValues])
|
||||
|
||||
const copySelectedRawJson = useCallback(() => {
|
||||
if (result.matchedValues.length === 0) return
|
||||
|
||||
const payload =
|
||||
result.matchedValues.length === 1 ? result.matchedValues[0] : result.matchedValues
|
||||
const json = JSON.stringify(payload, null, 2)
|
||||
|
||||
navigator.clipboard.writeText(json).then(() => {
|
||||
setCopiedRawJson(true)
|
||||
setTimeout(() => setCopiedRawJson(false), 2000)
|
||||
})
|
||||
}, [result.matchedValues])
|
||||
|
||||
return (
|
||||
<div className="h-full flex gap-4 overflow-hidden">
|
||||
<Seo
|
||||
title={t("seo.jsonViewer.title")}
|
||||
description={t("seo.jsonViewer.description")}
|
||||
path="/json-viewer"
|
||||
/>
|
||||
{/* Left panel - 30% */}
|
||||
<div className="w-[30%] flex flex-col gap-4 min-h-0">
|
||||
{/* JSON Source - fills remaining height */}
|
||||
@@ -83,12 +136,7 @@ export default function JsonViewer() {
|
||||
{t("jsonViewer.jsonSource")}
|
||||
</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}
|
||||
/>
|
||||
<JsonCodeEditor value={jsonInput} onChange={setJsonInput} />
|
||||
</div>
|
||||
|
||||
{/* JSONPath Expression - fixed height */}
|
||||
@@ -123,12 +171,17 @@ export default function JsonViewer() {
|
||||
<span className="text-xs font-medium px-2 py-0.5 bg-indigo-100 text-indigo-700 rounded-full">
|
||||
{result.matchedPaths.length} {t("jsonViewer.matches")}
|
||||
</span>
|
||||
<button
|
||||
onClick={copySelectedRawJson}
|
||||
disabled={result.matchedValues.length === 0 || !!result.error}
|
||||
className="text-xs font-medium px-3 py-1 bg-indigo-500 text-white rounded-lg hover:bg-indigo-600 disabled:bg-slate-300 disabled:cursor-not-allowed transition-colours">
|
||||
{copiedRawJson ? t("jsonViewer.copied") : t("jsonViewer.copyRawJson")}
|
||||
</button>
|
||||
<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 ? t("jsonViewer.copied") : t("jsonViewer.copyAsCsv")}
|
||||
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">
|
||||
{copiedCsv ? t("jsonViewer.copied") : t("jsonViewer.copyAsCsv")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -140,11 +193,7 @@ export default function JsonViewer() {
|
||||
</div>
|
||||
)}
|
||||
{result.parsed && (
|
||||
<JsonTreeNode
|
||||
data={result.parsed}
|
||||
path={["$"]}
|
||||
matchedPaths={result.matchedPaths}
|
||||
/>
|
||||
<JsonTreeNode data={result.parsed} path={["$"]} matchedPaths={result.matchedPaths} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ComponentType } from "react"
|
||||
import { createBrowserRouter } from "react-router-dom"
|
||||
import ErrorPage from "@/components/error-page"
|
||||
import HeroLayout from "@/layout/hero-layout"
|
||||
import ToolsLayout from "@/layout/tools-layout"
|
||||
|
||||
function lazy<T extends { default: ComponentType<unknown> }>(importer: () => Promise<T>) {
|
||||
return async () => {
|
||||
@@ -27,14 +28,6 @@ const router = createBrowserRouter(
|
||||
index: true,
|
||||
lazy: lazy(() => import("@/page/home")),
|
||||
},
|
||||
{
|
||||
path: "json-viewer",
|
||||
lazy: lazy(() => import("@/page/json-viewer")),
|
||||
},
|
||||
{
|
||||
path: "bmi-calculator",
|
||||
lazy: lazy(() => import("@/page/bmi-calculator")),
|
||||
},
|
||||
{
|
||||
path: "about",
|
||||
lazy: lazy(() => import("@/page/about")),
|
||||
@@ -43,6 +36,29 @@ const router = createBrowserRouter(
|
||||
path: "contact",
|
||||
lazy: lazy(() => import("@/page/contact")),
|
||||
},
|
||||
{
|
||||
path: "changelog",
|
||||
lazy: lazy(() => import("@/page/changelog")),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/",
|
||||
element: <ToolsLayout />,
|
||||
errorElement: <ErrorPage />,
|
||||
children: [
|
||||
{
|
||||
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")),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||