feat: add 10 new tools (30 total)
This commit is contained in:
parent
67aadda3d0
commit
f9f17164cd
12 changed files with 7424 additions and 21 deletions
35
README.md
35
README.md
|
|
@ -4,28 +4,21 @@ Free, fast, browser-based developer tools. No sign-up. No data collection.
|
|||
|
||||
**Live →** *coming soon*
|
||||
|
||||
## Tools
|
||||
## Tools (30)
|
||||
|
||||
- JSON Formatter & Validator
|
||||
- Markdown Preview
|
||||
- Base64 Encode/Decode
|
||||
- URL Encode/Decode
|
||||
- HTML Entity Encode/Decode
|
||||
- JWT Decoder
|
||||
- UUID Generator
|
||||
- Lorem Ipsum Generator
|
||||
- Hash Generator (SHA-1/256/384/512)
|
||||
- QR Code Generator
|
||||
- Password Generator
|
||||
- Unix Timestamp Converter
|
||||
- Color Converter (HEX/RGB/HSL)
|
||||
- Number Base Converter
|
||||
- Word & Character Counter
|
||||
- Regex Tester
|
||||
- Text Diff Checker
|
||||
- Case Converter
|
||||
- CSS Gradient Generator
|
||||
- CSS Box Shadow Generator
|
||||
**Formatters** — JSON Formatter, Markdown Preview, SQL Formatter, XML Formatter
|
||||
|
||||
**Encoders** — Base64, URL Encoder, HTML Entities, JWT Decoder
|
||||
|
||||
**Generators** — UUID, Lorem Ipsum, Hash, QR Code, Password
|
||||
|
||||
**Converters** — Timestamp, Color, Number Base, JSON→CSV, CSV→JSON
|
||||
|
||||
**Text Tools** — Word Counter, Regex Tester, Diff Checker, Case Converter
|
||||
|
||||
**CSS Tools** — Gradient Generator, Box Shadow, Px↔Rem Converter
|
||||
|
||||
**Web Tools** — Meta Tag Generator, Slug Generator, Cron Parser, Image→Base64, Placeholder Image
|
||||
|
||||
## Stack
|
||||
|
||||
|
|
|
|||
1015
src/app/tools/cron-parser/page.tsx
Normal file
1015
src/app/tools/cron-parser/page.tsx
Normal file
File diff suppressed because it is too large
Load diff
611
src/app/tools/csv-to-json/page.tsx
Normal file
611
src/app/tools/csv-to-json/page.tsx
Normal file
|
|
@ -0,0 +1,611 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useCallback, useId, useTransition } from "react";
|
||||
import ToolLayout from "@/components/ToolLayout";
|
||||
|
||||
interface StatusInfo {
|
||||
type: "idle" | "success" | "error";
|
||||
message?: string;
|
||||
rowCount?: number;
|
||||
colCount?: number;
|
||||
byteSize?: number;
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
const SAMPLE_CSV = `id,name,email,role,salary,active,address.city,address.state,skills
|
||||
101,"Alex Johnson",alex.johnson@example.com,"Senior Engineer",125000,true,"San Francisco",CA,"[""TypeScript"",""React"",""Node.js""]"
|
||||
102,"Sarah ""Dev"" Connor",sarah.c@example.com,"Product Designer",110000,true,Austin,TX,"[""Figma"",""UI/UX""]"
|
||||
103,"Liam O'Connor
|
||||
Remote Lead",liam.oc@example.com,"DevOps Engineer",130000,false,Seattle,WA,"[""Kubernetes"",""Docker"",""AWS""]"`;
|
||||
|
||||
function detectDelimiter(text: string): string {
|
||||
const sample = text.slice(0, 2000);
|
||||
const candidates = [",", ";", "\t", "|"];
|
||||
const counts: Record<string, number> = { ",": 0, ";": 0, "\t": 0, "|": 0 };
|
||||
|
||||
let inQuotes = false;
|
||||
for (let i = 0; i < sample.length; i++) {
|
||||
const char = sample[i];
|
||||
if (char === '"') {
|
||||
inQuotes = !inQuotes;
|
||||
} else if (!inQuotes && counts[char] !== undefined) {
|
||||
counts[char]++;
|
||||
}
|
||||
}
|
||||
|
||||
let bestDelimiter = ",";
|
||||
let maxCount = -1;
|
||||
for (const d of candidates) {
|
||||
if (counts[d] > maxCount && counts[d] > 0) {
|
||||
maxCount = counts[d];
|
||||
bestDelimiter = d;
|
||||
}
|
||||
}
|
||||
|
||||
return bestDelimiter;
|
||||
}
|
||||
|
||||
function parseCSV(text: string, delimiter: string): string[][] {
|
||||
const rows: string[][] = [];
|
||||
let currentRow: string[] = [];
|
||||
let currentField = "";
|
||||
let inQuotes = false;
|
||||
let i = 0;
|
||||
|
||||
while (i < text.length) {
|
||||
const char = text[i];
|
||||
const nextChar = text[i + 1];
|
||||
|
||||
if (inQuotes) {
|
||||
if (char === '"' && nextChar === '"') {
|
||||
currentField += '"';
|
||||
i += 2;
|
||||
} else if (char === '"') {
|
||||
inQuotes = false;
|
||||
i++;
|
||||
} else {
|
||||
currentField += char;
|
||||
i++;
|
||||
}
|
||||
} else {
|
||||
if (char === '"') {
|
||||
inQuotes = true;
|
||||
i++;
|
||||
} else if (text.slice(i, i + delimiter.length) === delimiter) {
|
||||
currentRow.push(currentField);
|
||||
currentField = "";
|
||||
i += delimiter.length;
|
||||
} else if (char === "\r" && nextChar === "\n") {
|
||||
currentRow.push(currentField);
|
||||
rows.push(currentRow);
|
||||
currentRow = [];
|
||||
currentField = "";
|
||||
i += 2;
|
||||
} else if (char === "\n" || char === "\r") {
|
||||
currentRow.push(currentField);
|
||||
rows.push(currentRow);
|
||||
currentRow = [];
|
||||
currentField = "";
|
||||
i++;
|
||||
} else {
|
||||
currentField += char;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentField.length > 0 || currentRow.length > 0) {
|
||||
currentRow.push(currentField);
|
||||
rows.push(currentRow);
|
||||
}
|
||||
|
||||
// Remove empty trailing lines
|
||||
while (rows.length > 0 && rows[rows.length - 1].length === 1 && rows[rows.length - 1][0].trim() === "") {
|
||||
rows.pop();
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function unflattenObject(data: Record<string, unknown>): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
const parts = key.split(".");
|
||||
let current: Record<string, unknown> = result;
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
if (i === parts.length - 1) {
|
||||
current[part] = value;
|
||||
} else {
|
||||
if (!current[part] || typeof current[part] !== "object" || Array.isArray(current[part])) {
|
||||
current[part] = {};
|
||||
}
|
||||
current = current[part] as Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseCellValue(value: string, autoParseTypes: boolean): unknown {
|
||||
if (!autoParseTypes) return value;
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === "") return "";
|
||||
if (trimmed.toLowerCase() === "null") return null;
|
||||
if (trimmed.toLowerCase() === "true") return true;
|
||||
if (trimmed.toLowerCase() === "false") return false;
|
||||
|
||||
// Numeric check (avoid converting leading zeros like phone numbers / zip codes "01234" to numbers)
|
||||
if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
|
||||
if (trimmed === "0" || !trimmed.startsWith("0") || trimmed.startsWith("0.")) {
|
||||
const num = Number(trimmed);
|
||||
if (!isNaN(num)) {
|
||||
return num;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if cell is serialized JSON (e.g. nested array or object)
|
||||
if ((trimmed.startsWith("[") && trimmed.endsWith("]")) || (trimmed.startsWith("{") && trimmed.endsWith("}"))) {
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
// fallback to string
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
export default function CsvToJsonPage() {
|
||||
const [input, setInput] = useState<string>("");
|
||||
const [output, setOutput] = useState<string>("");
|
||||
const [delimiterSetting, setDelimiterSetting] = useState<string>("auto");
|
||||
const [hasHeaders, setHasHeaders] = useState<boolean>(true);
|
||||
const [autoParseTypes, setAutoParseTypes] = useState<boolean>(true);
|
||||
const [unflatten, setUnflatten] = useState<boolean>(true);
|
||||
const [indent, setIndent] = useState<string>("2");
|
||||
const [status, setStatus] = useState<StatusInfo>({ type: "idle" });
|
||||
const [copied, setCopied] = useState<boolean>(false);
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const delimiterSelectId = useId();
|
||||
const indentSelectId = useId();
|
||||
|
||||
const convertCsvToJson = useCallback(
|
||||
(
|
||||
csvText: string,
|
||||
delimOpt: string,
|
||||
headersOpt: boolean,
|
||||
typesOpt: boolean,
|
||||
unflattenOpt: boolean,
|
||||
indentOpt: string
|
||||
) => {
|
||||
if (!csvText.trim()) {
|
||||
setOutput("");
|
||||
setStatus({ type: "idle" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const activeDelimiter = delimOpt === "auto" ? detectDelimiter(csvText) : delimOpt;
|
||||
const parsedRows = parseCSV(csvText, activeDelimiter);
|
||||
|
||||
if (parsedRows.length === 0) {
|
||||
setOutput("[]");
|
||||
setStatus({
|
||||
type: "success",
|
||||
message: "Parsed 0 rows.",
|
||||
rowCount: 0,
|
||||
colCount: 0,
|
||||
byteSize: 2,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let jsonResult: unknown;
|
||||
let columnCount = 0;
|
||||
let recordCount = 0;
|
||||
|
||||
if (headersOpt) {
|
||||
const headerRow = parsedRows[0];
|
||||
columnCount = headerRow.length;
|
||||
const dataRows = parsedRows.slice(1);
|
||||
recordCount = dataRows.length;
|
||||
|
||||
const records: Record<string, unknown>[] = [];
|
||||
for (const row of dataRows) {
|
||||
const record: Record<string, unknown> = {};
|
||||
for (let c = 0; c < headerRow.length; c++) {
|
||||
const header = headerRow[c]?.trim() || `column_${c + 1}`;
|
||||
const val = c < row.length ? row[c] : "";
|
||||
record[header] = parseCellValue(val, typesOpt);
|
||||
}
|
||||
|
||||
if (unflattenOpt) {
|
||||
records.push(unflattenObject(record));
|
||||
} else {
|
||||
records.push(record);
|
||||
}
|
||||
}
|
||||
jsonResult = records;
|
||||
} else {
|
||||
// No headers: array of arrays or array of objects with generic column keys
|
||||
recordCount = parsedRows.length;
|
||||
columnCount = Math.max(...parsedRows.map((r) => r.length), 0);
|
||||
jsonResult = parsedRows.map((row) => row.map((cell) => parseCellValue(cell, typesOpt)));
|
||||
}
|
||||
|
||||
const indentVal = indentOpt === "tab" ? "\t" : parseInt(indentOpt, 10);
|
||||
const formattedJson = JSON.stringify(jsonResult, null, indentVal);
|
||||
setOutput(formattedJson);
|
||||
|
||||
const byteSize = new Blob([formattedJson]).size;
|
||||
setStatus({
|
||||
type: "success",
|
||||
message: `Successfully converted ${recordCount} record${recordCount === 1 ? "" : "s"} (${columnCount} columns)!`,
|
||||
rowCount: recordCount,
|
||||
colCount: columnCount,
|
||||
byteSize,
|
||||
});
|
||||
} catch (err) {
|
||||
setOutput("");
|
||||
setStatus({
|
||||
type: "error",
|
||||
message: err instanceof Error ? err.message : "Failed to parse CSV data.",
|
||||
});
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleConvert = useCallback(() => {
|
||||
startTransition(() => {
|
||||
convertCsvToJson(input, delimiterSetting, hasHeaders, autoParseTypes, unflatten, indent);
|
||||
});
|
||||
}, [input, delimiterSetting, hasHeaders, autoParseTypes, unflatten, indent, convertCsvToJson]);
|
||||
|
||||
const handleLoadSample = useCallback(() => {
|
||||
setInput(SAMPLE_CSV);
|
||||
startTransition(() => {
|
||||
convertCsvToJson(SAMPLE_CSV, delimiterSetting, hasHeaders, autoParseTypes, unflatten, indent);
|
||||
});
|
||||
}, [delimiterSetting, hasHeaders, autoParseTypes, unflatten, indent, convertCsvToJson]);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
setInput("");
|
||||
setOutput("");
|
||||
setStatus({ type: "idle" });
|
||||
}, []);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
if (!output) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(output);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = output;
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(textarea);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
}, [output]);
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
if (!output) return;
|
||||
const blob = new Blob([output], { type: "application/json;charset=utf-8;" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "data.json";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}, [output]);
|
||||
|
||||
const inputLineCount = input ? input.split("\n").length : 0;
|
||||
const outputLineCount = output ? output.split("\n").length : 0;
|
||||
|
||||
return (
|
||||
<ToolLayout
|
||||
title="CSV to JSON Converter - Convert CSV Tables to JSON"
|
||||
description="Online CSV to JSON converter. Parse comma/semicolon/tab-separated values, handle multi-line strings, auto-detect data types, and unflatten nested keys into structured JSON."
|
||||
>
|
||||
<div className="space-y-5">
|
||||
{/* Top Controls Toolbar */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-gray-200 pb-4 dark:border-gray-800">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
onClick={handleConvert}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-xs transition-colors hover:bg-blue-700 active:scale-95 cursor-pointer"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
Convert to JSON
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleLoadSample}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-blue-50 px-3 py-2 text-sm font-medium text-blue-700 transition-colors hover:bg-blue-100 dark:bg-blue-950/40 dark:text-blue-300 dark:hover:bg-blue-900/50 cursor-pointer"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||
</svg>
|
||||
Load Sample Data
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleClear}
|
||||
disabled={!input && !output}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-gray-100 px-3 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-rose-50 hover:text-rose-600 disabled:opacity-40 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-rose-950/30 dark:hover:text-rose-400 cursor-pointer"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm text-gray-700 dark:text-gray-300">
|
||||
{/* Delimiter */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<label htmlFor={delimiterSelectId} className="text-xs font-semibold text-gray-500 uppercase dark:text-gray-400">
|
||||
Delimiter:
|
||||
</label>
|
||||
<select
|
||||
id={delimiterSelectId}
|
||||
value={delimiterSetting}
|
||||
onChange={(e) => setDelimiterSetting(e.target.value)}
|
||||
className="rounded-md border border-gray-300 bg-white px-2 py-1 text-xs text-gray-800 shadow-xs focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200"
|
||||
>
|
||||
<option value="auto">Auto-detect</option>
|
||||
<option value=",">Comma (,)</option>
|
||||
<option value=";">Semicolon (;)</option>
|
||||
<option value="	">Tab (\t)</option>
|
||||
<option value="|">Pipe (|)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Indent Selector */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<label htmlFor={indentSelectId} className="text-xs font-semibold text-gray-500 uppercase dark:text-gray-400">
|
||||
Indent:
|
||||
</label>
|
||||
<select
|
||||
id={indentSelectId}
|
||||
value={indent}
|
||||
onChange={(e) => setIndent(e.target.value)}
|
||||
className="rounded-md border border-gray-300 bg-white px-2 py-1 text-xs text-gray-800 shadow-xs focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200"
|
||||
>
|
||||
<option value="2">2 Spaces</option>
|
||||
<option value="4">4 Spaces</option>
|
||||
<option value="tab">Tab</option>
|
||||
<option value="0">Minified (0)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toggles Strip */}
|
||||
<div className="flex flex-wrap items-center gap-5 text-xs text-gray-600 dark:text-gray-400">
|
||||
<label className="flex cursor-pointer items-center gap-1.5 select-none hover:text-gray-900 dark:hover:text-gray-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hasHeaders}
|
||||
onChange={(e) => setHasHeaders(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800"
|
||||
/>
|
||||
<span className="font-medium">First row as headers (keys)</span>
|
||||
</label>
|
||||
|
||||
<label className="flex cursor-pointer items-center gap-1.5 select-none hover:text-gray-900 dark:hover:text-gray-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoParseTypes}
|
||||
onChange={(e) => setAutoParseTypes(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800"
|
||||
/>
|
||||
<span>Auto-parse numbers, booleans & null</span>
|
||||
</label>
|
||||
|
||||
<label className="flex cursor-pointer items-center gap-1.5 select-none hover:text-gray-900 dark:hover:text-gray-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={unflatten}
|
||||
disabled={!hasHeaders}
|
||||
onChange={(e) => setUnflatten(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 disabled:opacity-40 dark:border-gray-700 dark:bg-gray-800"
|
||||
/>
|
||||
<span>Unflatten dot keys (e.g. <code>address.city</code> → object)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Status Notification Banner */}
|
||||
{status.type === "success" && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-emerald-300 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-300">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="h-5 w-5 shrink-0 text-emerald-600 dark:text-emerald-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span className="font-medium">{status.message}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-emerald-800 dark:text-emerald-300">
|
||||
{status.rowCount !== undefined && <span>Rows: <strong>{status.rowCount}</strong></span>}
|
||||
{status.colCount !== undefined && <span>Columns: <strong>{status.colCount}</strong></span>}
|
||||
{status.byteSize !== undefined && <span>Size: <strong>{formatBytes(status.byteSize)}</strong></span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status.type === "error" && (
|
||||
<div className="rounded-lg border border-red-300 bg-red-50 p-4 text-sm text-red-900 dark:border-red-800 dark:bg-red-950/40 dark:text-red-300">
|
||||
<div className="flex items-start gap-2">
|
||||
<svg className="mt-0.5 h-5 w-5 shrink-0 text-red-600 dark:text-red-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<p className="font-semibold text-red-800 dark:text-red-200">Error Parsing CSV</p>
|
||||
<p className="mt-1 font-mono text-xs text-red-700 dark:text-red-300">{status.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input & Output Split Editor */}
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
{/* Input Area */}
|
||||
<div className="flex flex-col">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-gray-800 dark:text-gray-200">Input CSV</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
({inputLineCount} {inputLineCount === 1 ? "line" : "lines"}, {input.length} chars)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
setInput(text);
|
||||
} catch {
|
||||
// Permission denied
|
||||
}
|
||||
}}
|
||||
className="rounded px-2 py-1 text-xs font-medium text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800 cursor-pointer"
|
||||
title="Paste from clipboard"
|
||||
>
|
||||
Paste
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setInput("")}
|
||||
className="rounded px-2 py-1 text-xs font-medium text-gray-500 hover:bg-gray-100 hover:text-red-600 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-red-400 cursor-pointer"
|
||||
title="Clear input"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="Paste raw CSV text here (comma, semicolon, or tab separated)..."
|
||||
rows={18}
|
||||
spellCheck={false}
|
||||
className="w-full flex-1 resize-y rounded-lg border border-gray-300 bg-white p-3 font-mono text-sm leading-relaxed text-gray-900 shadow-xs focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Output Area */}
|
||||
<div className="flex flex-col">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-gray-800 dark:text-gray-200">JSON Output</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
({outputLineCount} {outputLineCount === 1 ? "line" : "lines"}, {output.length} chars)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
disabled={!output}
|
||||
className="inline-flex items-center gap-1 rounded px-2.5 py-1 text-xs font-medium text-gray-700 hover:bg-gray-100 disabled:opacity-40 dark:text-gray-300 dark:hover:bg-gray-800 cursor-pointer"
|
||||
title="Download as JSON file"
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Download .json
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
disabled={!output}
|
||||
className="inline-flex items-center gap-1 rounded-md bg-blue-600 px-3 py-1 text-xs font-semibold text-white shadow-xs transition-colors hover:bg-blue-700 disabled:opacity-40 cursor-pointer"
|
||||
title="Copy JSON to clipboard"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
Copy JSON
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
value={output}
|
||||
readOnly
|
||||
placeholder="Formatted JSON array will appear here..."
|
||||
rows={18}
|
||||
spellCheck={false}
|
||||
className="w-full flex-1 resize-y rounded-lg border border-gray-300 bg-gray-50 p-3 font-mono text-sm leading-relaxed text-gray-900 shadow-xs focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none dark:border-gray-700 dark:bg-gray-900/80 dark:text-gray-100 dark:placeholder-gray-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feature Highlights & Guide */}
|
||||
<div className="mt-8 rounded-xl border border-gray-200 bg-gray-50/70 p-5 text-sm text-gray-600 dark:border-gray-800 dark:bg-gray-800/30 dark:text-gray-400">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100">About CSV to JSON Converter</h3>
|
||||
<div className="mt-3 grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<p className="font-medium text-gray-800 dark:text-gray-200">🔍 Smart Delimiter Detection</p>
|
||||
<p className="mt-1 text-xs leading-normal">
|
||||
Automatically detects whether your data uses commas, tabs, semicolons, or pipes, or choose manually.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-800 dark:text-gray-200">🌳 Nested Object Reconstruction</p>
|
||||
<p className="mt-1 text-xs leading-normal">
|
||||
Reconstitutes dot-notation headers (such as <code>address.city</code>) back into rich nested JSON objects.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-800 dark:text-gray-200">🛡️ Full RFC 4180 Support</p>
|
||||
<p className="mt-1 text-xs leading-normal">
|
||||
Properly processes escaped quotes, multi-line values, special characters, and numeric types.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ToolLayout>
|
||||
);
|
||||
}
|
||||
663
src/app/tools/image-to-base64/page.tsx
Normal file
663
src/app/tools/image-to-base64/page.tsx
Normal file
|
|
@ -0,0 +1,663 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from "react";
|
||||
import ToolLayout from "@/components/ToolLayout";
|
||||
|
||||
type OutputFormat = "data-uri" | "raw" | "html" | "css";
|
||||
|
||||
interface ImageDetails {
|
||||
name: string;
|
||||
size: number;
|
||||
type: string;
|
||||
width: number;
|
||||
height: number;
|
||||
dataUri: string;
|
||||
rawBase64: string;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
export default function ImageToBase64Page() {
|
||||
const [image, setImage] = useState<ImageDetails | null>(null);
|
||||
const [isDragging, setIsDragging] = useState<boolean>(false);
|
||||
const [activeTab, setActiveTab] = useState<OutputFormat>("data-uri");
|
||||
const [copiedKey, setCopiedKey] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const processFile = useCallback((file: File) => {
|
||||
setError(null);
|
||||
if (!file.type.startsWith("image/")) {
|
||||
setError("Please select a valid image file (PNG, JPG, SVG, WebP, GIF, etc.).");
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const dataUri = e.target?.result as string;
|
||||
if (!dataUri) {
|
||||
setError("Failed to read image file.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract raw base64 (after comma)
|
||||
const commaIndex = dataUri.indexOf(",");
|
||||
const rawBase64 = commaIndex !== -1 ? dataUri.substring(commaIndex + 1) : "";
|
||||
|
||||
// Load dimensions
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
setImage({
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type || "image/png",
|
||||
width: img.naturalWidth || img.width,
|
||||
height: img.naturalHeight || img.height,
|
||||
dataUri,
|
||||
rawBase64,
|
||||
});
|
||||
};
|
||||
img.onerror = () => {
|
||||
// Fallback for SVG or unusual images
|
||||
setImage({
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type || "image/png",
|
||||
width: 0,
|
||||
height: 0,
|
||||
dataUri,
|
||||
rawBase64,
|
||||
});
|
||||
};
|
||||
img.src = dataUri;
|
||||
};
|
||||
reader.onerror = () => {
|
||||
setError("An error occurred while reading the file.");
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}, []);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
processFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file) {
|
||||
processFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
// Support paste from clipboard
|
||||
useEffect(() => {
|
||||
const handlePaste = (e: ClipboardEvent) => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].type.startsWith("image/")) {
|
||||
const file = items[i].getAsFile();
|
||||
if (file) {
|
||||
processFile(file);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("paste", handlePaste);
|
||||
return () => window.removeEventListener("paste", handlePaste);
|
||||
}, [processFile]);
|
||||
|
||||
const copyToClipboard = async (text: string, key: string) => {
|
||||
if (!text) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopiedKey(key);
|
||||
setTimeout(() => setCopiedKey(null), 2000);
|
||||
} catch {
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = text;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(textArea);
|
||||
setCopiedKey(key);
|
||||
setTimeout(() => setCopiedKey(null), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setImage(null);
|
||||
setError(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
// Sample images
|
||||
const loadSampleImage = (type: "badge" | "icon") => {
|
||||
setError(null);
|
||||
if (type === "badge") {
|
||||
// 240x240 canvas badge
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 240;
|
||||
canvas.height = 240;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (ctx) {
|
||||
// Gradient background
|
||||
const grad = ctx.createLinearGradient(0, 0, 240, 240);
|
||||
grad.addColorStop(0, "#3B82F6");
|
||||
grad.addColorStop(1, "#8B5CF6");
|
||||
ctx.fillStyle = grad;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(10, 10, 220, 220, 32);
|
||||
ctx.fill();
|
||||
|
||||
// Icon circle
|
||||
ctx.fillStyle = "rgba(255, 255, 255, 0.2)";
|
||||
ctx.beginPath();
|
||||
ctx.arc(120, 100, 50, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Text
|
||||
ctx.fillStyle = "#FFFFFF";
|
||||
ctx.font = "bold 44px sans-serif";
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText("🚀", 120, 100);
|
||||
|
||||
ctx.font = "bold 20px sans-serif";
|
||||
ctx.fillText("DevToolbox", 120, 175);
|
||||
ctx.font = "14px sans-serif";
|
||||
ctx.fillStyle = "rgba(255, 255, 255, 0.8)";
|
||||
ctx.fillText("Fast & Secure", 120, 200);
|
||||
|
||||
const dataUri = canvas.toDataURL("image/png");
|
||||
const rawBase64 = dataUri.substring(dataUri.indexOf(",") + 1);
|
||||
const approxSize = Math.round((rawBase64.length * 3) / 4);
|
||||
|
||||
setImage({
|
||||
name: "sample-badge.png",
|
||||
size: approxSize,
|
||||
type: "image/png",
|
||||
width: 240,
|
||||
height: 240,
|
||||
dataUri,
|
||||
rawBase64,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// SVG Icon
|
||||
const svgString = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100"><defs><linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stop-color="#10B981"/><stop offset="100%" stop-color="#06B6D4"/></linearGradient></defs><circle cx="50" cy="50" r="45" fill="url(#g)"/><path d="M30 52 L44 66 L72 36" fill="none" stroke="#ffffff" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
||||
const blob = new Blob([svgString], { type: "image/svg+xml" });
|
||||
const file = new File([blob], "sample-check-icon.svg", { type: "image/svg+xml" });
|
||||
processFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const getOutputText = (format: OutputFormat): string => {
|
||||
if (!image) return "";
|
||||
switch (format) {
|
||||
case "data-uri":
|
||||
return image.dataUri;
|
||||
case "raw":
|
||||
return image.rawBase64;
|
||||
case "html":
|
||||
return `<img src="${image.dataUri}" alt="${image.name}" width="${image.width || 100}" height="${image.height || 100}" />`;
|
||||
case "css":
|
||||
return `background-image: url("${image.dataUri}");`;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
// Base64 calculation stats
|
||||
const base64CharCount = image ? image.dataUri.length : 0;
|
||||
const base64Bytes = base64CharCount; // Base64 string in memory (1 byte per char UTF-8)
|
||||
const originalBytes = image ? image.size : 0;
|
||||
const sizeDiffBytes = image ? Math.max(0, base64Bytes - originalBytes) : 0;
|
||||
const sizeIncreasePercent =
|
||||
image && originalBytes > 0
|
||||
? ((sizeDiffBytes / originalBytes) * 100).toFixed(1)
|
||||
: "0";
|
||||
|
||||
return (
|
||||
<ToolLayout
|
||||
title="Image to Base64 Converter"
|
||||
description="Convert any PNG, JPG, SVG, WebP, or GIF image into Base64 data URI format with instant preview, CSS, and HTML code snippets."
|
||||
>
|
||||
<title>Image to Base64 Converter Online — DevToolbox</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Free client-side Image to Base64 converter. Drag and drop images to generate Data URIs, raw Base64 strings, HTML img tags, and CSS background-image declarations."
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Top Action / Samples Bar */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-gray-200 pb-3 dark:border-gray-700">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex cursor-pointer items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:bg-blue-700 active:bg-blue-800"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l4-4m0 0l4 4m-4-4v12" />
|
||||
</svg>
|
||||
Choose Image File
|
||||
</button>
|
||||
|
||||
{image && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="flex cursor-pointer items-center gap-1 rounded-lg bg-gray-100 px-3 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-red-50 hover:text-red-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-red-950/40 dark:hover:text-red-400"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
Clear Image
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="font-medium text-gray-500 dark:text-gray-400">Try Sample:</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => loadSampleImage("badge")}
|
||||
className="cursor-pointer rounded bg-gray-100 px-2.5 py-1 text-gray-700 transition-colors hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
>
|
||||
Badge (PNG)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => loadSampleImage("icon")}
|
||||
className="cursor-pointer rounded bg-gray-100 px-2.5 py-1 text-gray-700 transition-colors hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
>
|
||||
Vector Icon (SVG)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hidden File Input */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
|
||||
{/* Error notification */}
|
||||
{error && (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800 dark:border-red-800 dark:bg-red-950/40 dark:text-red-300">
|
||||
<svg className="mt-0.5 h-5 w-5 shrink-0 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<p className="font-semibold">Upload Error</p>
|
||||
<p className="mt-0.5">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Drag & Drop Upload Box */}
|
||||
{!image ? (
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={`flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed p-10 text-center transition-all ${
|
||||
isDragging
|
||||
? "border-blue-500 bg-blue-50/60 dark:border-blue-400 dark:bg-blue-950/30 scale-[1.01]"
|
||||
: "border-gray-300 bg-white hover:border-blue-400 hover:bg-gray-50/50 dark:border-gray-700 dark:bg-gray-800/40 dark:hover:border-gray-600 dark:hover:bg-gray-800/80"
|
||||
}`}
|
||||
>
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-blue-50 dark:bg-blue-950/60 text-blue-600 dark:text-blue-400 mb-4">
|
||||
<svg className="h-8 w-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<p className="text-base font-semibold text-gray-800 dark:text-gray-200">
|
||||
Drag & drop your image here, or <span className="text-blue-600 dark:text-blue-400 underline">browse files</span>
|
||||
</p>
|
||||
<p className="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
Supports PNG, JPG, JPEG, SVG, WebP, GIF, ICO, AVIF, BMP • Paste directly with Ctrl+V / ⌘V
|
||||
</p>
|
||||
<div className="mt-4 flex items-center gap-2 text-xs text-gray-400">
|
||||
<span className="inline-block h-2 w-2 rounded-full bg-green-500"></span>
|
||||
<span>100% Client-side processing — No files uploaded to servers</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Image Preview & Details Card */}
|
||||
<div className="grid grid-cols-1 gap-6 rounded-xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-700 dark:bg-gray-800 lg:grid-cols-12">
|
||||
{/* Preview Box */}
|
||||
<div className="flex flex-col items-center justify-center lg:col-span-4">
|
||||
<div className="relative flex max-h-56 min-h-[160px] w-full items-center justify-center overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700 bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] [background-size:12px_12px] dark:bg-[radial-gradient(#374151_1px,transparent_1px)] p-2">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={image.dataUri}
|
||||
alt={image.name}
|
||||
className="max-h-48 max-w-full rounded object-contain shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="mt-3 text-xs text-blue-600 hover:underline dark:text-blue-400 font-medium cursor-pointer"
|
||||
>
|
||||
Change Image
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Metadata & Quick Actions */}
|
||||
<div className="flex flex-col justify-between space-y-4 lg:col-span-8">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-gray-100 pb-3 dark:border-gray-700">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white truncate max-w-xs sm:max-w-md" title={image.name}>
|
||||
{image.name}
|
||||
</h3>
|
||||
<span className="rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-semibold text-blue-800 dark:bg-blue-900/60 dark:text-blue-300">
|
||||
{image.type}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Details Grid */}
|
||||
<div className="mt-3 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div className="rounded-lg bg-gray-50 p-2.5 dark:bg-gray-900/60 border border-gray-200 dark:border-gray-700/60">
|
||||
<span className="block text-[11px] font-medium text-gray-500 dark:text-gray-400 uppercase">Dimensions</span>
|
||||
<span className="font-mono text-sm font-semibold text-gray-800 dark:text-gray-200">
|
||||
{image.width > 0 ? `${image.width} × ${image.height} px` : "Vector (SVG)"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-gray-50 p-2.5 dark:bg-gray-900/60 border border-gray-200 dark:border-gray-700/60">
|
||||
<span className="block text-[11px] font-medium text-gray-500 dark:text-gray-400 uppercase">Original Size</span>
|
||||
<span className="font-mono text-sm font-semibold text-gray-800 dark:text-gray-200">
|
||||
{formatBytes(image.size)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-gray-50 p-2.5 dark:bg-gray-900/60 border border-gray-200 dark:border-gray-700/60">
|
||||
<span className="block text-[11px] font-medium text-gray-500 dark:text-gray-400 uppercase">Base64 Length</span>
|
||||
<span className="font-mono text-sm font-semibold text-gray-800 dark:text-gray-200">
|
||||
{image.dataUri.length.toLocaleString()} chars
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-gray-50 p-2.5 dark:bg-gray-900/60 border border-gray-200 dark:border-gray-700/60">
|
||||
<span className="block text-[11px] font-medium text-gray-500 dark:text-gray-400 uppercase">Base64 Size</span>
|
||||
<span className="font-mono text-sm font-semibold text-gray-800 dark:text-gray-200">
|
||||
{formatBytes(base64Bytes)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Size Comparison Bar */}
|
||||
<div className="rounded-lg bg-gray-50 p-3 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between text-xs mb-1.5 font-medium text-gray-700 dark:text-gray-300">
|
||||
<span>Size Overhead Comparison</span>
|
||||
<span className="font-mono text-amber-600 dark:text-amber-400">
|
||||
+{sizeIncreasePercent}% (+{formatBytes(sizeDiffBytes)})
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2.5 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700 flex">
|
||||
<div
|
||||
className="bg-blue-600 h-full"
|
||||
style={{ width: `${Math.min(100, (originalBytes / base64Bytes) * 100)}%` }}
|
||||
title={`Original File: ${formatBytes(originalBytes)}`}
|
||||
/>
|
||||
<div
|
||||
className="bg-amber-500 h-full"
|
||||
style={{ width: `${Math.max(0, 100 - (originalBytes / base64Bytes) * 100)}%` }}
|
||||
title={`Base64 Encoding Overhead: +${formatBytes(sizeDiffBytes)}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[11px] text-gray-500 dark:text-gray-400 mt-1.5">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="h-2 w-2 rounded-full bg-blue-600 inline-block"></span>
|
||||
Original ({formatBytes(originalBytes)})
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="h-2 w-2 rounded-full bg-amber-500 inline-block"></span>
|
||||
Base64 Overhead (~33% ASCII expansion)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Output Options & Formats */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
{/* Tabs */}
|
||||
<div className="inline-flex rounded-lg border border-gray-200 bg-gray-100 p-1 dark:border-gray-700 dark:bg-gray-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("data-uri")}
|
||||
className={`cursor-pointer rounded-md px-3 py-1.5 text-xs font-semibold transition-colors ${
|
||||
activeTab === "data-uri"
|
||||
? "bg-white text-blue-600 shadow-sm dark:bg-gray-700 dark:text-white"
|
||||
: "text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
}`}
|
||||
>
|
||||
Data URI
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("raw")}
|
||||
className={`cursor-pointer rounded-md px-3 py-1.5 text-xs font-semibold transition-colors ${
|
||||
activeTab === "raw"
|
||||
? "bg-white text-blue-600 shadow-sm dark:bg-gray-700 dark:text-white"
|
||||
: "text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
}`}
|
||||
>
|
||||
Raw Base64
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("html")}
|
||||
className={`cursor-pointer rounded-md px-3 py-1.5 text-xs font-semibold transition-colors ${
|
||||
activeTab === "html"
|
||||
? "bg-white text-blue-600 shadow-sm dark:bg-gray-700 dark:text-white"
|
||||
: "text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
}`}
|
||||
>
|
||||
HTML <img>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("css")}
|
||||
className={`cursor-pointer rounded-md px-3 py-1.5 text-xs font-semibold transition-colors ${
|
||||
activeTab === "css"
|
||||
? "bg-white text-blue-600 shadow-sm dark:bg-gray-700 dark:text-white"
|
||||
: "text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
}`}
|
||||
>
|
||||
CSS Background
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Primary Action for Current Tab */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyToClipboard(getOutputText(activeTab), `active-${activeTab}`)}
|
||||
className="flex cursor-pointer items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:bg-blue-700 active:bg-blue-800"
|
||||
>
|
||||
{copiedKey === `active-${activeTab}` ? (
|
||||
<>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Copied Active Format!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
|
||||
</svg>
|
||||
Copy {activeTab === "data-uri" ? "Data URI" : activeTab === "raw" ? "Raw Base64" : activeTab === "html" ? "HTML Snippet" : "CSS Snippet"}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Textarea Output */}
|
||||
<div className="relative">
|
||||
<textarea
|
||||
readOnly
|
||||
rows={8}
|
||||
value={getOutputText(activeTab)}
|
||||
placeholder="Base64 output will appear here..."
|
||||
className="w-full rounded-lg border border-gray-300 bg-white p-3.5 font-mono text-xs text-gray-900 shadow-inner focus:outline-none dark:border-gray-700 dark:bg-gray-900/80 dark:text-gray-100 leading-relaxed select-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Quick Copy Cards for All 4 Formats */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4 pt-2">
|
||||
{/* 1. Data URI */}
|
||||
<div className="flex flex-col justify-between rounded-lg border border-gray-200 bg-white p-3 dark:border-gray-700 dark:bg-gray-800">
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-gray-800 dark:text-gray-200">Full Data URI</span>
|
||||
<span className="text-[10px] text-gray-400">data:image/...</span>
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-gray-500 dark:text-gray-400 line-clamp-2 font-mono">
|
||||
{image.dataUri}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyToClipboard(image.dataUri, "quick-data-uri")}
|
||||
className="mt-2.5 w-full cursor-pointer rounded bg-gray-100 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600 transition-colors flex items-center justify-center gap-1"
|
||||
>
|
||||
{copiedKey === "quick-data-uri" ? "✓ Copied!" : "Copy Data URI"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 2. Raw Base64 */}
|
||||
<div className="flex flex-col justify-between rounded-lg border border-gray-200 bg-white p-3 dark:border-gray-700 dark:bg-gray-800">
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-gray-800 dark:text-gray-200">Raw Base64</span>
|
||||
<span className="text-[10px] text-gray-400">No header</span>
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-gray-500 dark:text-gray-400 line-clamp-2 font-mono">
|
||||
{image.rawBase64}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyToClipboard(image.rawBase64, "quick-raw")}
|
||||
className="mt-2.5 w-full cursor-pointer rounded bg-gray-100 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600 transition-colors flex items-center justify-center gap-1"
|
||||
>
|
||||
{copiedKey === "quick-raw" ? "✓ Copied!" : "Copy Raw Base64"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 3. HTML <img> */}
|
||||
<div className="flex flex-col justify-between rounded-lg border border-gray-200 bg-white p-3 dark:border-gray-700 dark:bg-gray-800">
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-gray-800 dark:text-gray-200">HTML <img></span>
|
||||
<span className="text-[10px] text-gray-400"><img src=...></span>
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-gray-500 dark:text-gray-400 line-clamp-2 font-mono">
|
||||
{`<img src="${image.dataUri}" alt="${image.name}" />`}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
copyToClipboard(
|
||||
`<img src="${image.dataUri}" alt="${image.name}" width="${image.width || 100}" height="${image.height || 100}" />`,
|
||||
"quick-html"
|
||||
)
|
||||
}
|
||||
className="mt-2.5 w-full cursor-pointer rounded bg-gray-100 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600 transition-colors flex items-center justify-center gap-1"
|
||||
>
|
||||
{copiedKey === "quick-html" ? "✓ Copied!" : "Copy HTML Tag"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 4. CSS background-image */}
|
||||
<div className="flex flex-col justify-between rounded-lg border border-gray-200 bg-white p-3 dark:border-gray-700 dark:bg-gray-800">
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-gray-800 dark:text-gray-200">CSS Background</span>
|
||||
<span className="text-[10px] text-gray-400">background-image</span>
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-gray-500 dark:text-gray-400 line-clamp-2 font-mono">
|
||||
{`background-image: url("${image.dataUri}");`}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
copyToClipboard(`background-image: url("${image.dataUri}");`, "quick-css")
|
||||
}
|
||||
className="mt-2.5 w-full cursor-pointer rounded bg-gray-100 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600 transition-colors flex items-center justify-center gap-1"
|
||||
>
|
||||
{copiedKey === "quick-css" ? "✓ Copied!" : "Copy CSS"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Informational Cards */}
|
||||
<div className="mt-8 grid grid-cols-1 gap-4 border-t border-gray-200 pt-6 text-xs text-gray-600 dark:border-gray-800 dark:text-gray-400 md:grid-cols-3">
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50/50 p-4 dark:border-gray-700/60 dark:bg-gray-800/40">
|
||||
<h4 className="font-semibold text-gray-800 dark:text-gray-200 mb-1">When to Use Base64 Images</h4>
|
||||
<p>
|
||||
Ideal for embedding small icons (<10KB), logos in standalone HTML/emails, offline web apps, and preventing render-blocking HTTP requests.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50/50 p-4 dark:border-gray-700/60 dark:bg-gray-800/40">
|
||||
<h4 className="font-semibold text-gray-800 dark:text-gray-200 mb-1">Understanding the ~33% Overhead</h4>
|
||||
<p>
|
||||
Base64 encodes every 3 binary bytes into 4 ASCII characters (a 33.3% size expansion). For large photographs, standard image hosting with CDN caching is recommended.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50/50 p-4 dark:border-gray-700/60 dark:bg-gray-800/40">
|
||||
<h4 className="font-semibold text-gray-800 dark:text-gray-200 mb-1">Private & In-Browser Execution</h4>
|
||||
<p>
|
||||
Your graphics never touch an external server or API. All binary-to-string transformation executes natively via HTML5 FileReader in your browser.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ToolLayout>
|
||||
);
|
||||
}
|
||||
628
src/app/tools/json-to-csv/page.tsx
Normal file
628
src/app/tools/json-to-csv/page.tsx
Normal file
|
|
@ -0,0 +1,628 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useCallback, useId, useTransition } from "react";
|
||||
import ToolLayout from "@/components/ToolLayout";
|
||||
|
||||
interface StatusInfo {
|
||||
type: "idle" | "success" | "error";
|
||||
message?: string;
|
||||
rowCount?: number;
|
||||
colCount?: number;
|
||||
byteSize?: number;
|
||||
line?: number;
|
||||
column?: number;
|
||||
}
|
||||
|
||||
const SAMPLE_JSON = [
|
||||
{
|
||||
id: 101,
|
||||
name: "Alex Johnson",
|
||||
email: "alex.johnson@example.com",
|
||||
role: "Senior Engineer",
|
||||
active: true,
|
||||
salary: 125000,
|
||||
address: {
|
||||
street: "123 Market St, Suite 400",
|
||||
city: "San Francisco",
|
||||
state: "CA",
|
||||
zip: "94105",
|
||||
geo: {
|
||||
lat: 37.7749,
|
||||
lng: -122.4194
|
||||
}
|
||||
},
|
||||
skills: ["TypeScript", "React", "Node.js"],
|
||||
manager: null
|
||||
},
|
||||
{
|
||||
id: 102,
|
||||
name: "Sarah \"Dev\" Connor",
|
||||
email: "sarah.c@example.com",
|
||||
role: "Product Designer",
|
||||
active: true,
|
||||
salary: 110000,
|
||||
address: {
|
||||
street: "456 Oak Avenue",
|
||||
city: "Austin",
|
||||
state: "TX",
|
||||
zip: "78701",
|
||||
geo: {
|
||||
lat: 30.2672,
|
||||
lng: -97.7431
|
||||
}
|
||||
},
|
||||
skills: ["Figma", "UI/UX", "Tailwind CSS"],
|
||||
manager: "Alex Johnson"
|
||||
},
|
||||
{
|
||||
id: 103,
|
||||
name: "Liam O'Connor\nRemote Lead",
|
||||
email: "liam.oc@example.com",
|
||||
role: "DevOps Engineer",
|
||||
active: false,
|
||||
salary: 130000,
|
||||
address: {
|
||||
street: "789 Pine Road",
|
||||
city: "Seattle",
|
||||
state: "WA",
|
||||
zip: "98101",
|
||||
geo: {
|
||||
lat: 47.6062,
|
||||
lng: -122.3321
|
||||
}
|
||||
},
|
||||
skills: ["Kubernetes", "Docker", "AWS", "Terraform"],
|
||||
manager: "Alex Johnson"
|
||||
}
|
||||
];
|
||||
|
||||
function flattenObject(
|
||||
obj: Record<string, unknown>,
|
||||
prefix = "",
|
||||
arrayMode: "json" | "join" = "join"
|
||||
): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
for (const key of Object.keys(obj)) {
|
||||
const val = obj[key];
|
||||
const newKey = prefix ? `${prefix}.${key}` : key;
|
||||
|
||||
if (val !== null && typeof val === "object") {
|
||||
if (Array.isArray(val)) {
|
||||
if (arrayMode === "join" && val.every((v) => typeof v !== "object" || v === null)) {
|
||||
result[newKey] = val.map((v) => (v === null ? "" : String(v))).join("; ");
|
||||
} else {
|
||||
result[newKey] = JSON.stringify(val);
|
||||
}
|
||||
} else if (Object.keys(val).length > 0) {
|
||||
Object.assign(result, flattenObject(val as Record<string, unknown>, newKey, arrayMode));
|
||||
} else {
|
||||
result[newKey] = "{}";
|
||||
}
|
||||
} else {
|
||||
result[newKey] = val;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseJsonError(input: string, err: unknown): { message: string; line?: number; column?: number } {
|
||||
if (!(err instanceof Error)) {
|
||||
return { message: String(err) };
|
||||
}
|
||||
const msg = err.message;
|
||||
const posMatch = msg.match(/at position (\d+)/i);
|
||||
if (posMatch) {
|
||||
const pos = parseInt(posMatch[1], 10);
|
||||
const before = input.slice(0, pos);
|
||||
const lines = before.split("\n");
|
||||
const line = lines.length;
|
||||
const column = lines[lines.length - 1].length + 1;
|
||||
return { message: msg, line, column };
|
||||
}
|
||||
const lineColMatch = msg.match(/line (\d+) column (\d+)/i);
|
||||
if (lineColMatch) {
|
||||
return {
|
||||
message: msg,
|
||||
line: parseInt(lineColMatch[1], 10),
|
||||
column: parseInt(lineColMatch[2], 10),
|
||||
};
|
||||
}
|
||||
return { message: msg };
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
export default function JsonToCsvPage() {
|
||||
const [input, setInput] = useState<string>("");
|
||||
const [output, setOutput] = useState<string>("");
|
||||
const [delimiter, setDelimiter] = useState<string>(",");
|
||||
const [flatten, setFlatten] = useState<boolean>(true);
|
||||
const [includeHeaders, setIncludeHeaders] = useState<boolean>(true);
|
||||
const [quoteAll, setQuoteAll] = useState<boolean>(false);
|
||||
const [arrayMode, setArrayMode] = useState<"join" | "json">("join");
|
||||
const [status, setStatus] = useState<StatusInfo>({ type: "idle" });
|
||||
const [copied, setCopied] = useState<boolean>(false);
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const delimiterId = useId();
|
||||
const arrayModeId = useId();
|
||||
|
||||
const convertJsonToCsv = useCallback(
|
||||
(
|
||||
rawJson: string,
|
||||
currentDelimiter: string,
|
||||
shouldFlatten: boolean,
|
||||
headersEnabled: boolean,
|
||||
allQuotes: boolean,
|
||||
arrMode: "join" | "json"
|
||||
) => {
|
||||
if (!rawJson.trim()) {
|
||||
setOutput("");
|
||||
setStatus({ type: "idle" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(rawJson);
|
||||
let array: unknown[] = [];
|
||||
|
||||
if (Array.isArray(parsed)) {
|
||||
array = parsed;
|
||||
} else if (parsed !== null && typeof parsed === "object") {
|
||||
array = [parsed];
|
||||
} else {
|
||||
setStatus({
|
||||
type: "error",
|
||||
message: "JSON root must be an array of objects (e.g. `[{...}, {...}]`) or a single object.",
|
||||
});
|
||||
setOutput("");
|
||||
return;
|
||||
}
|
||||
|
||||
if (array.length === 0) {
|
||||
setOutput("");
|
||||
setStatus({
|
||||
type: "success",
|
||||
message: "Empty array converted (0 rows, 0 columns).",
|
||||
rowCount: 0,
|
||||
colCount: 0,
|
||||
byteSize: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const processedRows: Record<string, unknown>[] = array.map((item) => {
|
||||
if (item === null || typeof item !== "object") {
|
||||
return { value: item };
|
||||
}
|
||||
if (shouldFlatten) {
|
||||
return flattenObject(item as Record<string, unknown>, "", arrMode);
|
||||
}
|
||||
return item as Record<string, unknown>;
|
||||
});
|
||||
|
||||
// Collect all unique keys in order of discovery
|
||||
const headerSet = new Set<string>();
|
||||
for (const row of processedRows) {
|
||||
for (const key of Object.keys(row)) {
|
||||
headerSet.add(key);
|
||||
}
|
||||
}
|
||||
const headers = Array.from(headerSet);
|
||||
|
||||
if (headers.length === 0) {
|
||||
setOutput("");
|
||||
setStatus({
|
||||
type: "success",
|
||||
message: "No data properties found.",
|
||||
rowCount: processedRows.length,
|
||||
colCount: 0,
|
||||
byteSize: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const escapeVal = (val: unknown): string => {
|
||||
if (val === null || val === undefined) return "";
|
||||
let str = typeof val === "object" ? JSON.stringify(val) : String(val);
|
||||
const needsQuote =
|
||||
allQuotes ||
|
||||
str.includes(currentDelimiter) ||
|
||||
str.includes('"') ||
|
||||
str.includes("\n") ||
|
||||
str.includes("\r");
|
||||
if (needsQuote) {
|
||||
str = `"${str.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return str;
|
||||
};
|
||||
|
||||
const lines: string[] = [];
|
||||
if (headersEnabled) {
|
||||
lines.push(headers.map((h) => escapeVal(h)).join(currentDelimiter));
|
||||
}
|
||||
|
||||
for (const row of processedRows) {
|
||||
const rowValues = headers.map((header) => {
|
||||
const val = row[header];
|
||||
return escapeVal(val);
|
||||
});
|
||||
lines.push(rowValues.join(currentDelimiter));
|
||||
}
|
||||
|
||||
const csvResult = lines.join("\n");
|
||||
setOutput(csvResult);
|
||||
|
||||
const byteSize = new Blob([csvResult]).size;
|
||||
setStatus({
|
||||
type: "success",
|
||||
message: "JSON successfully converted to CSV!",
|
||||
rowCount: processedRows.length,
|
||||
colCount: headers.length,
|
||||
byteSize,
|
||||
});
|
||||
} catch (err) {
|
||||
const errorInfo = parseJsonError(rawJson, err);
|
||||
setOutput("");
|
||||
setStatus({
|
||||
type: "error",
|
||||
message: errorInfo.message,
|
||||
line: errorInfo.line,
|
||||
column: errorInfo.column,
|
||||
});
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleConvert = useCallback(() => {
|
||||
startTransition(() => {
|
||||
convertJsonToCsv(input, delimiter, flatten, includeHeaders, quoteAll, arrayMode);
|
||||
});
|
||||
}, [input, delimiter, flatten, includeHeaders, quoteAll, arrayMode, convertJsonToCsv]);
|
||||
|
||||
const handleLoadSample = useCallback(() => {
|
||||
const sampleStr = JSON.stringify(SAMPLE_JSON, null, 2);
|
||||
setInput(sampleStr);
|
||||
startTransition(() => {
|
||||
convertJsonToCsv(sampleStr, delimiter, flatten, includeHeaders, quoteAll, arrayMode);
|
||||
});
|
||||
}, [delimiter, flatten, includeHeaders, quoteAll, arrayMode, convertJsonToCsv]);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
setInput("");
|
||||
setOutput("");
|
||||
setStatus({ type: "idle" });
|
||||
}, []);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
if (!output) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(output);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = output;
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(textarea);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
}, [output]);
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
if (!output) return;
|
||||
const blob = new Blob([output], { type: "text/csv;charset=utf-8;" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "data.csv";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}, [output]);
|
||||
|
||||
const inputLineCount = input ? input.split("\n").length : 0;
|
||||
const outputLineCount = output ? output.split("\n").length : 0;
|
||||
|
||||
return (
|
||||
<ToolLayout
|
||||
title="JSON to CSV Converter - Convert JSON Arrays & Objects to CSV"
|
||||
description="Fast, free client-side JSON to CSV converter. Flatten nested JSON objects using dot notation, customize delimiters, export, and download CSV files instantly."
|
||||
>
|
||||
<div className="space-y-5">
|
||||
{/* Top Controls Toolbar */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-gray-200 pb-4 dark:border-gray-800">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
onClick={handleConvert}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-xs transition-colors hover:bg-blue-700 active:scale-95 cursor-pointer"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
Convert to CSV
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleLoadSample}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-blue-50 px-3 py-2 text-sm font-medium text-blue-700 transition-colors hover:bg-blue-100 dark:bg-blue-950/40 dark:text-blue-300 dark:hover:bg-blue-900/50 cursor-pointer"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||
</svg>
|
||||
Load Sample Data
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleClear}
|
||||
disabled={!input && !output}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-gray-100 px-3 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-rose-50 hover:text-rose-600 disabled:opacity-40 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-rose-950/30 dark:hover:text-rose-400 cursor-pointer"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm text-gray-700 dark:text-gray-300">
|
||||
{/* Delimiter */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<label htmlFor={delimiterId} className="text-xs font-semibold text-gray-500 uppercase dark:text-gray-400">
|
||||
Delimiter:
|
||||
</label>
|
||||
<select
|
||||
id={delimiterId}
|
||||
value={delimiter}
|
||||
onChange={(e) => setDelimiter(e.target.value)}
|
||||
className="rounded-md border border-gray-300 bg-white px-2 py-1 text-xs text-gray-800 shadow-xs focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200"
|
||||
>
|
||||
<option value=",">Comma (,)</option>
|
||||
<option value=";">Semicolon (;)</option>
|
||||
<option value="	">Tab (\t)</option>
|
||||
<option value="|">Pipe (|)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Array Mode */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<label htmlFor={arrayModeId} className="text-xs font-semibold text-gray-500 uppercase dark:text-gray-400">
|
||||
Arrays:
|
||||
</label>
|
||||
<select
|
||||
id={arrayModeId}
|
||||
value={arrayMode}
|
||||
onChange={(e) => setArrayMode(e.target.value as "join" | "json")}
|
||||
className="rounded-md border border-gray-300 bg-white px-2 py-1 text-xs text-gray-800 shadow-xs focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200"
|
||||
>
|
||||
<option value="join">Join with ";"</option>
|
||||
<option value="json">JSON String</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toggles Strip */}
|
||||
<div className="flex flex-wrap items-center gap-5 text-xs text-gray-600 dark:text-gray-400">
|
||||
<label className="flex cursor-pointer items-center gap-1.5 select-none hover:text-gray-900 dark:hover:text-gray-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={flatten}
|
||||
onChange={(e) => setFlatten(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800"
|
||||
/>
|
||||
<span>Flatten nested objects (e.g. <code>address.city</code>)</span>
|
||||
</label>
|
||||
|
||||
<label className="flex cursor-pointer items-center gap-1.5 select-none hover:text-gray-900 dark:hover:text-gray-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeHeaders}
|
||||
onChange={(e) => setIncludeHeaders(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800"
|
||||
/>
|
||||
<span>Include header row</span>
|
||||
</label>
|
||||
|
||||
<label className="flex cursor-pointer items-center gap-1.5 select-none hover:text-gray-900 dark:hover:text-gray-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={quoteAll}
|
||||
onChange={(e) => setQuoteAll(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800"
|
||||
/>
|
||||
<span>Quote all fields</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Status Notification Banner */}
|
||||
{status.type === "success" && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-emerald-300 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-300">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="h-5 w-5 shrink-0 text-emerald-600 dark:text-emerald-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span className="font-medium">{status.message}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-emerald-800 dark:text-emerald-300">
|
||||
{status.rowCount !== undefined && <span>Rows: <strong>{status.rowCount}</strong></span>}
|
||||
{status.colCount !== undefined && <span>Columns: <strong>{status.colCount}</strong></span>}
|
||||
{status.byteSize !== undefined && <span>Size: <strong>{formatBytes(status.byteSize)}</strong></span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status.type === "error" && (
|
||||
<div className="rounded-lg border border-red-300 bg-red-50 p-4 text-sm text-red-900 dark:border-red-800 dark:bg-red-950/40 dark:text-red-300">
|
||||
<div className="flex items-start gap-2">
|
||||
<svg className="mt-0.5 h-5 w-5 shrink-0 text-red-600 dark:text-red-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold text-red-800 dark:text-red-200">Error Parsing JSON</p>
|
||||
<p className="mt-1 font-mono text-xs text-red-700 dark:text-red-300">{status.message}</p>
|
||||
{(status.line !== undefined || status.column !== undefined) && (
|
||||
<div className="mt-2 inline-flex items-center gap-2 rounded bg-red-100 px-2.5 py-1 text-xs font-medium text-red-800 dark:bg-red-900/60 dark:text-red-200">
|
||||
<span>📍 Location:</span>
|
||||
{status.line !== undefined && <span>Line <strong>{status.line}</strong></span>}
|
||||
{status.column !== undefined && <span>Column <strong>{status.column}</strong></span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input & Output Split Editor */}
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
{/* Input Area */}
|
||||
<div className="flex flex-col">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-gray-800 dark:text-gray-200">Input JSON</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
({inputLineCount} {inputLineCount === 1 ? "line" : "lines"}, {input.length} chars)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
setInput(text);
|
||||
} catch {
|
||||
// Clipboard permission denied
|
||||
}
|
||||
}}
|
||||
className="rounded px-2 py-1 text-xs font-medium text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800 cursor-pointer"
|
||||
title="Paste from clipboard"
|
||||
>
|
||||
Paste
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setInput("")}
|
||||
className="rounded px-2 py-1 text-xs font-medium text-gray-500 hover:bg-gray-100 hover:text-red-600 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-red-400 cursor-pointer"
|
||||
title="Clear input"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="Paste JSON array of objects here, e.g. [ { "id": 1, "name": "Alice" }, ... ]"
|
||||
rows={18}
|
||||
spellCheck={false}
|
||||
className="w-full flex-1 resize-y rounded-lg border border-gray-300 bg-white p-3 font-mono text-sm leading-relaxed text-gray-900 shadow-xs focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Output Area */}
|
||||
<div className="flex flex-col">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-gray-800 dark:text-gray-200">CSV Output</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
({outputLineCount} {outputLineCount === 1 ? "line" : "lines"}, {output.length} chars)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
disabled={!output}
|
||||
className="inline-flex items-center gap-1 rounded px-2.5 py-1 text-xs font-medium text-gray-700 hover:bg-gray-100 disabled:opacity-40 dark:text-gray-300 dark:hover:bg-gray-800 cursor-pointer"
|
||||
title="Download as CSV file"
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Download .csv
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
disabled={!output}
|
||||
className="inline-flex items-center gap-1 rounded-md bg-blue-600 px-3 py-1 text-xs font-semibold text-white shadow-xs transition-colors hover:bg-blue-700 disabled:opacity-40 cursor-pointer"
|
||||
title="Copy CSV to clipboard"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
Copy CSV
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
value={output}
|
||||
readOnly
|
||||
placeholder="Converted CSV tabular data will appear here..."
|
||||
rows={18}
|
||||
spellCheck={false}
|
||||
className="w-full flex-1 resize-y rounded-lg border border-gray-300 bg-gray-50 p-3 font-mono text-sm leading-relaxed text-gray-900 shadow-xs focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none dark:border-gray-700 dark:bg-gray-900/80 dark:text-gray-100 dark:placeholder-gray-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feature Highlights & Guide */}
|
||||
<div className="mt-8 rounded-xl border border-gray-200 bg-gray-50/70 p-5 text-sm text-gray-600 dark:border-gray-800 dark:bg-gray-800/30 dark:text-gray-400">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100">How to Convert JSON to CSV</h3>
|
||||
<div className="mt-3 grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<p className="font-medium text-gray-800 dark:text-gray-200">🌿 Nested Object Flattening</p>
|
||||
<p className="mt-1 text-xs leading-normal">
|
||||
Automatically unpacks deep nested objects into flat dot-notation headers such as <code>user.address.geo.lat</code>.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-800 dark:text-gray-200">🛡️ RFC 4180 Escaping</p>
|
||||
<p className="mt-1 text-xs leading-normal">
|
||||
Safely handles quotes, multi-line values, commas, and special delimiters with compliant CSV double-quote escaping.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-800 dark:text-gray-200">🔒 100% Client-Side Privacy</p>
|
||||
<p className="mt-1 text-xs leading-normal">
|
||||
Your data is processed directly inside your browser. No files or records are sent to any remote server.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ToolLayout>
|
||||
);
|
||||
}
|
||||
1007
src/app/tools/meta-tag-generator/page.tsx
Normal file
1007
src/app/tools/meta-tag-generator/page.tsx
Normal file
File diff suppressed because it is too large
Load diff
620
src/app/tools/placeholder-image/page.tsx
Normal file
620
src/app/tools/placeholder-image/page.tsx
Normal file
|
|
@ -0,0 +1,620 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, useCallback, useId } from "react";
|
||||
import ToolLayout from "@/components/ToolLayout";
|
||||
|
||||
interface PresetSize {
|
||||
name: string;
|
||||
width: number;
|
||||
height: number;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
const PRESET_SIZES: PresetSize[] = [
|
||||
{ name: "Thumbnail", width: 150, height: 150 },
|
||||
{ name: "Social (OG)", width: 1200, height: 630 },
|
||||
{ name: "Banner", width: 728, height: 90 },
|
||||
{ name: "Avatar", width: 64, height: 64 },
|
||||
{ name: "HD (1080p)", width: 1920, height: 1080 },
|
||||
{ name: "Square Post", width: 1080, height: 1080 },
|
||||
{ name: "Medium Box", width: 300, height: 250 },
|
||||
{ name: "Mobile Screen", width: 390, height: 844 },
|
||||
];
|
||||
|
||||
const COLOR_PALETTES = [
|
||||
{ name: "Default Gray", bg: "#CCCCCC", text: "#666666" },
|
||||
{ name: "Slate Dark", bg: "#1E293B", text: "#94A3B8" },
|
||||
{ name: "Indigo Modern", bg: "#4F46E5", text: "#EEF2FF" },
|
||||
{ name: "Sky Light", bg: "#E0F2FE", text: "#0369A1" },
|
||||
{ name: "Emerald Fresh", bg: "#D1FAE5", text: "#065F46" },
|
||||
{ name: "Amber Warm", bg: "#FEF3C7", text: "#92400E" },
|
||||
{ name: "Rose Soft", bg: "#FFE4E6", text: "#9F1239" },
|
||||
{ name: "Pure Dark", bg: "#111827", text: "#F9FAFB" },
|
||||
];
|
||||
|
||||
export default function PlaceholderImagePage() {
|
||||
const widthId = useId();
|
||||
const heightId = useId();
|
||||
const textId = useId();
|
||||
const fontSizeId = useId();
|
||||
|
||||
const [width, setWidth] = useState<number>(400);
|
||||
const [height, setHeight] = useState<number>(300);
|
||||
const [bgColor, setBgColor] = useState<string>("#CCCCCC");
|
||||
const [textColor, setTextColor] = useState<string>("#666666");
|
||||
const [customText, setCustomText] = useState<string>("");
|
||||
const [isAutoFontSize, setIsAutoFontSize] = useState<boolean>(true);
|
||||
const [manualFontSize, setManualFontSize] = useState<number>(28);
|
||||
const [fontFamily, setFontFamily] = useState<string>("sans-serif");
|
||||
const [fontWeight, setFontWeight] = useState<string>("600");
|
||||
const [showBorder, setShowBorder] = useState<boolean>(false);
|
||||
const [copiedKey, setCopiedKey] = useState<string | null>(null);
|
||||
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
// Compute active text to show
|
||||
const displayText = customText.trim()
|
||||
? customText
|
||||
.replace(/\{w\}/gi, String(width))
|
||||
.replace(/\{h\}/gi, String(height))
|
||||
: `${width} × ${height}`;
|
||||
|
||||
// Calculate font size
|
||||
const calculatedFontSize = isAutoFontSize
|
||||
? Math.max(12, Math.min(Math.round(Math.min(width, height) / 8), 120))
|
||||
: manualFontSize;
|
||||
|
||||
// Draw on Canvas
|
||||
const drawCanvas = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
// Set real canvas dimensions
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
// Background
|
||||
ctx.fillStyle = bgColor;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
// Optional border / outline
|
||||
if (showBorder) {
|
||||
ctx.strokeStyle = textColor;
|
||||
ctx.lineWidth = Math.max(2, Math.min(width, height) * 0.015);
|
||||
ctx.setLineDash([8, 6]);
|
||||
ctx.strokeRect(6, 6, width - 12, height - 12);
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
|
||||
// Text formatting
|
||||
ctx.fillStyle = textColor;
|
||||
const fontSpec = `${fontWeight} ${calculatedFontSize}px ${fontFamily}, system-ui, -apple-system, sans-serif`;
|
||||
ctx.font = fontSpec;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
|
||||
// Support multiline text
|
||||
const lines = displayText.split("\n");
|
||||
const lineHeight = calculatedFontSize * 1.25;
|
||||
const totalHeight = lines.length * lineHeight;
|
||||
const startY = height / 2 - totalHeight / 2 + lineHeight / 2;
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
ctx.fillText(line, width / 2, startY + index * lineHeight);
|
||||
});
|
||||
}, [width, height, bgColor, textColor, displayText, calculatedFontSize, fontFamily, fontWeight, showBorder]);
|
||||
|
||||
useEffect(() => {
|
||||
drawCanvas();
|
||||
}, [drawCanvas]);
|
||||
|
||||
// Generate SVG Markup
|
||||
const generateSvgMarkup = useCallback((): string => {
|
||||
const lines = displayText.split("\n");
|
||||
const lineHeight = calculatedFontSize * 1.25;
|
||||
const startYPercent = lines.length === 1 ? 50 : 50 - ((lines.length - 1) * 1.25 * (calculatedFontSize / height) * 50);
|
||||
|
||||
const textElements = lines
|
||||
.map((line, idx) => {
|
||||
const dy = idx === 0 ? "0" : `${lineHeight}px`;
|
||||
return `<tspan x="50%" dy="${dy}">${line.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")}</tspan>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
const borderElement = showBorder
|
||||
? `<rect x="6" y="6" width="${width - 12}" height="${height - 12}" fill="none" stroke="${textColor}" stroke-width="2" stroke-dasharray="8 6" />`
|
||||
: "";
|
||||
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
||||
<rect width="100%" height="100%" fill="${bgColor}"/>
|
||||
${borderElement}
|
||||
<text x="50%" y="${startYPercent}%" dominant-baseline="middle" text-anchor="middle" font-family="${fontFamily}, system-ui, sans-serif" font-size="${calculatedFontSize}px" font-weight="${fontWeight}" fill="${textColor}">
|
||||
${textElements}
|
||||
</text>
|
||||
</svg>`;
|
||||
}, [width, height, bgColor, textColor, displayText, calculatedFontSize, fontFamily, fontWeight, showBorder]);
|
||||
|
||||
// Actions
|
||||
const handleDownloadPng = () => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const dataUrl = canvas.toDataURL("image/png");
|
||||
const link = document.createElement("a");
|
||||
link.href = dataUrl;
|
||||
link.download = `placeholder-${width}x${height}.png`;
|
||||
link.click();
|
||||
};
|
||||
|
||||
const handleDownloadSvg = () => {
|
||||
const svgMarkup = generateSvgMarkup();
|
||||
const blob = new Blob([svgMarkup], { type: "image/svg+xml;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `placeholder-${width}x${height}.svg`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const copyToClipboard = async (text: string, key: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopiedKey(key);
|
||||
setTimeout(() => setCopiedKey(null), 2000);
|
||||
} catch {
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = text;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(textArea);
|
||||
setCopiedKey(key);
|
||||
setTimeout(() => setCopiedKey(null), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyDataUri = () => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const dataUrl = canvas.toDataURL("image/png");
|
||||
copyToClipboard(dataUrl, "dataUri");
|
||||
};
|
||||
|
||||
const handleCopySvgDataUri = () => {
|
||||
const svgMarkup = generateSvgMarkup();
|
||||
const base64Svg = btoa(unescape(encodeURIComponent(svgMarkup)));
|
||||
const dataUri = `data:image/svg+xml;base64,${base64Svg}`;
|
||||
copyToClipboard(dataUri, "svgDataUri");
|
||||
};
|
||||
|
||||
const handleCopySvgCode = () => {
|
||||
copyToClipboard(generateSvgMarkup(), "svgCode");
|
||||
};
|
||||
|
||||
const handleCopyImgTag = () => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const dataUrl = canvas.toDataURL("image/png");
|
||||
const tag = `<img src="${dataUrl}" width="${width}" height="${height}" alt="Placeholder ${width}x${height}" />`;
|
||||
copyToClipboard(tag, "imgTag");
|
||||
};
|
||||
|
||||
const handleSwapColors = () => {
|
||||
const temp = bgColor;
|
||||
setBgColor(textColor);
|
||||
setTextColor(temp);
|
||||
};
|
||||
|
||||
const applyPreset = (preset: PresetSize) => {
|
||||
setWidth(preset.width);
|
||||
setHeight(preset.height);
|
||||
};
|
||||
|
||||
return (
|
||||
<ToolLayout
|
||||
title="Placeholder Image Generator"
|
||||
description="Create custom mockups and placeholder images in any dimension, color, and text. Export instantly as PNG, SVG, or Data URI."
|
||||
>
|
||||
<title>Placeholder Image Generator Online — DevToolbox</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Free online placeholder image generator. Create custom dummy images with custom dimensions, background colors, text, and download as PNG or SVG."
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Preset Sizes Bar */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-gray-700 dark:text-gray-300">
|
||||
Popular Presets
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">Click to apply size</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{PRESET_SIZES.map((preset) => {
|
||||
const isSelected = width === preset.width && height === preset.height;
|
||||
return (
|
||||
<button
|
||||
key={preset.name}
|
||||
type="button"
|
||||
onClick={() => applyPreset(preset)}
|
||||
className={`cursor-pointer rounded-lg px-3 py-1.5 text-xs font-medium transition-all ${
|
||||
isSelected
|
||||
? "bg-blue-600 text-white shadow-sm ring-2 ring-blue-500/30"
|
||||
: "bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
}`}
|
||||
>
|
||||
<span className="font-semibold">{preset.name}</span>
|
||||
<span className="ml-1.5 text-[11px] opacity-75 font-mono">
|
||||
{preset.width}×{preset.height}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Grid: Controls + Live Canvas Preview */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-12">
|
||||
{/* Controls Column (7 Cols) */}
|
||||
<div className="space-y-5 lg:col-span-6">
|
||||
{/* Dimensions */}
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-4.5 shadow-sm dark:border-gray-700 dark:bg-gray-800 space-y-4">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-gray-700 dark:text-gray-300">
|
||||
Dimensions (Pixels)
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor={widthId} className="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
Width (px)
|
||||
</label>
|
||||
<input
|
||||
id={widthId}
|
||||
type="number"
|
||||
min="10"
|
||||
max="4000"
|
||||
step="1"
|
||||
value={width}
|
||||
onChange={(e) => setWidth(Math.max(10, Math.min(4000, parseInt(e.target.value) || 10)))}
|
||||
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 font-mono text-sm text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-900/60 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor={heightId} className="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
Height (px)
|
||||
</label>
|
||||
<input
|
||||
id={heightId}
|
||||
type="number"
|
||||
min="10"
|
||||
max="4000"
|
||||
step="1"
|
||||
value={height}
|
||||
onChange={(e) => setHeight(Math.max(10, Math.min(4000, parseInt(e.target.value) || 10)))}
|
||||
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 font-mono text-sm text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-900/60 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Colors & Palette Swatches */}
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-4.5 shadow-sm dark:border-gray-700 dark:bg-gray-800 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-gray-700 dark:text-gray-300">
|
||||
Colors & Theme
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSwapColors}
|
||||
className="flex items-center gap-1 text-xs text-blue-600 hover:underline dark:text-blue-400 font-medium cursor-pointer"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
|
||||
</svg>
|
||||
Swap Colors
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Background Color */}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
Background Color
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={bgColor}
|
||||
onChange={(e) => setBgColor(e.target.value)}
|
||||
className="h-9 w-10 cursor-pointer rounded border border-gray-300 bg-transparent p-0.5 dark:border-gray-600"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={bgColor}
|
||||
onChange={(e) => setBgColor(e.target.value)}
|
||||
maxLength={7}
|
||||
className="w-full rounded-lg border border-gray-300 bg-white px-2.5 py-1.5 font-mono text-xs text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-900/60 dark:text-gray-100 uppercase"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Text Color */}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
Text Color
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={textColor}
|
||||
onChange={(e) => setTextColor(e.target.value)}
|
||||
className="h-9 w-10 cursor-pointer rounded border border-gray-300 bg-transparent p-0.5 dark:border-gray-600"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={textColor}
|
||||
onChange={(e) => setTextColor(e.target.value)}
|
||||
maxLength={7}
|
||||
className="w-full rounded-lg border border-gray-300 bg-white px-2.5 py-1.5 font-mono text-xs text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-900/60 dark:text-gray-100 uppercase"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Palette swatches */}
|
||||
<div>
|
||||
<span className="mb-1.5 block text-[11px] font-medium text-gray-500 dark:text-gray-400">
|
||||
Quick Palettes
|
||||
</span>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{COLOR_PALETTES.map((palette) => (
|
||||
<button
|
||||
key={palette.name}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setBgColor(palette.bg);
|
||||
setTextColor(palette.text);
|
||||
}}
|
||||
className="flex cursor-pointer items-center justify-between rounded-lg border border-gray-200 p-1.5 text-[11px] transition-all hover:border-blue-400 hover:shadow-xs dark:border-gray-700"
|
||||
title={palette.name}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="h-4 w-4 rounded-full border border-gray-300 dark:border-gray-600"
|
||||
style={{ backgroundColor: palette.bg }}
|
||||
/>
|
||||
<span
|
||||
className="h-3 w-3 rounded-full border border-gray-300 dark:border-gray-600 -ml-2"
|
||||
style={{ backgroundColor: palette.text }}
|
||||
/>
|
||||
</div>
|
||||
<span className="truncate text-[10px] text-gray-600 dark:text-gray-300">
|
||||
{palette.name.split(" ")[0]}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Text & Typography */}
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-4.5 shadow-sm dark:border-gray-700 dark:bg-gray-800 space-y-4">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-gray-700 dark:text-gray-300">
|
||||
Custom Text & Typography
|
||||
</h3>
|
||||
|
||||
<div>
|
||||
<label htmlFor={textId} className="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
Label Text <span className="text-[11px] text-gray-400">(leave blank for W×H, use {"{w}"} and {"{h}"} for dynamic dimensions)</span>
|
||||
</label>
|
||||
<input
|
||||
id={textId}
|
||||
type="text"
|
||||
value={customText}
|
||||
onChange={(e) => setCustomText(e.target.value)}
|
||||
placeholder={`${width} × ${height}`}
|
||||
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 font-mono text-sm text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-900/60 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Font settings */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
Font Family
|
||||
</label>
|
||||
<select
|
||||
value={fontFamily}
|
||||
onChange={(e) => setFontFamily(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 bg-white px-2.5 py-1.5 text-xs text-gray-900 focus:border-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-900/60 dark:text-gray-100"
|
||||
>
|
||||
<option value="sans-serif">Sans-Serif</option>
|
||||
<option value="monospace">Monospace</option>
|
||||
<option value="serif">Serif</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
Font Weight
|
||||
</label>
|
||||
<select
|
||||
value={fontWeight}
|
||||
onChange={(e) => setFontWeight(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 bg-white px-2.5 py-1.5 text-xs text-gray-900 focus:border-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-900/60 dark:text-gray-100"
|
||||
>
|
||||
<option value="400">Normal (400)</option>
|
||||
<option value="600">Semibold (600)</option>
|
||||
<option value="700">Bold (700)</option>
|
||||
<option value="900">Black (900)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2 sm:col-span-1 flex items-end pb-1">
|
||||
<label className="inline-flex items-center gap-2 text-xs font-medium text-gray-700 dark:text-gray-300 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showBorder}
|
||||
onChange={(e) => setShowBorder(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
/>
|
||||
<span>Dashed Border</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Font Size Selector */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<label htmlFor={fontSizeId} className="text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
Font Size: <span className="font-semibold text-gray-900 dark:text-gray-100">{calculatedFontSize}px</span>
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-1.5 text-xs text-blue-600 dark:text-blue-400 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isAutoFontSize}
|
||||
onChange={(e) => setIsAutoFontSize(e.target.checked)}
|
||||
className="h-3.5 w-3.5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
/>
|
||||
<span>Auto calculate</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{!isAutoFontSize && (
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
id={fontSizeId}
|
||||
type="range"
|
||||
min="10"
|
||||
max="140"
|
||||
value={manualFontSize}
|
||||
onChange={(e) => setManualFontSize(parseInt(e.target.value) || 10)}
|
||||
className="h-2 w-full cursor-pointer appearance-none rounded-lg bg-gray-200 accent-blue-600 dark:bg-gray-700"
|
||||
/>
|
||||
<span className="w-8 text-right font-mono text-xs text-gray-700 dark:text-gray-300">
|
||||
{manualFontSize}px
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Canvas Preview & Export Actions Column (6 Cols) */}
|
||||
<div className="flex flex-col justify-between rounded-xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-700 dark:bg-gray-800 lg:col-span-6 space-y-5">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-gray-700 dark:text-gray-300">
|
||||
Live Canvas Preview
|
||||
</h3>
|
||||
<span className="font-mono text-xs text-gray-500 dark:text-gray-400">
|
||||
{width} × {height} px
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Responsive Canvas Container with Checkerboard Background */}
|
||||
<div className="relative flex min-h-[260px] max-h-[360px] w-full items-center justify-center overflow-hidden rounded-xl border border-gray-200 bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] [background-size:12px_12px] p-3 dark:border-gray-700 dark:bg-[radial-gradient(#374151_1px,transparent_1px)] shadow-inner">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="max-h-full max-w-full rounded-md shadow-md object-contain transition-transform"
|
||||
style={{
|
||||
aspectRatio: `${width} / ${height}`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Export & Download Action Buttons */}
|
||||
<div className="space-y-3 pt-2">
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownloadPng}
|
||||
className="flex cursor-pointer items-center justify-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-blue-700 active:bg-blue-800"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Download PNG
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownloadSvg}
|
||||
className="flex cursor-pointer items-center justify-center gap-1.5 rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-indigo-700 active:bg-indigo-800"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
Download SVG
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Secondary Copy Actions */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyDataUri}
|
||||
className="cursor-pointer rounded-lg bg-gray-100 px-3 py-2 text-xs font-medium text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600 transition-colors flex items-center justify-center gap-1"
|
||||
>
|
||||
{copiedKey === "dataUri" ? "✓ Copied Data URI!" : "Copy PNG Data URI"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopySvgDataUri}
|
||||
className="cursor-pointer rounded-lg bg-gray-100 px-3 py-2 text-xs font-medium text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600 transition-colors flex items-center justify-center gap-1"
|
||||
>
|
||||
{copiedKey === "svgDataUri" ? "✓ Copied SVG URI!" : "Copy SVG Data URI"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopySvgCode}
|
||||
className="cursor-pointer rounded-lg bg-gray-100 px-3 py-2 text-xs font-medium text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600 transition-colors flex items-center justify-center gap-1"
|
||||
>
|
||||
{copiedKey === "svgCode" ? "✓ Copied SVG Code!" : "Copy SVG Markup"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyImgTag}
|
||||
className="cursor-pointer rounded-lg bg-gray-100 px-3 py-2 text-xs font-medium text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600 transition-colors flex items-center justify-center gap-1"
|
||||
>
|
||||
{copiedKey === "imgTag" ? "✓ Copied HTML Tag!" : "Copy <img> Tag"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Informative Grid */}
|
||||
<div className="mt-8 grid grid-cols-1 gap-4 border-t border-gray-200 pt-6 text-xs text-gray-600 dark:border-gray-800 dark:text-gray-400 md:grid-cols-3">
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50/50 p-4 dark:border-gray-700/60 dark:bg-gray-800/40">
|
||||
<h4 className="font-semibold text-gray-800 dark:text-gray-200 mb-1">Standard Aspect Ratios</h4>
|
||||
<p>
|
||||
Includes pre-configured dimensions for Open Graph social cards (1200×630), leaderboard ad banners (728×90), and avatars (64×64).
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50/50 p-4 dark:border-gray-700/60 dark:bg-gray-800/40">
|
||||
<h4 className="font-semibold text-gray-800 dark:text-gray-200 mb-1">Crisp Vector SVG & PNG</h4>
|
||||
<p>
|
||||
Export as scalable XML vector graphics for lightweight mockups or rasterized PNG files for maximum software compatibility.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50/50 p-4 dark:border-gray-700/60 dark:bg-gray-800/40">
|
||||
<h4 className="font-semibold text-gray-800 dark:text-gray-200 mb-1">Client-Side Canvas Rendering</h4>
|
||||
<p>
|
||||
Zero network requests required. Placeholders render instantly inside your browser canvas with zero server overhead.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ToolLayout>
|
||||
);
|
||||
}
|
||||
587
src/app/tools/px-to-rem/page.tsx
Normal file
587
src/app/tools/px-to-rem/page.tsx
Normal file
|
|
@ -0,0 +1,587 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useId } from "react";
|
||||
import ToolLayout from "@/components/ToolLayout";
|
||||
|
||||
const COMMON_PX_VALUES = [8, 10, 12, 14, 16, 18, 20, 24, 28, 32, 36, 40, 48, 56, 64, 72, 80, 96];
|
||||
|
||||
const TAILWIND_SPACING_MAP: Record<number, string> = {
|
||||
8: "p-2 / 2",
|
||||
10: "2.5",
|
||||
12: "p-3 / 3",
|
||||
14: "3.5",
|
||||
16: "p-4 / 4 (1rem)",
|
||||
18: "4.5",
|
||||
20: "p-5 / 5",
|
||||
24: "p-6 / 6 (1.5rem)",
|
||||
28: "p-7 / 7",
|
||||
32: "p-8 / 8 (2rem)",
|
||||
36: "p-9 / 9",
|
||||
40: "p-10 / 10 (2.5rem)",
|
||||
48: "p-12 / 12 (3rem)",
|
||||
56: "p-14 / 14 (3.5rem)",
|
||||
64: "p-16 / 16 (4rem)",
|
||||
72: "18 (4.5rem)",
|
||||
80: "p-20 / 20 (5rem)",
|
||||
96: "p-24 / 24 (6rem)",
|
||||
};
|
||||
|
||||
const BASE_PRESETS = [
|
||||
{ label: "16px (Browser Standard)", value: 16 },
|
||||
{ label: "10px (62.5% HTML trick)", value: 10 },
|
||||
{ label: "14px", value: 14 },
|
||||
{ label: "18px", value: 18 },
|
||||
{ label: "20px", value: 20 },
|
||||
];
|
||||
|
||||
function formatNumber(num: number, maxDecimals = 4): string {
|
||||
if (isNaN(num)) return "0";
|
||||
const fixed = num.toFixed(maxDecimals);
|
||||
return parseFloat(fixed).toString();
|
||||
}
|
||||
|
||||
export default function PxToRemPage() {
|
||||
const baseId = useId();
|
||||
const pxInputId = useId();
|
||||
const remInputId = useId();
|
||||
const bulkInputId = useId();
|
||||
|
||||
const [baseSize, setBaseSize] = useState<number>(16);
|
||||
const [pxValue, setPxValue] = useState<string>("24");
|
||||
const [remValue, setRemValue] = useState<string>("1.5");
|
||||
const [selectedProperty, setSelectedProperty] = useState<string>("font-size");
|
||||
const [copiedKey, setCopiedKey] = useState<string | null>(null);
|
||||
const [tableSearch, setTableSearch] = useState<string>("");
|
||||
|
||||
// Bulk converter states
|
||||
const [bulkInput, setBulkInput] = useState<string>("8\n12\n16\n24\n32\n48\n64");
|
||||
const [bulkFormat, setBulkFormat] = useState<"rem-list" | "mapping" | "css-vars" | "tailwind">("rem-list");
|
||||
|
||||
// Handle PX input change
|
||||
const handlePxChange = (val: string) => {
|
||||
setPxValue(val);
|
||||
const num = parseFloat(val);
|
||||
if (!isNaN(num) && baseSize > 0) {
|
||||
setRemValue(formatNumber(num / baseSize));
|
||||
} else {
|
||||
setRemValue("");
|
||||
}
|
||||
};
|
||||
|
||||
// Handle REM input change
|
||||
const handleRemChange = (val: string) => {
|
||||
setRemValue(val);
|
||||
const num = parseFloat(val);
|
||||
if (!isNaN(num) && baseSize > 0) {
|
||||
setPxValue(formatNumber(num * baseSize));
|
||||
} else {
|
||||
setPxValue("");
|
||||
}
|
||||
};
|
||||
|
||||
// Handle Base size change
|
||||
const handleBaseChange = (newBase: number) => {
|
||||
const validBase = Math.max(1, newBase);
|
||||
setBaseSize(validBase);
|
||||
const currentPx = parseFloat(pxValue);
|
||||
if (!isNaN(currentPx)) {
|
||||
setRemValue(formatNumber(currentPx / validBase));
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = async (text: string, key: string) => {
|
||||
if (!text) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopiedKey(key);
|
||||
setTimeout(() => setCopiedKey(null), 2000);
|
||||
} catch {
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = text;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(textArea);
|
||||
setCopiedKey(key);
|
||||
setTimeout(() => setCopiedKey(null), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate bulk output
|
||||
const parsedBulkRows = bulkInput
|
||||
.split(/[\n,]+/)
|
||||
.map((s) => s.trim().replace(/px$/i, ""))
|
||||
.filter((s) => s.length > 0)
|
||||
.map((s) => {
|
||||
const num = parseFloat(s);
|
||||
if (isNaN(num)) return null;
|
||||
const rem = formatNumber(num / baseSize);
|
||||
return { px: num, rem };
|
||||
})
|
||||
.filter((row): row is { px: number; rem: string } => row !== null);
|
||||
|
||||
const getBulkOutputString = () => {
|
||||
if (parsedBulkRows.length === 0) return "";
|
||||
switch (bulkFormat) {
|
||||
case "rem-list":
|
||||
return parsedBulkRows.map((r) => `${r.rem}rem`).join("\n");
|
||||
case "mapping":
|
||||
return parsedBulkRows.map((r) => `${r.px}px = ${r.rem}rem`).join("\n");
|
||||
case "css-vars":
|
||||
return parsedBulkRows
|
||||
.map((r) => `--size-${r.px}: ${r.rem}rem; /* ${r.px}px */`)
|
||||
.join("\n");
|
||||
case "tailwind":
|
||||
return parsedBulkRows
|
||||
.map((r) => `'${r.px}': '${r.rem}rem', /* ${r.px}px */`)
|
||||
.join("\n");
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
// Extra units for currently active px
|
||||
const currentPxNum = parseFloat(pxValue) || 0;
|
||||
const currentRemNum = parseFloat(remValue) || 0;
|
||||
const percentageValue = formatNumber((currentPxNum / baseSize) * 100, 2);
|
||||
const ptValue = formatNumber(currentPxNum * 0.75, 2); // 1px = 0.75pt
|
||||
const cssDeclaration = `${selectedProperty}: ${remValue || "0"}rem; /* ${pxValue || "0"}px */`;
|
||||
|
||||
// Filtered reference table rows
|
||||
const filteredTableValues = COMMON_PX_VALUES.filter((px) => {
|
||||
if (!tableSearch.trim()) return true;
|
||||
const search = tableSearch.toLowerCase().trim();
|
||||
const rem = formatNumber(px / baseSize);
|
||||
return px.toString().includes(search) || rem.includes(search);
|
||||
});
|
||||
|
||||
return (
|
||||
<ToolLayout
|
||||
title="Px to Rem Converter"
|
||||
description="Convert pixels (px) to relative root ems (rem) and vice versa with real-time conversion, a customizable base font size, reference table, and bulk conversion."
|
||||
>
|
||||
<title>Px to Rem Converter Online — DevToolbox</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Free online PX to REM and REM to PX converter. Live two-way converter, interactive reference table, CSS code snippet generator, and bulk converter."
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Base Font Size Configuration Bar */}
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-4.5 shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label
|
||||
htmlFor={baseId}
|
||||
className="text-xs font-semibold uppercase tracking-wider text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Root Base Font Size
|
||||
</label>
|
||||
<span className="rounded-full bg-blue-100 px-2 py-0.5 text-[11px] font-semibold text-blue-800 dark:bg-blue-900/60 dark:text-blue-300">
|
||||
1rem = {baseSize}px
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Standard browser default is 16px. Change to 10px if using the 62.5% font-size technique.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
id={baseId}
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
value={baseSize}
|
||||
onChange={(e) => handleBaseChange(parseInt(e.target.value) || 16)}
|
||||
className="w-20 rounded-lg border border-gray-300 bg-white px-3 py-1.5 font-mono text-sm font-semibold text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-600 dark:text-gray-400">px</span>
|
||||
</div>
|
||||
|
||||
{/* Preset buttons */}
|
||||
<div className="flex items-center gap-1">
|
||||
{BASE_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.value}
|
||||
type="button"
|
||||
onClick={() => handleBaseChange(preset.value)}
|
||||
className={`cursor-pointer rounded-md px-2.5 py-1.5 text-xs font-medium transition-colors ${
|
||||
baseSize === preset.value
|
||||
? "bg-blue-600 text-white shadow-xs"
|
||||
: "bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||
}`}
|
||||
>
|
||||
{preset.value}px
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Two-Way Interactive Live Converter */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-12">
|
||||
{/* Main Converter Box (7 Cols) */}
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-700 dark:bg-gray-800 lg:col-span-7 space-y-5">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-gray-700 dark:text-gray-300">
|
||||
Live Two-Way Converter
|
||||
</h3>
|
||||
|
||||
{/* Input Inputs Row with Bidirectional Arrow */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-11 sm:items-center">
|
||||
{/* PX Input */}
|
||||
<div className="sm:col-span-5 space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label htmlFor={pxInputId} className="text-xs font-semibold text-gray-700 dark:text-gray-300">
|
||||
Pixels (PX)
|
||||
</label>
|
||||
<span className="text-[11px] text-gray-400">Fixed</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<input
|
||||
id={pxInputId}
|
||||
type="number"
|
||||
step="any"
|
||||
value={pxValue}
|
||||
onChange={(e) => handlePxChange(e.target.value)}
|
||||
placeholder="24"
|
||||
className="w-full rounded-lg border border-gray-300 bg-white p-3 pr-10 font-mono text-lg font-bold text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-900/70 dark:text-gray-100 shadow-inner"
|
||||
/>
|
||||
<span className="pointer-events-none absolute right-3 top-3.5 font-mono text-sm font-semibold text-gray-400">
|
||||
px
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Arrow Indicator */}
|
||||
<div className="sm:col-span-1 flex justify-center text-gray-400 pt-1">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300">
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* REM Input */}
|
||||
<div className="sm:col-span-5 space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label htmlFor={remInputId} className="text-xs font-semibold text-gray-700 dark:text-gray-300">
|
||||
Root Em (REM)
|
||||
</label>
|
||||
<span className="text-[11px] text-blue-600 dark:text-blue-400 font-medium">Relative</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<input
|
||||
id={remInputId}
|
||||
type="number"
|
||||
step="any"
|
||||
value={remValue}
|
||||
onChange={(e) => handleRemChange(e.target.value)}
|
||||
placeholder="1.5"
|
||||
className="w-full rounded-lg border border-gray-300 bg-white p-3 pr-12 font-mono text-lg font-bold text-blue-600 dark:text-blue-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-900/70 shadow-inner"
|
||||
/>
|
||||
<span className="pointer-events-none absolute right-3 top-3.5 font-mono text-sm font-semibold text-gray-400">
|
||||
rem
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Multi-Unit Equivalents Cards */}
|
||||
<div>
|
||||
<span className="block text-[11px] font-medium text-gray-500 dark:text-gray-400 mb-2">
|
||||
Equivalent Dimensions for {pxValue || 0}px:
|
||||
</span>
|
||||
<div className="grid grid-cols-2 gap-2.5 sm:grid-cols-4">
|
||||
<div className="rounded-lg bg-gray-50 p-2 dark:bg-gray-900/60 border border-gray-200 dark:border-gray-700/60">
|
||||
<span className="block text-[10px] text-gray-500 uppercase font-medium">REM</span>
|
||||
<span className="font-mono text-xs font-semibold text-blue-600 dark:text-blue-400">
|
||||
{remValue || "0"}rem
|
||||
</span>
|
||||
</div>
|
||||
<div className="rounded-lg bg-gray-50 p-2 dark:bg-gray-900/60 border border-gray-200 dark:border-gray-700/60">
|
||||
<span className="block text-[10px] text-gray-500 uppercase font-medium">EM</span>
|
||||
<span className="font-mono text-xs font-semibold text-gray-800 dark:text-gray-200">
|
||||
{remValue || "0"}em
|
||||
</span>
|
||||
</div>
|
||||
<div className="rounded-lg bg-gray-50 p-2 dark:bg-gray-900/60 border border-gray-200 dark:border-gray-700/60">
|
||||
<span className="block text-[10px] text-gray-500 uppercase font-medium">Percent (%)</span>
|
||||
<span className="font-mono text-xs font-semibold text-gray-800 dark:text-gray-200">
|
||||
{percentageValue}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="rounded-lg bg-gray-50 p-2 dark:bg-gray-900/60 border border-gray-200 dark:border-gray-700/60">
|
||||
<span className="block text-[10px] text-gray-500 uppercase font-medium">Points (pt)</span>
|
||||
<span className="font-mono text-xs font-semibold text-gray-800 dark:text-gray-200">
|
||||
{ptValue}pt
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CSS Declaration Generator */}
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50/70 p-3.5 dark:border-gray-700/60 dark:bg-gray-900/40 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-gray-700 dark:text-gray-300">
|
||||
CSS Declaration Snippet
|
||||
</span>
|
||||
<select
|
||||
value={selectedProperty}
|
||||
onChange={(e) => setSelectedProperty(e.target.value)}
|
||||
className="rounded-md border border-gray-300 bg-white px-2 py-1 text-xs text-gray-800 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
||||
>
|
||||
<option value="font-size">font-size</option>
|
||||
<option value="padding">padding</option>
|
||||
<option value="margin">margin</option>
|
||||
<option value="width">width</option>
|
||||
<option value="height">height</option>
|
||||
<option value="gap">gap</option>
|
||||
<option value="border-radius">border-radius</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 rounded-md border border-gray-300 bg-white px-3 py-2 font-mono text-xs text-gray-900 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100">
|
||||
<span className="truncate">{cssDeclaration}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyToClipboard(cssDeclaration, "css-decl")}
|
||||
className="cursor-pointer shrink-0 rounded bg-blue-50 px-2.5 py-1 text-xs font-semibold text-blue-600 hover:bg-blue-100 dark:bg-blue-900/40 dark:text-blue-300 dark:hover:bg-blue-900/70 transition-colors"
|
||||
>
|
||||
{copiedKey === "css-decl" ? "✓ Copied!" : "Copy CSS"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Formula & How It Works Card (5 Cols) */}
|
||||
<div className="flex flex-col justify-between rounded-xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-700 dark:bg-gray-800 lg:col-span-5 space-y-4">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-gray-700 dark:text-gray-300">
|
||||
Calculation Formula
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3 font-mono text-xs">
|
||||
<div className="rounded-lg bg-blue-50/70 p-3 dark:bg-blue-950/40 border border-blue-200 dark:border-blue-900/50">
|
||||
<span className="block text-[11px] font-sans font-semibold text-blue-800 dark:text-blue-300 mb-1">
|
||||
Pixels to Rem:
|
||||
</span>
|
||||
<p className="text-gray-900 dark:text-gray-100">
|
||||
rem = px ÷ baseFont ({baseSize})
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
|
||||
Example: {pxValue || 24}px ÷ {baseSize} = {remValue || 1.5}rem
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-indigo-50/70 p-3 dark:bg-indigo-950/40 border border-indigo-200 dark:border-indigo-900/50">
|
||||
<span className="block text-[11px] font-sans font-semibold text-indigo-800 dark:text-indigo-300 mb-1">
|
||||
Rem to Pixels:
|
||||
</span>
|
||||
<p className="text-gray-900 dark:text-gray-100">
|
||||
px = rem × baseFont ({baseSize})
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
|
||||
Example: {remValue || 1.5}rem × {baseSize} = {pxValue || 24}px
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3 text-xs text-gray-600 dark:border-gray-700 dark:bg-gray-900/50 dark:text-gray-400">
|
||||
<p className="font-semibold text-gray-800 dark:text-gray-200 mb-1">💡 Why use REM over PX?</p>
|
||||
<p>
|
||||
REM units scale proportionally when users adjust their operating system or browser font size settings, ensuring accessibility (WCAG) compliance.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Reference Table */}
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-700 dark:bg-gray-800 space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-gray-700 dark:text-gray-300">
|
||||
Quick Reference Table (Base: {baseSize}px)
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Click any row to load into converter or copy individual values.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search Input */}
|
||||
<div className="w-full sm:w-64">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search px or rem..."
|
||||
value={tableSearch}
|
||||
onChange={(e) => setTableSearch(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-xs text-gray-900 placeholder:text-gray-400 focus:border-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<table className="w-full text-left text-xs text-gray-700 dark:text-gray-300">
|
||||
<thead className="bg-gray-50 text-[11px] uppercase tracking-wider text-gray-500 dark:bg-gray-900 dark:text-gray-400">
|
||||
<tr>
|
||||
<th className="px-4 py-2.5 font-semibold">Pixels (px)</th>
|
||||
<th className="px-4 py-2.5 font-semibold">Root Em (rem)</th>
|
||||
<th className="px-4 py-2.5 font-semibold">Em (em)</th>
|
||||
<th className="px-4 py-2.5 font-semibold">Tailwind Class</th>
|
||||
<th className="px-4 py-2.5 text-right font-semibold">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{filteredTableValues.map((px) => {
|
||||
const rem = formatNumber(px / baseSize);
|
||||
const isSelected = px.toString() === pxValue;
|
||||
const tailwindClass = TAILWIND_SPACING_MAP[px] || "-";
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={px}
|
||||
onClick={() => handlePxChange(px.toString())}
|
||||
className={`cursor-pointer transition-colors hover:bg-blue-50/50 dark:hover:bg-blue-950/20 ${
|
||||
isSelected ? "bg-blue-50/80 dark:bg-blue-950/40 font-semibold" : ""
|
||||
}`}
|
||||
>
|
||||
<td className="whitespace-nowrap px-4 py-2.5 font-mono">
|
||||
<span className="rounded bg-gray-100 px-2 py-0.5 font-bold text-gray-900 dark:bg-gray-700 dark:text-gray-100">
|
||||
{px}px
|
||||
</span>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-2.5 font-mono text-blue-600 dark:text-blue-400 font-bold">
|
||||
{rem}rem
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-2.5 font-mono text-gray-600 dark:text-gray-400">
|
||||
{rem}em
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-2.5 font-mono text-emerald-600 dark:text-emerald-400">
|
||||
{tailwindClass}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-2.5 text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copyToClipboard(`${rem}rem`, `row-${px}`);
|
||||
}}
|
||||
className="cursor-pointer rounded bg-gray-100 px-2.5 py-1 text-[11px] font-medium text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
{copiedKey === `row-${px}` ? "✓ Copied" : "Copy REM"}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bulk Converter Section */}
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-700 dark:bg-gray-800 space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-gray-700 dark:text-gray-300">
|
||||
Bulk Pixel Converter
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Paste multiple pixel values (separated by newlines or commas) to convert all at once.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Bulk Format Selector */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">Format:</span>
|
||||
<select
|
||||
value={bulkFormat}
|
||||
onChange={(e) => setBulkFormat(e.target.value as "rem-list" | "mapping" | "css-vars" | "tailwind")}
|
||||
className="rounded-lg border border-gray-300 bg-white px-2.5 py-1.5 text-xs text-gray-800 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200"
|
||||
>
|
||||
<option value="rem-list">Rem Values (0.5rem)</option>
|
||||
<option value="mapping">Mapping (8px = 0.5rem)</option>
|
||||
<option value="css-vars">CSS Custom Properties</option>
|
||||
<option value="tailwind">Tailwind Theme Keys</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
{/* Input Box */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<label htmlFor={bulkInputId} className="text-xs font-medium text-gray-700 dark:text-gray-300">
|
||||
Input Pixel Values (one per line)
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBulkInput("8\n12\n16\n20\n24\n32\n40\n48\n64\n80\n96\n128")}
|
||||
className="text-xs text-blue-600 hover:underline dark:text-blue-400 font-medium cursor-pointer"
|
||||
>
|
||||
Load Sample Scale
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
id={bulkInputId}
|
||||
rows={7}
|
||||
value={bulkInput}
|
||||
onChange={(e) => setBulkInput(e.target.value)}
|
||||
placeholder="Paste numbers like: 12px 16px 24px 32px"
|
||||
className="w-full rounded-lg border border-gray-300 bg-white p-3 font-mono text-xs text-gray-900 shadow-inner focus:border-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-900/60 dark:text-gray-100 leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Output Box */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-xs font-medium text-gray-700 dark:text-gray-300">
|
||||
Converted Output ({parsedBulkRows.length} items)
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyToClipboard(getBulkOutputString(), "bulk-output")}
|
||||
disabled={!getBulkOutputString()}
|
||||
className="cursor-pointer rounded bg-blue-600 px-3 py-1 text-xs font-medium text-white hover:bg-blue-700 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
{copiedKey === "bulk-output" ? "✓ Copied All!" : "Copy All"}
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
readOnly
|
||||
rows={7}
|
||||
value={getBulkOutputString()}
|
||||
placeholder="Bulk conversion output will appear here..."
|
||||
className="w-full rounded-lg border border-gray-300 bg-gray-50 p-3 font-mono text-xs text-gray-900 shadow-inner focus:outline-none dark:border-gray-700 dark:bg-gray-900/80 dark:text-gray-100 leading-relaxed select-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Informational Guidelines */}
|
||||
<div className="mt-8 grid grid-cols-1 gap-4 border-t border-gray-200 pt-6 text-xs text-gray-600 dark:border-gray-800 dark:text-gray-400 md:grid-cols-3">
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50/50 p-4 dark:border-gray-700/60 dark:bg-gray-800/40">
|
||||
<h4 className="font-semibold text-gray-800 dark:text-gray-200 mb-1">REM vs EM Units</h4>
|
||||
<p>
|
||||
<code>rem</code> units are calculated relative to the root <code><html></code> element font size, avoiding compound scaling issues encountered with nested <code>em</code> elements.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50/50 p-4 dark:border-gray-700/60 dark:bg-gray-800/40">
|
||||
<h4 className="font-semibold text-gray-800 dark:text-gray-200 mb-1">The 62.5% Trick (10px Base)</h4>
|
||||
<p>
|
||||
Setting <code>html { font-size: 62.5%; }</code> makes 1rem = 10px (1.6rem = 16px, 2.4rem = 24px) while preserving user browser zoom scaling.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50/50 p-4 dark:border-gray-700/60 dark:bg-gray-800/40">
|
||||
<h4 className="font-semibold text-gray-800 dark:text-gray-200 mb-1">Instant Client Calculation</h4>
|
||||
<p>
|
||||
Computations happen instantly in your browser on each keystroke without sending queries over the network.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ToolLayout>
|
||||
);
|
||||
}
|
||||
757
src/app/tools/slug-generator/page.tsx
Normal file
757
src/app/tools/slug-generator/page.tsx
Normal file
|
|
@ -0,0 +1,757 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useMemo, useId, useCallback } from "react";
|
||||
import ToolLayout from "@/components/ToolLayout";
|
||||
|
||||
// Comprehensive diacritics / accent transliteration map
|
||||
const DIACRITICS_MAP: Record<string, string> = {
|
||||
á: "a", à: "a", ả: "a", ã: "a", ạ: "a", ă: "a", ắ: "a", ằ: "a", ẳ: "a", ẵ: "a", ặ: "a",
|
||||
â: "a", ấ: "a", ầ: "a", ẩ: "a", ẫ: "a", ậ: "a", ä: "a", å: "a", ā: "a", ą: "a", æ: "ae",
|
||||
Á: "A", À: "A", Ả: "A", Ã: "A", Ạ: "A", Ă: "A", Ắ: "A", Ằ: "A", Ẳ: "A", Ẵ: "A", Ặ: "A",
|
||||
Â: "A", Ấ: "A", Ầ: "A", Ẩ: "A", Ẫ: "A", Ậ: "A", Ä: "A", Å: "A", Ā: "A", Ą: "A", Æ: "AE",
|
||||
ć: "c", č: "c", ç: "c", ĉ: "c", ċ: "c",
|
||||
Ć: "C", Č: "C", Ç: "C", Ĉ: "C", Ċ: "C",
|
||||
ď: "d", đ: "d", ð: "d",
|
||||
Ď: "D", Đ: "D",
|
||||
é: "e", è: "e", ẻ: "e", ẽ: "e", ẹ: "e", ê: "e", ế: "e", ề: "e", ể: "e", ễ: "e", ệ: "e",
|
||||
ë: "e", ē: "e", ė: "e", ę: "e", ě: "e",
|
||||
É: "E", È: "E", Ẻ: "E", Ẽ: "E", Ẹ: "E", Ê: "E", Ế: "E", Ề: "E", Ể: "E", Ễ: "E", Ệ: "E",
|
||||
Ë: "E", Ē: "E", Ė: "E", Ę: "E", Ě: "E",
|
||||
ğ: "g", ĝ: "g", ġ: "g",ģ: "g",
|
||||
Ğ: "G", Ĝ: "G", Ġ: "G", Ģ: "G",
|
||||
ĥ: "h", ħ: "h",
|
||||
Ĥ: "H", Ħ: "H",
|
||||
í: "i", ì: "i", ỉ: "i", ĩ: "i", ị: "i", î: "i", ï: "i", ī: "i", į: "i", ı: "i",
|
||||
Í: "I", Ì: "I", Ỉ: "I", Ĩ: "I", Ị: "I", Î: "I", Ï: "I", Ī: "I", Į: "I", İ: "I",
|
||||
ĵ: "j", Ĵ: "J",
|
||||
ķ: "k", Ķ: "K",
|
||||
ĺ: "l", ľ: "l", ļ: "l", ł: "l",
|
||||
Ĺ: "L", Ľ: "L", Ļ: "L", Ł: "L",
|
||||
ń: "n", ň: "n", ñ: "n", ņ: "n", ŋ: "n",
|
||||
Ń: "N", Ň: "N", Ñ: "N", Ņ: "N", Ŋ: "N",
|
||||
ó: "o", ò: "o", ỏ: "o", õ: "o", ọ: "o", ô: "o", ố: "o", ồ: "o", ổ: "o", ỗ: "o", ộ: "o",
|
||||
ơ: "o", ớ: "o", ờ: "o", ở: "o", ỡ: "o", ợ: "o", ö: "o", ø: "o", ō: "o", ő: "o", œ: "oe",
|
||||
Ó: "O", Ò: "O", Ỏ: "O", Õ: "O", Ọ: "O", Ô: "O", Ố: "O", Ồ: "O", Ổ: "O", Ỗ: "O", Ộ: "O",
|
||||
Ơ: "O", Ớ: "O", Ờ: "O", Ở: "O", Ỡ: "O", Ợ: "O", Ö: "O", Ø: "O", Ō: "O", Ő: "O", Œ: "OE",
|
||||
ŕ: "r", ř: "r", ŗ: "r",
|
||||
Ŕ: "R", Ř: "R", Ŗ: "R",
|
||||
ś: "s", š: "s", ş: "s", ŝ: "s", ș: "s", ß: "ss",
|
||||
Ś: "S", Š: "S", Ş: "S", Ŝ: "S", Ș: "S",
|
||||
ť: "t", ţ: "t", ț: "t", ŧ: "t", þ: "th",
|
||||
Ť: "T", Ţ: "T", Ț: "T", Ŧ: "T", Þ: "TH",
|
||||
ú: "u", ù: "u", ủ: "u", ũ: "u", ụ: "u", ư: "u", ứ: "u", ừ: "u", ử: "u", ữ: "u", ự: "u",
|
||||
û: "u", ü: "u", ū: "u", ů: "u", ű: "u", ų: "u",
|
||||
Ú: "U", Ù: "U", Ủ: "U", Ũ: "U", Ụ: "U", Ư: "U", Ứ: "U", Ừ: "U", Ử: "U", Ữ: "U", Ự: "U",
|
||||
Û: "U", Ü: "U", Ū: "U", Ů: "U", Ű: "U", Ų: "U",
|
||||
ý: "y", ỳ: "y", ỷ: "y", ỹ: "y", ỵ: "y", ÿ: "y", ŷ: "y",
|
||||
Ý: "Y", Ỳ: "Y", Ỷ: "Y", Ỹ: "Y", Ỵ: "Y", Ÿ: "Y", Ŷ: "Y",
|
||||
ź: "z", ž: "z", ż: "z",
|
||||
Ź: "Z", Ž: "Z", Ż: "Z",
|
||||
};
|
||||
|
||||
// Common English Stop Words
|
||||
const STOP_WORDS = new Set([
|
||||
"a", "an", "the", "and", "or", "but", "in", "on", "at", "to", "for", "with",
|
||||
"of", "by", "from", "up", "about", "into", "over", "after", "is", "are", "was",
|
||||
"were", "be", "been", "being", "have", "has", "had", "do", "does", "did",
|
||||
"as", "it", "its", "this", "that",
|
||||
]);
|
||||
|
||||
interface SlugOptions {
|
||||
separator: string;
|
||||
caseFormat: "lowercase" | "uppercase" | "preserve";
|
||||
removeSpecialChars: boolean;
|
||||
convertSymbols: boolean; // & -> and, @ -> at
|
||||
removeStopWords: boolean;
|
||||
transliterateUnicode: boolean;
|
||||
maxLength: number | null;
|
||||
smartTruncate: boolean; // Truncate at word boundaries
|
||||
}
|
||||
|
||||
const DEFAULT_OPTIONS: SlugOptions = {
|
||||
separator: "-",
|
||||
caseFormat: "lowercase",
|
||||
removeSpecialChars: true,
|
||||
convertSymbols: true,
|
||||
removeStopWords: false,
|
||||
transliterateUnicode: true,
|
||||
maxLength: null,
|
||||
smartTruncate: true,
|
||||
};
|
||||
|
||||
const SAMPLE_TEXT = `How to Build a REST API with Node.js & TypeScript (2025 Guide!)
|
||||
Café & Restaurant: 100% Délicieux Français 🥐
|
||||
Top 10 Tips for Optimizing Next.js 15 Web Applications
|
||||
Internationalization: Über, Zürich, Niño, São Paulo & Kraków
|
||||
Database Migration: users_profile_v2_final`;
|
||||
|
||||
export default function SlugGeneratorPage() {
|
||||
const inputId = useId();
|
||||
const outputId = useId();
|
||||
|
||||
const [input, setInput] = useState<string>(SAMPLE_TEXT);
|
||||
const [options, setOptions] = useState<SlugOptions>(DEFAULT_OPTIONS);
|
||||
const [copiedAll, setCopiedAll] = useState<boolean>(false);
|
||||
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
|
||||
const [activePreset, setActivePreset] = useState<string>("url");
|
||||
|
||||
// Transliteration function
|
||||
const transliterate = useCallback((str: string): string => {
|
||||
let result = "";
|
||||
for (const char of str) {
|
||||
result += DIACRITICS_MAP[char] !== undefined ? DIACRITICS_MAP[char] : char;
|
||||
}
|
||||
// Fallback to NFD decomposition for any unmapped diacritics
|
||||
return result.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
|
||||
}, []);
|
||||
|
||||
// Generate slug for a single line of text
|
||||
const generateSlug = useCallback(
|
||||
(line: string, opts: SlugOptions): string => {
|
||||
let text = line.trim();
|
||||
if (!text) return "";
|
||||
|
||||
// 1. Symbol conversions if enabled
|
||||
if (opts.convertSymbols) {
|
||||
text = text
|
||||
.replace(/&/g, " and ")
|
||||
.replace(/@/g, " at ")
|
||||
.replace(/%/g, " percent ")
|
||||
.replace(/\+/g, " plus ");
|
||||
}
|
||||
|
||||
// 2. Transliterate Accents / Unicode if enabled
|
||||
if (opts.transliterateUnicode) {
|
||||
text = transliterate(text);
|
||||
}
|
||||
|
||||
// 3. Case conversion
|
||||
if (opts.caseFormat === "lowercase") {
|
||||
text = text.toLowerCase();
|
||||
} else if (opts.caseFormat === "uppercase") {
|
||||
text = text.toUpperCase();
|
||||
}
|
||||
|
||||
// 4. Remove stop words if enabled
|
||||
if (opts.removeStopWords) {
|
||||
const words = text.split(/\s+/);
|
||||
const filtered = words.filter((w) => {
|
||||
const cleanW = w.toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||
return !STOP_WORDS.has(cleanW);
|
||||
});
|
||||
text = filtered.join(" ");
|
||||
}
|
||||
|
||||
const sep = opts.separator;
|
||||
|
||||
// 5. Replace punctuation and special characters with separator
|
||||
if (opts.removeSpecialChars) {
|
||||
// Strip everything except alphanumeric characters and spaces
|
||||
text = text.replace(/[^a-zA-Z0-9\s-_.]/g, " ");
|
||||
}
|
||||
|
||||
// 6. Split by whitespace and existing separator characters
|
||||
const tokens = text
|
||||
.split(/[\s\-_/\\|.]+/)
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (tokens.length === 0) return "";
|
||||
|
||||
let slug = tokens.join(sep);
|
||||
|
||||
// 7. Deduplicate separators and trim
|
||||
if (sep) {
|
||||
const escapedSep = sep.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const dupRegex = new RegExp(`${escapedSep}{2,}`, "g");
|
||||
slug = slug.replace(dupRegex, sep);
|
||||
|
||||
const trimRegex = new RegExp(`^${escapedSep}+|${escapedSep}+$`, "g");
|
||||
slug = slug.replace(trimRegex, "");
|
||||
}
|
||||
|
||||
// 8. Max Length Truncation
|
||||
if (opts.maxLength && opts.maxLength > 0 && slug.length > opts.maxLength) {
|
||||
if (opts.smartTruncate && sep) {
|
||||
// Truncate at previous separator boundary
|
||||
const truncated = slug.substring(0, opts.maxLength);
|
||||
const lastSepIndex = truncated.lastIndexOf(sep);
|
||||
if (lastSepIndex > 0) {
|
||||
slug = truncated.substring(0, lastSepIndex);
|
||||
} else {
|
||||
slug = truncated;
|
||||
}
|
||||
} else {
|
||||
slug = slug.substring(0, opts.maxLength);
|
||||
}
|
||||
|
||||
// Clean trailing separator after truncation
|
||||
if (sep) {
|
||||
const escapedSep = sep.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const trimRegex = new RegExp(`${escapedSep}+$`, "g");
|
||||
slug = slug.replace(trimRegex, "");
|
||||
}
|
||||
}
|
||||
|
||||
return slug;
|
||||
},
|
||||
[transliterate]
|
||||
);
|
||||
|
||||
// Compute all slugs line by line
|
||||
const lines = useMemo(() => {
|
||||
return input.split(/\r\n|\r|\n/);
|
||||
}, [input]);
|
||||
|
||||
const slugs = useMemo(() => {
|
||||
return lines.map((line) => generateSlug(line, options));
|
||||
}, [lines, options, generateSlug]);
|
||||
|
||||
const bulkOutput = useMemo(() => {
|
||||
return slugs.join("\n");
|
||||
}, [slugs]);
|
||||
|
||||
// Non-empty slug count
|
||||
const validSlugCount = useMemo(() => {
|
||||
return slugs.filter((s) => s.length > 0).length;
|
||||
}, [slugs]);
|
||||
|
||||
// Copy All Slugs
|
||||
const handleCopyAll = async () => {
|
||||
if (!bulkOutput) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(bulkOutput);
|
||||
setCopiedAll(true);
|
||||
setTimeout(() => setCopiedAll(false), 2000);
|
||||
} catch {
|
||||
// Fallback
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = bulkOutput;
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(textarea);
|
||||
setCopiedAll(true);
|
||||
setTimeout(() => setCopiedAll(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
// Copy Single Slug
|
||||
const handleCopySingle = async (slugText: string, index: number) => {
|
||||
if (!slugText) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(slugText);
|
||||
setCopiedIndex(index);
|
||||
setTimeout(() => setCopiedIndex(null), 2000);
|
||||
} catch {
|
||||
// Fallback
|
||||
}
|
||||
};
|
||||
|
||||
// Preset Handlers
|
||||
const applyPreset = (presetKey: string) => {
|
||||
setActivePreset(presetKey);
|
||||
if (presetKey === "url") {
|
||||
setOptions({
|
||||
separator: "-",
|
||||
caseFormat: "lowercase",
|
||||
removeSpecialChars: true,
|
||||
convertSymbols: true,
|
||||
removeStopWords: false,
|
||||
transliterateUnicode: true,
|
||||
maxLength: null,
|
||||
smartTruncate: true,
|
||||
});
|
||||
} else if (presetKey === "filename") {
|
||||
setOptions({
|
||||
separator: "_",
|
||||
caseFormat: "lowercase",
|
||||
removeSpecialChars: true,
|
||||
convertSymbols: true,
|
||||
removeStopWords: false,
|
||||
transliterateUnicode: true,
|
||||
maxLength: null,
|
||||
smartTruncate: true,
|
||||
});
|
||||
} else if (presetKey === "wordpress") {
|
||||
setOptions({
|
||||
separator: "-",
|
||||
caseFormat: "lowercase",
|
||||
removeSpecialChars: true,
|
||||
convertSymbols: false,
|
||||
removeStopWords: false,
|
||||
transliterateUnicode: true,
|
||||
maxLength: 200,
|
||||
smartTruncate: true,
|
||||
});
|
||||
} else if (presetKey === "seo-clean") {
|
||||
setOptions({
|
||||
separator: "-",
|
||||
caseFormat: "lowercase",
|
||||
removeSpecialChars: true,
|
||||
convertSymbols: true,
|
||||
removeStopWords: true, // Strips "a", "the", "in" etc.
|
||||
transliterateUnicode: true,
|
||||
maxLength: 60,
|
||||
smartTruncate: true,
|
||||
});
|
||||
} else if (presetKey === "snake") {
|
||||
setOptions({
|
||||
separator: "_",
|
||||
caseFormat: "lowercase",
|
||||
removeSpecialChars: true,
|
||||
convertSymbols: true,
|
||||
removeStopWords: false,
|
||||
transliterateUnicode: true,
|
||||
maxLength: null,
|
||||
smartTruncate: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Download Output File
|
||||
const handleDownload = () => {
|
||||
const blob = new Blob([bulkOutput], { type: "text/plain;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "slugs.txt";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<ToolLayout
|
||||
title="Slug Generator"
|
||||
description="Transform titles and text into clean, SEO-friendly URL slugs, filename-safe strings, and WordPress permalinks with full Unicode accent support."
|
||||
>
|
||||
<title>Slug Generator Online — DevToolbox</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Free online slug generator. Convert titles to SEO-friendly URL slugs, filename-safe strings, WordPress permalinks, and snake_case with Unicode transliteration."
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Presets and Actions Bar */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 pb-3 border-b border-gray-200 dark:border-gray-800">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs font-semibold text-gray-500 dark:text-gray-400">Presets:</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => applyPreset("url")}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-semibold transition-all cursor-pointer ${
|
||||
activePreset === "url"
|
||||
? "bg-blue-600 text-white shadow-xs"
|
||||
: "bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-200"
|
||||
}`}
|
||||
>
|
||||
URL Slug (my-post-title)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => applyPreset("filename")}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-semibold transition-all cursor-pointer ${
|
||||
activePreset === "filename"
|
||||
? "bg-blue-600 text-white shadow-xs"
|
||||
: "bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-200"
|
||||
}`}
|
||||
>
|
||||
Filename-Safe (my_file_name)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => applyPreset("wordpress")}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-semibold transition-all cursor-pointer ${
|
||||
activePreset === "wordpress"
|
||||
? "bg-blue-600 text-white shadow-xs"
|
||||
: "bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-200"
|
||||
}`}
|
||||
>
|
||||
WordPress Style
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => applyPreset("seo-clean")}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-semibold transition-all cursor-pointer ${
|
||||
activePreset === "seo-clean"
|
||||
? "bg-blue-600 text-white shadow-xs"
|
||||
: "bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-200"
|
||||
}`}
|
||||
>
|
||||
SEO Clean (<60 + No Stop Words)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setInput(SAMPLE_TEXT)}
|
||||
className="px-3 py-1.5 text-xs font-medium bg-blue-50 hover:bg-blue-100 dark:bg-blue-950/50 dark:hover:bg-blue-900/50 text-blue-700 dark:text-blue-300 rounded-lg transition-colors cursor-pointer"
|
||||
>
|
||||
Load Sample
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setInput("")}
|
||||
disabled={!input}
|
||||
className="px-3 py-1.5 text-xs font-medium bg-gray-100 hover:bg-rose-50 dark:bg-gray-800 dark:hover:bg-rose-950/30 text-gray-700 dark:text-gray-300 hover:text-rose-600 dark:hover:text-rose-400 disabled:opacity-40 rounded-lg transition-colors cursor-pointer"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Configuration Options Card */}
|
||||
<div className="rounded-xl border border-gray-200 dark:border-gray-800 bg-gray-50/70 dark:bg-gray-900/50 p-4 space-y-4 shadow-xs">
|
||||
<div className="flex items-center justify-between border-b border-gray-200/80 dark:border-gray-800 pb-2">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300">
|
||||
Slug Configuration & Formatting Options
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 font-mono">Real-time update</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Separator Selection */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-semibold text-gray-700 dark:text-gray-300">
|
||||
Word Separator
|
||||
</label>
|
||||
<div className="grid grid-cols-4 gap-1">
|
||||
{[
|
||||
{ label: "Hyphen (-)", val: "-" },
|
||||
{ label: "Underscore (_)", val: "_" },
|
||||
{ label: "Dot (.)", val: "." },
|
||||
{ label: "Slash (/)", val: "/" },
|
||||
].map((s) => (
|
||||
<button
|
||||
key={s.val}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOptions({ ...options, separator: s.val });
|
||||
setActivePreset("custom");
|
||||
}}
|
||||
className={`py-1.5 text-center text-xs font-mono font-bold rounded-md border transition-colors cursor-pointer ${
|
||||
options.separator === s.val
|
||||
? "bg-blue-600 border-blue-600 text-white"
|
||||
: "bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-700 text-gray-800 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
}`}
|
||||
title={s.label}
|
||||
>
|
||||
{s.val}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Letter Casing */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-semibold text-gray-700 dark:text-gray-300">
|
||||
Letter Casing
|
||||
</label>
|
||||
<select
|
||||
value={options.caseFormat}
|
||||
onChange={(e) => {
|
||||
setOptions({
|
||||
...options,
|
||||
caseFormat: e.target.value as SlugOptions["caseFormat"],
|
||||
});
|
||||
setActivePreset("custom");
|
||||
}}
|
||||
className="w-full p-2 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-xs focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="lowercase">lowercase (recommended)</option>
|
||||
<option value="uppercase">UPPERCASE</option>
|
||||
<option value="preserve">Preserve Input Case</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Max Length Limit */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-semibold text-gray-700 dark:text-gray-300">
|
||||
Max Length (characters)
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="500"
|
||||
value={options.maxLength === null ? "" : options.maxLength}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value ? parseInt(e.target.value, 10) : null;
|
||||
setOptions({ ...options, maxLength: val });
|
||||
setActivePreset("custom");
|
||||
}}
|
||||
placeholder="Unlimited (default)"
|
||||
className="w-full p-2 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-xs focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
{options.maxLength !== null && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOptions({ ...options, maxLength: null })}
|
||||
className="p-2 text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
||||
title="Remove max length"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Toggle Checkboxes */}
|
||||
<div className="space-y-2 pt-1">
|
||||
<label className="flex items-center gap-2 text-xs text-gray-700 dark:text-gray-300 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={options.transliterateUnicode}
|
||||
onChange={(e) => {
|
||||
setOptions({ ...options, transliterateUnicode: e.target.checked });
|
||||
setActivePreset("custom");
|
||||
}}
|
||||
className="w-3.5 h-3.5 text-blue-600 rounded border-gray-300 dark:border-gray-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span>Convert Accents (é→e, ñ→n)</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 text-xs text-gray-700 dark:text-gray-300 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={options.convertSymbols}
|
||||
onChange={(e) => {
|
||||
setOptions({ ...options, convertSymbols: e.target.checked });
|
||||
setActivePreset("custom");
|
||||
}}
|
||||
className="w-3.5 h-3.5 text-blue-600 rounded border-gray-300 dark:border-gray-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span>Replace & with "and", @ with "at"</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Secondary Toggles Row */}
|
||||
<div className="flex flex-wrap items-center gap-6 pt-2 border-t border-gray-200 dark:border-gray-800 text-xs text-gray-700 dark:text-gray-300">
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={options.removeSpecialChars}
|
||||
onChange={(e) => {
|
||||
setOptions({ ...options, removeSpecialChars: e.target.checked });
|
||||
setActivePreset("custom");
|
||||
}}
|
||||
className="w-3.5 h-3.5 text-blue-600 rounded border-gray-300 dark:border-gray-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span>Remove Special Characters & Emojis</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={options.removeStopWords}
|
||||
onChange={(e) => {
|
||||
setOptions({ ...options, removeStopWords: e.target.checked });
|
||||
setActivePreset("custom");
|
||||
}}
|
||||
className="w-3.5 h-3.5 text-blue-600 rounded border-gray-300 dark:border-gray-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span>Remove Stop Words ("a", "the", "in", "with")</span>
|
||||
</label>
|
||||
|
||||
{options.maxLength !== null && (
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={options.smartTruncate}
|
||||
onChange={(e) => {
|
||||
setOptions({ ...options, smartTruncate: e.target.checked });
|
||||
setActivePreset("custom");
|
||||
}}
|
||||
className="w-3.5 h-3.5 text-blue-600 rounded border-gray-300 dark:border-gray-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span>Smart Truncate (don't split words in half)</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Two-Column Layout: Input on Left, Output on Right */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Input Area */}
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label htmlFor={inputId} className="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
Input Text or Multi-line Titles
|
||||
</label>
|
||||
<span className="text-xs text-gray-400 font-mono">
|
||||
{lines.length} {lines.length === 1 ? "line" : "lines"} | {input.length} chars
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
id={inputId}
|
||||
rows={11}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="Type or paste titles, post headlines, or phrases here (one per line for bulk conversion)..."
|
||||
className="w-full p-3.5 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 font-sans text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none resize-y shadow-inner leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Output Area */}
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor={outputId} className="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
Generated Slugs
|
||||
</label>
|
||||
<span className="px-2 py-0.5 rounded-full text-xs font-semibold bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300">
|
||||
{validSlugCount} {validSlugCount === 1 ? "slug" : "slugs"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
disabled={!bulkOutput}
|
||||
className="px-2.5 py-1 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 disabled:opacity-40 rounded text-xs font-medium transition-colors cursor-pointer"
|
||||
title="Download all slugs as .txt"
|
||||
>
|
||||
Download .txt
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyAll}
|
||||
disabled={!bulkOutput}
|
||||
className="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-40 rounded text-xs font-medium transition-colors cursor-pointer flex items-center gap-1"
|
||||
>
|
||||
{copiedAll ? (
|
||||
<>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span>Copied All!</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
|
||||
</svg>
|
||||
<span>Copy All</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
id={outputId}
|
||||
rows={11}
|
||||
value={bulkOutput}
|
||||
readOnly
|
||||
placeholder="Generated slugs will appear here in real time..."
|
||||
className="w-full p-3.5 rounded-lg border border-gray-300 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/70 text-gray-900 dark:text-gray-100 font-mono text-sm focus:outline-none resize-y select-all shadow-inner leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Live URL Preview Card for the First/Primary Slug */}
|
||||
{slugs.length > 0 && slugs[0] && (
|
||||
<div className="rounded-xl border border-blue-200 bg-blue-50/50 dark:border-blue-900/40 dark:bg-blue-950/20 p-4 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-blue-700 dark:text-blue-400 flex items-center gap-1.5">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
Primary URL Slug Mockup
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCopySingle(`https://example.com/blog/${slugs[0]}`, 999)}
|
||||
className="text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400 font-medium cursor-pointer"
|
||||
>
|
||||
{copiedIndex === 999 ? "Copied URL!" : "Copy Full URL"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-2.5 rounded-lg bg-white dark:bg-gray-900 border border-blue-100 dark:border-blue-900/60 font-mono text-xs text-gray-800 dark:text-gray-200 overflow-x-auto whitespace-nowrap">
|
||||
<span className="text-gray-400 select-none">https://example.com/blog/</span>
|
||||
<span className="font-bold text-blue-600 dark:text-blue-400">{slugs[0]}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detailed Item-by-Item Breakdown (Especially useful for multi-line inputs) */}
|
||||
{lines.length > 1 && (
|
||||
<div className="space-y-3 pt-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-bold text-gray-900 dark:text-gray-100">
|
||||
Individual Slugs Breakdown ({lines.length} items)
|
||||
</h3>
|
||||
<span className="text-xs text-gray-400">Click "Copy" on any individual slug</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 max-h-[400px] overflow-y-auto pr-1">
|
||||
{lines.map((rawLine, idx) => {
|
||||
const slugVal = slugs[idx] || "";
|
||||
const isCopied = copiedIndex === idx;
|
||||
|
||||
if (!rawLine.trim()) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 p-3 rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-850 hover:border-blue-300 dark:hover:border-blue-700 transition-colors"
|
||||
>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 truncate" title={rawLine}>
|
||||
<span className="font-semibold text-gray-400 mr-1.5">#{idx + 1}</span>
|
||||
{rawLine}
|
||||
</div>
|
||||
<div className="font-mono text-xs font-semibold text-gray-900 dark:text-gray-100 truncate">
|
||||
{slugVal || <span className="text-gray-400 italic">No output</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-[11px] text-gray-400 font-mono">
|
||||
{slugVal.length} chars
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCopySingle(slugVal, idx)}
|
||||
disabled={!slugVal}
|
||||
className={`px-2.5 py-1 rounded text-xs font-medium transition-colors cursor-pointer ${
|
||||
isCopied
|
||||
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300"
|
||||
: "bg-gray-100 hover:bg-gray-200 text-gray-700 dark:bg-gray-750 dark:hover:bg-gray-700 dark:text-gray-200"
|
||||
}`}
|
||||
>
|
||||
{isCopied ? "Copied!" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SEO & Developer Tips Section */}
|
||||
<div className="mt-8 pt-6 border-t border-gray-200 dark:border-gray-800 grid grid-cols-1 md:grid-cols-3 gap-4 text-xs text-gray-600 dark:text-gray-400">
|
||||
<div className="p-3.5 bg-gray-50 dark:bg-gray-850/60 rounded-xl border border-gray-200 dark:border-gray-800">
|
||||
<h4 className="font-bold text-gray-900 dark:text-gray-200 mb-1">SEO Best Practices</h4>
|
||||
<p>Keep slugs between 3 to 5 words (<60 chars) and separate words with hyphens for optimal search engine crawling.</p>
|
||||
</div>
|
||||
<div className="p-3.5 bg-gray-50 dark:bg-gray-850/60 rounded-xl border border-gray-200 dark:border-gray-800">
|
||||
<h4 className="font-bold text-gray-900 dark:text-gray-200 mb-1">Accent Transliteration</h4>
|
||||
<p>Accented characters from European languages (like é, ü, ç, ñ, ø, å, ß) are mapped to clean ASCII equivalents automatically.</p>
|
||||
</div>
|
||||
<div className="p-3.5 bg-gray-50 dark:bg-gray-850/60 rounded-xl border border-gray-200 dark:border-gray-800">
|
||||
<h4 className="font-bold text-gray-900 dark:text-gray-200 mb-1">Bulk Line Processing</h4>
|
||||
<p>Paste multiple titles or list of article headlines at once to generate clean slugs for all items simultaneously.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ToolLayout>
|
||||
);
|
||||
}
|
||||
851
src/app/tools/sql-formatter/page.tsx
Normal file
851
src/app/tools/sql-formatter/page.tsx
Normal file
|
|
@ -0,0 +1,851 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useCallback, useId } from "react";
|
||||
import ToolLayout from "@/components/ToolLayout";
|
||||
|
||||
interface StatusInfo {
|
||||
type: "idle" | "success" | "error";
|
||||
message?: string;
|
||||
charCount?: number;
|
||||
lineCount?: number;
|
||||
reduction?: number;
|
||||
}
|
||||
|
||||
const SAMPLE_SQL = `SELECT
|
||||
u.id AS user_id,
|
||||
u.first_name || ' ' || u.last_name AS full_name,
|
||||
u.email,
|
||||
COUNT(DISTINCT o.id) AS total_orders,
|
||||
SUM(o.total_amount) AS total_spent,
|
||||
AVG(o.total_amount) AS avg_order_value,
|
||||
CASE
|
||||
WHEN SUM(o.total_amount) >= 10000 THEN 'VIP'
|
||||
WHEN SUM(o.total_amount) BETWEEN 5000 AND 9999 THEN 'Gold'
|
||||
WHEN SUM(o.total_amount) BETWEEN 1000 AND 4999 THEN 'Silver'
|
||||
ELSE 'Standard'
|
||||
END AS customer_tier
|
||||
FROM users u
|
||||
INNER JOIN orders o ON u.id = o.user_id
|
||||
LEFT JOIN payments p ON o.id = p.order_id
|
||||
WHERE o.status IN ('completed', 'delivered')
|
||||
AND o.created_at >= '2025-01-01'
|
||||
AND u.active = true
|
||||
AND (u.country = 'US' OR u.country = 'CA')
|
||||
GROUP BY u.id, u.first_name, u.last_name, u.email
|
||||
HAVING COUNT(o.id) >= 2 AND SUM(o.total_amount) > 500
|
||||
ORDER BY total_spent DESC, total_orders DESC
|
||||
LIMIT 50 OFFSET 0;`;
|
||||
|
||||
const MAJOR_CLAUSES = [
|
||||
"CREATE TABLE",
|
||||
"ALTER TABLE",
|
||||
"DROP TABLE",
|
||||
"INSERT INTO",
|
||||
"DELETE FROM",
|
||||
"LEFT OUTER JOIN",
|
||||
"RIGHT OUTER JOIN",
|
||||
"FULL OUTER JOIN",
|
||||
"CROSS JOIN",
|
||||
"INNER JOIN",
|
||||
"LEFT JOIN",
|
||||
"RIGHT JOIN",
|
||||
"FULL JOIN",
|
||||
"UNION ALL",
|
||||
"UNION",
|
||||
"SELECT DISTINCT",
|
||||
"SELECT",
|
||||
"FROM",
|
||||
"WHERE",
|
||||
"GROUP BY",
|
||||
"HAVING",
|
||||
"ORDER BY",
|
||||
"LIMIT",
|
||||
"OFFSET",
|
||||
"SET",
|
||||
"VALUES",
|
||||
"JOIN",
|
||||
"UPDATE",
|
||||
"DELETE",
|
||||
"CREATE",
|
||||
"ALTER",
|
||||
"DROP",
|
||||
"WITH",
|
||||
];
|
||||
|
||||
const SUB_CLAUSES = [
|
||||
"AND",
|
||||
"OR",
|
||||
"ON",
|
||||
"WHEN",
|
||||
"THEN",
|
||||
"ELSE",
|
||||
"CASE",
|
||||
"END",
|
||||
];
|
||||
|
||||
const KEYWORDS_LIST = [
|
||||
"SELECT", "FROM", "WHERE", "JOIN", "ON", "AND", "OR", "ORDER", "BY", "GROUP",
|
||||
"HAVING", "INSERT", "UPDATE", "DELETE", "CREATE", "ALTER", "DROP", "SET",
|
||||
"VALUES", "INTO", "LIMIT", "OFFSET", "UNION", "INNER", "LEFT", "RIGHT", "OUTER",
|
||||
"CROSS", "AS", "IN", "NOT", "NULL", "IS", "BETWEEN", "LIKE", "EXISTS", "CASE",
|
||||
"WHEN", "THEN", "ELSE", "END", "DISTINCT", "COUNT", "SUM", "AVG", "MIN", "MAX",
|
||||
"ALL", "ANY", "ASC", "DESC", "TABLE", "DATABASE", "INDEX", "VIEW", "PRIMARY",
|
||||
"FOREIGN", "KEY", "REFERENCES", "DEFAULT", "CASCADE", "CONSTRAINT", "IF",
|
||||
"WITH", "RECURSIVE", "RETURNING", "FETCH", "FIRST", "ROWS", "ONLY", "TOP",
|
||||
"OVER", "PARTITION", "ROW_NUMBER", "RANK", "DENSE_RANK", "COALESCE", "NULLIF",
|
||||
"CAST", "CONVERT", "TRUE", "FALSE", "BOOLEAN", "VARCHAR", "INTEGER", "INT",
|
||||
"TEXT", "DECIMAL", "NUMERIC", "FLOAT", "DATE", "TIMESTAMP", "BIGINT", "SMALLINT"
|
||||
];
|
||||
|
||||
const KEYWORDS_SET = new Set(KEYWORDS_LIST.map((k) => k.toUpperCase()));
|
||||
|
||||
interface RawToken {
|
||||
type: "word" | "string" | "comment" | "symbol" | "number" | "space";
|
||||
value: string;
|
||||
}
|
||||
|
||||
function tokenizeSql(sql: string): RawToken[] {
|
||||
const tokens: RawToken[] = [];
|
||||
let i = 0;
|
||||
const n = sql.length;
|
||||
|
||||
while (i < n) {
|
||||
const char = sql[i];
|
||||
const next = sql[i + 1];
|
||||
|
||||
// Single-line comment (-- ...)
|
||||
if (char === "-" && next === "-") {
|
||||
let end = sql.indexOf("\n", i);
|
||||
if (end === -1) end = n;
|
||||
tokens.push({ type: "comment", value: sql.slice(i, end) });
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Multi-line comment (/* ... */)
|
||||
if (char === "/" && next === "*") {
|
||||
let end = sql.indexOf("*/", i + 2);
|
||||
if (end === -1) end = n;
|
||||
else end += 2;
|
||||
tokens.push({ type: "comment", value: sql.slice(i, end) });
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Single-quoted string ('...')
|
||||
if (char === "'") {
|
||||
let str = "'";
|
||||
i++;
|
||||
while (i < n) {
|
||||
if (sql[i] === "'") {
|
||||
str += "'";
|
||||
if (sql[i + 1] === "'") {
|
||||
str += "'";
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
break;
|
||||
} else if (sql[i] === "\\") {
|
||||
str += sql[i] + (sql[i + 1] || "");
|
||||
i += 2;
|
||||
} else {
|
||||
str += sql[i];
|
||||
i++;
|
||||
}
|
||||
}
|
||||
tokens.push({ type: "string", value: str });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Double-quoted string or identifier ("...")
|
||||
if (char === '"' || char === "`") {
|
||||
const quote = char;
|
||||
let str = quote;
|
||||
i++;
|
||||
while (i < n) {
|
||||
if (sql[i] === quote) {
|
||||
str += quote;
|
||||
i++;
|
||||
break;
|
||||
} else if (sql[i] === "\\") {
|
||||
str += sql[i] + (sql[i + 1] || "");
|
||||
i += 2;
|
||||
} else {
|
||||
str += sql[i];
|
||||
i++;
|
||||
}
|
||||
}
|
||||
tokens.push({ type: "string", value: str });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Whitespace
|
||||
if (/\s/.test(char)) {
|
||||
let space = "";
|
||||
while (i < n && /\s/.test(sql[i])) {
|
||||
space += sql[i];
|
||||
i++;
|
||||
}
|
||||
tokens.push({ type: "space", value: space });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Numbers
|
||||
if (/[0-9]/.test(char)) {
|
||||
let num = "";
|
||||
while (i < n && /[0-9.eE+-]/.test(sql[i])) {
|
||||
num += sql[i];
|
||||
i++;
|
||||
}
|
||||
tokens.push({ type: "number", value: num });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Word / Identifier / Keyword
|
||||
if (/[a-zA-Z_#$]/.test(char)) {
|
||||
let word = "";
|
||||
while (i < n && /[a-zA-Z0-9_#$]/.test(sql[i])) {
|
||||
word += sql[i];
|
||||
i++;
|
||||
}
|
||||
tokens.push({ type: "word", value: word });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Multi-char operators (>=, <=, <>, !=, ||, :=)
|
||||
if (
|
||||
(char === ">" && next === "=") ||
|
||||
(char === "<" && (next === "=" || next === ">")) ||
|
||||
(char === "!" && next === "=") ||
|
||||
(char === "|" && next === "|") ||
|
||||
(char === ":" && next === "=")
|
||||
) {
|
||||
tokens.push({ type: "symbol", value: sql.slice(i, i + 2) });
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Single-char symbols (comma, parens, semicolon, operators)
|
||||
tokens.push({ type: "symbol", value: char });
|
||||
i++;
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function formatSql(
|
||||
sql: string,
|
||||
options: {
|
||||
indentStr: string;
|
||||
keywordCase: "upper" | "lower" | "preserve";
|
||||
breakCommas: boolean;
|
||||
}
|
||||
): string {
|
||||
const rawTokens = tokenizeSql(sql);
|
||||
if (rawTokens.length === 0) return "";
|
||||
|
||||
// Combine consecutive words for compound clauses (e.g. ORDER + BY -> ORDER BY, LEFT + JOIN -> LEFT JOIN)
|
||||
const tokens: { type: "clause" | "subclause" | "keyword" | "word" | "string" | "comment" | "symbol" | "number"; value: string }[] = [];
|
||||
|
||||
for (let i = 0; i < rawTokens.length; i++) {
|
||||
const t = rawTokens[i];
|
||||
if (t.type === "space") continue;
|
||||
|
||||
if (t.type === "word") {
|
||||
// Lookahead for 2-word or 3-word clauses
|
||||
let matchedClause: string | null = null;
|
||||
let consumed = 0;
|
||||
|
||||
// Check 3 words
|
||||
if (i + 4 < rawTokens.length) {
|
||||
const t1 = rawTokens[i];
|
||||
const sp1 = rawTokens[i + 1];
|
||||
const t2 = rawTokens[i + 2];
|
||||
const sp2 = rawTokens[i + 3];
|
||||
const t3 = rawTokens[i + 4];
|
||||
if (t1.type === "word" && sp1.type === "space" && t2.type === "word" && sp2.type === "space" && t3.type === "word") {
|
||||
const phrase = `${t1.value} ${t2.value} ${t3.value}`.toUpperCase();
|
||||
if (MAJOR_CLAUSES.includes(phrase)) {
|
||||
matchedClause = phrase;
|
||||
consumed = 4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check 2 words
|
||||
if (!matchedClause && i + 2 < rawTokens.length) {
|
||||
const t1 = rawTokens[i];
|
||||
const sp1 = rawTokens[i + 1];
|
||||
const t2 = rawTokens[i + 2];
|
||||
if (t1.type === "word" && sp1.type === "space" && t2.type === "word") {
|
||||
const phrase = `${t1.value} ${t2.value}`.toUpperCase();
|
||||
if (MAJOR_CLAUSES.includes(phrase)) {
|
||||
matchedClause = phrase;
|
||||
consumed = 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedClause) {
|
||||
tokens.push({ type: "clause", value: matchedClause });
|
||||
i += consumed;
|
||||
continue;
|
||||
}
|
||||
|
||||
const upper = t.value.toUpperCase();
|
||||
if (MAJOR_CLAUSES.includes(upper)) {
|
||||
tokens.push({ type: "clause", value: upper });
|
||||
} else if (SUB_CLAUSES.includes(upper)) {
|
||||
tokens.push({ type: "subclause", value: upper });
|
||||
} else if (KEYWORDS_SET.has(upper)) {
|
||||
tokens.push({ type: "keyword", value: upper });
|
||||
} else {
|
||||
tokens.push({ type: "word", value: t.value });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
tokens.push(t as { type: "string" | "comment" | "symbol" | "number"; value: string });
|
||||
}
|
||||
|
||||
// Adjust keyword casing
|
||||
const applyCase = (kw: string) => {
|
||||
if (options.keywordCase === "upper") return kw.toUpperCase();
|
||||
if (options.keywordCase === "lower") return kw.toLowerCase();
|
||||
return kw;
|
||||
};
|
||||
|
||||
let formatted = "";
|
||||
let indentLevel = 0;
|
||||
let inSelect = false;
|
||||
let inParentheses = 0;
|
||||
|
||||
const getIndent = (lvl: number) => options.indentStr.repeat(Math.max(0, lvl));
|
||||
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const token = tokens[i];
|
||||
const prevToken = i > 0 ? tokens[i - 1] : null;
|
||||
|
||||
if (token.type === "clause") {
|
||||
const clauseUpper = token.value.toUpperCase();
|
||||
if (clauseUpper.startsWith("SELECT")) {
|
||||
inSelect = true;
|
||||
} else {
|
||||
inSelect = false;
|
||||
}
|
||||
|
||||
if (formatted.length > 0 && !formatted.endsWith("\n")) {
|
||||
formatted += "\n";
|
||||
}
|
||||
formatted += getIndent(indentLevel) + applyCase(token.value);
|
||||
|
||||
// Add a space after clause unless next token is newline or paren
|
||||
const nextToken = tokens[i + 1];
|
||||
if (nextToken && nextToken.value !== ";") {
|
||||
formatted += " ";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.type === "subclause") {
|
||||
const upper = token.value.toUpperCase();
|
||||
if (upper === "CASE") {
|
||||
formatted += applyCase(token.value) + " ";
|
||||
indentLevel++;
|
||||
} else if (upper === "END") {
|
||||
indentLevel = Math.max(0, indentLevel - 1);
|
||||
formatted += "\n" + getIndent(indentLevel + 1) + applyCase(token.value);
|
||||
if (tokens[i + 1] && tokens[i + 1].value !== "," && tokens[i + 1].value !== ";") {
|
||||
formatted += " ";
|
||||
}
|
||||
} else if (upper === "WHEN" || upper === "THEN" || upper === "ELSE") {
|
||||
formatted += "\n" + getIndent(indentLevel + 1) + applyCase(token.value) + " ";
|
||||
} else if (upper === "AND" || upper === "OR" || upper === "ON") {
|
||||
formatted += "\n" + getIndent(indentLevel + 1) + applyCase(token.value) + " ";
|
||||
} else {
|
||||
formatted += applyCase(token.value) + " ";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.type === "keyword") {
|
||||
formatted += applyCase(token.value);
|
||||
const next = tokens[i + 1];
|
||||
if (next && next.value !== "," && next.value !== ";" && next.value !== ")") {
|
||||
formatted += " ";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.type === "symbol") {
|
||||
if (token.value === ",") {
|
||||
formatted += ",";
|
||||
if (inSelect && inParentheses === 0 && options.breakCommas) {
|
||||
formatted += "\n" + getIndent(indentLevel + 1);
|
||||
} else {
|
||||
formatted += " ";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.value === "(") {
|
||||
inParentheses++;
|
||||
formatted += "(";
|
||||
// If next is SELECT (subquery)
|
||||
if (tokens[i + 1] && tokens[i + 1].type === "clause" && tokens[i + 1].value.toUpperCase().startsWith("SELECT")) {
|
||||
indentLevel++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.value === ")") {
|
||||
inParentheses = Math.max(0, inParentheses - 1);
|
||||
if (prevToken && prevToken.type === "clause") {
|
||||
indentLevel = Math.max(0, indentLevel - 1);
|
||||
}
|
||||
formatted += ")";
|
||||
const next = tokens[i + 1];
|
||||
if (next && next.value !== "," && next.value !== ";" && next.value !== ")") {
|
||||
formatted += " ";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.value === ";") {
|
||||
formatted += ";\n\n";
|
||||
indentLevel = 0;
|
||||
inSelect = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (["=", "+", "-", "*", "/", "<", ">", "<=", ">=", "<>", "!=", "||"].includes(token.value)) {
|
||||
formatted += ` ${token.value} `;
|
||||
continue;
|
||||
}
|
||||
|
||||
formatted += token.value;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.type === "comment") {
|
||||
if (formatted.length > 0 && !formatted.endsWith("\n") && !formatted.endsWith(" ")) {
|
||||
formatted += " ";
|
||||
}
|
||||
formatted += token.value;
|
||||
if (token.value.startsWith("--")) {
|
||||
formatted += "\n" + getIndent(indentLevel);
|
||||
} else {
|
||||
formatted += " ";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Default word / string / number
|
||||
formatted += token.value;
|
||||
const next = tokens[i + 1];
|
||||
if (next && next.value !== "," && next.value !== ";" && next.value !== ")" && next.value !== "." && token.value !== ".") {
|
||||
formatted += " ";
|
||||
}
|
||||
}
|
||||
|
||||
return formatted.trim();
|
||||
}
|
||||
|
||||
function minifySql(sql: string): string {
|
||||
const tokens = tokenizeSql(sql);
|
||||
let minified = "";
|
||||
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const t = tokens[i];
|
||||
if (t.type === "space") continue;
|
||||
if (t.type === "comment") {
|
||||
// Omit single-line comments or preserve multi-line comments with minimal space
|
||||
if (t.value.startsWith("/*")) {
|
||||
minified += `${t.value} `;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
minified += t.value;
|
||||
const next = tokens[i + 1];
|
||||
if (next && next.type !== "space" && next.type !== "comment" && next.value !== "," && next.value !== ";" && next.value !== ")" && next.value !== "(" && t.value !== "(" && t.value !== ".") {
|
||||
minified += " ";
|
||||
}
|
||||
}
|
||||
|
||||
return minified.trim();
|
||||
}
|
||||
|
||||
export default function SqlFormatterPage() {
|
||||
const [input, setInput] = useState<string>("");
|
||||
const [output, setOutput] = useState<string>("");
|
||||
const [indentStr, setIndentStr] = useState<string>(" ");
|
||||
const [keywordCase, setKeywordCase] = useState<"upper" | "lower" | "preserve">("upper");
|
||||
const [breakCommas, setBreakCommas] = useState<boolean>(true);
|
||||
const [status, setStatus] = useState<StatusInfo>({ type: "idle" });
|
||||
const [copied, setCopied] = useState<boolean>(false);
|
||||
|
||||
const indentSelectId = useId();
|
||||
const casingSelectId = useId();
|
||||
|
||||
const handleFormat = useCallback(() => {
|
||||
if (!input.trim()) {
|
||||
setOutput("");
|
||||
setStatus({ type: "idle" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const formatted = formatSql(input, {
|
||||
indentStr,
|
||||
keywordCase,
|
||||
breakCommas,
|
||||
});
|
||||
|
||||
setOutput(formatted);
|
||||
const lines = formatted.split("\n").length;
|
||||
setStatus({
|
||||
type: "success",
|
||||
message: "SQL formatted successfully with clean indentation!",
|
||||
charCount: formatted.length,
|
||||
lineCount: lines,
|
||||
});
|
||||
} catch (err) {
|
||||
setStatus({
|
||||
type: "error",
|
||||
message: err instanceof Error ? err.message : "Failed to format SQL.",
|
||||
});
|
||||
}
|
||||
}, [input, indentStr, keywordCase, breakCommas]);
|
||||
|
||||
const handleMinify = useCallback(() => {
|
||||
if (!input.trim()) {
|
||||
setOutput("");
|
||||
setStatus({ type: "idle" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const minified = minifySql(input);
|
||||
setOutput(minified);
|
||||
|
||||
const origLen = input.length;
|
||||
const minLen = minified.length;
|
||||
const reduction = origLen > 0 ? Math.max(0, Math.round(((origLen - minLen) / origLen) * 100)) : 0;
|
||||
|
||||
setStatus({
|
||||
type: "success",
|
||||
message: `SQL minified to a single line! Saved ${reduction}% characters.`,
|
||||
charCount: minLen,
|
||||
lineCount: 1,
|
||||
reduction,
|
||||
});
|
||||
} catch (err) {
|
||||
setStatus({
|
||||
type: "error",
|
||||
message: err instanceof Error ? err.message : "Failed to minify SQL.",
|
||||
});
|
||||
}
|
||||
}, [input]);
|
||||
|
||||
const handleLoadSample = useCallback(() => {
|
||||
setInput(SAMPLE_SQL);
|
||||
const formatted = formatSql(SAMPLE_SQL, {
|
||||
indentStr,
|
||||
keywordCase,
|
||||
breakCommas,
|
||||
});
|
||||
setOutput(formatted);
|
||||
setStatus({
|
||||
type: "success",
|
||||
message: "Sample SQL query loaded and beautified.",
|
||||
charCount: formatted.length,
|
||||
lineCount: formatted.split("\n").length,
|
||||
});
|
||||
}, [indentStr, keywordCase, breakCommas]);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
setInput("");
|
||||
setOutput("");
|
||||
setStatus({ type: "idle" });
|
||||
}, []);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
const textToCopy = output || input;
|
||||
if (!textToCopy) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(textToCopy);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = textToCopy;
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(textarea);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
}, [output, input]);
|
||||
|
||||
const inputLineCount = input ? input.split("\n").length : 0;
|
||||
const outputLineCount = output ? output.split("\n").length : 0;
|
||||
|
||||
return (
|
||||
<ToolLayout
|
||||
title="SQL Formatter & Beautifier - Format, Indent & Minify SQL"
|
||||
description="Clean, free online SQL formatter and beautifier. Indent major clauses (SELECT, FROM, WHERE, JOIN), uppercase standard SQL keywords, and minify queries instantly."
|
||||
>
|
||||
<div className="space-y-5">
|
||||
{/* Top Controls Toolbar */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-gray-200 pb-4 dark:border-gray-800">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
onClick={handleFormat}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-xs transition-colors hover:bg-blue-700 active:scale-95 cursor-pointer"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16M4 18h7" />
|
||||
</svg>
|
||||
Format / Beautify
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleMinify}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-gray-200 px-4 py-2 text-sm font-medium text-gray-800 transition-colors hover:bg-gray-300 active:scale-95 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700 cursor-pointer"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
||||
</svg>
|
||||
Minify
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleLoadSample}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-blue-50 px-3 py-2 text-sm font-medium text-blue-700 transition-colors hover:bg-blue-100 dark:bg-blue-950/40 dark:text-blue-300 dark:hover:bg-blue-900/50 cursor-pointer"
|
||||
>
|
||||
Load Sample SQL
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleClear}
|
||||
disabled={!input && !output}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-gray-100 px-3 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-rose-50 hover:text-rose-600 disabled:opacity-40 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-rose-950/30 dark:hover:text-rose-400 cursor-pointer"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm text-gray-700 dark:text-gray-300">
|
||||
{/* Indent Selector */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<label htmlFor={indentSelectId} className="text-xs font-semibold text-gray-500 uppercase dark:text-gray-400">
|
||||
Indent:
|
||||
</label>
|
||||
<select
|
||||
id={indentSelectId}
|
||||
value={indentStr}
|
||||
onChange={(e) => setIndentStr(e.target.value)}
|
||||
className="rounded-md border border-gray-300 bg-white px-2 py-1 text-xs text-gray-800 shadow-xs focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200"
|
||||
>
|
||||
<option value=" ">2 Spaces</option>
|
||||
<option value=" ">4 Spaces</option>
|
||||
<option value="	">Tab</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Keyword Casing */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<label htmlFor={casingSelectId} className="text-xs font-semibold text-gray-500 uppercase dark:text-gray-400">
|
||||
Keywords:
|
||||
</label>
|
||||
<select
|
||||
id={casingSelectId}
|
||||
value={keywordCase}
|
||||
onChange={(e) => setKeywordCase(e.target.value as "upper" | "lower" | "preserve")}
|
||||
className="rounded-md border border-gray-300 bg-white px-2 py-1 text-xs text-gray-800 shadow-xs focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200"
|
||||
>
|
||||
<option value="upper">UPPERCASE</option>
|
||||
<option value="lower">lowercase</option>
|
||||
<option value="preserve">Preserve</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Column Line Break */}
|
||||
<label className="flex cursor-pointer items-center gap-1.5 text-xs text-gray-600 select-none hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={breakCommas}
|
||||
onChange={(e) => setBreakCommas(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800"
|
||||
/>
|
||||
<span>Column per line</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Notification Banner */}
|
||||
{status.type === "success" && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-emerald-300 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-300">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="h-5 w-5 shrink-0 text-emerald-600 dark:text-emerald-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span className="font-medium">{status.message}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-emerald-800 dark:text-emerald-300">
|
||||
{status.lineCount !== undefined && <span>Lines: <strong>{status.lineCount}</strong></span>}
|
||||
{status.charCount !== undefined && <span>Characters: <strong>{status.charCount}</strong></span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status.type === "error" && (
|
||||
<div className="rounded-lg border border-red-300 bg-red-50 p-4 text-sm text-red-900 dark:border-red-800 dark:bg-red-950/40 dark:text-red-300">
|
||||
<div className="flex items-start gap-2">
|
||||
<svg className="mt-0.5 h-5 w-5 shrink-0 text-red-600 dark:text-red-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<p className="font-semibold text-red-800 dark:text-red-200">Formatting Error</p>
|
||||
<p className="mt-1 font-mono text-xs text-red-700 dark:text-red-300">{status.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input & Output Split Editor */}
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
{/* Input Area */}
|
||||
<div className="flex flex-col">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-gray-800 dark:text-gray-200">Raw SQL Input</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
({inputLineCount} {inputLineCount === 1 ? "line" : "lines"}, {input.length} chars)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
setInput(text);
|
||||
} catch {
|
||||
// Permission denied
|
||||
}
|
||||
}}
|
||||
className="rounded px-2 py-1 text-xs font-medium text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800 cursor-pointer"
|
||||
title="Paste from clipboard"
|
||||
>
|
||||
Paste
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setInput("")}
|
||||
className="rounded px-2 py-1 text-xs font-medium text-gray-500 hover:bg-gray-100 hover:text-red-600 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-red-400 cursor-pointer"
|
||||
title="Clear input"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="Enter or paste unformatted SQL queries here..."
|
||||
rows={18}
|
||||
spellCheck={false}
|
||||
className="w-full flex-1 resize-y rounded-lg border border-gray-300 bg-white p-3 font-mono text-sm leading-relaxed text-gray-900 shadow-xs focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Output Area */}
|
||||
<div className="flex flex-col">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-gray-800 dark:text-gray-200">Formatted SQL Result</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
({outputLineCount} {outputLineCount === 1 ? "line" : "lines"}, {output.length} chars)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
disabled={!output && !input}
|
||||
className="inline-flex items-center gap-1 rounded-md bg-blue-600 px-3 py-1 text-xs font-semibold text-white shadow-xs transition-colors hover:bg-blue-700 disabled:opacity-40 cursor-pointer"
|
||||
title="Copy SQL output"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
Copy SQL
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
value={output}
|
||||
readOnly
|
||||
placeholder="Formatted SQL output will appear here..."
|
||||
rows={18}
|
||||
spellCheck={false}
|
||||
className="w-full flex-1 resize-y rounded-lg border border-gray-300 bg-gray-50 p-3 font-mono text-sm leading-relaxed text-gray-900 shadow-xs focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none dark:border-gray-700 dark:bg-gray-900/80 dark:text-gray-100 dark:placeholder-gray-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feature Highlights & Guide */}
|
||||
<div className="mt-8 rounded-xl border border-gray-200 bg-gray-50/70 p-5 text-sm text-gray-600 dark:border-gray-800 dark:bg-gray-800/30 dark:text-gray-400">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100">About SQL Formatter</h3>
|
||||
<div className="mt-3 grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<p className="font-medium text-gray-800 dark:text-gray-200">📐 Major Clause Alignment</p>
|
||||
<p className="mt-1 text-xs leading-normal">
|
||||
Aligns SELECT, FROM, JOIN, WHERE, GROUP BY, HAVING, ORDER BY, and sub-clauses for optimal clarity.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-800 dark:text-gray-200">🔤 Keyword Casing</p>
|
||||
<p className="mt-1 text-xs leading-normal">
|
||||
Standardize keywords to UPPERCASE or lowercase across ANSI SQL, MySQL, PostgreSQL, SQLite, and SQL Server.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-800 dark:text-gray-200">⚡ Single-Line Minify</p>
|
||||
<p className="mt-1 text-xs leading-normal">
|
||||
Remove unnecessary linebreaks and comments to compact SQL queries for embedding into code or scripts.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ToolLayout>
|
||||
);
|
||||
}
|
||||
657
src/app/tools/xml-formatter/page.tsx
Normal file
657
src/app/tools/xml-formatter/page.tsx
Normal file
|
|
@ -0,0 +1,657 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useCallback, useId } from "react";
|
||||
import ToolLayout from "@/components/ToolLayout";
|
||||
|
||||
interface StatusInfo {
|
||||
type: "idle" | "success" | "error";
|
||||
message?: string;
|
||||
lineCount?: number;
|
||||
byteSize?: number;
|
||||
reduction?: number;
|
||||
line?: number;
|
||||
column?: number;
|
||||
}
|
||||
|
||||
const SAMPLE_XML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<bookstore name="Developer Library" location="Global">
|
||||
<!-- Top Bestsellers for Software Engineers -->
|
||||
<book category="programming" inStock="true" id="bk101">
|
||||
<title lang="en">Clean Architecture: A Craftsman's Guide</title>
|
||||
<author>Robert C. Martin</author>
|
||||
<year>2017</year>
|
||||
<price currency="USD">34.99</price>
|
||||
<tags>
|
||||
<tag>Architecture</tag>
|
||||
<tag>Design Patterns</tag>
|
||||
<tag>Best Practices</tag>
|
||||
</tags>
|
||||
<description><![CDATA[Practical software structure rules from legendary expert Robert C. Martin.]]></description>
|
||||
<metadata isPublished="true" reviewScore="4.9" />
|
||||
</book>
|
||||
<book category="distributed-systems" inStock="true" id="bk102">
|
||||
<title lang="en">Designing Data-Intensive Applications</title>
|
||||
<author>Martin Kleppmann</author>
|
||||
<year>2017</year>
|
||||
<price currency="USD">42.50</price>
|
||||
<tags>
|
||||
<tag>Distributed Systems</tag>
|
||||
<tag>Databases</tag>
|
||||
<tag>Storage</tag>
|
||||
</tags>
|
||||
<description><![CDATA[The definitive guide to the architecture of modern large-scale reliable data systems.]]></description>
|
||||
<metadata isPublished="true" reviewScore="5.0" />
|
||||
</book>
|
||||
</bookstore>`;
|
||||
|
||||
function escapeXmlText(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function escapeXmlAttr(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function validateXml(xml: string): { valid: boolean; error?: string; line?: number; column?: number } {
|
||||
if (!xml.trim()) {
|
||||
return { valid: false, error: "Input is empty. Please enter or paste XML to validate." };
|
||||
}
|
||||
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(xml, "application/xml");
|
||||
const parserError = doc.querySelector("parsererror");
|
||||
|
||||
if (parserError) {
|
||||
const fullErr = parserError.textContent || "XML Parsing Error";
|
||||
const cleanErr = fullErr.replace(/Below is a rendering of the page up to the first error\.[\s\S]*/, "").trim();
|
||||
|
||||
const lineMatch = fullErr.match(/line\s+(\d+)/i) || fullErr.match(/error on line (\d+)/i);
|
||||
const colMatch = fullErr.match(/column\s+(\d+)/i) || fullErr.match(/at column (\d+)/i);
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
error: cleanErr,
|
||||
line: lineMatch ? parseInt(lineMatch[1], 10) : undefined,
|
||||
column: colMatch ? parseInt(colMatch[1], 10) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
function serializeNode(
|
||||
node: Node,
|
||||
indentStr: string,
|
||||
currentDepth: number,
|
||||
collapseEmpty: boolean
|
||||
): string {
|
||||
const indent = indentStr.repeat(currentDepth);
|
||||
|
||||
if (node.nodeType === Node.COMMENT_NODE) {
|
||||
return `${indent}<!--${node.nodeValue}-->`;
|
||||
}
|
||||
|
||||
if (node.nodeType === Node.CDATA_SECTION_NODE) {
|
||||
return `${indent}<![CDATA[${node.nodeValue}]]>`;
|
||||
}
|
||||
|
||||
if (node.nodeType === Node.PROCESSING_INSTRUCTION_NODE) {
|
||||
const pi = node as ProcessingInstruction;
|
||||
return `${indent}<?${pi.target} ${pi.data}?>`;
|
||||
}
|
||||
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
const text = node.nodeValue?.trim() || "";
|
||||
if (!text) return "";
|
||||
return `${indent}${escapeXmlText(text)}`;
|
||||
}
|
||||
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const el = node as Element;
|
||||
const tagName = el.tagName;
|
||||
|
||||
let attrs = "";
|
||||
if (el.attributes && el.attributes.length > 0) {
|
||||
attrs =
|
||||
" " +
|
||||
Array.from(el.attributes)
|
||||
.map((a) => `${a.name}="${escapeXmlAttr(a.value)}"`)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
const childNodes = Array.from(el.childNodes).filter((child) => {
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
return (child.nodeValue || "").trim().length > 0;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (childNodes.length === 0) {
|
||||
if (collapseEmpty) {
|
||||
return `${indent}<${tagName}${attrs} />`;
|
||||
} else {
|
||||
return `${indent}<${tagName}${attrs}></${tagName}>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Single inline text child
|
||||
if (childNodes.length === 1 && childNodes[0].nodeType === Node.TEXT_NODE) {
|
||||
const text = childNodes[0].nodeValue?.trim() || "";
|
||||
if (!text.includes("\n") && text.length < 80) {
|
||||
return `${indent}<${tagName}${attrs}>${escapeXmlText(text)}</${tagName}>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Single inline CDATA child
|
||||
if (childNodes.length === 1 && childNodes[0].nodeType === Node.CDATA_SECTION_NODE) {
|
||||
const cdata = childNodes[0].nodeValue || "";
|
||||
if (!cdata.includes("\n") && cdata.length < 80) {
|
||||
return `${indent}<${tagName}${attrs}><![CDATA[${cdata}]]></${tagName}>`;
|
||||
}
|
||||
}
|
||||
|
||||
const childLines: string[] = [];
|
||||
for (const child of childNodes) {
|
||||
const serialized = serializeNode(child, indentStr, currentDepth + 1, collapseEmpty);
|
||||
if (serialized) {
|
||||
childLines.push(serialized);
|
||||
}
|
||||
}
|
||||
|
||||
return `${indent}<${tagName}${attrs}>\n${childLines.join("\n")}\n${indent}</${tagName}>`;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function formatXmlDocument(
|
||||
xml: string,
|
||||
indentStr: string,
|
||||
collapseEmpty: boolean
|
||||
): string {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(xml, "application/xml");
|
||||
const parserError = doc.querySelector("parsererror");
|
||||
|
||||
if (parserError) {
|
||||
const val = validateXml(xml);
|
||||
throw new Error(val.error || "XML parsing error");
|
||||
}
|
||||
|
||||
// Preserve initial <?xml ...?> declaration if present in original string
|
||||
const declMatch = xml.trim().match(/^<\?xml\s+[^?]*\?>/i);
|
||||
const declaration = declMatch ? declMatch[0] : "";
|
||||
|
||||
const lines: string[] = [];
|
||||
if (declaration) {
|
||||
lines.push(declaration);
|
||||
}
|
||||
|
||||
for (const child of Array.from(doc.childNodes)) {
|
||||
const formatted = serializeNode(child, indentStr, 0, collapseEmpty);
|
||||
if (formatted) {
|
||||
lines.push(formatted);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function minifyXmlString(xml: string): string {
|
||||
const val = validateXml(xml);
|
||||
if (!val.valid) {
|
||||
throw new Error(val.error || "Invalid XML syntax.");
|
||||
}
|
||||
|
||||
// Strip whitespace between tags and compact spaces
|
||||
const minified = xml
|
||||
.replace(/>\s+</g, "><")
|
||||
.replace(/\s{2,}/g, " ")
|
||||
.trim();
|
||||
|
||||
return minified;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
export default function XmlFormatterPage() {
|
||||
const [input, setInput] = useState<string>("");
|
||||
const [output, setOutput] = useState<string>("");
|
||||
const [indentStr, setIndentStr] = useState<string>(" ");
|
||||
const [collapseEmpty, setCollapseEmpty] = useState<boolean>(true);
|
||||
const [status, setStatus] = useState<StatusInfo>({ type: "idle" });
|
||||
const [copied, setCopied] = useState<boolean>(false);
|
||||
|
||||
const indentSelectId = useId();
|
||||
|
||||
const handleFormat = useCallback(() => {
|
||||
if (!input.trim()) {
|
||||
setOutput("");
|
||||
setStatus({ type: "idle" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const formatted = formatXmlDocument(input, indentStr, collapseEmpty);
|
||||
setOutput(formatted);
|
||||
|
||||
const byteSize = new Blob([formatted]).size;
|
||||
const lineCount = formatted.split("\n").length;
|
||||
|
||||
setStatus({
|
||||
type: "success",
|
||||
message: "XML beautified and indented successfully!",
|
||||
lineCount,
|
||||
byteSize,
|
||||
});
|
||||
} catch (err) {
|
||||
const val = validateXml(input);
|
||||
setStatus({
|
||||
type: "error",
|
||||
message: err instanceof Error ? err.message : "Failed to format XML.",
|
||||
line: val.line,
|
||||
column: val.column,
|
||||
});
|
||||
}
|
||||
}, [input, indentStr, collapseEmpty]);
|
||||
|
||||
const handleMinify = useCallback(() => {
|
||||
if (!input.trim()) {
|
||||
setOutput("");
|
||||
setStatus({ type: "idle" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const minified = minifyXmlString(input);
|
||||
setOutput(minified);
|
||||
|
||||
const origSize = new Blob([input]).size;
|
||||
const minSize = new Blob([minified]).size;
|
||||
const reduction = origSize > 0 ? Math.max(0, Math.round(((origSize - minSize) / origSize) * 100)) : 0;
|
||||
|
||||
setStatus({
|
||||
type: "success",
|
||||
message: `XML minified! Removed whitespace between tags (saved ${reduction}%).`,
|
||||
lineCount: minified.split("\n").length,
|
||||
byteSize: minSize,
|
||||
reduction,
|
||||
});
|
||||
} catch (err) {
|
||||
const val = validateXml(input);
|
||||
setStatus({
|
||||
type: "error",
|
||||
message: err instanceof Error ? err.message : "Failed to minify XML.",
|
||||
line: val.line,
|
||||
column: val.column,
|
||||
});
|
||||
}
|
||||
}, [input]);
|
||||
|
||||
const handleValidate = useCallback(() => {
|
||||
if (!input.trim()) {
|
||||
setStatus({
|
||||
type: "error",
|
||||
message: "Input is empty. Please enter XML data to validate.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const val = validateXml(input);
|
||||
if (val.valid) {
|
||||
const byteSize = new Blob([input]).size;
|
||||
setStatus({
|
||||
type: "success",
|
||||
message: "XML is well-formed! No syntax or structure errors detected.",
|
||||
byteSize,
|
||||
lineCount: input.split("\n").length,
|
||||
});
|
||||
} else {
|
||||
setStatus({
|
||||
type: "error",
|
||||
message: val.error || "XML is not well-formed.",
|
||||
line: val.line,
|
||||
column: val.column,
|
||||
});
|
||||
}
|
||||
}, [input]);
|
||||
|
||||
const handleLoadSample = useCallback(() => {
|
||||
setInput(SAMPLE_XML);
|
||||
try {
|
||||
const formatted = formatXmlDocument(SAMPLE_XML, indentStr, collapseEmpty);
|
||||
setOutput(formatted);
|
||||
setStatus({
|
||||
type: "success",
|
||||
message: "Sample XML loaded and formatted successfully.",
|
||||
lineCount: formatted.split("\n").length,
|
||||
byteSize: new Blob([formatted]).size,
|
||||
});
|
||||
} catch {
|
||||
setOutput(SAMPLE_XML);
|
||||
}
|
||||
}, [indentStr, collapseEmpty]);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
setInput("");
|
||||
setOutput("");
|
||||
setStatus({ type: "idle" });
|
||||
}, []);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
const textToCopy = output || input;
|
||||
if (!textToCopy) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(textToCopy);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = textToCopy;
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(textarea);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
}, [output, input]);
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
const textToDownload = output || input;
|
||||
if (!textToDownload) return;
|
||||
|
||||
const blob = new Blob([textToDownload], { type: "application/xml;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "document.xml";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}, [output, input]);
|
||||
|
||||
const inputLineCount = input ? input.split("\n").length : 0;
|
||||
const outputLineCount = output ? output.split("\n").length : 0;
|
||||
|
||||
return (
|
||||
<ToolLayout
|
||||
title="XML Formatter & Validator - Format, Beautify & Validate XML"
|
||||
description="Free online XML formatter, beautifier, and validator. Pretty-print raw XML with custom indentation, self-closing tags, CDATA handling, and instant validation."
|
||||
>
|
||||
<div className="space-y-5">
|
||||
{/* Top Controls Toolbar */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-gray-200 pb-4 dark:border-gray-800">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
onClick={handleFormat}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-xs transition-colors hover:bg-blue-700 active:scale-95 cursor-pointer"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16M4 18h7" />
|
||||
</svg>
|
||||
Format / Beautify
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleMinify}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-gray-200 px-4 py-2 text-sm font-medium text-gray-800 transition-colors hover:bg-gray-300 active:scale-95 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700 cursor-pointer"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
||||
</svg>
|
||||
Minify
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleValidate}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-emerald-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-emerald-700 active:scale-95 dark:bg-emerald-700 dark:hover:bg-emerald-600 cursor-pointer"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Validate XML
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleLoadSample}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-blue-50 px-3 py-2 text-sm font-medium text-blue-700 transition-colors hover:bg-blue-100 dark:bg-blue-950/40 dark:text-blue-300 dark:hover:bg-blue-900/50 cursor-pointer"
|
||||
>
|
||||
Load Sample
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleClear}
|
||||
disabled={!input && !output}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-gray-100 px-3 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-rose-50 hover:text-rose-600 disabled:opacity-40 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-rose-950/30 dark:hover:text-rose-400 cursor-pointer"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm text-gray-700 dark:text-gray-300">
|
||||
{/* Indent Selector */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<label htmlFor={indentSelectId} className="text-xs font-semibold text-gray-500 uppercase dark:text-gray-400">
|
||||
Indent:
|
||||
</label>
|
||||
<select
|
||||
id={indentSelectId}
|
||||
value={indentStr}
|
||||
onChange={(e) => setIndentStr(e.target.value)}
|
||||
className="rounded-md border border-gray-300 bg-white px-2 py-1 text-xs text-gray-800 shadow-xs focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200"
|
||||
>
|
||||
<option value=" ">2 Spaces</option>
|
||||
<option value=" ">4 Spaces</option>
|
||||
<option value="	">Tab</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Collapse Empty Tags */}
|
||||
<label className="flex cursor-pointer items-center gap-1.5 text-xs text-gray-600 select-none hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={collapseEmpty}
|
||||
onChange={(e) => setCollapseEmpty(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800"
|
||||
/>
|
||||
<span>Self-close empty tags (<code><tag /></code>)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Notification Banner */}
|
||||
{status.type === "success" && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-emerald-300 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-300">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="h-5 w-5 shrink-0 text-emerald-600 dark:text-emerald-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span className="font-medium">{status.message}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-emerald-800 dark:text-emerald-300">
|
||||
{status.lineCount !== undefined && <span>Lines: <strong>{status.lineCount}</strong></span>}
|
||||
{status.byteSize !== undefined && <span>Size: <strong>{formatBytes(status.byteSize)}</strong></span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status.type === "error" && (
|
||||
<div className="rounded-lg border border-red-300 bg-red-50 p-4 text-sm text-red-900 dark:border-red-800 dark:bg-red-950/40 dark:text-red-300">
|
||||
<div className="flex items-start gap-2">
|
||||
<svg className="mt-0.5 h-5 w-5 shrink-0 text-red-600 dark:text-red-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold text-red-800 dark:text-red-200">XML Parsing Error</p>
|
||||
<p className="mt-1 font-mono text-xs text-red-700 dark:text-red-300">{status.message}</p>
|
||||
{(status.line !== undefined || status.column !== undefined) && (
|
||||
<div className="mt-2 inline-flex items-center gap-2 rounded bg-red-100 px-2.5 py-1 text-xs font-medium text-red-800 dark:bg-red-900/60 dark:text-red-200">
|
||||
<span>📍 Error location:</span>
|
||||
{status.line !== undefined && <span>Line <strong>{status.line}</strong></span>}
|
||||
{status.column !== undefined && <span>Column <strong>{status.column}</strong></span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input & Output Split Editor */}
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
{/* Input Area */}
|
||||
<div className="flex flex-col">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-gray-800 dark:text-gray-200">Input XML</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
({inputLineCount} {inputLineCount === 1 ? "line" : "lines"}, {input.length} chars)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
setInput(text);
|
||||
} catch {
|
||||
// Permission denied
|
||||
}
|
||||
}}
|
||||
className="rounded px-2 py-1 text-xs font-medium text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800 cursor-pointer"
|
||||
title="Paste from clipboard"
|
||||
>
|
||||
Paste
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setInput("")}
|
||||
className="rounded px-2 py-1 text-xs font-medium text-gray-500 hover:bg-gray-100 hover:text-red-600 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-red-400 cursor-pointer"
|
||||
title="Clear input"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="Paste raw or unformatted XML here..."
|
||||
rows={18}
|
||||
spellCheck={false}
|
||||
className="w-full flex-1 resize-y rounded-lg border border-gray-300 bg-white p-3 font-mono text-sm leading-relaxed text-gray-900 shadow-xs focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Output Area */}
|
||||
<div className="flex flex-col">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-gray-800 dark:text-gray-200">Formatted XML Output</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
({outputLineCount} {outputLineCount === 1 ? "line" : "lines"}, {output.length} chars)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
disabled={!output && !input}
|
||||
className="inline-flex items-center gap-1 rounded px-2.5 py-1 text-xs font-medium text-gray-700 hover:bg-gray-100 disabled:opacity-40 dark:text-gray-300 dark:hover:bg-gray-800 cursor-pointer"
|
||||
title="Download as XML file"
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Download .xml
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
disabled={!output && !input}
|
||||
className="inline-flex items-center gap-1 rounded-md bg-blue-600 px-3 py-1 text-xs font-semibold text-white shadow-xs transition-colors hover:bg-blue-700 disabled:opacity-40 cursor-pointer"
|
||||
title="Copy XML output"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
Copy XML
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
value={output}
|
||||
readOnly
|
||||
placeholder="Formatted XML output will appear here..."
|
||||
rows={18}
|
||||
spellCheck={false}
|
||||
className="w-full flex-1 resize-y rounded-lg border border-gray-300 bg-gray-50 p-3 font-mono text-sm leading-relaxed text-gray-900 shadow-xs focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none dark:border-gray-700 dark:bg-gray-900/80 dark:text-gray-100 dark:placeholder-gray-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feature Highlights & Guide */}
|
||||
<div className="mt-8 rounded-xl border border-gray-200 bg-gray-50/70 p-5 text-sm text-gray-600 dark:border-gray-800 dark:bg-gray-800/30 dark:text-gray-400">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100">About XML Formatter & Validator</h3>
|
||||
<div className="mt-3 grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<p className="font-medium text-gray-800 dark:text-gray-200">✨ Proper Tree Serialization</p>
|
||||
<p className="mt-1 text-xs leading-normal">
|
||||
Indents elements, handles namespaces, attributes, comments, processing instructions, and CDATA blocks.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-800 dark:text-gray-200">🔍 Real-time DOM Validation</p>
|
||||
<p className="mt-1 text-xs leading-normal">
|
||||
Validates XML well-formedness and points directly to unclosed tags, mismatching brackets, or invalid characters.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-800 dark:text-gray-200">🔒 100% Client-Side</p>
|
||||
<p className="mt-1 text-xs leading-normal">
|
||||
Uses the browser's native DOMParser engine. No XML payload ever leaves your computer.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ToolLayout>
|
||||
);
|
||||
}
|
||||
|
|
@ -38,6 +38,20 @@ export const tools: Tool[] = [
|
|||
// CSS Tools
|
||||
{ name: "CSS Gradient Generator", slug: "css-gradient", description: "Create beautiful CSS gradients with a visual editor", category: "CSS Tools", icon: "🌈" },
|
||||
{ name: "CSS Box Shadow Generator", slug: "box-shadow", description: "Design and preview CSS box shadows visually", category: "CSS Tools", icon: "🖼️" },
|
||||
{ name: "Px to Rem Converter", slug: "px-to-rem", description: "Convert pixels to rem units and vice versa", category: "CSS Tools", icon: "📐" },
|
||||
|
||||
// Data Tools
|
||||
{ name: "JSON to CSV", slug: "json-to-csv", description: "Convert JSON arrays to downloadable CSV files", category: "Data Tools", icon: "📊" },
|
||||
{ name: "CSV to JSON", slug: "csv-to-json", description: "Convert CSV data to structured JSON", category: "Data Tools", icon: "📋" },
|
||||
{ name: "SQL Formatter", slug: "sql-formatter", description: "Format and beautify SQL queries instantly", category: "Data Tools", icon: "🗄️" },
|
||||
{ name: "XML Formatter", slug: "xml-formatter", description: "Format, validate, and beautify XML data", category: "Data Tools", icon: "📰" },
|
||||
|
||||
// Web Tools
|
||||
{ name: "Meta Tag Generator", slug: "meta-tag-generator", description: "Generate HTML meta tags for SEO and social sharing", category: "Web Tools", icon: "🏷️" },
|
||||
{ name: "Slug Generator", slug: "slug-generator", description: "Convert text to clean, URL-friendly slugs", category: "Web Tools", icon: "🔗" },
|
||||
{ name: "Cron Expression Parser", slug: "cron-parser", description: "Parse and explain cron expressions in plain English", category: "Web Tools", icon: "⏱️" },
|
||||
{ name: "Image to Base64", slug: "image-to-base64", description: "Convert images to Base64 encoded strings", category: "Web Tools", icon: "🖼️" },
|
||||
{ name: "Placeholder Image Generator", slug: "placeholder-image", description: "Generate placeholder images with custom size and colors", category: "Web Tools", icon: "🎨" },
|
||||
];
|
||||
|
||||
export const categories = [...new Set(tools.map((t) => t.category))];
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue