Merge branch 'main' into develop

This commit is contained in:
2026-02-24 14:52:43 +08:00
18 changed files with 907 additions and 81 deletions
+6
View File
@@ -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>
+6
View File
@@ -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 */}
+142
View File
@@ -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>
)
}
+6
View File
@@ -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>
+36 -27
View File
@@ -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>
+155
View File
@@ -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>
)
}
+71 -22
View File
@@ -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>