feat: enhance internationalization and update application branding

- Updated application title and page titles from "DevHub" to "DevLab" across multiple locales.
- Added new translations for the home, about, contact, and JSON viewer pages to support enhanced user experience.
- Integrated language switcher in the layout for improved accessibility.
- Revised BMI calculator and contact form to reflect new branding and improved user guidance.
This commit is contained in:
2026-01-19 15:17:03 +08:00
parent 0d783b605e
commit 11d0cc5765
8 changed files with 422 additions and 214 deletions
+14 -20
View File
@@ -1,31 +1,33 @@
import { useTranslation } from "react-i18next"
/**
* 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 DevLab</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>
+135 -101
View File
@@ -2,7 +2,7 @@ import { useState } from "react"
import { useTranslation } from "react-i18next"
/**
* Home page component that displays the BMI calculator.
* BMI Calculator page component that displays the BMI calculator tool.
*/
export default function BmiCalculator() {
const { t } = useTranslation()
@@ -55,115 +55,149 @@ export default function BmiCalculator() {
}
}
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="w-full max-w-3xl mx-auto p-4 sm:p-6">
<div className="w-full space-y-6">
{/* Header */}
<div className="text-center">
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 mb-2">{t("bmi.title")}</h1>
<p className="text-sm sm:text-base text-gray-600">{t("bmi.description")}</p>
<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>
{/* Calculator Form */}
<div className="bg-white rounded-lg shadow-md p-4 sm:p-6 w-full mx-auto">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 sm:gap-6 mb-6">
{/* Weight Input */}
<div>
<label className="block text-sm font-medium text-gray-700 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 px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-base"
min="1"
max="500"
/>
<span className="absolute right-3 top-3 text-gray-500 text-sm">kg</span>
<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>
{/* Height Input */}
<div>
<label className="block text-sm font-medium text-gray-700 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 px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-base"
min="50"
max="300"
/>
<span className="absolute right-3 top-3 text-gray-500 text-sm">cm</span>
{/* 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">&lt; 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">&ge; 30.0</span>
</div>
</div>
</div>
</div>
</div>
{/* Action Buttons */}
<div className="flex flex-col sm:flex-row gap-3 sm:gap-4">
<button
onClick={calculateBMI}
disabled={!weight || !height}
className="flex-1 bg-blue-600 text-white py-3 px-6 rounded-lg font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors text-base">
{t("bmi.calculate")}
</button>
<button
onClick={resetCalculator}
className="sm:w-auto px-6 py-3 border border-gray-300 text-gray-700 rounded-lg font-medium hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 transition-colors text-base">
{t("bmi.reset")}
</button>
</div>
)}
</div>
{/* BMI Result */}
{bmi !== null && (
<div className="bg-white rounded-lg shadow-md p-4 sm:p-6 w-full mx-auto">
<div className="text-center">
<h2 className="text-lg sm:text-xl font-semibold text-gray-900 mb-4">
{t("bmi.result.title")}
</h2>
<div className={`text-3xl sm:text-4xl font-bold mb-2 ${getBmiColour()}`}>{bmi}</div>
<div className={`text-base sm:text-lg font-medium mb-4 ${getBmiColour()}`}>
{t(`bmi.category.${bmiCategory}`)}
</div>
<div className="text-gray-600 text-sm sm:text-base px-4">
{t(`bmi.advice.${bmiCategory}`)}
</div>
</div>
{/* BMI Scale */}
<div className="mt-6 p-3 sm:p-4 bg-gray-50 rounded-lg">
<h3 className="text-sm font-medium text-gray-700 mb-3">{t("bmi.scale.title")}</h3>
<div className="space-y-2 text-xs sm:text-sm">
<div className="flex justify-between items-center">
<span className="text-blue-600 font-medium">{t("bmi.category.underweight")}</span>
<span className="text-gray-600">&lt; 18.5</span>
</div>
<div className="flex justify-between items-center">
<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">
<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">
<span className="text-red-600 font-medium">{t("bmi.category.obese")}</span>
<span className="text-gray-600">&ge; 30.0</span>
</div>
</div>
</div>
</div>
)}
</div>
</div>
)
+24 -24
View File
@@ -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 = "dev-hub"
const repo = "dev-lab"
const handleRedirect = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
@@ -27,7 +29,7 @@ ${message}
`.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>
+35 -31
View File
@@ -1,63 +1,67 @@
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 { 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">DevLab</h1>
<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">
A powerful, privacy-focused tool for debugging and visualising complex JSON data
structures.
{t("home.description")}
</p>
</div>
{/* Main CTA */}
<div className="bg-white shadow rounded-lg p-8 border border-gray-100 text-center">
<h2 className="text-2xl font-semibold text-gray-900 mb-4">Get Started</h2>
<p className="text-gray-600 mb-6">
Start visualising and querying your JSON data with our intuitive JSONPath-based tool.
</p>
<Link
to="/json-viewer"
className="inline-flex items-center gap-2 px-6 py-3 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colours font-medium">
Open JSON Viewer
</Link>
</div>
{/* 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>
<div className="bg-white shadow rounded-lg p-8 border border-gray-100 text-center">
<h2 className="text-2xl font-semibold text-gray-900 mb-4">Get Started</h2>
<p className="text-gray-600 mb-6">
BMI Calculator.
</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">
Open BMI Calculator
</Link>
<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>
{/* Features */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="bg-white shadow rounded-lg p-6 border border-gray-100">
<h3 className="text-lg font-semibold text-gray-900 mb-2">🔍 JSONPath Queries</h3>
<h3 className="text-lg font-semibold text-gray-900 mb-2">{t("home.features.tools.title")}</h3>
<p className="text-gray-600 text-sm">
Use powerful JSONPath expressions to query and filter your JSON data structures.
{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">🎨 Visual Highlighting</h3>
<h3 className="text-lg font-semibold text-gray-900 mb-2">{t("home.features.privacy.title")}</h3>
<p className="text-gray-600 text-sm">
See matching paths highlighted in real-time as you type your queries.
{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">🔒 Privacy First</h3>
<h3 className="text-lg font-semibold text-gray-900 mb-2">{t("home.features.free.title")}</h3>
<p className="text-gray-600 text-sm">
All processing happens locally in your browser. No data is ever sent to servers.
{t("home.features.free.description")}
</p>
</div>
</div>
+10 -8
View File
@@ -1,4 +1,5 @@
import { useCallback, useMemo, useState } from "react"
import { useTranslation } from "react-i18next"
import jp from "jsonpath"
import JsonTreeNode from "@/components/json-tree-node"
@@ -6,6 +7,7 @@ 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",
@@ -78,7 +80,7 @@ export default function JsonViewer() {
<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
@@ -92,9 +94,9 @@ export default function JsonViewer() {
{/* 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>
<span className="text-slate-500">{t("jsonViewer.jsonPathExpression")}</span>
{result.queryError && (
<span className="text-red-500 normal-case"> Invalid syntax</span>
<span className="text-red-500 normal-case">{t("jsonViewer.invalidSyntax")}</span>
)}
</label>
<input
@@ -106,7 +108,7 @@ export default function JsonViewer() {
}`}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="e.g. $..roles"
placeholder={t("jsonViewer.placeholder")}
/>
</div>
</div>
@@ -115,18 +117,18 @@ export default function JsonViewer() {
<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
{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} matches
{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 ? "Copied!" : "Copy as CSV"}
{copied ? t("jsonViewer.copied") : t("jsonViewer.copyAsCsv")}
</button>
</div>
</div>
@@ -134,7 +136,7 @@ export default function JsonViewer() {
<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}
<strong>{t("jsonViewer.error")}</strong> {result.error}
</div>
)}
{result.parsed && (