feat: add 10 new tools (30 total)

This commit is contained in:
katR 2026-08-31 08:12:07 +05:30
parent 67aadda3d0
commit f9f17164cd
12 changed files with 7424 additions and 21 deletions

View file

@ -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

File diff suppressed because it is too large Load diff

View 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="&#9;">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 &amp; 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> &rarr; 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>
);
}

View 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 &amp; 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 &lt;img&gt;
</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 &lt;img&gt;</span>
<span className="text-[10px] text-gray-400">&lt;img src=...&gt;</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 (&lt;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 &amp; 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>
);
}

View 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="&#9;">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 &quot;;&quot;</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. [ { &quot;id&quot;: 1, &quot;name&quot;: &quot;Alice&quot; }, ... ]"
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>
);
}

File diff suppressed because it is too large Load diff

View 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")}</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 &amp; 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 &amp; 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 &amp; 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>
);
}

View 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:&#10;12px&#10;16px&#10;24px&#10;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>&lt;html&gt;</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 &#123; font-size: 62.5%; &#125;</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>
);
}

View 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 (&lt;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 &amp; 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 &amp; with &quot;and&quot;, @ with &quot;at&quot;</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 &amp; 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 (&quot;a&quot;, &quot;the&quot;, &quot;in&quot;, &quot;with&quot;)</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&apos;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 &quot;Copy&quot; 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 (&lt;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>
);
}

View 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="&#9;">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>
);
}

View 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
function escapeXmlAttr(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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="&#9;">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>&lt;tag /&gt;</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&apos;s native DOMParser engine. No XML payload ever leaves your computer.
</p>
</div>
</div>
</div>
</div>
</ToolLayout>
);
}

View file

@ -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))];