@@ -1,11 +1,11 @@
|
||||
# JSON Visualiser
|
||||
# DevLab
|
||||
|
||||
A sophisticated, TypeScript-powered tool designed to parse JSON data and visualise **JSONPath** queries with precision. This tool allows developers to navigate complex data structures, highlight specific fields, and extract data efficiently.
|
||||
|
||||
## Access the App
|
||||
|
||||
You can use the live version of the application directly in your browser:
|
||||
👉 **[https://json-visualiser.onixbyte.dev](https://json-visualiser.onixbyte.dev)**
|
||||
👉 **[https://dev-hub.onixbyte.dev](https://dev-hub.onixbyte.dev)**
|
||||
|
||||
## New Features
|
||||
|
||||
@@ -49,8 +49,8 @@ Ensure you have Node.js installed on your machine.
|
||||
|
||||
1. Clone the repository
|
||||
```bash
|
||||
git clone https://github.com/onixbyte/json-visualiser.git
|
||||
cd json-visualiser
|
||||
git clone https://github.com/onixbyte/dev-lab.git
|
||||
cd dev-lab
|
||||
```
|
||||
|
||||
2. Install dependencies
|
||||
|
||||
@@ -4,7 +4,63 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/onixbyte.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>JSON Visualiser</title>
|
||||
|
||||
<!-- Primary Meta Tags -->
|
||||
<title>DevLab - Free Developer Tools Collection</title>
|
||||
<meta name="title" content="DevLab - Free Developer Tools Collection" />
|
||||
<meta name="description" content="A collection of powerful, privacy-focused developer tools. JSON Viewer with JSONPath queries, BMI Calculator, and more. All processing happens locally in your browser." />
|
||||
<meta name="keywords" content="developer tools, JSON viewer, JSONPath, BMI calculator, privacy-focused tools, free tools, online tools, JSON parser, JSON visualiser" />
|
||||
<meta name="author" content="OnixByte" />
|
||||
<meta name="robots" content="index, follow" />
|
||||
<meta name="language" content="English" />
|
||||
<meta name="revisit-after" content="7 days" />
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://dev-lab.onixbyte.dev/" />
|
||||
<meta property="og:title" content="DevLab - Free Developer Tools Collection" />
|
||||
<meta property="og:description" content="A collection of powerful, privacy-focused developer tools. JSON Viewer with JSONPath queries, BMI Calculator, and more. All processing happens locally in your browser." />
|
||||
<meta property="og:image" content="https://dev-lab.onixbyte.dev/onixbyte.svg" />
|
||||
<meta property="og:locale" content="en_GB" />
|
||||
<meta property="og:site_name" content="DevLab" />
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:url" content="https://dev-lab.onixbyte.dev/" />
|
||||
<meta property="twitter:title" content="DevLab - Free Developer Tools Collection" />
|
||||
<meta property="twitter:description" content="A collection of powerful, privacy-focused developer tools. JSON Viewer with JSONPath queries, BMI Calculator, and more." />
|
||||
<meta property="twitter:image" content="https://dev-lab.onixbyte.dev/onixbyte.svg" />
|
||||
|
||||
<!-- Canonical URL -->
|
||||
<link rel="canonical" href="https://dev-lab.onixbyte.dev/" />
|
||||
|
||||
<!-- Structured Data (JSON-LD) -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebApplication",
|
||||
"name": "DevLab",
|
||||
"description": "A collection of powerful, privacy-focused developer tools including JSON Viewer with JSONPath queries and BMI Calculator.",
|
||||
"url": "https://dev-lab.onixbyte.dev",
|
||||
"applicationCategory": "DeveloperApplication",
|
||||
"operatingSystem": "Web Browser",
|
||||
"offers": {
|
||||
"@type": "Offer",
|
||||
"price": "0",
|
||||
"priceCurrency": "USD"
|
||||
},
|
||||
"featureList": [
|
||||
"JSON Viewer with JSONPath queries",
|
||||
"BMI Calculator",
|
||||
"Privacy-focused local processing",
|
||||
"Free and open source"
|
||||
],
|
||||
"author": {
|
||||
"@type": "Organization",
|
||||
"name": "OnixByte"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "react-template",
|
||||
"name": "dev-lab",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
@@ -16,10 +16,13 @@
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"axios": "^1.13.2",
|
||||
"dayjs": "^1.11.19",
|
||||
"i18next": "^25.7.4",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"jsonpath": "^1.1.1",
|
||||
"lodash": "^4.17.21",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-i18next": "^16.5.3",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router": "^7.12.0",
|
||||
"react-router-dom": "^7.12.0",
|
||||
|
||||
@@ -20,6 +20,12 @@ importers:
|
||||
dayjs:
|
||||
specifier: ^1.11.19
|
||||
version: 1.11.19
|
||||
i18next:
|
||||
specifier: ^25.7.4
|
||||
version: 25.7.4(typescript@5.9.3)
|
||||
i18next-browser-languagedetector:
|
||||
specifier: ^8.2.0
|
||||
version: 8.2.0
|
||||
jsonpath:
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1
|
||||
@@ -32,6 +38,9 @@ importers:
|
||||
react-dom:
|
||||
specifier: ^19.2.3
|
||||
version: 19.2.3(react@19.2.3)
|
||||
react-i18next:
|
||||
specifier: ^16.5.3
|
||||
version: 16.5.3(i18next@25.7.4(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)
|
||||
react-redux:
|
||||
specifier: ^9.2.0
|
||||
version: 9.2.0(@types/react@19.2.8)(react@19.2.3)(redux@5.0.1)
|
||||
@@ -152,6 +161,10 @@ packages:
|
||||
peerDependencies:
|
||||
'@babel/core': ^7.0.0-0
|
||||
|
||||
'@babel/runtime@7.28.6':
|
||||
resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/template@7.28.6':
|
||||
resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -798,6 +811,20 @@ packages:
|
||||
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
html-parse-stringify@3.0.1:
|
||||
resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==}
|
||||
|
||||
i18next-browser-languagedetector@8.2.0:
|
||||
resolution: {integrity: sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g==}
|
||||
|
||||
i18next@25.7.4:
|
||||
resolution: {integrity: sha512-hRkpEblXXcXSNbw8mBNq9042OEetgyB/ahc/X17uV/khPwzV+uB8RHceHh3qavyrkPJvmXFKXME2Sy1E0KjAfw==}
|
||||
peerDependencies:
|
||||
typescript: ^5
|
||||
peerDependenciesMeta:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
immer@11.1.3:
|
||||
resolution: {integrity: sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==}
|
||||
|
||||
@@ -959,6 +986,22 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^19.2.3
|
||||
|
||||
react-i18next@16.5.3:
|
||||
resolution: {integrity: sha512-fo+/NNch37zqxOzlBYrWMx0uy/yInPkRfjSuy4lqKdaecR17nvCHnEUt3QyzA8XjQ2B/0iW/5BhaHR3ZmukpGw==}
|
||||
peerDependencies:
|
||||
i18next: '>= 25.6.2'
|
||||
react: '>= 16.8.0'
|
||||
react-dom: '*'
|
||||
react-native: '*'
|
||||
typescript: ^5
|
||||
peerDependenciesMeta:
|
||||
react-dom:
|
||||
optional: true
|
||||
react-native:
|
||||
optional: true
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
react-redux@9.2.0:
|
||||
resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==}
|
||||
peerDependencies:
|
||||
@@ -1119,6 +1162,10 @@ packages:
|
||||
yaml:
|
||||
optional: true
|
||||
|
||||
void-elements@3.1.0:
|
||||
resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
word-wrap@1.2.5:
|
||||
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -1217,6 +1264,8 @@ snapshots:
|
||||
'@babel/core': 7.28.6
|
||||
'@babel/helper-plugin-utils': 7.28.6
|
||||
|
||||
'@babel/runtime@7.28.6': {}
|
||||
|
||||
'@babel/template@7.28.6':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.28.6
|
||||
@@ -1733,6 +1782,20 @@ snapshots:
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
|
||||
html-parse-stringify@3.0.1:
|
||||
dependencies:
|
||||
void-elements: 3.1.0
|
||||
|
||||
i18next-browser-languagedetector@8.2.0:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.6
|
||||
|
||||
i18next@25.7.4(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.6
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
immer@11.1.3: {}
|
||||
|
||||
jiti@2.6.1: {}
|
||||
@@ -1857,6 +1920,17 @@ snapshots:
|
||||
react: 19.2.3
|
||||
scheduler: 0.27.0
|
||||
|
||||
react-i18next@16.5.3(i18next@25.7.4(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.6
|
||||
html-parse-stringify: 3.0.1
|
||||
i18next: 25.7.4(typescript@5.9.3)
|
||||
react: 19.2.3
|
||||
use-sync-external-store: 1.6.0(react@19.2.3)
|
||||
optionalDependencies:
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
typescript: 5.9.3
|
||||
|
||||
react-redux@9.2.0(@types/react@19.2.8)(react@19.2.3)(redux@5.0.1):
|
||||
dependencies:
|
||||
'@types/use-sync-external-store': 0.0.6
|
||||
@@ -1987,6 +2061,8 @@ snapshots:
|
||||
jiti: 2.6.1
|
||||
lightningcss: 1.30.2
|
||||
|
||||
void-elements@3.1.0: {}
|
||||
|
||||
word-wrap@1.2.5: {}
|
||||
|
||||
yallist@3.1.1: {}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# robots.txt for DevLab
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
# Sitemap location
|
||||
Sitemap: https://dev-lab.onixbyte.dev/sitemap.xml
|
||||
|
||||
# Disallow admin or private areas if any
|
||||
# Disallow: /admin/
|
||||
# Disallow: /private/
|
||||
@@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
|
||||
xmlns:xhtml="http://www.w3.org/1999/xhtml">
|
||||
<!-- Homepage -->
|
||||
<url>
|
||||
<loc>https://dev-lab.onixbyte.dev/</loc>
|
||||
<lastmod>2024-01-01</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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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,35 @@
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
/**
|
||||
* Language switcher component that allows users to switch between supported languages.
|
||||
*/
|
||||
export default function LanguageSwitcher() {
|
||||
const { i18n, t } = useTranslation()
|
||||
|
||||
const handleLanguageChange = (language: string) => {
|
||||
void i18n.changeLanguage(language)
|
||||
}
|
||||
|
||||
const currentLanguage = i18n.language
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<select
|
||||
value={currentLanguage}
|
||||
onChange={(e) => handleLanguageChange(e.target.value)}
|
||||
className="appearance-none bg-white border border-gray-300 rounded-md px-3 py-1 pr-8 text-sm text-gray-700 hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent min-w-[120px]"
|
||||
aria-label={t("language.switch")}>
|
||||
<option value="en-GB">{t("language.english")}</option>
|
||||
<option value="zh-CN">{t("language.chinese")}</option>
|
||||
</select>
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-gray-700">
|
||||
<svg
|
||||
className="fill-current h-4 w-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20">
|
||||
<path d="M9.293 12.95l.707.707L15.657 8l-1.414-1.414L10 10.828 5.757 6.586 4.343 8z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import i18n from "i18next"
|
||||
import { initReactI18next } from "react-i18next"
|
||||
import LanguageDetector from "i18next-browser-languagedetector"
|
||||
|
||||
// Import translation files
|
||||
import BritishEnglishTranslations from "./locales/BritishEnglish.json"
|
||||
import SimplifiedChineseTranslations from "./locales/SimplifiedChinese.json"
|
||||
|
||||
const resources = {
|
||||
"en-GB": {
|
||||
translation: BritishEnglishTranslations as Record<string, unknown>,
|
||||
},
|
||||
"zh-CN": {
|
||||
translation: SimplifiedChineseTranslations as Record<string, unknown>,
|
||||
},
|
||||
} as const
|
||||
|
||||
void i18n
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources,
|
||||
fallbackLng: "en-GB",
|
||||
debug: process.env.NODE_ENV === "development",
|
||||
|
||||
interpolation: {
|
||||
escapeValue: false, // React already does escaping
|
||||
},
|
||||
|
||||
detection: {
|
||||
order: ["localStorage", "navigator", "htmlTag"],
|
||||
caches: ["localStorage"],
|
||||
},
|
||||
})
|
||||
|
||||
export default i18n
|
||||
@@ -0,0 +1,131 @@
|
||||
{
|
||||
"app": {
|
||||
"title": "DevLab",
|
||||
"pageTitle": "DevLab",
|
||||
"copyright": "© {{year}} OnixByte. Built with React & TypeScript."
|
||||
},
|
||||
"navigation": {
|
||||
"home": "Home",
|
||||
"about": "About",
|
||||
"contact": "Contact"
|
||||
},
|
||||
"language": {
|
||||
"switch": "Switch Language",
|
||||
"english": "English (Great Britain)",
|
||||
"chinese": "简体中文"
|
||||
},
|
||||
"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",
|
||||
"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.",
|
||||
"features": {
|
||||
"tools": {
|
||||
"title": "🛠️ Developer Tools",
|
||||
"description": "A growing collection of practical tools for developers and everyday use."
|
||||
},
|
||||
"privacy": {
|
||||
"title": "🔒 Privacy First",
|
||||
"description": "All processing happens locally in your browser. No data is ever sent to servers."
|
||||
},
|
||||
"free": {
|
||||
"title": "✨ Free & Open",
|
||||
"description": "Completely free to use with no limitations. Open source and transparent."
|
||||
}
|
||||
}
|
||||
},
|
||||
"jsonViewer": {
|
||||
"jsonSource": "JSON Source",
|
||||
"jsonPathExpression": "JSONPath Expression",
|
||||
"invalidSyntax": "— Invalid syntax",
|
||||
"placeholder": "e.g. $..roles",
|
||||
"visualisedResult": "Visualised Result",
|
||||
"matches": "matches",
|
||||
"copied": "Copied!",
|
||||
"copyAsCsv": "Copy as CSV",
|
||||
"error": "Error:"
|
||||
},
|
||||
"about": {
|
||||
"title": "About DevLab",
|
||||
"description": "A powerful, privacy-focused tool for debugging and visualising complex JSON data structures.",
|
||||
"openSource": {
|
||||
"title": "📦 Open Source & Deployment",
|
||||
"description": "This project is open source and available on GitHub. You can view the source code, contribute, or deploy your own instance.",
|
||||
"viewOnGitHub": "View on GitHub",
|
||||
"starUs": "⭐ Star us on GitHub:",
|
||||
"starUsDescription": "If you find this project helpful, we'd greatly appreciate it if you could give us a star on GitHub. It helps others discover the project and motivates us to keep improving!",
|
||||
"selfHosting": "💡 Self-Hosting:",
|
||||
"selfHostingDescription": "You can deploy this application yourself! The repository includes all necessary configuration files. Simply clone the repository, install dependencies, and deploy to your preferred hosting platform (Vercel, Netlify, or any static hosting service)."
|
||||
},
|
||||
"privacy": {
|
||||
"title": "🔒 Privacy & Data Security",
|
||||
"description": "We believe that your data belongs to you. This application is designed as a purely client-side tool. All JSON parsing, path evaluation, and visual rendering are performed locally within your browser. No data is ever uploaded to any server, ensuring that sensitive configuration or user data remains entirely private."
|
||||
}
|
||||
},
|
||||
"contact": {
|
||||
"title": "Get in Touch",
|
||||
"description": "The best way to reach us is via our GitHub repository.",
|
||||
"whyGitHub": {
|
||||
"title": "🤝 Why GitHub Issues?",
|
||||
"description": "We use GitHub to track all bug reports and feature requests. This ensures our development process remains transparent and that your feedback is properly prioritised by the community."
|
||||
},
|
||||
"howItWorks": {
|
||||
"title": "How it works:",
|
||||
"step1": "Fill in the enquiry details in the form provided.",
|
||||
"step2": "Click \"Prepare GitHub Issue\".",
|
||||
"step3": "You will be redirected to GitHub with the data pre-filled.",
|
||||
"step4": "Simply hit \"Submit new issue\" on their site."
|
||||
},
|
||||
"email": "Alternatively, you can email us directly at:",
|
||||
"form": {
|
||||
"subject": "Subject",
|
||||
"subjectPlaceholder": "Bug report / Feature request",
|
||||
"type": "Enquiry Type",
|
||||
"typeHelp": "Selecting a type helps us categorise and prioritise your issue.",
|
||||
"message": "Message Details",
|
||||
"messagePlaceholder": "Describe your enquiry here...",
|
||||
"submit": "Prepare GitHub Issue"
|
||||
},
|
||||
"types": {
|
||||
"helpWanted": "General Enquiry",
|
||||
"enhancement": "Feature Request",
|
||||
"bug": "Bug"
|
||||
}
|
||||
},
|
||||
"bmi": {
|
||||
"title": "BMI Calculator",
|
||||
"description": "Calculate your Body Mass Index (BMI) to assess your weight status and health.",
|
||||
"weight": {
|
||||
"label": "Weight",
|
||||
"placeholder": "Enter your weight"
|
||||
},
|
||||
"height": {
|
||||
"label": "Height",
|
||||
"placeholder": "Enter your height"
|
||||
},
|
||||
"calculate": "Calculate BMI",
|
||||
"reset": "Reset",
|
||||
"result": {
|
||||
"title": "Your BMI Result",
|
||||
"emptyState": "Enter your weight and height to calculate your BMI"
|
||||
},
|
||||
"category": {
|
||||
"underweight": "Underweight",
|
||||
"normal": "Normal Weight",
|
||||
"overweight": "Overweight",
|
||||
"obese": "Obese"
|
||||
},
|
||||
"advice": {
|
||||
"underweight": "You may need to gain weight. Consider consulting with a healthcare professional for personalised advice.",
|
||||
"normal": "You have a healthy weight for your height. Maintain your current lifestyle with regular exercise and balanced nutrition.",
|
||||
"overweight": "You may benefit from losing some weight. Consider increasing physical activity and improving your diet.",
|
||||
"obese": "You may be at increased health risk. It's recommended to consult with a healthcare professional for guidance."
|
||||
},
|
||||
"scale": {
|
||||
"title": "BMI Categories"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
{
|
||||
"app": {
|
||||
"title": "DevLab",
|
||||
"pageTitle": "DevLab",
|
||||
"copyright": "© {{year}} OnixByte。 使用 React 和 TypeScript 构建。"
|
||||
},
|
||||
"navigation": {
|
||||
"home": "首页",
|
||||
"about": "关于",
|
||||
"contact": "联系"
|
||||
},
|
||||
"language": {
|
||||
"switch": "切换语言",
|
||||
"english": "English (Great Britain)",
|
||||
"chinese": "简体中文"
|
||||
},
|
||||
"home": {
|
||||
"title": "DevLab",
|
||||
"description": "一系列强大的、注重隐私的开发者工具集合。所有处理都在您的浏览器本地进行。",
|
||||
"getStarted": "开始使用",
|
||||
"getStartedDescription": "使用我们直观的基于 JSONPath 的工具开始可视化和查询您的 JSON 数据。",
|
||||
"openJsonViewer": "打开 JSON 查看器",
|
||||
"openBmiCalculator": "打开 BMI 计算器",
|
||||
"bmiCalculatorDescription": "计算您的身体质量指数(BMI)以评估您的体重状态和健康状况。根据您的 BMI 分类获得即时结果和个性化建议。",
|
||||
"features": {
|
||||
"tools": {
|
||||
"title": "🛠️ 开发者工具",
|
||||
"description": "不断增长的实用工具集合,适用于开发者和日常使用。"
|
||||
},
|
||||
"privacy": {
|
||||
"title": "🔒 隐私优先",
|
||||
"description": "所有处理都在您的浏览器本地进行。数据永远不会发送到服务器。"
|
||||
},
|
||||
"free": {
|
||||
"title": "✨ 免费开源",
|
||||
"description": "完全免费使用,无任何限制。开源且透明。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"jsonViewer": {
|
||||
"jsonSource": "JSON 源",
|
||||
"jsonPathExpression": "JSONPath 表达式",
|
||||
"invalidSyntax": "— 语法无效",
|
||||
"placeholder": "例如:$..roles",
|
||||
"visualisedResult": "可视化结果",
|
||||
"matches": "个匹配",
|
||||
"copied": "已复制!",
|
||||
"copyAsCsv": "复制为 CSV",
|
||||
"error": "错误:"
|
||||
},
|
||||
"about": {
|
||||
"title": "关于 DevLab",
|
||||
"description": "一个强大的、注重隐私的工具,用于调试和可视化复杂的 JSON 数据结构。",
|
||||
"openSource": {
|
||||
"title": "📦 开源与部署",
|
||||
"description": "这个项目是开源的,可在 GitHub 上获取。您可以查看源代码、贡献代码或部署您自己的实例。",
|
||||
"viewOnGitHub": "在 GitHub 上查看",
|
||||
"starUs": "⭐ 在 GitHub 上为我们加星:",
|
||||
"starUsDescription": "如果您觉得这个项目有帮助,我们非常感激您能在 GitHub 上为我们加星。这有助于其他人发现这个项目,并激励我们不断改进!",
|
||||
"selfHosting": "💡 自托管:",
|
||||
"selfHostingDescription": "您可以自己部署这个应用程序!仓库包含所有必要的配置文件。只需克隆仓库、安装依赖,然后部署到您首选的托管平台(Vercel、Netlify 或任何静态托管服务)。"
|
||||
},
|
||||
"privacy": {
|
||||
"title": "🔒 隐私与数据安全",
|
||||
"description": "我们相信您的数据属于您。此应用程序设计为纯客户端工具。所有 JSON 解析、路径评估和可视化渲染都在您的浏览器本地执行。数据永远不会上传到任何服务器,确保敏感配置或用户数据完全私密。"
|
||||
}
|
||||
},
|
||||
"contact": {
|
||||
"title": "联系我们",
|
||||
"description": "联系我们的最佳方式是通过我们的 GitHub 仓库。",
|
||||
"whyGitHub": {
|
||||
"title": "🤝 为什么使用 GitHub Issues?",
|
||||
"description": "我们使用 GitHub 来跟踪所有错误报告和功能请求。这确保我们的开发过程保持透明,并且您的反馈会被社区正确优先处理。"
|
||||
},
|
||||
"howItWorks": {
|
||||
"title": "使用方法:",
|
||||
"step1": "在提供的表单中填写查询详情。",
|
||||
"step2": "点击 \"准备 GitHub Issue\"。",
|
||||
"step3": "您将被重定向到 GitHub,数据已预填。",
|
||||
"step4": "只需在他们的网站上点击 \"提交新问题\"。"
|
||||
},
|
||||
"email": "或者,您可以直接发送电子邮件至:",
|
||||
"form": {
|
||||
"subject": "主题",
|
||||
"subjectPlaceholder": "错误报告 / 功能请求",
|
||||
"type": "查询类型",
|
||||
"typeHelp": "选择类型有助于我们分类和优先处理您的问题。",
|
||||
"message": "消息详情",
|
||||
"messagePlaceholder": "在此描述您的查询...",
|
||||
"submit": "准备 GitHub Issue"
|
||||
},
|
||||
"types": {
|
||||
"helpWanted": "一般查询",
|
||||
"enhancement": "功能请求",
|
||||
"bug": "错误"
|
||||
}
|
||||
},
|
||||
"bmi": {
|
||||
"title": "BMI 计算器",
|
||||
"description": "计算您的身体质量指数(BMI)以评估您的体重状态和健康状况。",
|
||||
"weight": {
|
||||
"label": "体重",
|
||||
"placeholder": "请输入您的体重"
|
||||
},
|
||||
"height": {
|
||||
"label": "身高",
|
||||
"placeholder": "请输入您的身高"
|
||||
},
|
||||
"calculate": "计算 BMI",
|
||||
"reset": "重置",
|
||||
"result": {
|
||||
"title": "您的 BMI 结果",
|
||||
"emptyState": "请输入您的体重和身高以计算 BMI"
|
||||
},
|
||||
"category": {
|
||||
"underweight": "体重过轻",
|
||||
"normal": "正常体重",
|
||||
"overweight": "超重",
|
||||
"obese": "肥胖"
|
||||
},
|
||||
"advice": {
|
||||
"underweight": "您可能需要增加体重。建议咨询医疗专业人士获取个性化建议。",
|
||||
"normal": "您的体重对于您的身高来说是健康的。保持目前的生活方式,规律运动和均衡营养。",
|
||||
"overweight": "您可能需要减轻一些体重。建议增加体育活动并改善饮食。",
|
||||
"obese": "您可能面临健康风险增加。建议咨询医疗专业人士获取指导。"
|
||||
},
|
||||
"scale": {
|
||||
"title": "BMI 分类"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
import "./dayjs"
|
||||
import "./dayjs"
|
||||
import "./i18n"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Outlet, Link, useLocation } from "react-router-dom"
|
||||
import { useMemo } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import dayjs from "dayjs"
|
||||
import LanguageSwitcher from "@/components/language-switcher"
|
||||
|
||||
/**
|
||||
* Main application component that serves as the root layout.
|
||||
@@ -8,6 +10,7 @@ import dayjs from "dayjs"
|
||||
*/
|
||||
export default function HeroLayout() {
|
||||
const today = useMemo(() => dayjs(), [])
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="h-screen bg-gray-50 flex flex-col overflow-hidden">
|
||||
@@ -17,29 +20,32 @@ export default function HeroLayout() {
|
||||
<div className="flex justify-between items-center h-16">
|
||||
<div className="flex items-center">
|
||||
<h1 className="text-xl font-semibold text-gray-900">
|
||||
JSON Visualiser
|
||||
{t("app.title")}
|
||||
</h1>
|
||||
</div>
|
||||
<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"
|
||||
>
|
||||
Home
|
||||
</Link>
|
||||
<Link
|
||||
to="/about"
|
||||
className="text-gray-500 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium"
|
||||
>
|
||||
About
|
||||
</Link>
|
||||
<Link
|
||||
to="/contact"
|
||||
className="text-gray-500 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium"
|
||||
>
|
||||
Contact
|
||||
</Link>
|
||||
</nav>
|
||||
<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>
|
||||
</nav>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -53,7 +59,7 @@ export default function HeroLayout() {
|
||||
<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">
|
||||
© 2024-{today.year()} OnixByte. Built with React & TypeScript.
|
||||
{t("app.copyright", { year: today.year() })}
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -1,31 +1,33 @@
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
/**
|
||||
* About page component that displays information about the JSON Visualiser application.
|
||||
* About page component that displays information about the DevLab application.
|
||||
*/
|
||||
export default function About() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<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">About JSON Visualiser</h1>
|
||||
<h1 className="text-3xl font-bold text-gray-900 sm:text-4xl">{t("about.title")}</h1>
|
||||
<p className="mt-4 text-lg text-gray-600">
|
||||
A powerful, privacy-focused tool for debugging and visualising complex JSON data
|
||||
structures.
|
||||
{t("about.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* GitHub & Deployment */}
|
||||
<div className="bg-white shadow rounded-lg p-6 border border-gray-100">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4 flex items-center">
|
||||
<span className="mr-2">📦</span> Open Source & Deployment
|
||||
<span className="mr-2">📦</span> {t("about.openSource.title")}
|
||||
</h2>
|
||||
<div className="space-y-4 text-gray-700">
|
||||
<p>
|
||||
This project is open source and available on GitHub. You can view the source code,
|
||||
contribute, or deploy your own instance.
|
||||
{t("about.openSource.description")}
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<a
|
||||
href="https://github.com/onixbyte/json-visualiser"
|
||||
href="https://github.com/onixbyte/dev-lab"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-900 text-white rounded-lg hover:bg-gray-800 transition-colours font-medium">
|
||||
@@ -36,22 +38,17 @@ export default function About() {
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
View on GitHub
|
||||
{t("about.openSource.viewOnGitHub")}
|
||||
</a>
|
||||
</div>
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||
<p className="text-amber-900">
|
||||
<strong>⭐ Star us on GitHub:</strong> If you find this project helpful, we'd greatly
|
||||
appreciate it if you could give us a star on GitHub. It helps others discover the
|
||||
project and motivates us to keep improving!
|
||||
<strong>{t("about.openSource.starUs")}</strong> {t("about.openSource.starUsDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-blue-50 border border-blue-100 rounded-lg p-4 mt-4">
|
||||
<p className="text-blue-900">
|
||||
<strong>💡 Self-Hosting:</strong> You can deploy this application yourself! The
|
||||
repository includes all necessary configuration files. Simply clone the repository,
|
||||
install dependencies, and deploy to your preferred hosting platform (Vercel, Netlify,
|
||||
or any static hosting service).
|
||||
<strong>{t("about.openSource.selfHosting")}</strong> {t("about.openSource.selfHostingDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -60,13 +57,10 @@ export default function About() {
|
||||
{/* Privacy Commitment */}
|
||||
<div className="bg-indigo-50 border border-indigo-100 rounded-lg p-6 text-indigo-900">
|
||||
<h2 className="text-xl font-semibold mb-3 flex items-center">
|
||||
<span className="mr-2">🔒</span> Privacy & Data Security
|
||||
<span className="mr-2">🔒</span> {t("about.privacy.title")}
|
||||
</h2>
|
||||
<p className="leading-relaxed">
|
||||
We believe that your data belongs to you. This application is designed as a{" "}
|
||||
<strong>purely client-side tool</strong>. All JSON parsing, path evaluation, and visual
|
||||
rendering are performed locally within your browser. No data is ever uploaded to any
|
||||
server, ensuring that sensitive configuration or user data remains entirely private.
|
||||
{t("about.privacy.description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import { useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
/**
|
||||
* BMI Calculator page component that displays the BMI calculator tool.
|
||||
*/
|
||||
export default function BmiCalculator() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [weight, setWeight] = useState<string>("")
|
||||
const [height, setHeight] = useState<string>("")
|
||||
const [bmi, setBmi] = useState<number | null>(null)
|
||||
const [bmiCategory, setBmiCategory] = useState<string>("")
|
||||
|
||||
const calculateBMI = () => {
|
||||
const weightNum = parseFloat(weight)
|
||||
const heightNum = parseFloat(height) / 100 // Convert cm to meters
|
||||
|
||||
if (weightNum > 0 && heightNum > 0) {
|
||||
const bmiValue = weightNum / (heightNum * heightNum)
|
||||
setBmi(parseFloat(bmiValue.toFixed(1)))
|
||||
|
||||
// Determine BMI category
|
||||
if (bmiValue < 18.5) {
|
||||
setBmiCategory("underweight")
|
||||
} else if (bmiValue < 25) {
|
||||
setBmiCategory("normal")
|
||||
} else if (bmiValue < 30) {
|
||||
setBmiCategory("overweight")
|
||||
} else {
|
||||
setBmiCategory("obese")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resetCalculator = () => {
|
||||
setWeight("")
|
||||
setHeight("")
|
||||
setBmi(null)
|
||||
setBmiCategory("")
|
||||
}
|
||||
|
||||
const getBmiColour = () => {
|
||||
switch (bmiCategory) {
|
||||
case "underweight":
|
||||
return "text-blue-600"
|
||||
case "normal":
|
||||
return "text-green-600"
|
||||
case "overweight":
|
||||
return "text-yellow-600"
|
||||
case "obese":
|
||||
return "text-red-600"
|
||||
default:
|
||||
return "text-gray-600"
|
||||
}
|
||||
}
|
||||
|
||||
const getBmiBgColour = () => {
|
||||
switch (bmiCategory) {
|
||||
case "underweight":
|
||||
return "bg-blue-50 border-blue-100"
|
||||
case "normal":
|
||||
return "bg-green-50 border-green-100"
|
||||
case "overweight":
|
||||
return "bg-yellow-50 border-yellow-100"
|
||||
case "obese":
|
||||
return "bg-red-50 border-red-100"
|
||||
default:
|
||||
return "bg-gray-50 border-gray-100"
|
||||
}
|
||||
}
|
||||
|
||||
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">
|
||||
{/* Input Form - 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">
|
||||
{t("bmi.title")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 p-4 overflow-auto min-h-0">
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600 mb-4">{t("bmi.description")}</p>
|
||||
|
||||
{/* Weight Input */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-slate-500 mb-2">
|
||||
{t("bmi.weight.label")}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="number"
|
||||
value={weight}
|
||||
onChange={(e) => setWeight(e.target.value)}
|
||||
placeholder={t("bmi.weight.placeholder")}
|
||||
className="w-full p-3 text-sm border border-slate-200 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition-all shadow-sm"
|
||||
min="1"
|
||||
max="500"
|
||||
/>
|
||||
<span className="absolute right-3 top-3 text-gray-500 text-xs">kg</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Height Input */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-slate-500 mb-2">
|
||||
{t("bmi.height.label")}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="number"
|
||||
value={height}
|
||||
onChange={(e) => setHeight(e.target.value)}
|
||||
placeholder={t("bmi.height.placeholder")}
|
||||
className="w-full p-3 text-sm border border-slate-200 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition-all shadow-sm"
|
||||
min="50"
|
||||
max="300"
|
||||
/>
|
||||
<span className="absolute right-3 top-3 text-gray-500 text-xs">cm</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col gap-2 pt-2">
|
||||
<button
|
||||
onClick={calculateBMI}
|
||||
disabled={!weight || !height}
|
||||
className="w-full bg-indigo-600 text-white py-2.5 px-4 rounded-lg text-sm font-medium hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:bg-slate-300 disabled:cursor-not-allowed transition-colours">
|
||||
{t("bmi.calculate")}
|
||||
</button>
|
||||
<button
|
||||
onClick={resetCalculator}
|
||||
className="w-full border border-slate-200 text-slate-700 py-2.5 px-4 rounded-lg text-sm font-medium hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-slate-500 transition-colours">
|
||||
{t("bmi.reset")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right result 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 shrink-0">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
{t("bmi.result.title")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 p-6 overflow-auto min-h-0">
|
||||
{bmi === null ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center text-gray-400">
|
||||
<p className="text-sm">{t("bmi.description")}</p>
|
||||
<p className="text-xs mt-2">{t("bmi.result.emptyState")}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* BMI Value Display */}
|
||||
<div className={`text-center p-6 rounded-lg border ${getBmiBgColour()}`}>
|
||||
<div className={`text-5xl font-bold mb-2 ${getBmiColour()}`}>{bmi}</div>
|
||||
<div className={`text-lg font-semibold mb-3 ${getBmiColour()}`}>
|
||||
{t(`bmi.category.${bmiCategory}`)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-700 max-w-md mx-auto">
|
||||
{t(`bmi.advice.${bmiCategory}`)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* BMI Scale */}
|
||||
<div className="bg-slate-50 rounded-lg p-4 border border-slate-200">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-500 mb-3">
|
||||
{t("bmi.scale.title")}
|
||||
</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between items-center py-1.5 px-2 rounded hover:bg-white transition-colours">
|
||||
<span className="text-blue-600 font-medium">{t("bmi.category.underweight")}</span>
|
||||
<span className="text-gray-600">< 18.5</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-1.5 px-2 rounded hover:bg-white transition-colours">
|
||||
<span className="text-green-600 font-medium">{t("bmi.category.normal")}</span>
|
||||
<span className="text-gray-600">18.5 - 24.9</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-1.5 px-2 rounded hover:bg-white transition-colours">
|
||||
<span className="text-yellow-600 font-medium">{t("bmi.category.overweight")}</span>
|
||||
<span className="text-gray-600">25.0 - 29.9</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-1.5 px-2 rounded hover:bg-white transition-colours">
|
||||
<span className="text-red-600 font-medium">{t("bmi.category.obese")}</span>
|
||||
<span className="text-gray-600">≥ 30.0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import React from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
/**
|
||||
* Contact page component that encourages manual GitHub Issue submission.
|
||||
*/
|
||||
export default function Contact() {
|
||||
const { t } = useTranslation()
|
||||
const owner = "onixbyte"
|
||||
const repo = "json-visualiser"
|
||||
const repo = "dev-lab"
|
||||
|
||||
const handleRedirect = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
@@ -23,11 +25,11 @@ export default function Contact() {
|
||||
${message}
|
||||
|
||||
---
|
||||
*Generated via JSON Visualiser Contact Page*
|
||||
*Generated via DevLab Contact Page*
|
||||
`.trim()
|
||||
|
||||
const githubUrl = `https://github.com/${owner}/${repo}/issues/new?title=${encodeURIComponent(
|
||||
`[${type == "help wanted" ? "General Enquiry" : (type == "enhancement" ? "Feature request" : "Bug")}] ${subject}`
|
||||
`[${type == "help wanted" ? t("contact.types.helpWanted") : (type == "enhancement" ? t("contact.types.enhancement") : t("contact.types.bug"))}] ${subject}`
|
||||
)}&body=${encodeURIComponent(issueBody)}&labels=${type}`
|
||||
|
||||
// Open in a new tab so they don't lose their place on your site
|
||||
@@ -38,9 +40,9 @@ ${message}
|
||||
<div className="max-w-5xl mx-auto py-12 px-4 sm:px-6">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-3xl font-bold text-gray-900 sm:text-4xl">Get in Touch</h1>
|
||||
<h1 className="text-3xl font-bold text-gray-900 sm:text-4xl">{t("contact.title")}</h1>
|
||||
<p className="mt-4 text-lg text-gray-600">
|
||||
The best way to reach us is via our GitHub repository.
|
||||
{t("contact.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -49,32 +51,30 @@ ${message}
|
||||
<div className="space-y-8">
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4 flex items-center">
|
||||
<span className="mr-2">🤝</span> Why GitHub Issues?
|
||||
<span className="mr-2">🤝</span> {t("contact.whyGitHub.title")}
|
||||
</h2>
|
||||
<p className="text-gray-600 leading-relaxed">
|
||||
We use GitHub to track all bug reports and feature requests. This ensures our
|
||||
development process remains <span className="font-bold">transparent</span> and that
|
||||
your feedback is properly prioritised by the community.
|
||||
{t("contact.whyGitHub.description")}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="bg-blue-50 border border-blue-100 p-6 rounded-2xl">
|
||||
<h3 className="text-blue-900 font-bold mb-2">How it works:</h3>
|
||||
<h3 className="text-blue-900 font-bold mb-2">{t("contact.howItWorks.title")}</h3>
|
||||
<ol className="list-decimal list-inside text-blue-800 text-sm space-y-2">
|
||||
<li>Fill in the enquiry details in the form provided.</li>
|
||||
<li>{t("contact.howItWorks.step1")}</li>
|
||||
<li>
|
||||
Click <strong>"Prepare GitHub Issue"</strong>.
|
||||
{t("contact.howItWorks.step2")}
|
||||
</li>
|
||||
<li>You will be redirected to GitHub with the data pre-filled.</li>
|
||||
<li>{t("contact.howItWorks.step3")}</li>
|
||||
<li>
|
||||
Simply hit <strong>"Submit new issue"</strong> on their site.
|
||||
{t("contact.howItWorks.step4")}
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<div className="pt-4 border-t border-gray-100">
|
||||
<p className="text-sm text-gray-500 italic">
|
||||
Alternatively, you can email us directly at: <br />
|
||||
{t("contact.email")} <br />
|
||||
<a
|
||||
href="mailto:real@zihluwang.me"
|
||||
className="text-blue-600 font-medium hover:underline">
|
||||
@@ -89,7 +89,7 @@ ${message}
|
||||
<form onSubmit={handleRedirect} className="space-y-5">
|
||||
<div>
|
||||
<label htmlFor="subject" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Subject
|
||||
{t("contact.form.subject")}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -97,7 +97,7 @@ ${message}
|
||||
name="subject"
|
||||
required
|
||||
className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:bg-white outline-none transition-all"
|
||||
placeholder="Bug report / Feature request"
|
||||
placeholder={t("contact.form.subjectPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -105,7 +105,7 @@ ${message}
|
||||
<label
|
||||
htmlFor="type"
|
||||
className="block text-sm font-semibold text-gray-700 mb-1.5 ml-1">
|
||||
Enquiry Type
|
||||
{t("contact.form.type")}
|
||||
</label>
|
||||
<div className="relative group">
|
||||
<select
|
||||
@@ -113,9 +113,9 @@ ${message}
|
||||
name="type"
|
||||
required
|
||||
className="w-full appearance-none px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl text-gray-700 text-sm cursor-pointer transition-all duration-200 hover:border-gray-300 hover:bg-gray-100/50 focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500 focus:bg-white outline-none">
|
||||
<option value="help wanted">General Enquiry</option>
|
||||
<option value="enhancement">Feature Request</option>
|
||||
<option value="bug">Bug</option>
|
||||
<option value="help wanted">{t("contact.types.helpWanted")}</option>
|
||||
<option value="enhancement">{t("contact.types.enhancement")}</option>
|
||||
<option value="bug">{t("contact.types.bug")}</option>
|
||||
</select>
|
||||
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center px-3 text-gray-400 group-hover:text-gray-600 transition-colors">
|
||||
@@ -128,13 +128,13 @@ ${message}
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 ml-1 text-[11px] text-gray-400">
|
||||
Selecting a type helps us categorise and prioritise your issue.
|
||||
{t("contact.form.typeHelp")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="message" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Message Details
|
||||
{t("contact.form.message")}
|
||||
</label>
|
||||
<textarea
|
||||
id="message"
|
||||
@@ -142,7 +142,7 @@ ${message}
|
||||
rows={4}
|
||||
required
|
||||
className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:bg-white outline-none transition-all resize-none"
|
||||
placeholder="Describe your enquiry here..."
|
||||
placeholder={t("contact.form.messagePlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -152,7 +152,7 @@ ${message}
|
||||
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 21.795 24 17.297 24 12.017c0-6.627-5.373-12-12-12" />
|
||||
</svg>
|
||||
Prepare GitHub Issue
|
||||
{t("contact.form.submit")}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,149 +1,68 @@
|
||||
import { useCallback, useMemo, useState } from "react"
|
||||
import jp from "jsonpath"
|
||||
import JsonTreeNode from "@/components/json-tree-node"
|
||||
import { Link } from "react-router-dom"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
/**
|
||||
* 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])
|
||||
const { t } = useTranslation()
|
||||
|
||||
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">{t("home.title")}</h1>
|
||||
<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="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>
|
||||
<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")}
|
||||
</Link>
|
||||
</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 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>
|
||||
<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")}
|
||||
</Link>
|
||||
</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>
|
||||
{/* 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>
|
||||
</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">{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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useCallback, useMemo, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import jp from "jsonpath"
|
||||
import JsonTreeNode from "@/components/json-tree-node"
|
||||
|
||||
/**
|
||||
* JSON Viewer page component that displays the JSON visualisation tool in DevLab.
|
||||
*/
|
||||
export default function JsonViewer() {
|
||||
const { t } = useTranslation()
|
||||
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">
|
||||
{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}
|
||||
/>
|
||||
</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">{t("jsonViewer.jsonPathExpression")}</span>
|
||||
{result.queryError && (
|
||||
<span className="text-red-500 normal-case">{t("jsonViewer.invalidSyntax")}</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={t("jsonViewer.placeholder")}
|
||||
/>
|
||||
</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">
|
||||
{t("jsonViewer.visualisedResult")}
|
||||
</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} {t("jsonViewer.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 ? t("jsonViewer.copied") : t("jsonViewer.copyAsCsv")}
|
||||
</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>{t("jsonViewer.error")}</strong> {result.error}
|
||||
</div>
|
||||
)}
|
||||
{result.parsed && (
|
||||
<JsonTreeNode
|
||||
data={result.parsed}
|
||||
path={["$"]}
|
||||
matchedPaths={result.matchedPaths}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -27,6 +27,14 @@ 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")),
|
||||
|
||||