sync from local backup

This commit is contained in:
chunyu
2026-08-05 23:59:22 +08:00
commit a59f46b894
409 changed files with 166027 additions and 0 deletions
+582
View File
@@ -0,0 +1,582 @@
.tool-detail-page {
min-height: 100vh;
background: #f0f2f5;
}
.tool-detail-hero {
min-height: 200px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%);
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
padding: 40px 20px;
}
.tool-detail-hero::before {
content: "";
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(circle at 30% 50%, rgba(255, 255, 255, 0.1) 0%, transparent 50%),
radial-gradient(circle at 70% 80%, rgba(255, 255, 255, 0.08) 0%, transparent 40%);
animation: tdHeroLight 8s ease-in-out infinite alternate;
}
@keyframes tdHeroLight {
0% { transform: translate(0, 0); }
100% { transform: translate(-20px, -10px); }
}
.tool-detail-hero-particles {
position: absolute;
inset: 0;
pointer-events: none;
overflow: hidden;
}
.td-particle {
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, 0.12);
animation: tdFloat 6s ease-in-out infinite;
}
.td-particle-1 { width: 50px; height: 50px; top: 15%; left: 8%; animation-delay: 0s; }
.td-particle-2 { width: 35px; height: 35px; top: 65%; left: 18%; animation-delay: 1s; }
.td-particle-3 { width: 65px; height: 65px; top: 25%; right: 12%; animation-delay: 2s; }
.td-particle-4 { width: 25px; height: 25px; top: 70%; right: 25%; animation-delay: 0.5s; }
.td-particle-5 { width: 40px; height: 40px; top: 45%; left: 45%; animation-delay: 1.5s; }
.td-particle-6 { width: 30px; height: 30px; bottom: 20%; left: 30%; animation-delay: 3s; }
@keyframes tdFloat {
0%, 100% { transform: translateY(0) scale(1); opacity: 0.3; }
50% { transform: translateY(-18px) scale(1.1); opacity: 0.6; }
}
.tool-detail-hero-content {
text-align: center;
position: relative;
z-index: 1;
}
.tool-detail-hero-icon {
width: 64px;
height: 64px;
border-radius: 20px;
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(10px);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 28px;
color: #fff;
margin-bottom: 16px;
border: 1px solid rgba(255, 255, 255, 0.3);
}
.tool-detail-hero-title {
font-size: 32px;
font-weight: 800;
color: #fff;
margin: 0 0 8px;
letter-spacing: 2px;
}
.tool-detail-hero-desc {
font-size: 15px;
color: rgba(255, 255, 255, 0.85);
margin: 0 0 20px;
}
.tool-detail-hero-stats {
display: inline-flex;
align-items: center;
gap: 20px;
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 30px;
padding: 10px 28px;
}
.td-hero-stat {
display: flex;
align-items: center;
gap: 6px;
color: white;
font-size: 14px;
font-weight: 500;
}
.td-hero-stat-divider {
width: 1px;
height: 16px;
background: rgba(255, 255, 255, 0.3);
}
.tool-detail-container {
max-width: 1100px;
margin: -30px auto 0;
padding: 0 20px 60px;
position: relative;
z-index: 2;
}
.tool-detail-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.td-back-btn {
border-radius: 8px;
font-weight: 500;
color: #64748b;
border-color: #e2e8f0;
}
.td-back-btn:hover {
color: #667eea;
border-color: #667eea;
}
.tool-detail-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.td-action-btn {
height: 38px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
display: inline-flex;
align-items: center;
gap: 5px;
transition: all 0.2s ease;
}
.td-action-btn:hover {
transform: translateY(-1px);
}
.td-action-primary {
background: linear-gradient(135deg, #667eea, #764ba2) !important;
border: none !important;
color: #fff !important;
box-shadow: 0 4px 14px rgba(102, 126, 234, 0.35);
}
.td-action-primary:hover {
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.45) !important;
}
.td-action-danger {
color: #ef4444 !important;
border-color: #fca5a5 !important;
}
.td-action-danger:hover {
background: #fef2f2 !important;
}
.tool-detail-panels {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-bottom: 24px;
}
.tool-detail-panel {
background: #fff;
border-radius: 14px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
border: 1px solid #e8ecf1;
overflow: hidden;
display: flex;
flex-direction: column;
transition: box-shadow 0.3s ease;
}
.tool-detail-panel:hover {
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
}
.tool-detail-panel-header {
padding: 12px 16px;
border-bottom: 1px solid #f1f5f9;
display: flex;
align-items: center;
justify-content: space-between;
background: #fafbfc;
}
.tool-detail-panel-title {
font-size: 14px;
font-weight: 600;
color: #334155;
margin: 0;
display: flex;
align-items: center;
gap: 8px;
}
.td-panel-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #cbd5e1;
transition: background 0.3s ease;
}
.td-panel-dot.input-dot {
background: #667eea;
}
.td-panel-dot.success-dot {
background: #10b981;
}
.td-panel-dot.error-dot {
background: #ef4444;
}
.tool-detail-panel-body {
flex: 1;
display: flex;
flex-direction: column;
}
.tool-detail-panel-body textarea {
width: 100%;
min-height: 380px;
border: none;
outline: none;
resize: vertical;
padding: 16px;
font-family: "JetBrains Mono", "Fira Code", "Consolas", monospace;
font-size: 13px;
line-height: 1.7;
background: #1e1e2e;
color: #a6e3a1;
box-sizing: border-box;
flex: 1;
}
.tool-detail-panel-body textarea::placeholder {
color: #6c7086;
}
.tool-detail-panel-footer {
padding: 8px 16px;
border-top: 1px solid #f1f5f9;
display: flex;
align-items: center;
justify-content: space-between;
background: #fafbfc;
}
.td-char-count {
font-size: 12px;
color: #94a3b8;
}
.td-copy-inline {
font-size: 12px;
color: #667eea !important;
padding: 0 !important;
height: auto !important;
}
.json-output-panel-body {
flex: 1;
background: #1e1e2e;
}
.json-view-tabs {
flex: 1;
display: flex;
flex-direction: column;
}
.json-view-tabs .ant-tabs-nav {
margin: 0;
padding: 0 16px;
background: #181825;
}
.json-view-tabs .ant-tabs-tab {
font-size: 12px;
padding: 8px 0;
color: #6c7086 !important;
}
.json-view-tabs .ant-tabs-tab-active .ant-tabs-tab-btn {
color: #89dceb !important;
}
.json-view-tabs .ant-tabs-ink-bar {
background: #89dceb;
}
.json-view-tabs .ant-tabs-content-holder {
flex: 1;
overflow: hidden;
}
.json-view-tabs .ant-tabs-content {
height: 100%;
}
.json-view-tabs .ant-tabs-tabpane {
height: 100%;
}
.json-code-output {
width: 100%;
min-height: 380px;
padding: 16px;
font-family: "JetBrains Mono", "Fira Code", "Consolas", monospace;
font-size: 13px;
line-height: 1.7;
background: #1e1e2e;
color: #a6e3a1;
white-space: pre-wrap;
word-break: break-all;
overflow: auto;
box-sizing: border-box;
margin: 0;
}
.json-code-output.output-error {
color: #f38ba8;
}
.json-code-output.output-empty {
color: #6c7086;
font-style: italic;
}
.json-key {
color: #89dceb;
}
.json-string {
color: #a6e3a1;
}
.json-number {
color: #fab387;
}
.json-boolean {
color: #89b4fa;
}
.json-null {
color: #f38ba8;
}
.json-tree-output {
width: 100%;
min-height: 380px;
padding: 16px;
background: #1e1e2e;
overflow: auto;
box-sizing: border-box;
font-family: "JetBrains Mono", "Fira Code", "Consolas", monospace;
font-size: 13px;
line-height: 1.7;
}
.json-tree-output ul {
padding-left: 1em !important;
margin: 0 !important;
}
.json-tree-output li {
list-style: none !important;
}
.tree-placeholder {
color: #6c7086;
font-style: italic;
font-size: 13px;
}
.tool-detail-tips {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.td-tip-card {
background: white;
border-radius: 14px;
padding: 20px;
border: 1px solid #e8ecf1;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
display: flex;
gap: 14px;
transition: all 0.3s ease;
}
.td-tip-card:hover {
transform: translateY(-3px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
}
.td-tip-icon {
font-size: 28px;
flex-shrink: 0;
line-height: 1;
}
.td-tip-content h4 {
font-size: 14px;
font-weight: 700;
color: #1e293b;
margin: 0 0 6px;
}
.td-tip-content p {
font-size: 13px;
color: #64748b;
margin: 0;
line-height: 1.6;
}
@media (max-width: 768px) {
.tool-detail-hero {
min-height: 160px;
padding: 30px 16px;
}
.tool-detail-hero-title {
font-size: 24px;
}
.tool-detail-container {
padding: 0 12px 40px;
margin-top: -20px;
}
.tool-detail-toolbar {
flex-direction: column;
align-items: stretch;
}
.tool-detail-actions {
justify-content: center;
}
.tool-detail-panels {
grid-template-columns: 1fr;
gap: 14px;
}
.tool-detail-panel-body textarea,
.json-code-output,
.json-tree-output {
min-height: 250px;
}
.td-action-btn {
flex: 1;
min-width: 0;
justify-content: center;
}
.tool-detail-tips {
grid-template-columns: 1fr;
gap: 10px;
}
}
/* ===== 暗色模式 ===== */
[data-theme="dark"] .tool-detail-page {
background: #0f172a;
}
[data-theme="dark"] .tool-detail-panel {
background: #1e293b;
border-color: #334155;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.2);
}
[data-theme="dark"] .tool-detail-panel-header,
[data-theme="dark"] .tool-detail-panel-footer {
background: rgba(102, 126, 234, 0.08);
border-color: #334155;
}
[data-theme="dark"] .tool-detail-panel-title {
color: #f1f5f9;
}
[data-theme="dark"] .td-panel-dot {
background: #475569;
}
[data-theme="dark"] .td-back-btn {
color: #94a3b8 !important;
border-color: #334155 !important;
}
[data-theme="dark"] .td-back-btn:hover {
color: #818cf8 !important;
border-color: #818cf8 !important;
}
[data-theme="dark"] .td-tip-card {
background: #1e293b;
border-color: #334155;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
[data-theme="dark"] .td-tip-content h4 {
color: #f1f5f9;
}
[data-theme="dark"] .td-tip-content p {
color: #94a3b8;
}
[data-theme="dark"] .td-char-count {
color: #64748b;
}
[data-theme="dark"] .td-action-danger {
color: #f87171 !important;
border-color: #f87171 !important;
}
[data-theme="dark"] .td-action-danger:hover {
background: rgba(239, 68, 68, 0.1) !important;
}
/* JsonFormatter 特有样式 */
[data-theme="dark"] .json-view-tabs .ant-tabs-nav {
background: #181825;
}
[data-theme="dark"] .json-view-tabs .ant-tabs-tab {
color: #6c7086 !important;
}
[data-theme="dark"] .json-view-tabs .ant-tabs-tab-active .ant-tabs-tab-btn {
color: #89dceb !important;
}
[data-theme="dark"] .json-view-tabs .ant-tabs-ink-bar {
background: #89dceb;
}
+372
View File
@@ -0,0 +1,372 @@
import "./JsonFormatter.css";
import React, { useState } from "react";
import { Button, Tag, Tabs } from "antd";
import { message } from "@/utils/message";
import {
CopyOutlined,
ClearOutlined,
CodeOutlined,
CheckCircleOutlined,
ThunderboltOutlined,
BookOutlined,
ArrowLeftOutlined,
} from "@ant-design/icons";
import { useNavigate } from "react-router-dom";
import { JSONTree } from "react-json-tree";
import { useTranslation } from "react-i18next";
import { useRecordHistory } from "@/hooks/useRecordHistory";
import ToolFavoriteButton from "@/components/ToolFavoriteButton/ToolFavoriteButton";
const treeTheme = {
scheme: "custom",
author: "custom",
base00: "#1e1e2e",
base01: "#313244",
base02: "#45475a",
base03: "#585b70",
base04: "#6c7086",
base05: "#cdd6f4",
base06: "#cdd6f4",
base07: "#cdd6f4",
base08: "#f38ba8",
base09: "#fab387",
base0A: "#f9e2af",
base0B: "#a6e3a1",
base0C: "#89dceb",
base0D: "#89b4fa",
base0E: "#cba6f7",
base0F: "#fab387",
};
function syntaxHighlight(json: string): string {
const escaped = json
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
return escaped.replace(
/("(\\u[\da-fA-F]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g,
(match) => {
let cls = "json-number";
if (/^"/.test(match)) {
if (/:$/.test(match)) {
cls = "json-key";
const key = match.replace(/:$/, "");
return `<span class="${cls}">${key}</span>:`;
} else {
cls = "json-string";
}
} else if (/true|false/.test(match)) {
cls = "json-boolean";
} else if (/null/.test(match)) {
cls = "json-null";
}
return `<span class="${cls}">${match}</span>`;
}
);
}
const JsonFormatter: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [input, setInput] = useState("");
useRecordHistory({
type: "tool",
title: t("jsonFormatter.heroTitle"),
description: t("jsonFormatter.heroSubtitle"),
category: t("jsonFormatter.category"),
link: "/json-formatter",
});
const [output, setOutput] = useState("");
const [parsedData, setParsedData] = useState<any>(null);
const [isError, setIsError] = useState(false);
const [viewMode, setViewMode] = useState<"code" | "tree">("code");
const handleFormat = () => {
if (!input.trim()) {
message.warning(t("jsonFormatter.pleaseEnterJson"));
return;
}
try {
const parsed = JSON.parse(input);
setOutput(JSON.stringify(parsed, null, 2));
setParsedData(parsed);
setIsError(false);
message.success(t("jsonFormatter.formatSuccess"));
} catch (e: any) {
setOutput(`${t("jsonFormatter.parseError")}: ${e.message}`);
setParsedData(null);
setIsError(true);
}
};
const handleCompress = () => {
if (!input.trim()) {
message.warning(t("jsonFormatter.pleaseEnterJson"));
return;
}
try {
const parsed = JSON.parse(input);
setOutput(JSON.stringify(parsed));
setParsedData(parsed);
setIsError(false);
message.success(t("jsonFormatter.compressSuccess"));
} catch (e: any) {
setOutput(`${t("jsonFormatter.parseError")}: ${e.message}`);
setParsedData(null);
setIsError(true);
}
};
const handleCopy = () => {
if (!output) {
message.warning(t("jsonFormatter.noContentToCopy"));
return;
}
navigator.clipboard.writeText(output).then(() => {
message.success(t("jsonFormatter.copiedToClipboard"));
});
};
const handleClear = () => {
setInput("");
setOutput("");
setParsedData(null);
setIsError(false);
message.info(t("jsonFormatter.cleared"));
};
const handleSample = () => {
const sample = JSON.stringify(
{
name: t("jsonFormatter.sampleName"),
version: "2.0",
features: [
t("jsonFormatter.sampleFeature1"),
t("jsonFormatter.sampleFeature2"),
t("jsonFormatter.sampleFeature3"),
],
stats: { users: 128000, articles: 2680 },
isActive: true,
metadata: null,
},
null,
2
);
setInput(sample);
message.info(t("jsonFormatter.sampleLoaded"));
};
const tabItems = [
{
key: "code",
label: t("jsonFormatter.codeView"),
children: (
<div
className={`json-code-output ${isError ? "output-error" : ""} ${
!output ? "output-empty" : ""
}`}
dangerouslySetInnerHTML={{
__html: output
? isError
? output
: syntaxHighlight(output)
: t("jsonFormatter.resultPlaceholder"),
}}
/>
),
},
{
key: "tree",
label: t("jsonFormatter.treeView"),
children: (
<div className="json-tree-output">
{parsedData ? (
<JSONTree data={parsedData} theme={treeTheme} invertTheme={false} />
) : (
<span className="tree-placeholder">
{t("jsonFormatter.treePlaceholder")}
</span>
)}
</div>
),
},
];
return (
<div className="tool-detail-page">
<div className="tool-detail-hero">
<div className="tool-detail-hero-particles">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className={`td-particle td-particle-${i + 1}`} />
))}
</div>
<div className="tool-detail-hero-content">
<div className="tool-detail-hero-icon">
<CodeOutlined />
</div>
<div className="tool-detail-hero-title-wrapper">
<h1 className="tool-detail-hero-title">
{t("jsonFormatter.heroTitle")}
</h1>
<ToolFavoriteButton urlPath="/utility/json-formatter" variant="hero" />
</div>
<p className="tool-detail-hero-desc">
{t("jsonFormatter.heroSubtitle")}
</p>
</div>
</div>
<div className="tool-detail-container">
<div className="tool-detail-toolbar">
<div className="tool-detail-toolbar-left">
<Button
icon={<ArrowLeftOutlined />}
className="td-back-btn"
onClick={() => navigate("/utility")}
>
{t("jsonFormatter.backToTools")}
</Button>
</div>
<div className="tool-detail-actions">
<Button
className="td-action-btn td-action-primary"
type="primary"
icon={<CheckCircleOutlined />}
onClick={handleFormat}
>
{t("jsonFormatter.format")}
</Button>
<Button
className="td-action-btn"
icon={<ThunderboltOutlined />}
onClick={handleCompress}
>
{t("jsonFormatter.compress")}
</Button>
<Button
className="td-action-btn"
icon={<CopyOutlined />}
onClick={handleCopy}
>
{t("jsonFormatter.copyResult")}
</Button>
<Button
className="td-action-btn"
icon={<BookOutlined />}
onClick={handleSample}
>
{t("jsonFormatter.sample")}
</Button>
<Button
className="td-action-btn td-action-danger"
icon={<ClearOutlined />}
onClick={handleClear}
>
{t("jsonFormatter.clear")}
</Button>
</div>
</div>
<div className="tool-detail-panels">
<div className="tool-detail-panel">
<div className="tool-detail-panel-header">
<h3 className="tool-detail-panel-title">
<span className="td-panel-dot input-dot" />
{t("jsonFormatter.input")}
</h3>
<Tag color="blue">JSON</Tag>
</div>
<div className="tool-detail-panel-body">
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={t("jsonFormatter.inputPlaceholder")}
spellCheck={false}
/>
</div>
<div className="tool-detail-panel-footer">
<span className="td-char-count">
{input.length} {t("jsonFormatter.characters")}
</span>
</div>
</div>
<div className="tool-detail-panel">
<div className="tool-detail-panel-header">
<h3 className="tool-detail-panel-title">
<span
className={`td-panel-dot ${
isError
? "error-dot"
: output
? "success-dot"
: ""
}`}
/>
{t("jsonFormatter.output")}
</h3>
{output && !isError && (
<Tag color="green">{t("jsonFormatter.valid")}</Tag>
)}
{output && isError && (
<Tag color="red">{t("jsonFormatter.error")}</Tag>
)}
</div>
<div className="tool-detail-panel-body json-output-panel-body">
<Tabs
activeKey={viewMode}
onChange={(key) => setViewMode(key as "code" | "tree")}
className="json-view-tabs"
size="small"
items={tabItems}
/>
</div>
<div className="tool-detail-panel-footer">
<span className="td-char-count">
{output.length} {t("jsonFormatter.characters")}
</span>
{output && !isError && (
<Button
type="link"
size="small"
icon={<CopyOutlined />}
onClick={handleCopy}
className="td-copy-inline"
>
{t("jsonFormatter.copy")}
</Button>
)}
</div>
</div>
</div>
<div className="tool-detail-tips">
<div className="td-tip-card">
<div className="td-tip-icon">💡</div>
<div className="td-tip-content">
<h4>{t("jsonFormatter.tipUsageTitle")}</h4>
<p>{t("jsonFormatter.tipUsageText")}</p>
</div>
</div>
<div className="td-tip-card">
<div className="td-tip-icon">⚡</div>
<div className="td-tip-content">
<h4>{t("jsonFormatter.tipQuickTitle")}</h4>
<p>{t("jsonFormatter.tipQuickText")}</p>
</div>
</div>
<div className="td-tip-card">
<div className="td-tip-icon">🔒</div>
<div className="td-tip-content">
<h4>{t("jsonFormatter.tipSecurityTitle")}</h4>
<p>{t("jsonFormatter.tipSecurityText")}</p>
</div>
</div>
</div>
</div>
</div>
);
};
export default JsonFormatter;
@@ -0,0 +1,233 @@
.jf-mobile-page {
background: #f0f2f5;
min-height: 100vh;
padding-bottom: 20px;
}
.jf-mobile-hero {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 12px 16px 24px;
text-align: center;
color: white;
}
.jf-mobile-hero-nav {
display: flex;
align-items: center;
margin-bottom: 8px;
}
.jf-mobile-back-btn {
color: white !important;
font-size: 18px;
width: 36px;
height: 36px;
border-radius: 50% !important;
display: flex;
align-items: center;
justify-content: center;
}
.jf-mobile-back-btn:hover {
background: rgba(255, 255, 255, 0.15) !important;
}
.jf-mobile-hero-title {
margin: 0 0 4px;
font-size: 22px;
font-weight: 600;
letter-spacing: 0.5px;
}
.jf-mobile-hero-subtitle {
margin: 0;
font-size: 13px;
opacity: 0.9;
font-weight: 400;
}
.jf-mobile-content {
padding: 12px 12px 0;
}
.jf-mobile-actions {
display: flex;
gap: 8px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
padding-bottom: 4px;
margin-bottom: 12px;
}
.jf-mobile-actions::-webkit-scrollbar {
display: none;
}
.jf-mobile-action-btn {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 6px;
padding: 10px 16px;
border-radius: 20px;
border: 1px solid #e2e8f0;
background: white;
color: #6366f1;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
min-height: 40px;
}
.jf-mobile-action-btn:active {
background: #f1f5f9;
transform: scale(0.97);
}
.jf-mobile-action-btn--danger {
color: #ef4444;
}
.jf-mobile-action-btn--danger:active {
background: #fef2f2;
}
.jf-mobile-status {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.jf-mobile-char-count {
font-size: 12px;
color: #94a3b8;
}
.jf-mobile-section {
margin-bottom: 14px;
}
.jf-mobile-label {
display: block;
font-size: 13px;
font-weight: 500;
color: #475569;
margin-bottom: 6px;
}
.jf-mobile-textarea {
width: 100%;
min-height: 140px;
padding: 12px;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #1e1e2e;
color: #cdd6f4;
font-family: "Fira Code", "Cascadia Code", "JetBrains Mono", monospace;
font-size: 13px;
line-height: 1.6;
resize: vertical;
outline: none;
box-sizing: border-box;
transition: border-color 0.2s ease;
}
.jf-mobile-textarea::placeholder {
color: #6c7086;
}
.jf-mobile-textarea:focus {
border-color: #667eea;
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.15);
}
.jf-mobile-output {
background: #1e1e2e;
border-radius: 10px;
padding: 12px;
min-height: 120px;
max-height: 360px;
overflow: auto;
-webkit-overflow-scrolling: touch;
}
.jf-mobile-output-pre {
margin: 0;
font-family: "Fira Code", "Cascadia Code", "JetBrains Mono", monospace;
font-size: 13px;
line-height: 1.6;
color: #cdd6f4;
white-space: pre-wrap;
word-break: break-all;
}
.jf-mobile-output-placeholder {
color: #6c7086;
font-size: 13px;
}
.json-key {
color: #89dceb;
}
.json-string {
color: #a6e3a1;
}
.json-number {
color: #fab387;
}
.json-boolean {
color: #89b4fa;
}
.json-null {
color: #f38ba8;
}
@media (max-width: 320px) {
.jf-mobile-action-btn {
padding: 8px 12px;
font-size: 12px;
gap: 4px;
}
.jf-mobile-textarea {
font-size: 12px;
min-height: 120px;
}
.jf-mobile-output {
min-height: 100px;
max-height: 300px;
}
.jf-mobile-output-pre {
font-size: 12px;
}
}
[data-theme="dark"] .jf-mobile-page {
background: #0f172a;
}
[data-theme="dark"] .jf-mobile-action-btn {
border-color: #334155;
background: #1e293b;
}
[data-theme="dark"] .jf-mobile-action-btn:active {
background: #334155;
}
[data-theme="dark"] .jf-mobile-action-btn--danger:active {
background: #451a1a;
}
[data-theme="dark"] .jf-mobile-label {
color: #94a3b8;
}
@@ -0,0 +1,221 @@
import React, { useState } from "react";
import { Button, Tag } from "antd";
import { message } from "@/utils/message";
import {
ArrowLeftOutlined, FormatPainterOutlined, CompressOutlined,
CopyOutlined, FileTextOutlined, DeleteOutlined,
} from "@ant-design/icons";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import "./JsonFormatterMobile.css";
import ToolFavoriteButton from "@/components/ToolFavoriteButton/ToolFavoriteButton";
function highlightJson(json: string): string {
const escaped = json
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
return escaped.replace(
/("(\\u[\da-fA-F]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/g,
(match) => {
let cls = "json-number";
if (/^"/.test(match)) {
if (/:$/.test(match)) {
cls = "json-key";
} else {
cls = "json-string";
}
} else if (/true|false/.test(match)) {
cls = "json-boolean";
} else if (/null/.test(match)) {
cls = "json-null";
}
return `<span class="${cls}">${match}</span>`;
}
);
}
const JsonFormatterMobile: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [input, setInput] = useState<string>("");
const [output, setOutput] = useState<string>("");
const [isError, setIsError] = useState<boolean>(false);
const getSampleData = () => ({
name: t("jsonFormatter.sampleName"),
age: 28,
isActive: true,
address: {
city: t("jsonFormatter.sampleCity"),
district: t("jsonFormatter.sampleDistrict"),
zipCode: null,
},
hobbies: [
t("jsonFormatter.sampleHobby1"),
t("jsonFormatter.sampleHobby2"),
t("jsonFormatter.sampleHobby3"),
],
education: {
degree: t("jsonFormatter.sampleDegree"),
school: t("jsonFormatter.sampleSchool"),
graduated: false,
},
});
const handleFormat = () => {
if (!input.trim()) {
message.warning(t("jsonFormatter.pleaseEnterJson"));
return;
}
try {
const parsed = JSON.parse(input);
const formatted = JSON.stringify(parsed, null, 2);
setOutput(formatted);
setIsError(false);
message.success(t("jsonFormatter.formatSuccess"));
} catch (e) {
setOutput((e as Error).message);
setIsError(true);
}
};
const handleCompress = () => {
if (!input.trim()) {
message.warning(t("jsonFormatter.pleaseEnterJson"));
return;
}
try {
const parsed = JSON.parse(input);
const compressed = JSON.stringify(parsed);
setOutput(compressed);
setIsError(false);
message.success(t("jsonFormatter.compressSuccess"));
} catch (e) {
setOutput((e as Error).message);
setIsError(true);
}
};
const handleCopy = () => {
if (!output) {
message.warning(t("jsonFormatter.noContentToCopy"));
return;
}
navigator.clipboard.writeText(output).then(() => {
message.success(t("jsonFormatter.copiedToClipboard"));
});
};
const handleSample = () => {
const sample = JSON.stringify(getSampleData(), null, 2);
setInput(sample);
message.success(t("jsonFormatter.sampleLoaded"));
};
const handleClear = () => {
setInput("");
setOutput("");
setIsError(false);
message.info(t("jsonFormatter.cleared"));
};
const hasOutput = output && !isError;
return (
<div className="jf-mobile-page">
<div className="jf-mobile-hero">
<div className="jf-mobile-hero-nav">
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={() => navigate("/utility")}
className="jf-mobile-back-btn"
/>
</div>
<div className="jf-mobile-hero-title-row">
<h1 className="jf-mobile-hero-title">
{t("jsonFormatter.heroTitle")}
</h1>
<ToolFavoriteButton urlPath="/utility/json-formatter" variant="hero" />
</div>
<p className="jf-mobile-hero-subtitle">
{t("jsonFormatter.heroSubtitle")}
</p>
</div>
<div className="jf-mobile-content">
<div className="jf-mobile-actions">
<button className="jf-mobile-action-btn" onClick={handleFormat}>
<FormatPainterOutlined />
<span>{t("jsonFormatter.format")}</span>
</button>
<button className="jf-mobile-action-btn" onClick={handleCompress}>
<CompressOutlined />
<span>{t("jsonFormatter.compress")}</span>
</button>
<button className="jf-mobile-action-btn" onClick={handleCopy}>
<CopyOutlined />
<span>{t("jsonFormatter.copy")}</span>
</button>
<button className="jf-mobile-action-btn" onClick={handleSample}>
<FileTextOutlined />
<span>{t("jsonFormatter.sample")}</span>
</button>
<button className="jf-mobile-action-btn jf-mobile-action-btn--danger" onClick={handleClear}>
<DeleteOutlined />
<span>{t("jsonFormatter.clear")}</span>
</button>
</div>
{input && (
<div className="jf-mobile-status">
<Tag color={isError ? "error" : "success"}>
{isError
? t("jsonFormatter.formatError")
: t("jsonFormatter.formatValid")}
</Tag>
<span className="jf-mobile-char-count">
{input.length} {t("jsonFormatter.characters")}
</span>
</div>
)}
<div className="jf-mobile-section">
<label className="jf-mobile-label">
{t("jsonFormatter.inputJson")}
</label>
<textarea
className="jf-mobile-textarea"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={t("jsonFormatter.mobileInputPlaceholder")}
rows={6}
spellCheck={false}
/>
</div>
<div className="jf-mobile-section">
<label className="jf-mobile-label">
{t("jsonFormatter.outputResult")}
</label>
<div className="jf-mobile-output">
{hasOutput ? (
<pre
className="jf-mobile-output-pre"
dangerouslySetInnerHTML={{ __html: highlightJson(output) }}
/>
) : (
<span className="jf-mobile-output-placeholder">
{isError ? output : t("jsonFormatter.mobileResultPlaceholder")}
</span>
)}
</div>
</div>
</div>
</div>
);
};
export default JsonFormatterMobile;