init: 20 developer tools

This commit is contained in:
katR 2026-08-31 07:58:10 +05:30
parent 307b957d1a
commit 67aadda3d0
38 changed files with 11637 additions and 115 deletions

View file

@ -1,9 +0,0 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->

View file

@ -1 +0,0 @@
@AGENTS.md

View file

@ -1,36 +1,43 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
# kattoolbox
## Getting Started
Free, fast, browser-based developer tools. No sign-up. No data collection.
First, run the development server:
**Live →** *coming soon*
## Tools
- 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
## Stack
Next.js · TypeScript · Tailwind CSS · Static Export
## Run locally
```bash
npm install
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
## License
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
MIT

View file

@ -1,7 +1,10 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
output: "export",
images: {
unoptimized: true,
},
};
export default nextConfig;

292
package-lock.json generated
View file

@ -8,13 +8,16 @@
"name": "toolbox",
"version": "0.1.0",
"dependencies": {
"marked": "^18.0.11",
"next": "16.3.3",
"qrcode": "^1.5.4",
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/qrcode": "^1.5.6",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
@ -1718,6 +1721,16 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/react": {
"version": "19.2.18",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
@ -2427,11 +2440,19 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
@ -2810,6 +2831,15 @@
"node": ">=6"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001810",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz",
@ -2853,11 +2883,21 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@ -2870,7 +2910,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/concat-map": {
@ -2988,6 +3027,15 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@ -3041,6 +3089,12 @@
"node": ">=8"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/doctrine": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
@ -3931,6 +3985,15 @@
"node": ">=6.9.0"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@ -4427,6 +4490,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-generator-function": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
@ -5166,6 +5238,18 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/marked": {
"version": "18.0.11",
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.11.tgz",
"integrity": "sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -5573,6 +5657,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@ -5590,7 +5683,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@ -5632,6 +5724,15 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@ -5703,6 +5804,23 @@
"node": ">=6"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@ -5796,6 +5914,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/resolve": {
"version": "2.0.0-next.7",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz",
@ -5946,6 +6079,12 @@
"semver": "bin/semver.js"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@ -6187,6 +6326,26 @@
"node": ">= 0.4"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/string-width/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/string.prototype.includes": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
@ -6301,6 +6460,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-bom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
@ -6817,6 +6988,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/which-typed-array": {
"version": "1.1.22",
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz",
@ -6849,6 +7026,26 @@
"node": ">=0.10.0"
}
},
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
@ -6856,6 +7053,93 @@
"dev": true,
"license": "ISC"
},
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/yargs/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/yargs/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View file

@ -9,13 +9,16 @@
"lint": "eslint"
},
"dependencies": {
"marked": "^18.0.11",
"next": "16.3.3",
"qrcode": "^1.5.4",
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/qrcode": "^1.5.6",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",

4
public/robots.txt Normal file
View file

@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://devtoolbox.com/sitemap.xml

View file

@ -1,8 +1,8 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
--background: #f9fafb;
--foreground: #111827;
}
@theme inline {
@ -14,13 +14,39 @@
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
--background: #030712;
--foreground: #f3f4f6;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
font-family: var(--font-sans), Arial, Helvetica, sans-serif;
}
/* AdSense placeholder styling — remove once real ads are connected */
.ad-container ins.adsbygoogle {
min-height: 90px;
background: repeating-linear-gradient(
-45deg,
transparent,
transparent 10px,
rgba(128, 128, 128, 0.03) 10px,
rgba(128, 128, 128, 0.03) 20px
);
border: 1px dashed rgba(128, 128, 128, 0.2);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
.ad-container ins.adsbygoogle::after {
content: "Ad Space";
font-size: 0.75rem;
color: rgba(128, 128, 128, 0.4);
font-weight: 500;
letter-spacing: 0.05em;
}

View file

@ -1,6 +1,8 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
const geistSans = Geist({
variable: "--font-geist-sans",
@ -13,17 +15,60 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: {
default: "DevToolbox — Free Online Developer Tools",
template: "%s | DevToolbox",
},
description:
"A collection of free, fast, and privacy-first online developer tools. JSON formatter, Base64 encoder, UUID generator, hash generator, and 20+ more. All tools run 100% in your browser.",
keywords: [
"developer tools",
"online tools",
"JSON formatter",
"Base64 encoder",
"UUID generator",
"hash generator",
"regex tester",
"free dev tools",
],
openGraph: {
type: "website",
title: "DevToolbox — Free Online Developer Tools",
description:
"20+ free, fast, privacy-first developer tools. JSON formatter, Base64 encoder, UUID generator, and more.",
siteName: "DevToolbox",
},
twitter: {
card: "summary_large_image",
title: "DevToolbox — Free Online Developer Tools",
description:
"20+ free, fast, privacy-first developer tools. JSON formatter, Base64 encoder, UUID generator, and more.",
},
robots: {
index: true,
follow: true,
},
};
export default function RootLayout({ children }: LayoutProps<"/">) {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
<head>
{/* Google AdSense — replace ca-pub-XXXXXXXXXXXXXXXX with your publisher ID */}
<script
async
src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-XXXXXXXXXXXXXXXX"
crossOrigin="anonymous"
/>
</head>
<body className="flex min-h-full flex-col bg-background text-foreground">
<Header />
<main className="flex-1">{children}</main>
<Footer />
</body>
</html>
);
}

View file

@ -1,69 +1,31 @@
import Image from "next/image";
import ToolCard from "@/components/ToolCard";
import AdSlot from "@/components/AdSlot";
import { getToolsByCategory } from "@/lib/tools";
export default function Home() {
const toolsByCategory = getToolsByCategory();
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert h-5 w-[100px]"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the{" "}
<code className="rounded bg-black/[.06] px-1.5 py-0.5 font-mono text-[0.9em] dark:bg-white/[.08]">
page.tsx
</code>{" "}
file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert h-[14px] w-4"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={14}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
<div className="mx-auto max-w-4xl px-4 py-8">
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Developer Tools</h1>
<p className="mb-6 mt-1 text-sm text-gray-400">Free. Private. Browser-based.</p>
<AdSlot slot="homepage-top" format="horizontal" />
{Object.entries(toolsByCategory).map(([category, categoryTools]) => (
<section key={category} className="mb-6">
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wider text-gray-400">
{category}
</h2>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{categoryTools.map((tool) => (
<ToolCard key={tool.slug} tool={tool} />
))}
</div>
</section>
))}
<AdSlot slot="homepage-bottom" format="horizontal" />
</div>
);
}

26
src/app/sitemap.ts Normal file
View file

@ -0,0 +1,26 @@
import type { MetadataRoute } from "next";
import { tools } from "@/lib/tools";
export const dynamic = "force-static";
// TODO: Replace with your actual domain
const BASE_URL = "https://devtoolbox.com";
export default function sitemap(): MetadataRoute.Sitemap {
const toolPages = tools.map((tool) => ({
url: `${BASE_URL}/tools/${tool.slug}`,
lastModified: new Date(),
changeFrequency: "monthly" as const,
priority: 0.8,
}));
return [
{
url: BASE_URL,
lastModified: new Date(),
changeFrequency: "weekly",
priority: 1,
},
...toolPages,
];
}

View file

@ -0,0 +1,377 @@
"use client";
import { useState, useCallback, useId } from "react";
import ToolLayout from "@/components/ToolLayout";
export default function Base64Page() {
const inputId = useId();
const outputId = useId();
const [input, setInput] = useState("");
const [output, setOutput] = useState("");
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [urlSafe, setUrlSafe] = useState(false);
const [splitLines, setSplitLines] = useState(false);
const [lastAction, setLastAction] = useState<"encode" | "decode" | null>(null);
const encodeBase64 = useCallback((text: string, isUrlSafe: boolean, isSplit: boolean) => {
if (!text) {
setOutput("");
setError(null);
return;
}
try {
const bytes = new TextEncoder().encode(text);
let binary = "";
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
let base64 = btoa(binary);
if (isUrlSafe) {
base64 = base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
if (isSplit && base64.length > 76) {
base64 = base64.match(/.{1,76}/g)?.join("\n") || base64;
}
setOutput(base64);
setError(null);
setLastAction("encode");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to encode input.");
setOutput("");
}
}, []);
const decodeBase64 = useCallback((text: string) => {
if (!text.trim()) {
setOutput("");
setError(null);
return;
}
try {
let cleaned = text.trim().replace(/\s+/g, "");
cleaned = cleaned.replace(/-/g, "+").replace(/_/g, "/");
while (cleaned.length % 4 !== 0) {
cleaned += "=";
}
if (!/^[A-Za-z0-9+/=]+$/.test(cleaned)) {
throw new Error("Invalid Base64 string: Contains characters outside the Base64 alphabet.");
}
const binary = atob(cleaned);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
try {
const decoded = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
setOutput(decoded);
} catch {
setOutput(binary);
}
setError(null);
setLastAction("decode");
} catch (err) {
setError(
err instanceof Error
? err.message
: "Invalid Base64 string. Please check your input and try again."
);
setOutput("");
}
}, []);
const handleEncode = () => {
encodeBase64(input, urlSafe, splitLines);
};
const handleDecode = () => {
decodeBase64(input);
};
const handleCopy = 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);
}
};
const handleSwap = () => {
const temp = output;
setInput(temp);
setOutput(input);
setError(null);
if (lastAction === "encode") {
setLastAction("decode");
} else if (lastAction === "decode") {
setLastAction("encode");
}
};
const handleClear = () => {
setInput("");
setOutput("");
setError(null);
setLastAction(null);
};
const loadSample = (type: "text" | "unicode" | "base64") => {
setError(null);
if (type === "text") {
const sample = "Hello, Developer! DevToolbox makes Base64 encoding fast and easy.";
setInput(sample);
encodeBase64(sample, urlSafe, splitLines);
} else if (type === "unicode") {
const sample = "Unicode test: 🚀 🌍 Привет! こんにちは 你好 €100 — café & naïve.";
setInput(sample);
encodeBase64(sample, urlSafe, splitLines);
} else {
const sample = "SGVsbG8sIFdvcmxkISBUaGlzIGlzIGEgZGVjb2RlZCBCYXNlNjQgbWVzc2FnZS4=";
setInput(sample);
decodeBase64(sample);
}
};
const inputBytes = new TextEncoder().encode(input).length;
const outputBytes = new TextEncoder().encode(output).length;
return (
<ToolLayout
title="Base64 Encode / Decode"
description="Encode and decode text to and from Base64 format with full Unicode and UTF-8 support."
>
<title>Base64 Encode &amp; Decode Online — DevToolbox</title>
<meta
name="description"
content="Free online Base64 encoder and decoder. Supports UTF-8, Unicode, URL-safe Base64, line wrapping, and instant copy."
/>
<div className="space-y-6">
{/* Controls Bar */}
<div className="flex flex-wrap items-center justify-between gap-3 pb-2 border-b border-gray-200 dark:border-gray-700">
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={handleEncode}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 active:bg-blue-800 text-white rounded-lg font-medium text-sm transition-colors shadow-sm cursor-pointer 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="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
</svg>
Encode to Base64
</button>
<button
type="button"
onClick={handleDecode}
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white rounded-lg font-medium text-sm transition-colors shadow-sm cursor-pointer 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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Decode from Base64
</button>
<button
type="button"
onClick={handleSwap}
disabled={!output && !input}
className="px-3 py-2 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed text-gray-700 dark:text-gray-200 rounded-lg text-sm font-medium transition-colors cursor-pointer flex items-center gap-1"
title="Swap input and output"
>
<svg className="w-4 h-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>
Swap
</button>
<button
type="button"
onClick={handleClear}
disabled={!input && !output}
className="px-3 py-2 bg-gray-100 dark:bg-gray-800 hover:bg-red-50 dark:hover:bg-red-950/30 text-gray-700 dark:text-gray-300 hover:text-red-600 dark:hover:text-red-400 disabled:opacity-40 disabled:cursor-not-allowed rounded-lg text-sm font-medium transition-colors cursor-pointer"
>
Clear
</button>
</div>
{/* Sample buttons */}
<div className="flex items-center gap-2 text-xs">
<span className="text-gray-500 dark:text-gray-400 font-medium">Samples:</span>
<button
type="button"
onClick={() => loadSample("text")}
className="px-2.5 py-1 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded transition-colors cursor-pointer"
>
Plain Text
</button>
<button
type="button"
onClick={() => loadSample("unicode")}
className="px-2.5 py-1 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded transition-colors cursor-pointer"
>
Unicode / Emojis
</button>
<button
type="button"
onClick={() => loadSample("base64")}
className="px-2.5 py-1 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded transition-colors cursor-pointer"
>
Base64
</button>
</div>
</div>
{/* Options */}
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-700 dark:text-gray-300">
<label className="inline-flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={urlSafe}
onChange={(e) => {
const val = e.target.checked;
setUrlSafe(val);
if (lastAction === "encode" && input) {
encodeBase64(input, val, splitLines);
}
}}
className="w-4 h-4 text-blue-600 rounded border-gray-300 dark:border-gray-600 focus:ring-blue-500 dark:bg-gray-700"
/>
<span>URL-safe Base64 <code className="text-xs bg-gray-100 dark:bg-gray-800 px-1 py-0.5 rounded">(-_ instead of +/)</code></span>
</label>
<label className="inline-flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={splitLines}
onChange={(e) => {
const val = e.target.checked;
setSplitLines(val);
if (lastAction === "encode" && input) {
encodeBase64(input, urlSafe, val);
}
}}
className="w-4 h-4 text-blue-600 rounded border-gray-300 dark:border-gray-600 focus:ring-blue-500 dark:bg-gray-700"
/>
<span>Wrap lines at 76 characters (MIME)</span>
</label>
</div>
{/* Error Notification */}
{error && (
<div className="flex items-start gap-3 p-4 bg-red-50 dark:bg-red-950/40 border border-red-200 dark:border-red-800 rounded-lg text-red-800 dark:text-red-300 text-sm">
<svg className="w-5 h-5 text-red-500 shrink-0 mt-0.5" 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">Decoding Error</p>
<p className="mt-0.5">{error}</p>
</div>
</div>
)}
{/* Two-Column Editor Grid */}
<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="font-medium text-sm text-gray-700 dark:text-gray-300">
Input String
</label>
<span className="text-xs text-gray-500 dark:text-gray-400 font-mono">
{input.length} chars | {inputBytes} bytes
</span>
</div>
<textarea
id={inputId}
value={input}
onChange={(e) => {
setInput(e.target.value);
if (error) setError(null);
}}
placeholder="Enter or paste text to encode, or Base64 string to decode..."
rows={12}
className="w-full p-3 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 font-mono text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none resize-y placeholder:text-gray-400 dark:placeholder:text-gray-500 leading-relaxed shadow-inner"
/>
</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="font-medium text-sm text-gray-700 dark:text-gray-300">
Output Result
</label>
{lastAction && (
<span className="px-2 py-0.5 text-xs rounded-full font-medium bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300">
{lastAction === "encode" ? "Base64 Encoded" : "Decoded Text"}
</span>
)}
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-gray-500 dark:text-gray-400 font-mono">
{output.length} chars | {outputBytes} bytes
</span>
<button
type="button"
onClick={handleCopy}
disabled={!output}
className="px-2.5 py-1 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed text-gray-700 dark:text-gray-300 rounded text-xs font-medium transition-colors cursor-pointer flex items-center gap-1"
>
{copied ? (
<>
<svg className="w-3.5 h-3.5 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-green-600 dark:text-green-400 font-semibold">Copied!</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</span>
</>
)}
</button>
</div>
</div>
<textarea
id={outputId}
value={output}
readOnly
placeholder="Encoded or decoded output will appear here..."
rows={12}
className="w-full p-3 rounded-lg border border-gray-300 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/60 text-gray-900 dark:text-gray-100 font-mono text-sm focus:outline-none resize-y placeholder:text-gray-400 dark:placeholder:text-gray-500 leading-relaxed select-all"
/>
</div>
</div>
{/* Information & Feature Highlights */}
<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 bg-gray-50 dark:bg-gray-800/40 rounded-lg border border-gray-200 dark:border-gray-700/60">
<h4 className="font-semibold text-gray-800 dark:text-gray-200 mb-1">Full UTF-8 Unicode Support</h4>
<p>Handles international character sets, special symbols, and emojis accurately without Latin1 byte truncation errors.</p>
</div>
<div className="p-3 bg-gray-50 dark:bg-gray-800/40 rounded-lg border border-gray-200 dark:border-gray-700/60">
<h4 className="font-semibold text-gray-800 dark:text-gray-200 mb-1">100% Client-Side &amp; Private</h4>
<p>Your data never leaves your browser. All encoding and decoding operations execute locally in JavaScript.</p>
</div>
<div className="p-3 bg-gray-50 dark:bg-gray-800/40 rounded-lg border border-gray-200 dark:border-gray-700/60">
<h4 className="font-semibold text-gray-800 dark:text-gray-200 mb-1">URL-Safe &amp; MIME Formatting</h4>
<p>Convert standard Base64 characters into URL-friendly variants or wrap output lines for MIME/email standards.</p>
</div>
</div>
</div>
</ToolLayout>
);
}

View file

@ -0,0 +1,726 @@
"use client";
import React, { useState, useMemo } from "react";
import ToolLayout from "@/components/ToolLayout";
interface ShadowPreset {
name: string;
horizontal: number;
vertical: number;
blur: number;
spread: number;
color: string;
opacity: number;
inset: boolean;
}
const PRESETS: ShadowPreset[] = [
{
name: "Default Soft",
horizontal: 5,
vertical: 5,
blur: 10,
spread: 0,
color: "#000000",
opacity: 0.3,
inset: false,
},
{
name: "Subtle Card",
horizontal: 0,
vertical: 4,
blur: 6,
spread: -1,
color: "#000000",
opacity: 0.1,
inset: false,
},
{
name: "Floating Depth",
horizontal: 0,
vertical: 20,
blur: 25,
spread: -5,
color: "#000000",
opacity: 0.15,
inset: false,
},
{
name: "Hard Brutalism",
horizontal: 6,
vertical: 6,
blur: 0,
spread: 0,
color: "#000000",
opacity: 1,
inset: false,
},
{
name: "Blue Glow",
horizontal: 0,
vertical: 0,
blur: 25,
spread: 5,
color: "#3b82f6",
opacity: 0.5,
inset: false,
},
{
name: "Purple Halo",
horizontal: 0,
vertical: 12,
blur: 30,
spread: 2,
color: "#8b5cf6",
opacity: 0.35,
inset: false,
},
{
name: "Inset Well",
horizontal: 0,
vertical: 4,
blur: 8,
spread: 0,
color: "#000000",
opacity: 0.25,
inset: true,
},
{
name: "Emerald Light",
horizontal: 0,
vertical: 8,
blur: 20,
spread: -2,
color: "#10b981",
opacity: 0.4,
inset: false,
},
];
// Helper to convert hex to RGB
function hexToRgb(hex: string): { r: number; g: number; b: number } {
let cleanHex = hex.replace("#", "").trim();
if (cleanHex.length === 3) {
cleanHex = cleanHex
.split("")
.map((c) => c + c)
.join("");
}
const num = parseInt(cleanHex, 16);
if (isNaN(num) || cleanHex.length !== 6) {
return { r: 0, g: 0, b: 0 };
}
return {
r: (num >> 16) & 255,
g: (num >> 8) & 255,
b: num & 255,
};
}
export default function BoxShadowGeneratorPage() {
// Shadow state variables
const [horizontal, setHorizontal] = useState<number>(5);
const [vertical, setVertical] = useState<number>(5);
const [blur, setBlur] = useState<number>(10);
const [spread, setSpread] = useState<number>(0);
const [color, setColor] = useState<string>("#000000");
const [opacity, setOpacity] = useState<number>(0.3);
const [inset, setInset] = useState<boolean>(false);
// Box & Preview customization
const [boxColor, setBoxColor] = useState<string>("#ffffff");
const [borderRadius, setBorderRadius] = useState<number>(16);
const [previewBg, setPreviewBg] = useState<"light" | "dark" | "checker">("light");
const [includeVendorPrefixes, setIncludeVendorPrefixes] = useState<boolean>(false);
const [codeFormat, setCodeFormat] = useState<"standard" | "full" | "tailwind">("standard");
const [copied, setCopied] = useState<boolean>(false);
// Computed RGBA string
const rgbaColor = useMemo(() => {
const { r, g, b } = hexToRgb(color);
const roundedOpacity = Math.round(opacity * 100) / 100;
return `rgba(${r}, ${g}, ${b}, ${roundedOpacity})`;
}, [color, opacity]);
// Computed box-shadow value
const shadowValue = useMemo(() => {
const insetText = inset ? "inset " : "";
return `${insetText}${horizontal}px ${vertical}px ${blur}px ${spread}px ${rgbaColor}`;
}, [inset, horizontal, vertical, blur, spread, rgbaColor]);
// Generated CSS Code
const generatedCode = useMemo(() => {
if (codeFormat === "tailwind") {
const tailwindArbitrary = shadowValue.replace(/\s+/g, "_");
return `className="shadow-[${tailwindArbitrary}]"`;
}
let code = "";
if (includeVendorPrefixes) {
code += `-webkit-box-shadow: ${shadowValue};\n`;
code += `-moz-box-shadow: ${shadowValue};\n`;
}
code += `box-shadow: ${shadowValue};`;
if (codeFormat === "full") {
return `.my-custom-box {\n background-color: ${boxColor};\n border-radius: ${borderRadius}px;\n ${code.split("\n").join("\n ")}\n}`;
}
return code;
}, [shadowValue, includeVendorPrefixes, codeFormat, boxColor, borderRadius]);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(generatedCode);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
const textArea = document.createElement("textarea");
textArea.value = generatedCode;
document.body.appendChild(textArea);
textArea.select();
document.execCommand("copy");
document.body.removeChild(textArea);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
const applyPreset = (preset: ShadowPreset) => {
setHorizontal(preset.horizontal);
setVertical(preset.vertical);
setBlur(preset.blur);
setSpread(preset.spread);
setColor(preset.color);
setOpacity(preset.opacity);
setInset(preset.inset);
};
const resetDefaults = () => {
setHorizontal(5);
setVertical(5);
setBlur(10);
setSpread(0);
setColor("#000000");
setOpacity(0.3);
setInset(false);
setBoxColor("#ffffff");
setBorderRadius(16);
};
return (
<ToolLayout
title="CSS Box Shadow Generator"
description="Design and preview CSS box shadows visually with real-time sliders for offset, blur, spread, opacity, and inset effects."
>
<div className="space-y-8">
{/* Live Preview Area */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
Live Preview
</h2>
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-gray-500 dark:text-gray-400 hidden sm:inline">
Background:
</span>
<div className="flex items-center gap-1 bg-gray-100 dark:bg-gray-800 p-0.5 rounded-lg text-xs">
<button
type="button"
onClick={() => setPreviewBg("light")}
className={`px-2.5 py-1 rounded-md transition-colors ${
previewBg === "light"
? "bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-medium shadow-xs"
: "text-gray-600 dark:text-gray-400 hover:text-gray-900"
}`}
>
Light
</button>
<button
type="button"
onClick={() => setPreviewBg("dark")}
className={`px-2.5 py-1 rounded-md transition-colors ${
previewBg === "dark"
? "bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-medium shadow-xs"
: "text-gray-600 dark:text-gray-400 hover:text-gray-900"
}`}
>
Dark
</button>
<button
type="button"
onClick={() => setPreviewBg("checker")}
className={`px-2.5 py-1 rounded-md transition-colors ${
previewBg === "checker"
? "bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-medium shadow-xs"
: "text-gray-600 dark:text-gray-400 hover:text-gray-900"
}`}
>
Pattern
</button>
</div>
</div>
</div>
<div
className={`relative w-full h-72 sm:h-80 rounded-2xl border border-gray-200 dark:border-gray-700 flex items-center justify-center p-6 overflow-hidden transition-all ${
previewBg === "light"
? "bg-gray-100"
: previewBg === "dark"
? "bg-gray-950"
: "bg-[radial-gradient(#cbd5e1_1px,transparent_1px)] dark:bg-[radial-gradient(#334155_1px,transparent_1px)] [background-size:16px_16px] bg-gray-100 dark:bg-gray-900"
}`}
>
{/* The Shadow Card */}
<div
className="w-52 h-44 sm:w-64 sm:h-52 flex flex-col items-center justify-center p-6 text-center transition-all duration-150 select-none"
style={{
backgroundColor: boxColor,
borderRadius: `${borderRadius}px`,
boxShadow: shadowValue,
}}
>
<div className="w-10 h-10 rounded-full bg-blue-50 dark:bg-blue-900/30 flex items-center justify-center text-blue-600 dark:text-blue-400 mb-2">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
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>
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-800">
Shadow Preview
</h3>
<p className="text-[11px] font-mono text-gray-500 mt-1">
{horizontal}px {vertical}px {blur}px {spread}px
</p>
</div>
</div>
</div>
{/* Presets Gallery */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
Quick Presets
</h2>
<button
type="button"
onClick={resetDefaults}
className="text-xs text-blue-600 dark:text-blue-400 hover:underline font-medium cursor-pointer"
>
Reset to Default
</button>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-8 gap-2.5">
{PRESETS.map((preset) => {
const { r, g, b } = hexToRgb(preset.color);
const pRgba = `rgba(${r}, ${g}, ${b}, ${preset.opacity})`;
const pShadow = `${preset.inset ? "inset " : ""}${preset.horizontal}px ${preset.vertical}px ${preset.blur}px ${preset.spread}px ${pRgba}`;
return (
<button
key={preset.name}
type="button"
onClick={() => applyPreset(preset)}
className="group flex flex-col items-center justify-center p-3 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:border-blue-500 dark:hover:border-blue-500 transition-all hover:scale-[1.03]"
>
<div
className="w-10 h-8 rounded-md bg-white border border-gray-100 transition-all mb-2"
style={{ boxShadow: pShadow }}
/>
<span className="text-[11px] font-medium text-gray-700 dark:text-gray-300 text-center w-full truncate">
{preset.name}
</span>
</button>
);
})}
</div>
</div>
{/* Shadow Control Sliders Grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Controls Column 1: Dimensions & Geometry */}
<div className="space-y-5 p-5 rounded-xl border border-gray-200 dark:border-gray-700/80 bg-gray-50/50 dark:bg-gray-800/50">
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100 flex items-center gap-2">
<svg className="w-4 h-4 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16m-7 6h7" />
</svg>
Shadow Geometry
</h3>
{/* Horizontal Offset */}
<div className="space-y-1.5">
<div className="flex justify-between items-center">
<label className="text-xs font-medium text-gray-700 dark:text-gray-300">
Horizontal Offset (X)
</label>
<div className="flex items-center gap-1">
<input
type="number"
min="-50"
max="50"
value={horizontal}
onChange={(e) => setHorizontal(Number(e.target.value))}
className="w-16 px-1.5 py-0.5 text-xs font-mono text-right rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
<span className="text-xs text-gray-500 dark:text-gray-400 font-mono">px</span>
</div>
</div>
<input
type="range"
min="-50"
max="50"
value={horizontal}
onChange={(e) => setHorizontal(Number(e.target.value))}
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700 accent-blue-600"
/>
<div className="flex justify-between text-[10px] text-gray-400 font-mono">
<span>-50px</span>
<span>0px</span>
<span>50px</span>
</div>
</div>
{/* Vertical Offset */}
<div className="space-y-1.5">
<div className="flex justify-between items-center">
<label className="text-xs font-medium text-gray-700 dark:text-gray-300">
Vertical Offset (Y)
</label>
<div className="flex items-center gap-1">
<input
type="number"
min="-50"
max="50"
value={vertical}
onChange={(e) => setVertical(Number(e.target.value))}
className="w-16 px-1.5 py-0.5 text-xs font-mono text-right rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
<span className="text-xs text-gray-500 dark:text-gray-400 font-mono">px</span>
</div>
</div>
<input
type="range"
min="-50"
max="50"
value={vertical}
onChange={(e) => setVertical(Number(e.target.value))}
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700 accent-blue-600"
/>
<div className="flex justify-between text-[10px] text-gray-400 font-mono">
<span>-50px</span>
<span>0px</span>
<span>50px</span>
</div>
</div>
{/* Blur Radius */}
<div className="space-y-1.5">
<div className="flex justify-between items-center">
<label className="text-xs font-medium text-gray-700 dark:text-gray-300">
Blur Radius
</label>
<div className="flex items-center gap-1">
<input
type="number"
min="0"
max="100"
value={blur}
onChange={(e) => setBlur(Math.max(0, Number(e.target.value)))}
className="w-16 px-1.5 py-0.5 text-xs font-mono text-right rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
<span className="text-xs text-gray-500 dark:text-gray-400 font-mono">px</span>
</div>
</div>
<input
type="range"
min="0"
max="100"
value={blur}
onChange={(e) => setBlur(Number(e.target.value))}
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700 accent-blue-600"
/>
<div className="flex justify-between text-[10px] text-gray-400 font-mono">
<span>0px</span>
<span>50px</span>
<span>100px</span>
</div>
</div>
{/* Spread Radius */}
<div className="space-y-1.5">
<div className="flex justify-between items-center">
<label className="text-xs font-medium text-gray-700 dark:text-gray-300">
Spread Radius
</label>
<div className="flex items-center gap-1">
<input
type="number"
min="-50"
max="50"
value={spread}
onChange={(e) => setSpread(Number(e.target.value))}
className="w-16 px-1.5 py-0.5 text-xs font-mono text-right rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
<span className="text-xs text-gray-500 dark:text-gray-400 font-mono">px</span>
</div>
</div>
<input
type="range"
min="-50"
max="50"
value={spread}
onChange={(e) => setSpread(Number(e.target.value))}
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700 accent-blue-600"
/>
<div className="flex justify-between text-[10px] text-gray-400 font-mono">
<span>-50px</span>
<span>0px</span>
<span>50px</span>
</div>
</div>
</div>
{/* Controls Column 2: Color, Opacity & Box Appearance */}
<div className="space-y-5 p-5 rounded-xl border border-gray-200 dark:border-gray-700/80 bg-gray-50/50 dark:bg-gray-800/50">
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100 flex items-center gap-2">
<svg className="w-4 h-4 text-purple-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01"
/>
</svg>
Shadow Color & Box Styling
</h3>
{/* Shadow Color & Inset Toggle */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="text-xs font-medium text-gray-700 dark:text-gray-300">
Shadow Color
</label>
<div className="flex items-center gap-2">
<div className="relative flex-shrink-0 w-9 h-9 rounded-lg border border-gray-300 dark:border-gray-600 overflow-hidden shadow-inner cursor-pointer">
<input
type="color"
value={color}
onChange={(e) => setColor(e.target.value)}
className="absolute -top-2 -left-2 w-14 h-14 cursor-pointer border-0"
/>
</div>
<input
type="text"
value={color}
onChange={(e) => setColor(e.target.value)}
className="w-full px-2.5 py-1.5 text-xs font-mono rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:outline-none uppercase"
/>
</div>
</div>
{/* Inset Toggle Checkbox */}
<div className="flex flex-col justify-center space-y-1.5 pt-1">
<label className="text-xs font-medium text-gray-700 dark:text-gray-300">
Shadow Type
</label>
<label className="flex items-center gap-2.5 p-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 cursor-pointer hover:border-blue-500 transition-colors">
<input
type="checkbox"
checked={inset}
onChange={(e) => setInset(e.target.checked)}
className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
/>
<span className="text-xs font-medium text-gray-800 dark:text-gray-200">
Inset Shadow (Inner)
</span>
</label>
</div>
</div>
{/* Opacity Slider */}
<div className="space-y-1.5">
<div className="flex justify-between items-center">
<label className="text-xs font-medium text-gray-700 dark:text-gray-300">
Shadow Opacity
</label>
<div className="flex items-center gap-1">
<input
type="number"
min="0"
max="1"
step="0.01"
value={opacity}
onChange={(e) => {
const val = Math.max(0, Math.min(1, Number(e.target.value)));
setOpacity(isNaN(val) ? 0 : val);
}}
className="w-16 px-1.5 py-0.5 text-xs font-mono text-right rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
<span className="text-xs text-gray-500 dark:text-gray-400 font-mono">
({Math.round(opacity * 100)}%)
</span>
</div>
</div>
<input
type="range"
min="0"
max="1"
step="0.01"
value={opacity}
onChange={(e) => setOpacity(Number(e.target.value))}
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700 accent-blue-600"
/>
<div className="flex justify-between text-[10px] text-gray-400 font-mono">
<span>0 (Transparent)</span>
<span>0.5</span>
<span>1.0 (Opaque)</span>
</div>
</div>
{/* Box Appearance: Box Background & Border Radius */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 pt-2 border-t border-gray-200 dark:border-gray-700">
{/* Box Color */}
<div className="space-y-1.5">
<label className="text-xs font-medium text-gray-700 dark:text-gray-300">
Box Background Color
</label>
<div className="flex items-center gap-2">
<div className="relative flex-shrink-0 w-8 h-8 rounded-lg border border-gray-300 dark:border-gray-600 overflow-hidden shadow-inner cursor-pointer">
<input
type="color"
value={boxColor}
onChange={(e) => setBoxColor(e.target.value)}
className="absolute -top-2 -left-2 w-12 h-12 cursor-pointer border-0"
/>
</div>
<input
type="text"
value={boxColor}
onChange={(e) => setBoxColor(e.target.value)}
className="w-full px-2 py-1 text-xs font-mono rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:outline-none uppercase"
/>
</div>
</div>
{/* Border Radius */}
<div className="space-y-1.5">
<div className="flex justify-between items-center">
<label className="text-xs font-medium text-gray-700 dark:text-gray-300">
Border Radius
</label>
<span className="text-xs font-mono text-gray-500 dark:text-gray-400">
{borderRadius}px
</span>
</div>
<input
type="range"
min="0"
max="50"
value={borderRadius}
onChange={(e) => setBorderRadius(Number(e.target.value))}
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700 accent-blue-600"
/>
</div>
</div>
</div>
</div>
{/* Generated CSS Code Block */}
<div className="space-y-3 pt-4 border-t border-gray-200 dark:border-gray-800">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-3">
<h2 className="text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
Generated CSS
</h2>
<div className="flex items-center gap-1 bg-gray-100 dark:bg-gray-800 p-0.5 rounded-lg text-xs">
<button
type="button"
onClick={() => setCodeFormat("standard")}
className={`px-2.5 py-1 rounded-md transition-colors ${
codeFormat === "standard"
? "bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-medium shadow-xs"
: "text-gray-600 dark:text-gray-400 hover:text-gray-900"
}`}
>
Standard
</button>
<button
type="button"
onClick={() => setCodeFormat("full")}
className={`px-2.5 py-1 rounded-md transition-colors ${
codeFormat === "full"
? "bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-medium shadow-xs"
: "text-gray-600 dark:text-gray-400 hover:text-gray-900"
}`}
>
Full CSS Class
</button>
<button
type="button"
onClick={() => setCodeFormat("tailwind")}
className={`px-2.5 py-1 rounded-md transition-colors ${
codeFormat === "tailwind"
? "bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-medium shadow-xs"
: "text-gray-600 dark:text-gray-400 hover:text-gray-900"
}`}
>
Tailwind
</button>
</div>
{codeFormat !== "tailwind" && (
<label className="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-400 cursor-pointer ml-2">
<input
type="checkbox"
checked={includeVendorPrefixes}
onChange={(e) => setIncludeVendorPrefixes(e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
/>
Vendor prefixes (-webkit, -moz)
</label>
)}
</div>
<button
type="button"
onClick={handleCopy}
className="inline-flex items-center justify-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium text-sm transition-colors shadow-sm cursor-pointer"
>
{copied ? (
<>
<svg className="w-4 h-4 text-emerald-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
Copied!
</>
) : (
<>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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 CSS
</>
)}
</button>
</div>
<div className="relative group">
<pre className="w-full p-4 rounded-xl border border-gray-200 dark:border-gray-700 bg-gray-900 text-gray-100 font-mono text-sm overflow-x-auto leading-relaxed shadow-sm">
<code>{generatedCode}</code>
</pre>
</div>
</div>
</div>
</ToolLayout>
);
}

View file

@ -0,0 +1,508 @@
"use client";
import { useState, useId, useCallback, useEffect } from "react";
import ToolLayout from "@/components/ToolLayout";
// Helper to extract words from various formats (camelCase, PascalCase, snake_case, kebab-case, normal text)
function getWords(str: string): string[] {
if (!str) return [];
return (
str
// Insert space before capital letters in camelCase / PascalCase
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
// Insert space between consecutive uppercase letters followed by lowercase (e.g. "JSONParser" -> "JSON Parser")
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
// Replace punctuation, underscores, hyphens, slashes, dots with space
.replace(/[-_./\\:[\](){}<>]+/g, " ")
.trim()
.split(/\s+/)
.filter(Boolean)
);
}
// Case conversion algorithms
const CONVERTERS: Record<
string,
{ name: string; description: string; example: string; fn: (text: string) => string }
> = {
uppercase: {
name: "UPPERCASE",
description: "Convert all characters to uppercase",
example: "HELLO WORLD",
fn: (text) => text.toUpperCase(),
},
lowercase: {
name: "lowercase",
description: "Convert all characters to lowercase",
example: "hello world",
fn: (text) => text.toLowerCase(),
},
titleCase: {
name: "Title Case",
description: "Capitalize the first letter of every word",
example: "Hello World",
fn: (text) =>
text
.toLowerCase()
.replace(/(^|\s|[-_./])([a-z0-9])/g, (_, boundary, char) => boundary + char.toUpperCase()),
},
sentenceCase: {
name: "Sentence case",
description: "Capitalize the first letter of each sentence",
example: "Hello world. This is a sentence.",
fn: (text) =>
text
.toLowerCase()
.replace(/(^\s*|\.\s*|\?\s*|!\s*|\n\s*)([a-z])/g, (_, p1, p2) => p1 + p2.toUpperCase()),
},
camelCase: {
name: "camelCase",
description: "First word lowercase, subsequent words capitalized without spaces",
example: "helloWorld",
fn: (text) => {
const lines = text.split("\n");
return lines
.map((line) => {
const words = getWords(line);
if (words.length === 0) return "";
return (
words[0].toLowerCase() +
words
.slice(1)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
.join("")
);
})
.join("\n");
},
},
pascalCase: {
name: "PascalCase",
description: "Capitalize every word without spaces",
example: "HelloWorld",
fn: (text) => {
const lines = text.split("\n");
return lines
.map((line) => {
const words = getWords(line);
return words
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
.join("");
})
.join("\n");
},
},
snakeCase: {
name: "snake_case",
description: "Lowercase words separated by underscores",
example: "hello_world",
fn: (text) => {
const lines = text.split("\n");
return lines
.map((line) => {
const words = getWords(line);
return words.map((w) => w.toLowerCase()).join("_");
})
.join("\n");
},
},
kebabCase: {
name: "kebab-case",
description: "Lowercase words separated by hyphens",
example: "hello-world",
fn: (text) => {
const lines = text.split("\n");
return lines
.map((line) => {
const words = getWords(line);
return words.map((w) => w.toLowerCase()).join("-");
})
.join("\n");
},
},
constantCase: {
name: "CONSTANT_CASE",
description: "Uppercase words separated by underscores",
example: "HELLO_WORLD",
fn: (text) => {
const lines = text.split("\n");
return lines
.map((line) => {
const words = getWords(line);
return words.map((w) => w.toUpperCase()).join("_");
})
.join("\n");
},
},
dotCase: {
name: "dot.case",
description: "Lowercase words separated by dots",
example: "hello.world",
fn: (text) => {
const lines = text.split("\n");
return lines
.map((line) => {
const words = getWords(line);
return words.map((w) => w.toLowerCase()).join(".");
})
.join("\n");
},
},
headerCase: {
name: "Header-Case",
description: "Capitalized words separated by hyphens (Train-Case)",
example: "Hello-World",
fn: (text) => {
const lines = text.split("\n");
return lines
.map((line) => {
const words = getWords(line);
return words
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
.join("-");
})
.join("\n");
},
},
pathCase: {
name: "path/case",
description: "Lowercase words separated by forward slashes",
example: "hello/world",
fn: (text) => {
const lines = text.split("\n");
return lines
.map((line) => {
const words = getWords(line);
return words.map((w) => w.toLowerCase()).join("/");
})
.join("\n");
},
},
alternatingCase: {
name: "aLtErNaTiNg cAsE",
description: "Alternate between lowercase and uppercase characters",
example: "hElLo wOrLd",
fn: (text) => {
let isUpper = false;
return text
.split("")
.map((char) => {
if (/[a-zA-Z]/.test(char)) {
const res = isUpper ? char.toUpperCase() : char.toLowerCase();
isUpper = !isUpper;
return res;
}
return char;
})
.join("");
},
},
inverseCase: {
name: "InVeRsE cAsE",
description: "Invert case of each individual character",
example: "hELLO wORLD",
fn: (text) =>
text
.split("")
.map((c) =>
c === c.toUpperCase() ? c.toLowerCase() : c.toUpperCase()
)
.join(""),
},
};
const SAMPLE_TEXT =
"the quick brown fox jumps over the lazy dog. developer tools make life easier!";
export default function CaseConverterPage() {
const inputId = useId();
const outputId = useId();
const [input, setInput] = useState(SAMPLE_TEXT);
const [output, setOutput] = useState("");
const [activeCaseKey, setActiveCaseKey] = useState<string>("titleCase");
const [copied, setCopied] = useState(false);
const [previewCopiedKey, setPreviewCopiedKey] = useState<string | null>(null);
// Auto-convert when input or activeCaseKey changes
const applyConversion = useCallback(
(key: string, textToConvert: string) => {
const converter = CONVERTERS[key];
if (converter) {
setOutput(converter.fn(textToConvert));
}
},
[]
);
useEffect(() => {
applyConversion(activeCaseKey, input);
}, [input, activeCaseKey, applyConversion]);
const handleCaseButtonClick = (key: string) => {
setActiveCaseKey(key);
applyConversion(key, input);
};
const handleCopy = async () => {
if (!output) return;
try {
await navigator.clipboard.writeText(output);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Fallback
}
};
const handleCopyPreview = async (val: string, key: string) => {
try {
await navigator.clipboard.writeText(val);
setPreviewCopiedKey(key);
setTimeout(() => setPreviewCopiedKey(null), 2000);
} catch {
// Fallback
}
};
const handleSwap = () => {
const temp = output;
setInput(temp);
setOutput(input);
};
const handleClear = () => {
setInput("");
setOutput("");
};
const handleLoadSample = () => {
setInput(SAMPLE_TEXT);
};
return (
<ToolLayout
title="Case Converter"
description="Convert text between UPPERCASE, lowercase, Title Case, Sentence case, camelCase, PascalCase, snake_case, kebab-case, CONSTANT_CASE, and more."
>
<title>Case Converter Online — DevToolbox</title>
<meta
name="description"
content="Free online text case converter. Convert between UPPERCASE, lowercase, camelCase, PascalCase, snake_case, kebab-case, CONSTANT_CASE, and dot.case."
/>
<div className="space-y-6">
{/* Top Actions Bar */}
<div className="flex flex-wrap items-center justify-between gap-3 pb-2 border-b border-gray-200 dark:border-gray-700">
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={handleLoadSample}
className="px-3 py-1.5 bg-blue-50 dark:bg-blue-950/40 hover:bg-blue-100 dark:hover:bg-blue-900/50 text-blue-700 dark:text-blue-300 rounded-lg text-xs font-semibold transition-colors cursor-pointer"
>
Load Sample
</button>
<button
type="button"
onClick={handleSwap}
disabled={!output && !input}
className="px-3 py-1.5 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-40 text-gray-700 dark:text-gray-200 rounded-lg text-xs font-semibold transition-colors cursor-pointer flex items-center gap-1"
>
<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 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
</svg>
Swap
</button>
<button
type="button"
onClick={handleClear}
disabled={!input && !output}
className="px-3 py-1.5 bg-gray-100 dark:bg-gray-800 hover:bg-rose-50 dark:hover:bg-rose-950/30 disabled:opacity-40 text-gray-700 dark:text-gray-300 hover:text-rose-600 dark:hover:text-rose-400 rounded-lg text-xs font-semibold transition-colors cursor-pointer"
>
Clear
</button>
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
Click any case button below to transform immediately
</div>
</div>
{/* Case Transformation Buttons Grid */}
<div className="space-y-2">
<label className="text-xs font-bold uppercase tracking-wider text-gray-600 dark:text-gray-400">
Choose Transformation Case
</label>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-2">
{Object.entries(CONVERTERS).map(([key, conv]) => {
const isActive = activeCaseKey === key;
return (
<button
key={key}
type="button"
onClick={() => handleCaseButtonClick(key)}
className={`p-2.5 rounded-lg border text-left transition-all cursor-pointer shadow-xs ${
isActive
? "bg-blue-600 border-blue-600 text-white ring-2 ring-blue-500/40"
: "bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-gray-900 dark:text-gray-100 hover:bg-blue-50/70 dark:hover:bg-blue-950/30 hover:border-blue-300 dark:hover:border-blue-700"
}`}
>
<div className="font-semibold text-xs truncate">{conv.name}</div>
<div
className={`text-[10px] font-mono mt-0.5 truncate ${
isActive
? "text-blue-100"
: "text-gray-500 dark:text-gray-400"
}`}
>
{conv.example}
</div>
</button>
);
})}
</div>
</div>
{/* Input & Output Two-Column Grid */}
<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
</label>
<span className="text-xs text-gray-500 dark:text-gray-400 font-mono">
{input.length} chars | {input.trim() ? input.trim().split(/\s+/).length : 0} words
</span>
</div>
<textarea
id={inputId}
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Enter or paste text to convert..."
rows={10}
className="w-full p-3.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 font-mono text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none resize-y leading-relaxed shadow-inner"
/>
</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"
>
Converted Output
</label>
<span className="px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800 dark:bg-blue-950 dark:text-blue-300">
{CONVERTERS[activeCaseKey]?.name || "Result"}
</span>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-gray-500 dark:text-gray-400 font-mono">
{output.length} chars
</span>
<button
type="button"
onClick={handleCopy}
disabled={!output}
className="px-2.5 py-1 bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-40 disabled:cursor-not-allowed rounded text-xs font-medium transition-colors cursor-pointer flex items-center gap-1"
>
{copied ? (
<>
<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!</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</span>
</>
)}
</button>
</div>
</div>
<textarea
id={outputId}
value={output}
readOnly
placeholder="Converted result will appear here..."
rows={10}
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 leading-relaxed select-all"
/>
</div>
</div>
{/* All Cases Live Preview Cards */}
<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">
Live Preview of All Cases
</h3>
<span className="text-xs text-gray-500 dark:text-gray-400">
Click &quot;Apply&quot; or &quot;Copy&quot; on any case
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{Object.entries(CONVERTERS).map(([key, conv]) => {
const convertedVal = input ? conv.fn(input) : conv.example;
const isSelected = activeCaseKey === key;
return (
<div
key={key}
className={`p-3 rounded-lg border transition-all ${
isSelected
? "border-blue-500 bg-blue-50/40 dark:border-blue-600 dark:bg-blue-950/20"
: "border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-850"
}`}
>
<div className="flex items-center justify-between mb-1.5">
<div className="flex items-center gap-2">
<span className="font-semibold text-xs text-gray-900 dark:text-gray-100">
{conv.name}
</span>
{isSelected && (
<span className="text-[10px] px-1.5 py-0.2 rounded font-bold bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-200">
Active
</span>
)}
</div>
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={() => handleCaseButtonClick(key)}
className="px-2 py-0.5 text-xs text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-950/40 rounded transition-colors cursor-pointer font-medium"
>
Apply
</button>
<button
type="button"
onClick={() => handleCopyPreview(convertedVal, key)}
className="px-2 py-0.5 text-xs text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors cursor-pointer"
>
{previewCopiedKey === key ? "Copied!" : "Copy"}
</button>
</div>
</div>
<div className="font-mono text-xs text-gray-800 dark:text-gray-200 p-2 rounded bg-gray-50 dark:bg-gray-900/60 truncate">
{convertedVal || <span className="text-gray-400 italic">Empty</span>}
</div>
</div>
);
})}
</div>
</div>
</div>
</ToolLayout>
);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,723 @@
"use client";
import React, { useState, useMemo } from "react";
import ToolLayout from "@/components/ToolLayout";
interface ColorStop {
id: string;
color: string;
stop: number;
}
type GradientType = "linear" | "radial";
interface Preset {
name: string;
type: GradientType;
angle?: number;
shape?: "circle" | "ellipse";
position?: string;
stops: { color: string; stop: number }[];
}
const PRESETS: Preset[] = [
{
name: "Ocean Blue",
type: "linear",
angle: 135,
stops: [
{ color: "#2563eb", stop: 0 },
{ color: "#06b6d4", stop: 100 },
],
},
{
name: "Sunset Glow",
type: "linear",
angle: 135,
stops: [
{ color: "#f97316", stop: 0 },
{ color: "#ec4899", stop: 100 },
],
},
{
name: "Purple Haze",
type: "linear",
angle: 135,
stops: [
{ color: "#6366f1", stop: 0 },
{ color: "#a855f7", stop: 50 },
{ color: "#ec4899", stop: 100 },
],
},
{
name: "Emerald Forest",
type: "linear",
angle: 135,
stops: [
{ color: "#059669", stop: 0 },
{ color: "#10b981", stop: 50 },
{ color: "#6ee7b7", stop: 100 },
],
},
{
name: "Neon Cyber",
type: "linear",
angle: 90,
stops: [
{ color: "#00f2fe", stop: 0 },
{ color: "#4facfe", stop: 100 },
],
},
{
name: "Flame",
type: "linear",
angle: 45,
stops: [
{ color: "#f12711", stop: 0 },
{ color: "#f5af19", stop: 100 },
],
},
{
name: "Radial Glow",
type: "radial",
shape: "circle",
position: "center",
stops: [
{ color: "#3b82f6", stop: 0 },
{ color: "#1e1b4b", stop: 100 },
],
},
{
name: "Midnight Aura",
type: "radial",
shape: "circle",
position: "center",
stops: [
{ color: "#8b5cf6", stop: 0 },
{ color: "#0f172a", stop: 100 },
],
},
];
const LINEAR_DIRECTIONS = [
{ label: "→ Right", angle: 90, cssDir: "to right" },
{ label: "← Left", angle: 270, cssDir: "to left" },
{ label: "↓ Bottom", angle: 180, cssDir: "to bottom" },
{ label: "↑ Top", angle: 0, cssDir: "to top" },
{ label: "↗ Top Right", angle: 45, cssDir: "to top right" },
{ label: "↘ Bottom Right", angle: 135, cssDir: "to bottom right" },
{ label: "↙ Bottom Left", angle: 225, cssDir: "to bottom left" },
{ label: "↖ Top Left", angle: 315, cssDir: "to top left" },
];
const RADIAL_POSITIONS = [
{ label: "Center", value: "center" },
{ label: "Top", value: "top center" },
{ label: "Bottom", value: "bottom center" },
{ label: "Left", value: "center left" },
{ label: "Right", value: "center right" },
{ label: "Top Left", value: "top left" },
{ label: "Top Right", value: "top right" },
{ label: "Bottom Left", value: "bottom left" },
{ label: "Bottom Right", value: "bottom right" },
];
export default function CssGradientGeneratorPage() {
const [gradientType, setGradientType] = useState<GradientType>("linear");
const [angle, setAngle] = useState<number>(135);
const [radialShape, setRadialShape] = useState<"circle" | "ellipse">("circle");
const [radialPosition, setRadialPosition] = useState<string>("center");
const [colorStops, setColorStops] = useState<ColorStop[]>([
{ id: "1", color: "#3b82f6", stop: 0 },
{ id: "2", color: "#8b5cf6", stop: 100 },
]);
const [copied, setCopied] = useState<boolean>(false);
const [previewText, setPreviewText] = useState<boolean>(true);
const [codeFormat, setCodeFormat] = useState<"standard" | "full" | "tailwind">("standard");
// Sort stops for rendering CSS
const sortedStops = useMemo(() => {
return [...colorStops].sort((a, b) => a.stop - b.stop);
}, [colorStops]);
// Construct CSS gradient value
const gradientValue = useMemo(() => {
const stopsString = sortedStops
.map((s) => `${s.color} ${s.stop}%`)
.join(", ");
if (gradientType === "linear") {
return `linear-gradient(${angle}deg, ${stopsString})`;
} else {
return `radial-gradient(${radialShape} at ${radialPosition}, ${stopsString})`;
}
}, [gradientType, angle, radialShape, radialPosition, sortedStops]);
// Output CSS code block
const generatedCode = useMemo(() => {
const fallbackColor = sortedStops[0]?.color || "#3b82f6";
if (codeFormat === "tailwind") {
return `className="bg-[${gradientValue}]"`;
}
if (codeFormat === "full") {
return `/* Fallback for older browsers */\nbackground-color: ${fallbackColor};\n\n/* Modern CSS Gradient */\nbackground: ${gradientValue};`;
}
return `background: ${gradientValue};`;
}, [gradientValue, sortedStops, codeFormat]);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(generatedCode);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Fallback
const textArea = document.createElement("textarea");
textArea.value = generatedCode;
document.body.appendChild(textArea);
textArea.select();
document.execCommand("copy");
document.body.removeChild(textArea);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
const updateStopColor = (id: string, newColor: string) => {
setColorStops((prev) =>
prev.map((stop) => (stop.id === id ? { ...stop, color: newColor } : stop))
);
};
const updateStopPosition = (id: string, newStop: number) => {
const clamped = Math.max(0, Math.min(100, isNaN(newStop) ? 0 : newStop));
setColorStops((prev) =>
prev.map((stop) => (stop.id === id ? { ...stop, stop: clamped } : stop))
);
};
const addColorStop = () => {
if (colorStops.length >= 6) return;
// Pick a midpoint or random color
const newId = Date.now().toString();
if (colorStops.length === 2) {
// Add a nice middle color
const middleColor = "#ec4899";
setColorStops([
colorStops[0],
{ id: newId, color: middleColor, stop: 50 },
colorStops[1],
]);
} else {
const lastStop = colorStops[colorStops.length - 1];
const secondLast = colorStops[colorStops.length - 2];
const midPosition = Math.round((lastStop.stop + (secondLast?.stop ?? 0)) / 2);
setColorStops((prev) => [
...prev,
{ id: newId, color: "#10b981", stop: midPosition },
]);
}
};
const removeColorStop = (id: string) => {
if (colorStops.length <= 2) return;
setColorStops((prev) => prev.filter((stop) => stop.id !== id));
};
const reverseColors = () => {
setColorStops((prev) => {
const reversed = [...prev].reverse();
return reversed.map((stop, idx) => ({
...stop,
stop: prev[idx].stop,
}));
});
};
const generateRandomGradient = () => {
const randomHex = () =>
"#" +
Math.floor(Math.random() * 16777215)
.toString(16)
.padStart(6, "0");
const count = colorStops.length;
const newStops = colorStops.map((stop, idx) => ({
...stop,
color: randomHex(),
stop: Math.round((idx / (count - 1)) * 100),
}));
setColorStops(newStops);
if (gradientType === "linear") {
setAngle(Math.floor(Math.random() * 360));
}
};
const applyPreset = (preset: Preset) => {
setGradientType(preset.type);
if (preset.angle !== undefined) setAngle(preset.angle);
if (preset.shape) setRadialShape(preset.shape);
if (preset.position) setRadialPosition(preset.position);
setColorStops(
preset.stops.map((s, idx) => ({
id: (idx + 1).toString(),
color: s.color,
stop: s.stop,
}))
);
};
return (
<ToolLayout
title="CSS Gradient Generator"
description="Create beautiful linear and radial CSS gradients visually, customize color stops, adjust angles, and copy clean CSS code instantly."
>
<div className="space-y-8">
{/* Live Preview Area */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
Live Preview
</h2>
<div className="flex items-center gap-3">
<label className="flex items-center gap-2 text-xs font-medium text-gray-600 dark:text-gray-300 cursor-pointer">
<input
type="checkbox"
checked={previewText}
onChange={(e) => setPreviewText(e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
/>
Show Text Overlay
</label>
<button
type="button"
onClick={generateRandomGradient}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors"
title="Generate Random Gradient"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
Randomize
</button>
</div>
</div>
<div
className="relative w-full h-64 sm:h-72 rounded-2xl border border-gray-200 dark:border-gray-700 shadow-inner flex items-center justify-center p-6 transition-all duration-300 overflow-hidden"
style={{ background: gradientValue }}
>
{previewText && (
<div className="bg-black/30 backdrop-blur-md text-white rounded-xl p-5 max-w-md text-center shadow-lg border border-white/10 transition-all">
<h3 className="text-xl sm:text-2xl font-bold tracking-tight mb-1 drop-shadow-sm">
CSS Gradient Preview
</h3>
<p className="text-xs sm:text-sm text-gray-100/90 font-mono">
{gradientType === "linear" ? `${angle}° Linear` : `Radial ${radialShape}`}
</p>
</div>
)}
</div>
</div>
{/* Presets Gallery */}
<div className="space-y-3">
<h2 className="text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
Quick Presets
</h2>
<div className="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-8 gap-2.5">
{PRESETS.map((preset) => {
const bgString =
preset.type === "linear"
? `linear-gradient(${preset.angle}deg, ${preset.stops.map((s) => `${s.color} ${s.stop}%`).join(", ")})`
: `radial-gradient(${preset.shape} at ${preset.position}, ${preset.stops.map((s) => `${s.color} ${s.stop}%`).join(", ")})`;
return (
<button
key={preset.name}
type="button"
onClick={() => applyPreset(preset)}
className="group relative flex flex-col items-center gap-1.5 p-1.5 rounded-xl border border-gray-200 dark:border-gray-700 hover:border-blue-500 dark:hover:border-blue-500 bg-white dark:bg-gray-800 transition-all hover:scale-[1.03] text-left"
>
<div
className="w-full h-12 rounded-lg shadow-sm"
style={{ background: bgString }}
/>
<span className="text-[11px] font-medium text-gray-700 dark:text-gray-300 truncate w-full text-center">
{preset.name}
</span>
</button>
);
})}
</div>
</div>
{/* Editor Controls Grid */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
{/* Left Column: Type & Direction Settings */}
<div className="lg:col-span-5 space-y-6">
{/* Gradient Type */}
<div className="space-y-2">
<label className="text-sm font-medium text-gray-900 dark:text-gray-100">
Gradient Type
</label>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => setGradientType("linear")}
className={`py-2.5 px-4 text-sm font-medium rounded-lg border transition-all ${
gradientType === "linear"
? "bg-blue-600 border-blue-600 text-white shadow-sm"
: "bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50"
}`}
>
Linear
</button>
<button
type="button"
onClick={() => setGradientType("radial")}
className={`py-2.5 px-4 text-sm font-medium rounded-lg border transition-all ${
gradientType === "radial"
? "bg-blue-600 border-blue-600 text-white shadow-sm"
: "bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50"
}`}
>
Radial
</button>
</div>
</div>
{/* Linear Direction Options */}
{gradientType === "linear" && (
<div className="space-y-4">
<div className="space-y-2">
<div className="flex justify-between items-center">
<label className="text-sm font-medium text-gray-900 dark:text-gray-100">
Angle: <span className="font-mono text-blue-600 dark:text-blue-400">{angle}°</span>
</label>
<input
type="number"
min="0"
max="360"
value={angle}
onChange={(e) => setAngle(Number(e.target.value) % 361)}
className="w-20 px-2 py-1 text-sm font-mono text-right rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
</div>
<input
type="range"
min="0"
max="360"
value={angle}
onChange={(e) => setAngle(Number(e.target.value))}
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700 accent-blue-600"
/>
</div>
{/* Quick Direction Selector */}
<div className="space-y-2">
<label className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
Quick Directions
</label>
<div className="grid grid-cols-4 gap-2">
{LINEAR_DIRECTIONS.map((dir) => (
<button
key={dir.angle}
type="button"
onClick={() => setAngle(dir.angle)}
className={`py-2 px-1.5 text-xs font-medium rounded-lg border text-center transition-all ${
angle === dir.angle
? "bg-blue-50 dark:bg-blue-900/30 border-blue-500 text-blue-700 dark:text-blue-300 font-semibold"
: "bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
}`}
>
{dir.label}
</button>
))}
</div>
</div>
</div>
)}
{/* Radial Options */}
{gradientType === "radial" && (
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium text-gray-900 dark:text-gray-100">
Radial Shape
</label>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => setRadialShape("circle")}
className={`py-2 px-3 text-sm font-medium rounded-lg border transition-all ${
radialShape === "circle"
? "bg-blue-600 border-blue-600 text-white"
: "bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
}`}
>
Circle
</button>
<button
type="button"
onClick={() => setRadialShape("ellipse")}
className={`py-2 px-3 text-sm font-medium rounded-lg border transition-all ${
radialShape === "ellipse"
? "bg-blue-600 border-blue-600 text-white"
: "bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
}`}
>
Ellipse
</button>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-gray-900 dark:text-gray-100">
Radial Position
</label>
<select
value={radialPosition}
onChange={(e) => setRadialPosition(e.target.value)}
className="w-full p-2.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
{RADIAL_POSITIONS.map((pos) => (
<option key={pos.value} value={pos.value}>
{pos.label}
</option>
))}
</select>
</div>
</div>
)}
</div>
{/* Right Column: Color Stops */}
<div className="lg:col-span-7 space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
Color Stops ({colorStops.length})
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400">
Customize start, middle, and end colors with position stops.
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={reverseColors}
className="px-2.5 py-1.5 text-xs font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors flex items-center gap-1"
title="Reverse Color Order"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4"
/>
</svg>
Swap
</button>
<button
type="button"
onClick={addColorStop}
disabled={colorStops.length >= 6}
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors flex items-center gap-1 ${
colorStops.length >= 6
? "bg-gray-200 text-gray-400 cursor-not-allowed dark:bg-gray-800 dark:text-gray-600"
: "bg-blue-600 hover:bg-blue-700 text-white shadow-sm"
}`}
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Add Stop
</button>
</div>
</div>
{/* Stops List */}
<div className="space-y-3">
{colorStops.map((stop, index) => {
const label =
index === 0
? "Start Color"
: index === colorStops.length - 1
? "End Color"
: `Stop ${index + 1} (Middle)`;
return (
<div
key={stop.id}
className="p-3.5 rounded-xl border border-gray-200 dark:border-gray-700/80 bg-gray-50/50 dark:bg-gray-800/50 space-y-2.5 transition-all"
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: stop.color }} />
<span className="text-xs font-semibold text-gray-700 dark:text-gray-300">
{label}
</span>
</div>
{colorStops.length > 2 && (
<button
type="button"
onClick={() => removeColorStop(stop.id)}
className="text-xs text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 transition-colors p-1 rounded"
title="Remove Stop"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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>
</button>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-12 gap-3 items-center">
{/* Color Picker and Hex Input */}
<div className="sm:col-span-5 flex items-center gap-2">
<div className="relative flex-shrink-0 w-9 h-9 rounded-lg border border-gray-300 dark:border-gray-600 overflow-hidden shadow-inner cursor-pointer">
<input
type="color"
value={stop.color}
onChange={(e) => updateStopColor(stop.id, e.target.value)}
className="absolute -top-2 -left-2 w-14 h-14 cursor-pointer border-0"
/>
</div>
<input
type="text"
value={stop.color}
onChange={(e) => updateStopColor(stop.id, e.target.value)}
className="w-full px-2.5 py-1.5 text-xs font-mono rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:outline-none uppercase"
placeholder="#000000"
/>
</div>
{/* Position Slider and Input */}
<div className="sm:col-span-7 flex items-center gap-2">
<input
type="range"
min="0"
max="100"
value={stop.stop}
onChange={(e) => updateStopPosition(stop.id, Number(e.target.value))}
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700 accent-blue-600"
/>
<div className="flex items-center gap-1">
<input
type="number"
min="0"
max="100"
value={stop.stop}
onChange={(e) => updateStopPosition(stop.id, Number(e.target.value))}
className="w-14 px-1.5 py-1 text-xs font-mono text-right rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
<span className="text-xs text-gray-500 dark:text-gray-400 font-mono">%</span>
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
{/* Generated CSS Output Block */}
<div className="space-y-3 pt-4 border-t border-gray-200 dark:border-gray-800">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div className="flex items-center gap-3">
<h2 className="text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
Generated CSS
</h2>
<div className="flex items-center gap-1 bg-gray-100 dark:bg-gray-800 p-0.5 rounded-lg text-xs">
<button
type="button"
onClick={() => setCodeFormat("standard")}
className={`px-2.5 py-1 rounded-md transition-colors ${
codeFormat === "standard"
? "bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-medium shadow-xs"
: "text-gray-600 dark:text-gray-400 hover:text-gray-900"
}`}
>
Standard
</button>
<button
type="button"
onClick={() => setCodeFormat("full")}
className={`px-2.5 py-1 rounded-md transition-colors ${
codeFormat === "full"
? "bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-medium shadow-xs"
: "text-gray-600 dark:text-gray-400 hover:text-gray-900"
}`}
>
Full CSS
</button>
<button
type="button"
onClick={() => setCodeFormat("tailwind")}
className={`px-2.5 py-1 rounded-md transition-colors ${
codeFormat === "tailwind"
? "bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-medium shadow-xs"
: "text-gray-600 dark:text-gray-400 hover:text-gray-900"
}`}
>
Tailwind
</button>
</div>
</div>
<button
type="button"
onClick={handleCopy}
className="inline-flex items-center justify-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium text-sm transition-colors shadow-sm cursor-pointer"
>
{copied ? (
<>
<svg className="w-4 h-4 text-emerald-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
Copied!
</>
) : (
<>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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 CSS
</>
)}
</button>
</div>
<div className="relative group">
<pre className="w-full p-4 rounded-xl border border-gray-200 dark:border-gray-700 bg-gray-900 text-gray-100 font-mono text-sm overflow-x-auto leading-relaxed shadow-sm">
<code>{generatedCode}</code>
</pre>
</div>
</div>
</div>
</ToolLayout>
);
}

View file

@ -0,0 +1,552 @@
"use client";
import { useState, useMemo, useCallback } from "react";
import ToolLayout from "@/components/ToolLayout";
type DiffType = "unchanged" | "added" | "removed";
interface DiffItem {
type: DiffType;
text: string;
origLine?: number;
modLine?: number;
}
const SAMPLE_ORIGINAL = `// User Authentication Service
function authenticateUser(username, password) {
if (!username || !password) {
return { success: false, error: "Missing credentials" };
}
const user = database.findUserByUsername(username);
if (!user) {
return { success: false, error: "User not found" };
}
// Legacy plain text check (insecure)
if (user.password === password) {
return { success: true, token: "legacy-session-token" };
}
return { success: false, error: "Invalid password" };
}`;
const SAMPLE_MODIFIED = `// User Authentication Service v2.0
import { verifyPasswordHash, generateJwtToken } from "@/lib/auth";
export async function authenticateUser(username, password) {
if (!username || !password) {
return { success: false, error: "Missing credentials" };
}
const user = await database.findUserByUsername(username);
if (!user || !user.isActive) {
return { success: false, error: "User not found or disabled" };
}
// Secure bcrypt password verification
const isValid = await verifyPasswordHash(password, user.passwordHash);
if (isValid) {
const token = generateJwtToken(user.id, user.role);
return { success: true, token, user: { id: user.id, name: user.name } };
}
return { success: false, error: "Invalid credentials" };
}`;
function computeLcsDiff(
originalText: string,
modifiedText: string,
ignoreWhitespace: boolean,
ignoreCase: boolean
): DiffItem[] {
const origLines = originalText ? originalText.split(/\r\n|\r|\n/) : [];
const modLines = modifiedText ? modifiedText.split(/\r\n|\r|\n/) : [];
const norm = (line: string) => {
let s = line;
if (ignoreWhitespace) s = s.trim().replace(/\s+/g, " ");
if (ignoreCase) s = s.toLowerCase();
return s;
};
const n = origLines.length;
const m = modLines.length;
if (n === 0 && m === 0) return [];
// 2D LCS Table
const dp: number[][] = Array.from({ length: n + 1 }, () =>
new Array(m + 1).fill(0)
);
for (let i = 1; i <= n; i++) {
const s1 = norm(origLines[i - 1]);
for (let j = 1; j <= m; j++) {
const s2 = norm(modLines[j - 1]);
if (s1 === s2) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
// Backtrack
let i = n;
let j = m;
const result: DiffItem[] = [];
while (i > 0 || j > 0) {
if (
i > 0 &&
j > 0 &&
norm(origLines[i - 1]) === norm(modLines[j - 1])
) {
result.push({
type: "unchanged",
text: origLines[i - 1],
origLine: i,
modLine: j,
});
i--;
j--;
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
result.push({
type: "added",
text: modLines[j - 1],
modLine: j,
});
j--;
} else if (i > 0 && (j === 0 || dp[i][j - 1] < dp[i - 1][j])) {
result.push({
type: "removed",
text: origLines[i - 1],
origLine: i,
});
i--;
}
}
return result.reverse();
}
export default function DiffCheckerPage() {
const [original, setOriginal] = useState(SAMPLE_ORIGINAL);
const [modified, setModified] = useState(SAMPLE_MODIFIED);
const [ignoreWhitespace, setIgnoreWhitespace] = useState(false);
const [ignoreCase, setIgnoreCase] = useState(false);
const [viewMode, setViewMode] = useState<"unified" | "split">("unified");
const [copiedPatch, setCopiedPatch] = useState(false);
const diffResult = useMemo(() => {
return computeLcsDiff(original, modified, ignoreWhitespace, ignoreCase);
}, [original, modified, ignoreWhitespace, ignoreCase]);
const stats = useMemo(() => {
let added = 0;
let removed = 0;
let unchanged = 0;
diffResult.forEach((d) => {
if (d.type === "added") added++;
else if (d.type === "removed") removed++;
else if (d.type === "unchanged") unchanged++;
});
const totalLines = added + removed + unchanged;
const similarity =
totalLines > 0
? Math.round((unchanged / (unchanged + Math.max(added, removed))) * 100)
: 100;
return { added, removed, unchanged, totalLines, similarity };
}, [diffResult]);
const handleSwap = () => {
const temp = original;
setOriginal(modified);
setModified(temp);
};
const handleClear = () => {
setOriginal("");
setModified("");
};
const handleLoadSample = () => {
setOriginal(SAMPLE_ORIGINAL);
setModified(SAMPLE_MODIFIED);
};
const handleCopyUnifiedDiff = useCallback(async () => {
if (diffResult.length === 0) return;
const patchHeader = `--- original\n+++ modified\n@@ -1,${stats.unchanged + stats.removed} +1,${stats.unchanged + stats.added} @@\n`;
const patchBody = diffResult
.map((d) => {
const prefix = d.type === "added" ? "+" : d.type === "removed" ? "-" : " ";
return `${prefix} ${d.text}`;
})
.join("\n");
try {
await navigator.clipboard.writeText(patchHeader + patchBody);
setCopiedPatch(true);
setTimeout(() => setCopiedPatch(false), 2000);
} catch {
// Fallback
}
}, [diffResult, stats]);
// Generate split view rows
const splitRows = useMemo(() => {
const rows: {
left?: { text: string; line?: number; type: "unchanged" | "removed" };
right?: { text: string; line?: number; type: "unchanged" | "added" };
}[] = [];
let i = 0;
while (i < diffResult.length) {
const item = diffResult[i];
if (item.type === "unchanged") {
rows.push({
left: { text: item.text, line: item.origLine, type: "unchanged" },
right: { text: item.text, line: item.modLine, type: "unchanged" },
});
i++;
} else if (item.type === "removed") {
// Check if immediately followed by an added line (replacement)
if (i + 1 < diffResult.length && diffResult[i + 1].type === "added") {
rows.push({
left: { text: item.text, line: item.origLine, type: "removed" },
right: {
text: diffResult[i + 1].text,
line: diffResult[i + 1].modLine,
type: "added",
},
});
i += 2;
} else {
rows.push({
left: { text: item.text, line: item.origLine, type: "removed" },
});
i++;
}
} else if (item.type === "added") {
rows.push({
right: { text: item.text, line: item.modLine, type: "added" },
});
i++;
}
}
return rows;
}, [diffResult]);
return (
<ToolLayout
title="Text Diff Checker"
description="Compare two text files or code snippets line by line. Highlight added, removed, and modified content in unified or split views."
>
<title>Text Diff Checker Online — DevToolbox</title>
<meta
name="description"
content="Free online text and code diff comparison tool. Compare two strings line-by-line with Longest Common Subsequence diffing, added/removed stats, and split view."
/>
<div className="space-y-6">
{/* Controls and Stats Bar */}
<div className="flex flex-wrap items-center justify-between gap-3 pb-3 border-b border-gray-200 dark:border-gray-700">
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={handleSwap}
className="px-3.5 py-1.5 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-200 rounded-lg text-xs font-semibold transition-colors cursor-pointer flex items-center gap-1.5"
>
<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 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
</svg>
Swap Sides
</button>
<button
type="button"
onClick={handleLoadSample}
className="px-3.5 py-1.5 bg-blue-50 dark:bg-blue-950/40 hover:bg-blue-100 dark:hover:bg-blue-900/50 text-blue-700 dark:text-blue-300 rounded-lg text-xs font-semibold transition-colors cursor-pointer"
>
Load Sample
</button>
<button
type="button"
onClick={handleClear}
disabled={!original && !modified}
className="px-3.5 py-1.5 bg-gray-100 dark:bg-gray-800 hover:bg-rose-50 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 text-xs font-semibold transition-colors cursor-pointer"
>
Clear
</button>
</div>
{/* Options & View mode */}
<div className="flex flex-wrap items-center gap-4 text-xs">
<label className="inline-flex items-center gap-1.5 cursor-pointer text-gray-700 dark:text-gray-300 select-none">
<input
type="checkbox"
checked={ignoreWhitespace}
onChange={(e) => setIgnoreWhitespace(e.target.checked)}
className="w-3.5 h-3.5 text-blue-600 rounded border-gray-300 dark:border-gray-600 focus:ring-blue-500"
/>
<span>Ignore whitespace</span>
</label>
<label className="inline-flex items-center gap-1.5 cursor-pointer text-gray-700 dark:text-gray-300 select-none">
<input
type="checkbox"
checked={ignoreCase}
onChange={(e) => setIgnoreCase(e.target.checked)}
className="w-3.5 h-3.5 text-blue-600 rounded border-gray-300 dark:border-gray-600 focus:ring-blue-500"
/>
<span>Ignore case</span>
</label>
{/* View Mode Toggle */}
<div className="flex items-center rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-100 dark:bg-gray-800 p-0.5">
<button
type="button"
onClick={() => setViewMode("unified")}
className={`px-2.5 py-1 rounded-md text-xs font-medium transition-colors cursor-pointer ${
viewMode === "unified"
? "bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-xs"
: "text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
}`}
>
Unified
</button>
<button
type="button"
onClick={() => setViewMode("split")}
className={`px-2.5 py-1 rounded-md text-xs font-medium transition-colors cursor-pointer ${
viewMode === "split"
? "bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-xs"
: "text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
}`}
>
Split
</button>
</div>
</div>
</div>
{/* Input Textareas Side by Side */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="flex flex-col space-y-1.5">
<div className="flex items-center justify-between">
<label
htmlFor="diff-original-text"
className="text-xs font-bold uppercase tracking-wider text-gray-600 dark:text-gray-300 flex items-center gap-1.5"
>
<span className="w-2.5 h-2.5 rounded-full bg-rose-500 inline-block" />
Original Text
</label>
<span className="text-xs text-gray-400 font-mono">
{original ? original.split("\n").length : 0} lines
</span>
</div>
<textarea
id="diff-original-text"
rows={8}
value={original}
onChange={(e) => setOriginal(e.target.value)}
placeholder="Paste original / before text..."
className="w-full p-3 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 font-mono text-xs focus:ring-2 focus:ring-blue-500 focus:outline-none resize-y leading-relaxed shadow-inner"
/>
</div>
<div className="flex flex-col space-y-1.5">
<div className="flex items-center justify-between">
<label
htmlFor="diff-modified-text"
className="text-xs font-bold uppercase tracking-wider text-gray-600 dark:text-gray-300 flex items-center gap-1.5"
>
<span className="w-2.5 h-2.5 rounded-full bg-emerald-500 inline-block" />
Modified Text
</label>
<span className="text-xs text-gray-400 font-mono">
{modified ? modified.split("\n").length : 0} lines
</span>
</div>
<textarea
id="diff-modified-text"
rows={8}
value={modified}
onChange={(e) => setModified(e.target.value)}
placeholder="Paste modified / after text..."
className="w-full p-3 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 font-mono text-xs focus:ring-2 focus:ring-blue-500 focus:outline-none resize-y leading-relaxed shadow-inner"
/>
</div>
</div>
{/* Diff Stats Banner */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="rounded-xl border border-emerald-200 bg-emerald-50/70 dark:border-emerald-900/50 dark:bg-emerald-950/20 p-3 text-center">
<span className="text-xs font-semibold uppercase tracking-wider text-emerald-700 dark:text-emerald-400">
+ Lines Added
</span>
<p className="mt-1 text-2xl font-extrabold text-emerald-900 dark:text-emerald-200">
+{stats.added}
</p>
</div>
<div className="rounded-xl border border-rose-200 bg-rose-50/70 dark:border-rose-900/50 dark:bg-rose-950/20 p-3 text-center">
<span className="text-xs font-semibold uppercase tracking-wider text-rose-700 dark:text-rose-400">
- Lines Removed
</span>
<p className="mt-1 text-2xl font-extrabold text-rose-900 dark:text-rose-200">
-{stats.removed}
</p>
</div>
<div className="rounded-xl border border-gray-200 bg-gray-50/70 dark:border-gray-800 dark:bg-gray-850 p-3 text-center">
<span className="text-xs font-semibold uppercase tracking-wider text-gray-600 dark:text-gray-400">
Unchanged Lines
</span>
<p className="mt-1 text-2xl font-extrabold text-gray-800 dark:text-gray-200">
{stats.unchanged}
</p>
</div>
<div className="rounded-xl border border-blue-200 bg-blue-50/70 dark:border-blue-900/50 dark:bg-blue-950/20 p-3 text-center">
<span className="text-xs font-semibold uppercase tracking-wider text-blue-700 dark:text-blue-400">
Similarity
</span>
<p className="mt-1 text-2xl font-extrabold text-blue-900 dark:text-blue-200">
{stats.similarity}%
</p>
</div>
</div>
{/* Diff Output Viewer */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-sm font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
<span>Diff Comparison Output</span>
<span className="text-xs font-normal text-gray-500">
({viewMode === "unified" ? "Unified View" : "Split View"})
</span>
</h3>
<button
type="button"
onClick={handleCopyUnifiedDiff}
disabled={diffResult.length === 0}
className="px-3 py-1 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-40 text-gray-700 dark:text-gray-300 rounded text-xs font-medium transition-colors cursor-pointer flex items-center gap-1"
>
{copiedPatch ? "Copied Patch!" : "Copy Diff Patch"}
</button>
</div>
{diffResult.length === 0 ? (
<div className="p-8 text-center rounded-xl border border-dashed border-gray-300 dark:border-gray-700 text-gray-400 text-sm">
Enter original and modified texts above to calculate diff.
</div>
) : viewMode === "unified" ? (
/* Unified Diff View */
<div className="rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 overflow-hidden font-mono text-xs shadow-xs">
<div className="max-h-[500px] overflow-y-auto overflow-x-auto divide-y divide-gray-100 dark:divide-gray-800">
{diffResult.map((item, idx) => {
let bgClass = "bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200";
let badge = <span className="text-gray-300 dark:text-gray-600 select-none">&nbsp;</span>;
let borderClass = "border-l-4 border-transparent";
if (item.type === "added") {
bgClass = "bg-emerald-50/80 dark:bg-emerald-950/30 text-emerald-950 dark:text-emerald-200";
borderClass = "border-l-4 border-emerald-500";
badge = <span className="text-emerald-600 dark:text-emerald-400 font-bold select-none">+</span>;
} else if (item.type === "removed") {
bgClass = "bg-rose-50/80 dark:bg-rose-950/30 text-rose-950 dark:text-rose-200";
borderClass = "border-l-4 border-rose-500";
badge = <span className="text-rose-600 dark:text-rose-400 font-bold select-none">-</span>;
}
return (
<div
key={idx}
className={`flex items-stretch hover:bg-black/5 dark:hover:bg-white/5 transition-colors ${bgClass} ${borderClass}`}
>
{/* Line numbers */}
<div className="w-10 py-1 pr-2 text-right text-gray-400 dark:text-gray-500 select-none border-r border-gray-200 dark:border-gray-800 shrink-0">
{item.origLine || ""}
</div>
<div className="w-10 py-1 pr-2 text-right text-gray-400 dark:text-gray-500 select-none border-r border-gray-200 dark:border-gray-800 shrink-0">
{item.modLine || ""}
</div>
{/* Symbol */}
<div className="w-6 py-1 text-center shrink-0">
{badge}
</div>
{/* Line content */}
<div className="flex-1 py-1 pr-4 whitespace-pre-wrap break-all leading-relaxed select-text">
{item.text || <span className="opacity-0">.</span>}
</div>
</div>
);
})}
</div>
</div>
) : (
/* Split Diff View */
<div className="rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 overflow-hidden font-mono text-xs shadow-xs">
<div className="grid grid-cols-2 bg-gray-100 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 py-1.5 px-3 text-xs font-semibold text-gray-600 dark:text-gray-300">
<div>Original (Before)</div>
<div>Modified (After)</div>
</div>
<div className="max-h-[500px] overflow-y-auto overflow-x-auto divide-y divide-gray-100 dark:divide-gray-800">
{splitRows.map((row, rIdx) => (
<div key={rIdx} className="grid grid-cols-2 divide-x divide-gray-200 dark:divide-gray-800">
{/* Left Column */}
<div
className={`flex items-stretch ${
row.left
? row.left.type === "removed"
? "bg-rose-50/80 dark:bg-rose-950/30 text-rose-950 dark:text-rose-200 border-l-4 border-rose-500"
: "bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200 border-l-4 border-transparent"
: "bg-gray-50 dark:bg-gray-950/50"
}`}
>
<div className="w-9 py-1 pr-1.5 text-right text-gray-400 dark:text-gray-500 select-none border-r border-gray-200 dark:border-gray-800 shrink-0">
{row.left?.line || ""}
</div>
<div className="w-5 py-1 text-center shrink-0 font-bold select-none">
{row.left?.type === "removed" ? <span className="text-rose-600">-</span> : ""}
</div>
<div className="flex-1 py-1 pr-2 whitespace-pre-wrap break-all leading-relaxed select-text">
{row.left?.text || ""}
</div>
</div>
{/* Right Column */}
<div
className={`flex items-stretch ${
row.right
? row.right.type === "added"
? "bg-emerald-50/80 dark:bg-emerald-950/30 text-emerald-950 dark:text-emerald-200 border-l-4 border-emerald-500"
: "bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200 border-l-4 border-transparent"
: "bg-gray-50 dark:bg-gray-950/50"
}`}
>
<div className="w-9 py-1 pr-1.5 text-right text-gray-400 dark:text-gray-500 select-none border-r border-gray-200 dark:border-gray-800 shrink-0">
{row.right?.line || ""}
</div>
<div className="w-5 py-1 text-center shrink-0 font-bold select-none">
{row.right?.type === "added" ? <span className="text-emerald-600">+</span> : ""}
</div>
<div className="flex-1 py-1 pr-2 whitespace-pre-wrap break-all leading-relaxed select-text">
{row.right?.text || ""}
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
</ToolLayout>
);
}

View file

@ -0,0 +1,468 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import ToolLayout from "@/components/ToolLayout";
// Pure JavaScript MD5 Implementation (RFC 1321)
function md5(string: string): string {
function add32(a: number, b: number): number {
return (a + b) & 0xffffffff;
}
function cmn(q: number, a: number, b: number, x: number, s: number, t: number): number {
a = add32(add32(a, q), add32(x, t));
return add32((a << s) | (a >>> (32 - s)), b);
}
function ff(a: number, b: number, c: number, d: number, x: number, s: number, t: number): number {
return cmn((b & c) | (~b & d), a, b, x, s, t);
}
function gg(a: number, b: number, c: number, d: number, x: number, s: number, t: number): number {
return cmn((b & d) | (c & ~d), a, b, x, s, t);
}
function hh(a: number, b: number, c: number, d: number, x: number, s: number, t: number): number {
return cmn(b ^ c ^ d, a, b, x, s, t);
}
function ii(a: number, b: number, c: number, d: number, x: number, s: number, t: number): number {
return cmn(c ^ (b | ~d), a, b, x, s, t);
}
function md5cycle(x: number[], k: number[]) {
let a = x[0],
b = x[1],
c = x[2],
d = x[3];
a = ff(a, b, c, d, k[0], 7, -680876936);
d = ff(d, a, b, c, k[1], 12, -389564586);
c = ff(c, d, a, b, k[2], 17, 606105819);
b = ff(b, c, d, a, k[3], 22, -1044525330);
a = ff(a, b, c, d, k[4], 7, -176418897);
d = ff(d, a, b, c, k[5], 12, 1200080426);
c = ff(c, d, a, b, k[6], 17, -1473231341);
b = ff(b, c, d, a, k[7], 22, -45705983);
a = ff(a, b, c, d, k[8], 7, 1770035416);
d = ff(d, a, b, c, k[9], 12, -1958414417);
c = ff(c, d, a, b, k[10], 17, -42063);
b = ff(b, c, d, a, k[11], 22, -1990404162);
a = ff(a, b, c, d, k[12], 7, 1804603682);
d = ff(d, a, b, c, k[13], 12, -40341101);
c = ff(c, d, a, b, k[14], 17, -1502002290);
b = ff(b, c, d, a, k[15], 22, 1236535329);
a = gg(a, b, c, d, k[1], 5, -165796510);
d = gg(d, a, b, c, k[6], 9, -1069501632);
c = gg(c, d, a, b, k[11], 14, 643717713);
b = gg(b, c, d, a, k[0], 20, -373897302);
a = gg(a, b, c, d, k[5], 5, -701558691);
d = gg(d, a, b, c, k[10], 9, 38016083);
c = gg(c, d, a, b, k[15], 14, -660478335);
b = gg(b, c, d, a, k[4], 20, -405537848);
a = gg(a, b, c, d, k[9], 5, 568446438);
d = gg(d, a, b, c, k[14], 9, -1019803690);
c = gg(c, d, a, b, k[3], 14, -187363961);
b = gg(b, c, d, a, k[8], 20, 1163531501);
a = gg(a, b, c, d, k[13], 5, -1444681467);
d = gg(d, a, b, c, k[2], 9, -51403784);
c = gg(c, d, a, b, k[7], 14, 1735328473);
b = gg(b, c, d, a, k[12], 20, -1926607734);
a = hh(a, b, c, d, k[5], 4, -378558);
d = hh(d, a, b, c, k[8], 11, -2022574463);
c = hh(d, a, b, c, k[11], 16, 1839030562);
b = hh(b, c, d, a, k[14], 23, -35309556);
a = hh(a, b, c, d, k[1], 4, -1530992060);
d = hh(d, a, b, c, k[4], 11, 1272893353);
c = hh(c, d, a, b, k[7], 16, -155497632);
b = hh(b, c, d, a, k[10], 23, -1094730640);
a = hh(a, b, c, d, k[13], 4, 681279174);
d = hh(d, a, b, c, k[0], 11, -358537222);
c = hh(c, d, a, b, k[3], 16, -722521979);
b = hh(b, c, d, a, k[6], 23, 76029189);
a = hh(a, b, c, d, k[9], 4, -640364487);
d = hh(d, a, b, c, k[12], 11, -421815835);
c = hh(c, d, a, b, k[15], 16, 530742520);
b = hh(b, c, d, a, k[2], 23, -995338651);
a = ii(a, b, c, d, k[0], 6, -198630844);
d = ii(d, a, b, c, k[7], 10, 1126891415);
c = ii(c, d, a, b, k[14], 15, -1416354905);
b = ii(b, c, d, a, k[5], 21, -57434055);
a = ii(a, b, c, d, k[12], 6, 1700485571);
d = ii(d, a, b, c, k[3], 10, -1894986606);
c = ii(c, d, a, b, k[10], 15, -1051523);
b = ii(b, c, d, a, k[1], 21, -2054922799);
a = ii(a, b, c, d, k[8], 6, 1873313359);
d = ii(d, a, b, c, k[15], 10, -30611744);
c = ii(c, d, a, b, k[6], 15, -1560198380);
b = ii(b, c, d, a, k[13], 21, 1309151649);
a = ii(a, b, c, d, k[4], 6, -145523070);
d = ii(d, a, b, c, k[11], 10, -1120210379);
c = ii(c, d, a, b, k[2], 15, 718787259);
b = ii(b, c, d, a, k[9], 21, -343485551);
x[0] = add32(a, x[0]);
x[1] = add32(b, x[1]);
x[2] = add32(c, x[2]);
x[3] = add32(d, x[3]);
}
const utf8 = new TextEncoder().encode(string);
const n = utf8.length;
const state = [1732584193, -271733879, -1732584194, 271733878];
let i = 0;
for (i = 64; i <= n; i += 64) {
const blk: number[] = [];
for (let j = 0; j < 64; j += 4) {
const idx = i - 64 + j;
blk[j >> 2] =
utf8[idx] | (utf8[idx + 1] << 8) | (utf8[idx + 2] << 16) | (utf8[idx + 3] << 24);
}
md5cycle(state, blk);
}
const tail = utf8.slice(i - 64);
const tailBlk: number[] = new Array(16).fill(0);
for (let j = 0; j < tail.length; j++) {
tailBlk[j >> 2] |= tail[j] << ((j % 4) << 3);
}
tailBlk[tail.length >> 2] |= 0x80 << ((tail.length % 4) << 3);
if (tail.length > 55) {
md5cycle(state, tailBlk);
for (let j = 0; j < 16; j++) tailBlk[j] = 0;
}
const bitLen = n * 8;
tailBlk[14] = bitLen & 0xffffffff;
tailBlk[15] = Math.floor(bitLen / 0x100000000);
md5cycle(state, tailBlk);
const hex_chr = "0123456789abcdef";
let result = "";
for (let j = 0; j < 4; j++) {
const val = state[j];
for (let k = 0; k <= 3; k++) {
result += hex_chr[(val >> (k * 8 + 4)) & 0x0f] + hex_chr[(val >> (k * 8)) & 0x0f];
}
}
return result;
}
interface HashResult {
algorithm: string;
name: string;
bitLength: number;
hash: string;
securityNote: string;
}
export default function HashGeneratorPage() {
const [inputText, setInputText] = useState<string>("Hello, DevToolbox!");
const [uppercase, setUppercase] = useState<boolean>(false);
const [hashes, setHashes] = useState<HashResult[]>([]);
const [copiedKey, setCopiedKey] = useState<string | null>(null);
const [copiedAll, setCopiedAll] = useState<boolean>(false);
const computeHashes = useCallback(async (text: string) => {
const encoder = new TextEncoder();
const data = encoder.encode(text);
// 1. MD5
const md5Hash = md5(text);
// 2. Web Crypto subtle digests
const getSubtleHash = async (algo: string): Promise<string> => {
try {
const buffer = await crypto.subtle.digest(algo, data);
const hashArray = Array.from(new Uint8Array(buffer));
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
} catch (err) {
console.error(`Failed to calculate ${algo}:`, err);
return "";
}
};
const [sha1Hash, sha256Hash, sha384Hash, sha512Hash] = await Promise.all([
getSubtleHash("SHA-1"),
getSubtleHash("SHA-256"),
getSubtleHash("SHA-384"),
getSubtleHash("SHA-512"),
]);
setHashes([
{
algorithm: "MD5",
name: "MD5 (Message Digest 5)",
bitLength: 128,
hash: md5Hash,
securityNote: "Legacy / Checksums only (vulnerable to collisions)",
},
{
algorithm: "SHA-1",
name: "SHA-1 (Secure Hash Algorithm 1)",
bitLength: 160,
hash: sha1Hash,
securityNote: "Legacy (Git commits, legacy checksums)",
},
{
algorithm: "SHA-256",
name: "SHA-256 (SHA-2 Family)",
bitLength: 256,
hash: sha256Hash,
securityNote: "Industry Standard (High security, SSL, Bitcoin)",
},
{
algorithm: "SHA-384",
name: "SHA-384 (SHA-2 Family)",
bitLength: 384,
hash: sha384Hash,
securityNote: "High Security (Government & enterprise grade)",
},
{
algorithm: "SHA-512",
name: "SHA-512 (SHA-2 Family)",
bitLength: 512,
hash: sha512Hash,
securityNote: "Maximum Security (64-bit optimized systems)",
},
]);
}, []);
useEffect(() => {
computeHashes(inputText);
}, [inputText, computeHashes]);
const copyHash = async (hashValue: string, key: string) => {
const formatted = uppercase ? hashValue.toUpperCase() : hashValue.toLowerCase();
try {
await navigator.clipboard.writeText(formatted);
setCopiedKey(key);
setTimeout(() => setCopiedKey(null), 2000);
} catch (err) {
console.error("Failed to copy hash: ", err);
}
};
const copyAllHashes = async () => {
const allFormatted = hashes
.map((h) => {
const val = uppercase ? h.hash.toUpperCase() : h.hash.toLowerCase();
return `${h.algorithm}: ${val}`;
})
.join("\n");
try {
await navigator.clipboard.writeText(allFormatted);
setCopiedAll(true);
setTimeout(() => setCopiedAll(false), 2000);
} catch (err) {
console.error("Failed to copy all hashes: ", err);
}
};
const sampleTexts = [
{ label: "Hello World", text: "Hello, world!" },
{ label: "Password", text: "correct horse battery staple" },
{ label: "Empty String", text: "" },
{ label: "Quick Fox", text: "The quick brown fox jumps over the lazy dog" },
];
const byteLength = new TextEncoder().encode(inputText).length;
return (
<ToolLayout
title="Hash Generator"
description="Compute MD5, SHA-1, SHA-256, SHA-384, and SHA-512 cryptographic message digests in real-time with zero server latency."
>
<div className="space-y-6">
{/* Input Section */}
<div>
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<label
htmlFor="hash-input"
className="text-xs font-semibold uppercase tracking-wider text-gray-700 dark:text-gray-300"
>
Input Text
</label>
<div className="flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400">
<span>{inputText.length} characters</span>
<span>•</span>
<span>{byteLength} UTF-8 bytes</span>
</div>
</div>
<textarea
id="hash-input"
rows={4}
value={inputText}
onChange={(e) => setInputText(e.target.value)}
placeholder="Type or paste text to generate cryptographic hashes..."
className="w-full rounded-lg border border-gray-300 bg-white p-3 font-mono text-sm text-gray-900 shadow-inner focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
/>
{/* Quick Presets and Clear */}
<div className="mt-2.5 flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-xs text-gray-500 dark:text-gray-400">Samples:</span>
{sampleTexts.map((sample) => (
<button
key={sample.label}
type="button"
onClick={() => setInputText(sample.text)}
className="rounded-md bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
{sample.label}
</button>
))}
</div>
{inputText && (
<button
type="button"
onClick={() => setInputText("")}
className="text-xs font-medium text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
>
Clear input
</button>
)}
</div>
</div>
{/* Hashes Section Toolbar */}
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-gray-200 pb-3 dark:border-gray-700">
<h2 className="text-sm font-semibold uppercase tracking-wider text-gray-700 dark:text-gray-300">
Computed Hashes
</h2>
<div className="flex flex-wrap items-center gap-3">
<label className="inline-flex cursor-pointer items-center gap-2 text-xs font-medium text-gray-700 dark:text-gray-300">
<input
type="checkbox"
checked={uppercase}
onChange={(e) => setUppercase(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>UPPERCASE HEX</span>
</label>
<button
type="button"
onClick={copyAllHashes}
className={`inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors ${
copiedAll
? "bg-green-600 text-white"
: "bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
}`}
>
{copiedAll ? (
<>
<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>
All 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 All Hashes
</>
)}
</button>
</div>
</div>
{/* Hashes List */}
<div className="space-y-4">
{hashes.map((item) => {
const displayHash = uppercase ? item.hash.toUpperCase() : item.hash.toLowerCase();
const isCopied = copiedKey === item.algorithm;
return (
<div
key={item.algorithm}
className="rounded-lg border border-gray-200 bg-gray-50/50 p-4 transition-all hover:border-gray-300 dark:border-gray-700/80 dark:bg-gray-800/40 dark:hover:border-gray-600"
>
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span className="font-bold text-gray-900 dark:text-white">
{item.algorithm}
</span>
<span className="rounded bg-gray-200 px-1.5 py-0.5 text-[10px] font-semibold text-gray-700 dark:bg-gray-700 dark:text-gray-300">
{item.bitLength}-bit ({item.hash.length} chars)
</span>
</div>
<span className="text-xs text-gray-500 dark:text-gray-400">
{item.securityNote}
</span>
</div>
<div className="flex items-center gap-2">
<div className="relative flex-1 overflow-hidden rounded-md border border-gray-300 bg-white p-2.5 dark:border-gray-700 dark:bg-gray-900">
<p className="select-all break-all font-mono text-xs font-medium text-gray-800 dark:text-gray-200 sm:text-sm">
{displayHash || "—"}
</p>
</div>
<button
type="button"
onClick={() => copyHash(item.hash, item.algorithm)}
aria-label={`Copy ${item.algorithm} hash`}
className={`flex shrink-0 items-center gap-1.5 rounded-md px-3 py-2 text-xs font-medium transition-all ${
isCopied
? "bg-green-600 text-white"
: "bg-blue-600 text-white hover:bg-blue-700 active:scale-95"
}`}
>
{isCopied ? (
<>
<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
</>
) : (
<>
<svg className="h-4 w-4" 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
</>
)}
</button>
</div>
</div>
);
})}
</div>
{/* Educational / Comparison Guide */}
<div className="rounded-lg border border-gray-100 bg-gray-50/80 p-4 text-xs text-gray-500 dark:border-gray-800 dark:bg-gray-800/40 dark:text-gray-400">
<h4 className="mb-2 font-semibold text-gray-800 dark:text-gray-200">
About Cryptographic Hashes
</h4>
<p className="leading-relaxed">
A cryptographic hash function is a one-way deterministic mathematical algorithm that maps arbitrary-size data to a fixed-size string. Hashes are widely used for data integrity verification, password storage with salt, digital signatures, and content-addressable storage systems.
</p>
</div>
</div>
</ToolLayout>
);
}

View file

@ -0,0 +1,458 @@
"use client";
import { useState, useCallback, useId } from "react";
import ToolLayout from "@/components/ToolLayout";
const BASIC_ENTITIES_MAP: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
};
const EXTENDED_ENTITIES_MAP: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
"©": "&copy;",
"®": "&reg;",
"™": "&trade;",
"€": "&euro;",
"£": "&pound;",
"¥": "&yen;",
"¢": "&cent;",
"§": "&sect;",
"°": "&deg;",
"±": "&plusmn;",
"«": "&laquo;",
"»": "&raquo;",
"•": "&bull;",
"…": "&hellip;",
"—": "&mdash;",
"–": "&ndash;",
"“": "&ldquo;",
"”": "&rdquo;",
"‘": "&lsquo;",
"’": "&rsquo;",
"×": "&times;",
"÷": "&divide;",
"µ": "&micro;",
"¶": "&para;",
"¼": "&frac14;",
"½": "&frac12;",
"¾": "&frac34;",
};
type EncodeMode = "basic" | "extended" | "decimal" | "hex";
export default function HtmlEntitiesPage() {
const inputId = useId();
const outputId = useId();
const [input, setInput] = useState("");
const [output, setOutput] = useState("");
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [encodeMode, setEncodeMode] = useState<EncodeMode>("basic");
const [lastAction, setLastAction] = useState<"encode" | "decode" | null>(null);
const encodeText = useCallback((text: string, mode: EncodeMode) => {
if (!text) {
setOutput("");
setError(null);
return;
}
try {
let result = "";
if (mode === "basic") {
result = text.replace(/[&<>"']/g, (char) => BASIC_ENTITIES_MAP[char] || char);
} else if (mode === "extended") {
result = text.replace(
/[&<>"'©®™€£¥¢§°±«»•…—–“”‘’×÷µ¶¼½¾]/g,
(char) => EXTENDED_ENTITIES_MAP[char] || char
);
} else if (mode === "decimal") {
result = text.replace(/[&<>"']|[^\x20-\x7E\r\n\t]/g, (char) => {
const code = char.codePointAt(0);
return code !== undefined ? `&#${code};` : char;
});
} else if (mode === "hex") {
result = text.replace(/[&<>"']|[^\x20-\x7E\r\n\t]/g, (char) => {
const code = char.codePointAt(0);
return code !== undefined ? `&#x${code.toString(16).toUpperCase()};` : char;
});
}
setOutput(result);
setError(null);
setLastAction("encode");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to encode HTML entities.");
setOutput("");
}
}, []);
const decodeText = useCallback((text: string) => {
if (!text) {
setOutput("");
setError(null);
return;
}
try {
const textarea = document.createElement("textarea");
textarea.innerHTML = text;
const decoded = textarea.value;
setOutput(decoded);
setError(null);
setLastAction("decode");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to decode HTML entities.");
setOutput("");
}
}, []);
const handleEncode = () => {
encodeText(input, encodeMode);
};
const handleDecode = () => {
decodeText(input);
};
const handleCopy = 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);
}
};
const handleSwap = () => {
const temp = output;
setInput(temp);
setOutput(input);
setError(null);
if (lastAction === "encode") {
setLastAction("decode");
} else if (lastAction === "decode") {
setLastAction("encode");
}
};
const handleClear = () => {
setInput("");
setOutput("");
setError(null);
setLastAction(null);
};
const loadSample = (type: "html" | "symbols" | "entities") => {
setError(null);
if (type === "html") {
const sample = '<div class="alert alert-info">\n <h1>Title: "Welcome & Enjoy"</h1>\n <p>5 > 3 and 2 < 4 are true \'math\' facts!</p>\n</div>';
setInput(sample);
encodeText(sample, encodeMode);
} else if (type === "symbols") {
const sample = 'Price: €99.99 / £85.00 © 2026 DevToolbox™ — "Fast & Secure" — 100° angle • ½ cup';
setInput(sample);
setEncodeMode("extended");
encodeText(sample, "extended");
} else {
const sample = '&lt;div class=&quot;card&quot;&gt;\n &lt;h2&gt;Hello &amp;amp; Welcome!&lt;/h2&gt;\n &lt;p&gt;Price: &amp;euro;50 &amp;copy; 2026&lt;/p&gt;\n&lt;/div&gt;';
setInput(sample);
decodeText(sample);
}
};
return (
<ToolLayout
title="HTML Entity Encode / Decode"
description="Convert special characters to their corresponding HTML entity codes or decode HTML entities back to plain text."
>
<title>HTML Entity Encode &amp; Decode Online — DevToolbox</title>
<meta
name="description"
content="Free online HTML entity encoder and decoder. Convert <, >, &, quotes, symbols to named, decimal, and hex HTML entities."
/>
<div className="space-y-6">
{/* Controls Bar */}
<div className="flex flex-wrap items-center justify-between gap-3 pb-2 border-b border-gray-200 dark:border-gray-700">
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={handleEncode}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 active:bg-blue-800 text-white rounded-lg font-medium text-sm transition-colors shadow-sm cursor-pointer 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="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
Encode HTML Entities
</button>
<button
type="button"
onClick={handleDecode}
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white rounded-lg font-medium text-sm transition-colors shadow-sm cursor-pointer 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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Decode to Plain Text
</button>
<button
type="button"
onClick={handleSwap}
disabled={!output && !input}
className="px-3 py-2 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed text-gray-700 dark:text-gray-200 rounded-lg text-sm font-medium transition-colors cursor-pointer flex items-center gap-1"
title="Swap input and output"
>
<svg className="w-4 h-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>
Swap
</button>
<button
type="button"
onClick={handleClear}
disabled={!input && !output}
className="px-3 py-2 bg-gray-100 dark:bg-gray-800 hover:bg-red-50 dark:hover:bg-red-950/30 text-gray-700 dark:text-gray-300 hover:text-red-600 dark:hover:text-red-400 disabled:opacity-40 disabled:cursor-not-allowed rounded-lg text-sm font-medium transition-colors cursor-pointer"
>
Clear
</button>
</div>
{/* Sample buttons */}
<div className="flex items-center gap-2 text-xs">
<span className="text-gray-500 dark:text-gray-400 font-medium">Samples:</span>
<button
type="button"
onClick={() => loadSample("html")}
className="px-2.5 py-1 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded transition-colors cursor-pointer"
>
HTML Code
</button>
<button
type="button"
onClick={() => loadSample("symbols")}
className="px-2.5 py-1 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded transition-colors cursor-pointer"
>
Symbols &amp; Quotes
</button>
<button
type="button"
onClick={() => loadSample("entities")}
className="px-2.5 py-1 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded transition-colors cursor-pointer"
>
Encoded Entities
</button>
</div>
</div>
{/* Encoding Mode Selector */}
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-800/50 p-3 rounded-lg border border-gray-200 dark:border-gray-700/60">
<span className="font-medium text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Encoding Format:
</span>
<div className="flex flex-wrap items-center gap-4">
<label className="inline-flex items-center gap-1.5 cursor-pointer">
<input
type="radio"
name="encodeMode"
value="basic"
checked={encodeMode === "basic"}
onChange={() => {
setEncodeMode("basic");
if (lastAction === "encode" && input) {
encodeText(input, "basic");
}
}}
className="w-4 h-4 text-blue-600 border-gray-300 dark:border-gray-600 focus:ring-blue-500"
/>
<span>Basic Special Chars <code className="text-xs bg-gray-200 dark:bg-gray-700 px-1 py-0.5 rounded font-mono">(&lt; &gt; &amp; &quot; &#39;)</code></span>
</label>
<label className="inline-flex items-center gap-1.5 cursor-pointer">
<input
type="radio"
name="encodeMode"
value="extended"
checked={encodeMode === "extended"}
onChange={() => {
setEncodeMode("extended");
if (lastAction === "encode" && input) {
encodeText(input, "extended");
}
}}
className="w-4 h-4 text-blue-600 border-gray-300 dark:border-gray-600 focus:ring-blue-500"
/>
<span>Extended Named <code className="text-xs bg-gray-200 dark:bg-gray-700 px-1 py-0.5 rounded font-mono">(&amp;copy;, &amp;euro;, etc.)</code></span>
</label>
<label className="inline-flex items-center gap-1.5 cursor-pointer">
<input
type="radio"
name="encodeMode"
value="decimal"
checked={encodeMode === "decimal"}
onChange={() => {
setEncodeMode("decimal");
if (lastAction === "encode" && input) {
encodeText(input, "decimal");
}
}}
className="w-4 h-4 text-blue-600 border-gray-300 dark:border-gray-600 focus:ring-blue-500"
/>
<span>Decimal Numeric <code className="text-xs bg-gray-200 dark:bg-gray-700 px-1 py-0.5 rounded font-mono">(&amp;#60;)</code></span>
</label>
<label className="inline-flex items-center gap-1.5 cursor-pointer">
<input
type="radio"
name="encodeMode"
value="hex"
checked={encodeMode === "hex"}
onChange={() => {
setEncodeMode("hex");
if (lastAction === "encode" && input) {
encodeText(input, "hex");
}
}}
className="w-4 h-4 text-blue-600 border-gray-300 dark:border-gray-600 focus:ring-blue-500"
/>
<span>Hexadecimal <code className="text-xs bg-gray-200 dark:bg-gray-700 px-1 py-0.5 rounded font-mono">(&amp;#x3C;)</code></span>
</label>
</div>
</div>
{/* Error Notification */}
{error && (
<div className="flex items-start gap-3 p-4 bg-red-50 dark:bg-red-950/40 border border-red-200 dark:border-red-800 rounded-lg text-red-800 dark:text-red-300 text-sm">
<svg className="w-5 h-5 text-red-500 shrink-0 mt-0.5" 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">Error</p>
<p className="mt-0.5">{error}</p>
</div>
</div>
)}
{/* Two-Column Editor Grid */}
<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="font-medium text-sm text-gray-700 dark:text-gray-300">
Input Text / HTML
</label>
<span className="text-xs text-gray-500 dark:text-gray-400 font-mono">
{input.length} chars
</span>
</div>
<textarea
id={inputId}
value={input}
onChange={(e) => {
setInput(e.target.value);
if (error) setError(null);
}}
placeholder="Type or paste HTML code or text to encode/decode..."
rows={12}
className="w-full p-3 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 font-mono text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none resize-y placeholder:text-gray-400 dark:placeholder:text-gray-500 leading-relaxed shadow-inner"
/>
</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="font-medium text-sm text-gray-700 dark:text-gray-300">
Output Result
</label>
{lastAction && (
<span className="px-2 py-0.5 text-xs rounded-full font-medium bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300">
{lastAction === "encode" ? "HTML Entities Encoded" : "Decoded Plain Text"}
</span>
)}
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-gray-500 dark:text-gray-400 font-mono">
{output.length} chars
</span>
<button
type="button"
onClick={handleCopy}
disabled={!output}
className="px-2.5 py-1 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed text-gray-700 dark:text-gray-300 rounded text-xs font-medium transition-colors cursor-pointer flex items-center gap-1"
>
{copied ? (
<>
<svg className="w-3.5 h-3.5 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-green-600 dark:text-green-400 font-semibold">Copied!</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</span>
</>
)}
</button>
</div>
</div>
<textarea
id={outputId}
value={output}
readOnly
placeholder="Output will appear here..."
rows={12}
className="w-full p-3 rounded-lg border border-gray-300 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/60 text-gray-900 dark:text-gray-100 font-mono text-sm focus:outline-none resize-y placeholder:text-gray-400 dark:placeholder:text-gray-500 leading-relaxed select-all"
/>
</div>
</div>
{/* HTML Entities Reference Table */}
<div className="mt-8 pt-6 border-t border-gray-200 dark:border-gray-800">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-3">Key HTML Entities Reference</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-2 text-xs font-mono">
<div className="p-2 bg-gray-50 dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 flex justify-between items-center">
<span className="text-gray-600 dark:text-gray-400 font-sans">&lt; (Less than)</span>
<span className="font-semibold text-blue-600 dark:text-blue-400">&amp;lt;</span>
</div>
<div className="p-2 bg-gray-50 dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 flex justify-between items-center">
<span className="text-gray-600 dark:text-gray-400 font-sans">&gt; (Greater)</span>
<span className="font-semibold text-blue-600 dark:text-blue-400">&amp;gt;</span>
</div>
<div className="p-2 bg-gray-50 dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 flex justify-between items-center">
<span className="text-gray-600 dark:text-gray-400 font-sans">&amp; (Ampersand)</span>
<span className="font-semibold text-blue-600 dark:text-blue-400">&amp;amp;</span>
</div>
<div className="p-2 bg-gray-50 dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 flex justify-between items-center">
<span className="text-gray-600 dark:text-gray-400 font-sans">&quot; (Double quote)</span>
<span className="font-semibold text-blue-600 dark:text-blue-400">&amp;quot;</span>
</div>
<div className="p-2 bg-gray-50 dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 flex justify-between items-center">
<span className="text-gray-600 dark:text-gray-400 font-sans">&#39; (Single quote)</span>
<span className="font-semibold text-blue-600 dark:text-blue-400">&amp;#39;</span>
</div>
<div className="p-2 bg-gray-50 dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 flex justify-between items-center">
<span className="text-gray-600 dark:text-gray-400 font-sans">&copy; (Copyright)</span>
<span className="font-semibold text-blue-600 dark:text-blue-400">&amp;copy;</span>
</div>
</div>
</div>
</div>
</ToolLayout>
);
}

View file

@ -0,0 +1,563 @@
"use client";
import { useState, useCallback, useId } from "react";
import ToolLayout from "@/components/ToolLayout";
interface StatusInfo {
type: "idle" | "success" | "error";
message?: string;
line?: number;
column?: number;
byteSize?: number;
itemCount?: number;
depth?: number;
reduction?: number;
}
const SAMPLE_JSON = {
name: "DevToolbox",
version: "1.0.0",
description: "Fast, privacy-first developer utilities",
active: true,
rating: 4.95,
tags: ["developer", "tools", "utilities", "open-source"],
author: {
name: "DevToolbox Team",
email: "support@example.com",
social: {
github: "https://github.com",
twitter: null
}
},
features: [
{ id: 1, name: "JSON Formatter", status: "ready" },
{ id: 2, name: "Markdown Preview", status: "ready" },
{ id: 3, name: "Base64 Encoder", status: "ready" }
],
stats: {
totalTools: 20,
dailyUsers: 15400,
uptimePercent: 99.98
}
};
function parseJsonError(input: string, err: unknown): { message: string; line?: number; column?: number } {
if (!(err instanceof Error)) {
return { message: String(err) };
}
const msg = err.message;
// Match "at position 123"
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 };
}
// Match "line X column Y"
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 sortJsonKeys(obj: unknown): unknown {
if (Array.isArray(obj)) {
return obj.map(sortJsonKeys);
} else if (obj !== null && typeof obj === "object") {
const sortedObj: Record<string, unknown> = {};
const keys = Object.keys(obj as Record<string, unknown>).sort();
for (const key of keys) {
sortedObj[key] = sortJsonKeys((obj as Record<string, unknown>)[key]);
}
return sortedObj;
}
return obj;
}
function calculateDepth(obj: unknown, currentDepth = 1): number {
if (obj === null || typeof obj !== "object") return currentDepth;
const values = Array.isArray(obj) ? obj : Object.values(obj as Record<string, unknown>);
if (values.length === 0) return currentDepth;
return Math.max(...values.map((v) => calculateDepth(v, currentDepth + 1)));
}
function countTotalKeys(obj: unknown): number {
if (obj === null || typeof obj !== "object") return 0;
let count = 0;
if (Array.isArray(obj)) {
for (const item of obj) {
count += countTotalKeys(item);
}
} else {
const entries = Object.entries(obj as Record<string, unknown>);
count += entries.length;
for (const [, val] of entries) {
count += countTotalKeys(val);
}
}
return count;
}
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 JsonFormatterPage() {
const [input, setInput] = useState<string>("");
const [output, setOutput] = useState<string>("");
const [indent, setIndent] = useState<string>("2");
const [sortKeys, setSortKeys] = useState<boolean>(false);
const [status, setStatus] = useState<StatusInfo>({ type: "idle" });
const [copied, setCopied] = useState<boolean>(false);
const indentSelectId = useId();
const getIndentValue = useCallback(() => {
if (indent === "tab") return "\t";
return parseInt(indent, 10) || 2;
}, [indent]);
const handleFormat = useCallback(() => {
if (!input.trim()) {
setOutput("");
setStatus({ type: "idle" });
return;
}
try {
let parsed = JSON.parse(input);
if (sortKeys) {
parsed = sortJsonKeys(parsed);
}
const formatted = JSON.stringify(parsed, null, getIndentValue());
setOutput(formatted);
const byteSize = new Blob([formatted]).size;
const keyCount = countTotalKeys(parsed);
const depth = calculateDepth(parsed);
setStatus({
type: "success",
message: "JSON formatted successfully! Valid JSON structure.",
byteSize,
itemCount: keyCount,
depth,
});
} catch (err) {
const errorInfo = parseJsonError(input, err);
setStatus({
type: "error",
message: errorInfo.message,
line: errorInfo.line,
column: errorInfo.column,
});
}
}, [input, sortKeys, getIndentValue]);
const handleMinify = useCallback(() => {
if (!input.trim()) {
setOutput("");
setStatus({ type: "idle" });
return;
}
try {
let parsed = JSON.parse(input);
if (sortKeys) {
parsed = sortJsonKeys(parsed);
}
const minified = JSON.stringify(parsed);
setOutput(minified);
const origSize = new Blob([input]).size;
const minSize = new Blob([minified]).size;
const reduction = origSize > 0 ? Math.max(0, ((origSize - minSize) / origSize) * 100) : 0;
setStatus({
type: "success",
message: `JSON minified! Saved ${reduction.toFixed(1)}% (${formatBytes(origSize - minSize)})`,
byteSize: minSize,
reduction,
});
} catch (err) {
const errorInfo = parseJsonError(input, err);
setStatus({
type: "error",
message: errorInfo.message,
line: errorInfo.line,
column: errorInfo.column,
});
}
}, [input, sortKeys]);
const handleValidate = useCallback(() => {
if (!input.trim()) {
setStatus({
type: "error",
message: "Input is empty. Please enter or paste JSON to validate.",
});
return;
}
try {
const parsed = JSON.parse(input);
const byteSize = new Blob([input]).size;
const keyCount = countTotalKeys(parsed);
const depth = calculateDepth(parsed);
setStatus({
type: "success",
message: "Valid JSON syntax! No parsing errors detected.",
byteSize,
itemCount: keyCount,
depth,
});
} catch (err) {
const errorInfo = parseJsonError(input, err);
setStatus({
type: "error",
message: errorInfo.message,
line: errorInfo.line,
column: errorInfo.column,
});
}
}, [input]);
const handleLoadSample = useCallback(() => {
const sampleStr = JSON.stringify(SAMPLE_JSON, null, getIndentValue());
setInput(sampleStr);
setOutput(sampleStr);
const byteSize = new Blob([sampleStr]).size;
const keyCount = countTotalKeys(SAMPLE_JSON);
const depth = calculateDepth(SAMPLE_JSON);
setStatus({
type: "success",
message: "Sample JSON loaded successfully.",
byteSize,
itemCount: keyCount,
depth,
});
}, [getIndentValue]);
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 {
// Fallback
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/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, input]);
const inputLineCount = input ? input.split("\n").length : 0;
const outputLineCount = output ? output.split("\n").length : 0;
return (
<ToolLayout
title="JSON Formatter & Validator - Format, Beautify, Validate JSON"
description="Free online JSON formatter, validator, and beautifier. Paste your JSON and format it instantly. Supports minification and validation with error messages."
>
<div className="space-y-5">
{/* 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"
>
<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"
>
<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"
>
<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
</button>
</div>
<div className="flex flex-wrap items-center gap-3">
{/* Indent Selector */}
<div className="flex items-center gap-1.5 text-sm text-gray-600 dark:text-gray-400">
<label htmlFor={indentSelectId} className="text-xs font-medium uppercase tracking-wide">
Indent:
</label>
<select
id={indentSelectId}
value={indent}
onChange={(e) => setIndent(e.target.value)}
className="rounded-md border border-gray-300 bg-white px-2.5 py-1 text-sm 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="1">1 Space</option>
</select>
</div>
{/* Sort Keys Toggle */}
<label className="flex cursor-pointer items-center gap-1.5 text-sm text-gray-700 select-none dark:text-gray-300">
<input
type="checkbox"
checked={sortKeys}
onChange={(e) => setSortKeys(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>Sort Keys</span>
</label>
<button
onClick={handleLoadSample}
className="rounded-md px-2.5 py-1 text-xs font-medium text-gray-600 underline-offset-2 hover:text-blue-600 hover:underline dark:text-gray-400 dark:hover:text-blue-400"
>
Load Sample
</button>
<button
onClick={handleClear}
className="rounded-md px-2.5 py-1 text-xs font-medium text-red-600 underline-offset-2 hover:text-red-700 hover:underline dark:text-red-400 dark:hover:text-red-300"
>
Clear
</button>
</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.byteSize !== undefined && <span>Size: <strong>{formatBytes(status.byteSize)}</strong></span>}
{status.itemCount !== undefined && <span>Keys: <strong>{status.itemCount}</strong></span>}
{status.depth !== undefined && <span>Depth: <strong>{status.depth}</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">Invalid JSON Syntax</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 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 or unavailable
}
}}
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"
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"
title="Clear input"
>
Clear
</button>
</div>
</div>
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Paste or type raw JSON 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">Output 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={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"
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
</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"
title="Copy 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 Output
</>
)}
</button>
</div>
</div>
<textarea
value={output}
readOnly
placeholder="Formatted or minified 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 JSON 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">✨ Beautify & Format</p>
<p className="mt-1 text-xs leading-normal">
Format unorganized or compact JSON with customizable 2-space, 4-space, or tab indentation for maximum readability.
</p>
</div>
<div>
<p className="font-medium text-gray-800 dark:text-gray-200">🔍 Strict Validation</p>
<p className="mt-1 text-xs leading-normal">
Identify syntax mistakes, unquoted keys, trailing commas, and locate exact line numbers and column coordinates instantly.
</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">
Your data never leaves your browser. Parsing and formatting run entirely on your local machine for complete privacy.
</p>
</div>
</div>
</div>
</div>
</ToolLayout>
);
}

View file

@ -0,0 +1,517 @@
"use client";
import { useState, useMemo, useId } from "react";
import ToolLayout from "@/components/ToolLayout";
interface DecodedToken {
header: Record<string, unknown> | null;
payload: Record<string, unknown> | null;
signature: string;
rawHeader: string;
rawPayload: string;
rawSignature: string;
}
function base64UrlDecode(str: string): string {
let base64 = str.trim().replace(/-/g, "+").replace(/_/g, "/");
while (base64.length % 4 !== 0) {
base64 += "=";
}
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return new TextDecoder("utf-8").decode(bytes);
}
function formatTimestamp(timestamp: number): { formatted: string; relative: string; isExpired: boolean; diffMs: number } {
const date = new Date(timestamp > 1e11 ? timestamp : timestamp * 1000);
const now = new Date();
const diffMs = date.getTime() - now.getTime();
const isExpired = diffMs <= 0;
const formatted = date.toUTCString() + ` (${date.toLocaleString()})`;
const absDiffSec = Math.floor(Math.abs(diffMs) / 1000);
const days = Math.floor(absDiffSec / 86400);
const hours = Math.floor((absDiffSec % 86400) / 3600);
const minutes = Math.floor((absDiffSec % 3600) / 60);
const seconds = absDiffSec % 60;
let relative = "";
if (days > 0) relative += `${days}d `;
if (hours > 0 || days > 0) relative += `${hours}h `;
if (minutes > 0 || hours > 0 || days > 0) relative += `${minutes}m `;
relative += `${seconds}s`;
if (isExpired) {
relative = `Expired ${relative} ago`;
} else {
relative = `Expires in ${relative}`;
}
return { formatted, relative, isExpired, diffMs };
}
// Pre-computed sample tokens
const SAMPLE_VALID =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." +
"eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkphbmUgRG9lIiwiZW1haWwiOiJqYW5lLmRvZUBleGFtcGxlLmNvbSIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTczNTY4OTYwMCwiZXhwIjoyMDgwNzg5NjAwLCJpc3MiOiJodHRwczovL2F1dGguZGV2dG9vbGJveC5pbyIsImF1ZCI6Imh0dHBzOi8vYXBpLmRldnRvb2xib3guaW8ifQ." +
"SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
const SAMPLE_EXPIRED =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." +
"eyJzdWIiOiJ1c3JfOTA4MTIzNDU2IiwibmFtZSI6IkFsZXggSm9obnNvbiIsImVtYWlsIjoiYWxleEBleGFtcGxlLmNvbSIsInJvbGUiOiJ1c2VyIiwiaWF0IjoxNjcyNTMxMjAwLCJleHAiOjE2NzI1MzQ4MDAsImlzcyI6Imh0dHBzOi8vYXV0aC5leGFtcGxlLmNvbSJ9." +
"dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
const SAMPLE_FIREBASE =
"eyJhbGciOiJSUzI1NiIsImtpZCI6ImFkNWUxMWExYmRlODFjODg3ZjAwOGQ0NDYwNWI1MzA0NDAxMjM0NTYiLCJ0eXAiOiJKV1QifQ." +
"eyJpc3MiOiJodHRwczovL3NlY3VyZXRva2VuLmdvb2dsZS5jb20vbXktYXBwIiwiYXVkIjoibXktYXBwIiwiYXV0aF90aW1lIjoxNzM1Njg5NjAwLCJ1c2VyX2lkIjoidzlVejFXcDlQWlhrOHRNYkEiLCJzdWIiOiJ3OVV6MVdwOVBaWGs4dE1iQSIsImlhdCI6MTczNTY4OTYwMCwiZXhwIjoyMDgwNzg5NjAwLCJlbWFpbCI6ImRldkBkZXZ0b29sYm94LmlvIiwiZW1haWxfdmVyaWZpZWQiOnRydWV9." +
"K3wF9m5R6Y3N2X4a9_7L8P1Q0Z3v9R6Y3N2X4a9_7L8P1Q0Z3v9R6Y3N2X4a9_7L8P1Q0Z3v";
export default function JwtDecoderPage() {
const inputId = useId();
const [tokenInput, setTokenInput] = useState<string>(SAMPLE_VALID);
const [copiedHeader, setCopiedHeader] = useState(false);
const [copiedPayload, setCopiedPayload] = useState(false);
const [copiedToken, setCopiedToken] = useState(false);
const decodedResult = useMemo(() => {
const raw = tokenInput.trim();
if (!raw) {
return { token: null, error: null, parts: [] };
}
const parts = raw.split(".");
if (parts.length !== 3) {
return {
token: null,
error: `Invalid JWT format: A JSON Web Token must contain exactly 3 dot-separated parts (header.payload.signature). Found ${parts.length} part(s).`,
parts,
};
}
const [rawHeader, rawPayload, rawSignature] = parts;
let headerObj: Record<string, unknown> | null = null;
let payloadObj: Record<string, unknown> | null = null;
try {
const decodedHeaderJson = base64UrlDecode(rawHeader);
headerObj = JSON.parse(decodedHeaderJson);
} catch (err) {
return {
token: null,
error: `Failed to decode JWT Header: ${err instanceof Error ? err.message : "Invalid Base64URL or JSON"}`,
parts,
};
}
try {
const decodedPayloadJson = base64UrlDecode(rawPayload);
payloadObj = JSON.parse(decodedPayloadJson);
} catch (err) {
return {
token: null,
error: `Failed to decode JWT Payload: ${err instanceof Error ? err.message : "Invalid Base64URL or JSON"}`,
parts,
};
}
const token: DecodedToken = {
header: headerObj,
payload: payloadObj,
signature: rawSignature,
rawHeader,
rawPayload,
rawSignature,
};
return { token, error: null, parts };
}, [tokenInput]);
const { token, error, parts } = decodedResult;
const copyToClipboard = async (text: string, setFn: (val: boolean) => void) => {
try {
await navigator.clipboard.writeText(text);
setFn(true);
setTimeout(() => setFn(false), 2000);
} catch {
const textArea = document.createElement("textarea");
textArea.value = text;
document.body.appendChild(textArea);
textArea.select();
document.execCommand("copy");
document.body.removeChild(textArea);
setFn(true);
setTimeout(() => setFn(false), 2000);
}
};
const expClaim = token?.payload && typeof token.payload.exp === "number" ? token.payload.exp : null;
const iatClaim = token?.payload && typeof token.payload.iat === "number" ? token.payload.iat : null;
const nbfClaim = token?.payload && typeof token.payload.nbf === "number" ? token.payload.nbf : null;
const expInfo = expClaim ? formatTimestamp(expClaim) : null;
const iatInfo = iatClaim ? formatTimestamp(iatClaim) : null;
const nbfInfo = nbfClaim ? formatTimestamp(nbfClaim) : null;
const headerString = token?.header ? JSON.stringify(token.header, null, 2) : "";
const payloadString = token?.payload ? JSON.stringify(token.payload, null, 2) : "";
return (
<ToolLayout
title="JWT Decoder"
description="Decode and inspect JSON Web Tokens (JWT) client-side. View decoded header, payload claims, expiration status, and signature."
>
<title>JWT Decoder Online — DevToolbox</title>
<meta
name="description"
content="Free online JWT Decoder. Inspect JSON Web Token header, payload, claims, and signature with color-coded syntax and expiration validator."
/>
<div className="space-y-6">
{/* Samples & Actions Bar */}
<div className="flex flex-wrap items-center justify-between gap-3 pb-2 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-gray-500 dark:text-gray-400">Sample Tokens:</span>
<button
type="button"
onClick={() => setTokenInput(SAMPLE_VALID)}
className="px-2.5 py-1 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded text-xs font-medium transition-colors cursor-pointer"
>
Active Token
</button>
<button
type="button"
onClick={() => setTokenInput(SAMPLE_EXPIRED)}
className="px-2.5 py-1 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded text-xs font-medium transition-colors cursor-pointer"
>
Expired Token
</button>
<button
type="button"
onClick={() => setTokenInput(SAMPLE_FIREBASE)}
className="px-2.5 py-1 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded text-xs font-medium transition-colors cursor-pointer"
>
RS256 Token
</button>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => copyToClipboard(tokenInput, setCopiedToken)}
disabled={!tokenInput}
className="px-3 py-1.5 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed text-gray-700 dark:text-gray-300 rounded text-xs font-medium transition-colors cursor-pointer flex items-center gap-1"
>
{copiedToken ? (
<>
<svg className="w-3.5 h-3.5 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-green-600 dark:text-green-400 font-semibold">Copied!</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 Token</span>
</>
)}
</button>
<button
type="button"
onClick={() => setTokenInput("")}
disabled={!tokenInput}
className="px-3 py-1.5 bg-gray-100 dark:bg-gray-800 hover:bg-red-50 dark:hover:bg-red-950/30 text-gray-700 dark:text-gray-300 hover:text-red-600 dark:hover:text-red-400 disabled:opacity-40 disabled:cursor-not-allowed rounded text-xs font-medium transition-colors cursor-pointer"
>
Clear
</button>
</div>
</div>
{/* Input Area */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label htmlFor={inputId} className="font-semibold text-sm text-gray-800 dark:text-gray-200 flex items-center gap-2">
<span>Encoded JWT Token</span>
<span className="text-xs font-normal text-gray-500 dark:text-gray-400">
(Paste token below)
</span>
</label>
<div className="flex items-center gap-3 text-xs font-medium">
<span className="inline-flex items-center gap-1 text-rose-600 dark:text-rose-400">
<span className="w-2.5 h-2.5 rounded-full bg-rose-500 inline-block"></span>
Header
</span>
<span className="inline-flex items-center gap-1 text-purple-600 dark:text-purple-400">
<span className="w-2.5 h-2.5 rounded-full bg-purple-500 inline-block"></span>
Payload
</span>
<span className="inline-flex items-center gap-1 text-sky-600 dark:text-sky-400">
<span className="w-2.5 h-2.5 rounded-full bg-sky-500 inline-block"></span>
Signature
</span>
</div>
</div>
<textarea
id={inputId}
value={tokenInput}
onChange={(e) => setTokenInput(e.target.value)}
placeholder="Paste a JSON Web Token here (e.g. eyJhbGciOi...)"
rows={4}
className="w-full p-3 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 font-mono text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none resize-y leading-relaxed shadow-inner"
/>
{/* Color-Coded Token Preview Bar */}
{tokenInput.trim() && parts.length === 3 && (
<div className="p-3 bg-gray-50 dark:bg-gray-900/70 border border-gray-200 dark:border-gray-700/70 rounded-lg font-mono text-xs break-all leading-relaxed">
<span className="text-rose-600 dark:text-rose-400 font-semibold">{parts[0]}</span>
<span className="text-gray-400 dark:text-gray-600 font-bold">.</span>
<span className="text-purple-600 dark:text-purple-400 font-semibold">{parts[1]}</span>
<span className="text-gray-400 dark:text-gray-600 font-bold">.</span>
<span className="text-sky-600 dark:text-sky-400 font-semibold">{parts[2]}</span>
</div>
)}
</div>
{/* Error Notification */}
{error && (
<div className="flex items-start gap-3 p-4 bg-red-50 dark:bg-red-950/40 border border-red-200 dark:border-red-800 rounded-lg text-red-800 dark:text-red-300 text-sm">
<svg className="w-5 h-5 text-red-500 shrink-0 mt-0.5" 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">Invalid JWT Token</p>
<p className="mt-0.5">{error}</p>
</div>
</div>
)}
{/* Expiration Status Banner */}
{token && (
<div>
{expInfo ? (
<div
className={`p-4 rounded-xl border flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 ${
expInfo.isExpired
? "bg-red-50 dark:bg-red-950/40 border-red-200 dark:border-red-800 text-red-900 dark:text-red-300"
: "bg-emerald-50 dark:bg-emerald-950/40 border-emerald-200 dark:border-emerald-800 text-emerald-900 dark:text-emerald-300"
}`}
>
<div className="flex items-center gap-3">
<div
className={`w-9 h-9 rounded-lg flex items-center justify-center shrink-0 ${
expInfo.isExpired
? "bg-red-100 dark:bg-red-900/60 text-red-600 dark:text-red-300"
: "bg-emerald-100 dark:bg-emerald-900/60 text-emerald-600 dark:text-emerald-300"
}`}
>
{expInfo.isExpired ? (
<svg className="w-5 h-5" 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>
) : (
<svg className="w-5 h-5" 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>
)}
</div>
<div>
<h3 className="font-bold text-base flex items-center gap-2">
<span>{expInfo.isExpired ? "Token is Expired" : "Token is Active"}</span>
<span className="text-xs px-2 py-0.5 rounded-full font-semibold uppercase tracking-wide bg-white/60 dark:bg-black/30">
{expInfo.relative}
</span>
</h3>
<p className="text-xs opacity-90 mt-0.5">
<strong>Expiration (exp):</strong> {expInfo.formatted}
</p>
</div>
</div>
{iatInfo && (
<div className="text-xs opacity-80 sm:text-right">
<p><strong>Issued At (iat):</strong> {iatInfo.formatted}</p>
</div>
)}
</div>
) : (
<div className="p-3.5 bg-amber-50 dark:bg-amber-950/40 border border-amber-200 dark:border-amber-800 rounded-xl text-amber-900 dark:text-amber-300 text-xs flex items-center gap-2">
<svg className="w-4 h-4 shrink-0 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>No expiration (<code className="font-mono bg-amber-100 dark:bg-amber-900/60 px-1 py-0.5 rounded">exp</code>) claim present in this token payload.</span>
</div>
)}
</div>
)}
{/* Decoded Content Grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Header Panel */}
<div className="flex flex-col space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="w-3 h-3 rounded-full bg-rose-500"></span>
<h3 className="font-bold text-sm text-gray-900 dark:text-gray-100">
Decoded Header
</h3>
<span className="text-xs text-rose-600 dark:text-rose-400 font-mono">
(Algorithm &amp; Token Type)
</span>
</div>
<button
type="button"
onClick={() => copyToClipboard(headerString, setCopiedHeader)}
disabled={!headerString}
className="px-2.5 py-1 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed text-gray-700 dark:text-gray-300 rounded text-xs font-medium transition-colors cursor-pointer flex items-center gap-1"
>
{copiedHeader ? (
<>
<svg className="w-3.5 h-3.5 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-green-600 dark:text-green-400 font-semibold">Copied!</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 JSON</span>
</>
)}
</button>
</div>
<pre className="p-4 rounded-lg border border-rose-200 dark:border-rose-900/50 bg-rose-50/50 dark:bg-rose-950/20 text-rose-950 dark:text-rose-200 font-mono text-xs leading-relaxed min-h-[140px] overflow-auto select-all shadow-inner">
{headerString || "// Decoded header JSON will appear here"}
</pre>
{token?.header && (
<div className="grid grid-cols-2 gap-2 text-xs font-mono">
<div className="p-2 bg-gray-50 dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700">
<span className="text-gray-500 dark:text-gray-400">Algorithm (alg):</span>{" "}
<span className="font-semibold text-rose-600 dark:text-rose-400">{String(token.header.alg || "none")}</span>
</div>
<div className="p-2 bg-gray-50 dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700">
<span className="text-gray-500 dark:text-gray-400">Type (typ):</span>{" "}
<span className="font-semibold text-rose-600 dark:text-rose-400">{String(token.header.typ || "JWT")}</span>
</div>
</div>
)}
</div>
{/* Payload Panel */}
<div className="flex flex-col space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="w-3 h-3 rounded-full bg-purple-500"></span>
<h3 className="font-bold text-sm text-gray-900 dark:text-gray-100">
Decoded Payload
</h3>
<span className="text-xs text-purple-600 dark:text-purple-400 font-mono">
(Data Claims)
</span>
</div>
<button
type="button"
onClick={() => copyToClipboard(payloadString, setCopiedPayload)}
disabled={!payloadString}
className="px-2.5 py-1 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed text-gray-700 dark:text-gray-300 rounded text-xs font-medium transition-colors cursor-pointer flex items-center gap-1"
>
{copiedPayload ? (
<>
<svg className="w-3.5 h-3.5 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-green-600 dark:text-green-400 font-semibold">Copied!</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 JSON</span>
</>
)}
</button>
</div>
<pre className="p-4 rounded-lg border border-purple-200 dark:border-purple-900/50 bg-purple-50/50 dark:bg-purple-950/20 text-purple-950 dark:text-purple-200 font-mono text-xs leading-relaxed min-h-[140px] overflow-auto select-all shadow-inner">
{payloadString || "// Decoded payload JSON will appear here"}
</pre>
{token?.payload && (
<div className="space-y-1.5 text-xs font-mono">
{token.payload.sub !== undefined && (
<div className="p-2 bg-gray-50 dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 flex justify-between">
<span className="text-gray-500 dark:text-gray-400">Subject (sub):</span>
<span className="font-semibold text-purple-600 dark:text-purple-400">{String(token.payload.sub)}</span>
</div>
)}
{token.payload.iss !== undefined && (
<div className="p-2 bg-gray-50 dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 flex justify-between">
<span className="text-gray-500 dark:text-gray-400">Issuer (iss):</span>
<span className="font-semibold text-purple-600 dark:text-purple-400">{String(token.payload.iss)}</span>
</div>
)}
{token.payload.aud !== undefined && (
<div className="p-2 bg-gray-50 dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 flex justify-between">
<span className="text-gray-500 dark:text-gray-400">Audience (aud):</span>
<span className="font-semibold text-purple-600 dark:text-purple-400">{String(token.payload.aud)}</span>
</div>
)}
{nbfInfo && (
<div className="p-2 bg-gray-50 dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 flex justify-between">
<span className="text-gray-500 dark:text-gray-400">Not Before (nbf):</span>
<span className="font-semibold text-purple-600 dark:text-purple-400">{nbfInfo.formatted}</span>
</div>
)}
</div>
)}
</div>
</div>
{/* Signature Inspection Card */}
{token && (
<div className="p-4 rounded-xl border border-sky-200 dark:border-sky-900/50 bg-sky-50/40 dark:bg-sky-950/20 space-y-2">
<div className="flex items-center gap-2">
<span className="w-3 h-3 rounded-full bg-sky-500"></span>
<h4 className="font-bold text-sm text-gray-900 dark:text-gray-100">
Signature Component
</h4>
</div>
<p className="text-xs text-gray-600 dark:text-gray-400">
The signature is calculated by taking the encoded header, the encoded payload, a secret key, and signing them using the specified algorithm ({String(token.header?.alg || "HS256")}).
</p>
<div className="p-2.5 bg-white dark:bg-gray-900 rounded border border-sky-200 dark:border-sky-800 font-mono text-xs text-sky-700 dark:text-sky-300 break-all select-all">
{token.signature || "// No signature"}
</div>
</div>
)}
{/* Informative Guide */}
<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 bg-gray-50 dark:bg-gray-800/40 rounded-lg border border-gray-200 dark:border-gray-700/60">
<h4 className="font-semibold text-gray-800 dark:text-gray-200 mb-1">🔴 Header (Red)</h4>
<p>Contains metadata about the token, such as the signing algorithm (e.g. HS256, RS256) and token type (JWT).</p>
</div>
<div className="p-3 bg-gray-50 dark:bg-gray-800/40 rounded-lg border border-gray-200 dark:border-gray-700/60">
<h4 className="font-semibold text-gray-800 dark:text-gray-200 mb-1">🟣 Payload (Purple)</h4>
<p>Contains the claims and entity statements (user ID, roles, permissions, expiration timestamps).</p>
</div>
<div className="p-3 bg-gray-50 dark:bg-gray-800/40 rounded-lg border border-gray-200 dark:border-gray-700/60">
<h4 className="font-semibold text-gray-800 dark:text-gray-200 mb-1">🔵 Signature (Blue)</h4>
<p>Cryptographic hash used by the server to verify that the message wasn&apos;t changed along the way.</p>
</div>
</div>
</div>
</ToolLayout>
);
}

View file

@ -0,0 +1,394 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import ToolLayout from "@/components/ToolLayout";
const LOREM_WORDS = [
"lorem", "ipsum", "dolor", "sit", "amet", "consectetur", "adipiscing", "elit",
"sed", "do", "eiusmod", "tempor", "incididunt", "ut", "labore", "et",
"dolore", "magna", "aliqua", "enim", "ad", "minim", "veniam", "quis",
"nostrud", "exercitation", "ullamco", "laboris", "nisi", "ut", "aliquip",
"ex", "ea", "commodo", "consequat", "duis", "aute", "irure", "in",
"reprehenderit", "in", "voluptate", "velit", "esse", "cillum", "dolore",
"eu", "fugiat", "nulla", "pariatur", "excepteur", "sint", "occaecat",
"cupidatat", "non", "proident", "sunt", "in", "culpa", "qui", "officia",
"deserunt", "mollit", "anim", "id", "est", "laborum", "curabitur",
"pretium", "tincidunt", "lacus", "nulla", "gravida", "orci", "a",
"odio", "nullam", "varius", "turpis", "et", "commodo", "pharetra",
"est", "eros", "bibendum", "elit", "nec", "luctus", "magna", "felis",
"sollicitudin", "mauris", "integer", "in", "mauris", "eu", "nibh",
"euismod", "gravida", "duis", "ac", "tellus", "et", "risus", "vulputate",
"vehicula", "donec", "lobortis", "risus", "a", "elit", "etiam", "tempor",
"ut", "ullamcorper", "venenatis", "pellentesque", "faucibus", "viverra",
"sapien", "auctor", "vitae", "massa", "porta", "semper", "quisque",
"posuere", "feugiat", "nunc", "molestie", "efficitur", "rutrum"
];
const CLASSIC_INTRO = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.";
function getRandomWord(): string {
const index = Math.floor(Math.random() * LOREM_WORDS.length);
return LOREM_WORDS[index];
}
function generateSentence(targetWords: number): string {
const words: string[] = [];
for (let i = 0; i < targetWords; i++) {
words.push(getRandomWord());
}
// Capitalize first letter
if (words.length > 0) {
words[0] = words[0].charAt(0).toUpperCase() + words[0].slice(1);
}
// Add random comma for longer sentences
if (words.length > 7 && Math.random() > 0.4) {
const commaIndex = Math.floor(Math.random() * (words.length - 4)) + 2;
words[commaIndex] = words[commaIndex] + ",";
}
return words.join(" ") + ".";
}
function generateParagraph(targetWordCount: number, isFirst: boolean, startWithLorem: boolean): string {
if (isFirst && startWithLorem) {
let currentWords = CLASSIC_INTRO.split(" ").length;
const sentences = [CLASSIC_INTRO];
while (currentWords < targetWordCount) {
const sentenceLen = Math.floor(Math.random() * 8) + 6; // 6 - 13 words
sentences.push(generateSentence(sentenceLen));
currentWords += sentenceLen;
}
return sentences.join(" ");
}
let currentWords = 0;
const sentences: string[] = [];
while (currentWords < targetWordCount) {
const sentenceLen = Math.floor(Math.random() * 8) + 6; // 6 - 13 words
sentences.push(generateSentence(sentenceLen));
currentWords += sentenceLen;
}
return sentences.join(" ");
}
type OutputFormat = "plain" | "html" | "markdown";
type ParagraphLength = "short" | "medium" | "long" | "custom";
const LENGTH_RANGES: Record<ParagraphLength, { min: number; max: number; label: string }> = {
short: { min: 30, max: 45, label: "Short (~35 words)" },
medium: { min: 65, max: 85, label: "Medium (~75 words)" },
long: { min: 110, max: 140, label: "Long (~125 words)" },
custom: { min: 20, max: 200, label: "Custom" },
};
export default function LoremIpsumPage() {
const [numParagraphs, setNumParagraphs] = useState<number>(3);
const [lengthPreset, setLengthPreset] = useState<ParagraphLength>("medium");
const [customWords, setCustomWords] = useState<number>(75);
const [startWithLorem, setStartWithLorem] = useState<boolean>(true);
const [format, setFormat] = useState<OutputFormat>("plain");
const [generatedText, setGeneratedText] = useState<string>("");
const [copied, setCopied] = useState<boolean>(false);
const generate = useCallback(() => {
const targetWordsPerPara =
lengthPreset === "custom"
? customWords
: Math.floor(
Math.random() * (LENGTH_RANGES[lengthPreset].max - LENGTH_RANGES[lengthPreset].min + 1)
) + LENGTH_RANGES[lengthPreset].min;
const paras: string[] = [];
for (let i = 0; i < numParagraphs; i++) {
paras.push(generateParagraph(targetWordsPerPara, i === 0, startWithLorem));
}
if (format === "html") {
setGeneratedText(paras.map((p) => `<p>${p}</p>`).join("\n\n"));
} else if (format === "markdown") {
setGeneratedText(paras.join("\n\n"));
} else {
setGeneratedText(paras.join("\n\n"));
}
}, [numParagraphs, lengthPreset, customWords, startWithLorem, format]);
useEffect(() => {
generate();
}, [generate]);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(generatedText);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error("Failed to copy lorem ipsum: ", err);
}
};
const handleDownload = () => {
const ext = format === "html" ? "html" : "txt";
const mime = format === "html" ? "text/html" : "text/plain";
const blob = new Blob([generatedText], { type: `${mime};charset=utf-8` });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `lorem-ipsum-${Date.now()}.${ext}`;
a.click();
URL.revokeObjectURL(url);
};
// Stats calculation
const wordCount = generatedText
.replace(/<[^>]*>/g, "")
.trim()
.split(/\s+/)
.filter(Boolean).length;
const charCount = generatedText.length;
const paragraphCount = numParagraphs;
return (
<ToolLayout
title="Lorem Ipsum Generator"
description="Create customizable placeholder text for layouts, web designs, mockups, and typography previews."
>
<div className="space-y-6">
{/* Controls Section */}
<div className="rounded-lg border border-gray-200 bg-gray-50/60 p-4 dark:border-gray-700/60 dark:bg-gray-800/40">
<div className="grid grid-cols-1 gap-5 md:grid-cols-2 lg:grid-cols-4">
{/* Number of Paragraphs */}
<div>
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-wider text-gray-700 dark:text-gray-300">
Paragraphs (1 - 20)
</label>
<div className="flex items-center gap-2">
<input
type="number"
min="1"
max="20"
value={numParagraphs}
onChange={(e) => {
const val = Math.min(Math.max(1, parseInt(e.target.value) || 1), 20);
setNumParagraphs(val);
}}
className="w-20 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium 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-800 dark:text-gray-100"
/>
<div className="flex flex-wrap gap-1">
{[1, 3, 5, 10].map((count) => (
<button
key={count}
type="button"
onClick={() => setNumParagraphs(count)}
className={`rounded px-2 py-1 text-xs font-medium transition-colors ${
numParagraphs === count
? "bg-blue-600 text-white"
: "bg-gray-200 text-gray-700 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
}`}
>
{count}
</button>
))}
</div>
</div>
</div>
{/* Paragraph Length */}
<div>
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-wider text-gray-700 dark:text-gray-300">
Paragraph Length
</label>
<select
value={lengthPreset}
onChange={(e) => setLengthPreset(e.target.value as ParagraphLength)}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium 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-800 dark:text-gray-100"
>
<option value="short">Short (~35 words)</option>
<option value="medium">Medium (~75 words)</option>
<option value="long">Long (~125 words)</option>
<option value="custom">Custom word count</option>
</select>
{lengthPreset === "custom" && (
<div className="mt-2 flex items-center gap-2">
<span className="text-xs text-gray-500 dark:text-gray-400">Words:</span>
<input
type="number"
min="10"
max="300"
value={customWords}
onChange={(e) =>
setCustomWords(Math.min(Math.max(10, parseInt(e.target.value) || 10), 300))
}
className="w-24 rounded border border-gray-300 bg-white px-2 py-1 text-xs font-medium text-gray-900 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
/>
</div>
)}
</div>
{/* Output Format */}
<div>
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-wider text-gray-700 dark:text-gray-300">
Format
</label>
<div className="flex rounded-lg border border-gray-300 bg-gray-100 p-0.5 dark:border-gray-700 dark:bg-gray-800">
<button
type="button"
onClick={() => setFormat("plain")}
className={`flex-1 rounded-md py-1.5 text-xs font-medium transition-colors ${
format === "plain"
? "bg-white text-gray-900 shadow-xs dark:bg-gray-700 dark:text-white"
: "text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
}`}
>
Plain Text
</button>
<button
type="button"
onClick={() => setFormat("html")}
className={`flex-1 rounded-md py-1.5 text-xs font-medium transition-colors ${
format === "html"
? "bg-white text-gray-900 shadow-xs dark:bg-gray-700 dark:text-white"
: "text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
}`}
>
HTML &lt;p&gt;
</button>
</div>
</div>
{/* Action Options */}
<div className="flex flex-col justify-between">
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-wider text-gray-700 dark:text-gray-300">
Options
</label>
<label className="inline-flex cursor-pointer items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
type="checkbox"
checked={startWithLorem}
onChange={(e) => setStartWithLorem(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>Start with &ldquo;Lorem ipsum...&rdquo;</span>
</label>
<button
type="button"
onClick={generate}
className="mt-3 flex w-full items-center justify-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 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
Regenerate Text
</button>
</div>
</div>
</div>
{/* Output Section Header with Stats & Actions */}
<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-3 text-xs text-gray-500 dark:text-gray-400">
<span className="inline-flex items-center gap-1 font-medium text-gray-700 dark:text-gray-300">
<span className="font-semibold text-blue-600 dark:text-blue-400">{paragraphCount}</span> paragraphs
</span>
<span>•</span>
<span className="inline-flex items-center gap-1 font-medium text-gray-700 dark:text-gray-300">
<span className="font-semibold text-blue-600 dark:text-blue-400">{wordCount}</span> words
</span>
<span>•</span>
<span className="inline-flex items-center gap-1 font-medium text-gray-700 dark:text-gray-300">
<span className="font-semibold text-blue-600 dark:text-blue-400">{charCount}</span> characters
</span>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleCopy}
className={`inline-flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium transition-all ${
copied
? "bg-green-600 text-white"
: "bg-blue-600 text-white hover:bg-blue-700 active:scale-95"
}`}
>
{copied ? (
<>
<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!
</>
) : (
<>
<svg className="h-4 w-4" 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 Text
</>
)}
</button>
<button
type="button"
onClick={handleDownload}
title="Download text file"
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-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
>
<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
</button>
</div>
</div>
{/* Text Area Output */}
<div>
<textarea
readOnly
value={generatedText}
rows={12}
className="w-full rounded-lg border border-gray-300 bg-white p-4 font-sans text-sm leading-relaxed text-gray-900 shadow-inner focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800/80 dark:text-gray-100"
aria-label="Generated Lorem Ipsum Text"
/>
</div>
{/* Informational Details */}
<div className="grid grid-cols-1 gap-4 pt-2 text-xs text-gray-500 dark:text-gray-400 sm:grid-cols-2">
<div className="rounded-lg border border-gray-100 bg-gray-50 p-3.5 dark:border-gray-800 dark:bg-gray-800/40">
<h4 className="font-semibold text-gray-800 dark:text-gray-200">Origin of Lorem Ipsum</h4>
<p className="mt-1 leading-normal">
Lorem Ipsum has been standard placeholder text since the 1500s, adapted from Cicero&apos;s philosophical work <em>&ldquo;de Finibus Bonorum et Malorum&rdquo;</em> written in 45 BC.
</p>
</div>
<div className="rounded-lg border border-gray-100 bg-gray-50 p-3.5 dark:border-gray-800 dark:bg-gray-800/40">
<h4 className="font-semibold text-gray-800 dark:text-gray-200">Design &amp; Layout Utility</h4>
<p className="mt-1 leading-normal">
Using realistic filler text with natural word and sentence distribution avoids distracting reviewers with readable content while focusing purely on layout and typography.
</p>
</div>
</div>
</div>
</ToolLayout>
);
}

View file

@ -0,0 +1,684 @@
"use client";
import { useState, useMemo, useCallback } from "react";
import { marked } from "marked";
import ToolLayout from "@/components/ToolLayout";
const SAMPLE_MARKDOWN = `# DevToolbox Markdown Preview
Welcome to the **live Markdown previewer**! Write or paste your Markdown in the editor on the left, and watch the formatted HTML update in *real time*.
---
## Typography & Formatting
You can format text with **bold**, *italic*, ***bold and italic***, or ~~strikethrough~~. You can also highlight \`inline code\` or add [links to DevToolbox](https://example.com).
> 💡 **Pro tip:** Markdown is lightweight, readable, and widely supported across GitHub, documentation sites, and content platforms.
---
## Lists & Checklists
### Features Roadmap
- [x] Instant client-side live rendering
- [x] Full GitHub Flavored Markdown (GFM) support
- [x] Syntax-styled tables & code blocks
- [ ] Export directly to PDF
- [ ] Custom CSS themes
### Developer Resources
1. **Frontend**: Next.js 15 App Router, React 19, Tailwind CSS 4
2. **Parsing**: High-performance \`marked\` engine
3. **Privacy**: 100% in-browser processing with zero server calls
---
## Code Blocks
\`\`\`typescript
interface UserProfile {
id: string;
name: string;
role: "admin" | "developer" | "viewer";
preferences: {
theme: "light" | "dark" | "system";
autoSave: boolean;
};
}
function greet(user: UserProfile): string {
return \`Hello, \${user.name}! Welcome back to DevToolbox.\`;
}
\`\`\`
---
## Structured Tables
| Tool Name | Category | Status | Speed |
| :--- | :--- | :---: | :--- |
| **JSON Formatter** | Formatters | ✅ Ready | Instant |
| **Markdown Preview** | Formatters | ✅ Ready | Instant |
| **Base64 Encoder** | Encoders | ✅ Ready | Instant |
| **UUID Generator** | Generators | ✅ Ready | Instant |
---
## Blockquotes & Citations
> "Simplicity is prerequisite for reliability."
> — *Edsger W. Dijkstra*
`;
export default function MarkdownPreviewPage() {
const [markdown, setMarkdown] = useState<string>(SAMPLE_MARKDOWN);
const [viewMode, setViewMode] = useState<"split" | "preview" | "rawHtml" | "edit">("split");
const [copiedHtml, setCopiedHtml] = useState<boolean>(false);
const [copiedMd, setCopiedMd] = useState<boolean>(false);
// Parse markdown live using marked
const renderedHtml = useMemo(() => {
try {
return marked.parse(markdown, {
gfm: true,
breaks: true,
}) as string;
} catch (err) {
return `<div class="text-red-500 font-semibold p-4">Error parsing markdown: ${String(err)}</div>`;
}
}, [markdown]);
// Statistics
const stats = useMemo(() => {
const trimmed = markdown.trim();
const chars = markdown.length;
const words = trimmed ? trimmed.split(/\s+/).filter(Boolean).length : 0;
const lines = markdown ? markdown.split("\n").length : 0;
const readingTimeMinutes = Math.max(1, Math.ceil(words / 200));
return { chars, words, lines, readingTimeMinutes };
}, [markdown]);
const handleCopyHtml = useCallback(async () => {
try {
await navigator.clipboard.writeText(renderedHtml);
setCopiedHtml(true);
setTimeout(() => setCopiedHtml(false), 2000);
} catch {
const textarea = document.createElement("textarea");
textarea.value = renderedHtml;
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);
setCopiedHtml(true);
setTimeout(() => setCopiedHtml(false), 2000);
}
}, [renderedHtml]);
const handleCopyMarkdown = useCallback(async () => {
try {
await navigator.clipboard.writeText(markdown);
setCopiedMd(true);
setTimeout(() => setCopiedMd(false), 2000);
} catch {
const textarea = document.createElement("textarea");
textarea.value = markdown;
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);
setCopiedMd(true);
setTimeout(() => setCopiedMd(false), 2000);
}
}, [markdown]);
const handleDownloadHtml = useCallback(() => {
const fullHtml = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Rendered Markdown</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; max-width: 800px; margin: 40px auto; padding: 0 20px; color: #24292e; }
h1, h2, h3, h4, h5, h6 { margin-top: 24px; margin-bottom: 16px; font-weight: 600; line-height: 1.25; }
h1 { font-size: 2em; border-bottom: 1px solid #eaecef; padding-bottom: .3em; }
h2 { font-size: 1.5em; border-bottom: 1px solid #eaecef; padding-bottom: .3em; }
code { font-family: monospace; background: #f6f8fa; padding: 0.2em 0.4em; border-radius: 3px; }
pre { background: #f6f8fa; padding: 16px; overflow: auto; border-radius: 6px; }
pre code { background: none; padding: 0; }
blockquote { border-left: 4px solid #dfe2e5; padding: 0 1em; color: #6a737d; margin: 0; }
table { border-collapse: collapse; width: 100%; margin: 16px 0; }
table th, table td { border: 1px solid #dfe2e5; padding: 6px 13px; }
table tr:nth-child(2n) { background-color: #f6f8fa; }
</style>
</head>
<body>
${renderedHtml}
</body>
</html>`;
const blob = new Blob([fullHtml], { type: "text/html;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "document.html";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, [renderedHtml]);
const handleDownloadMd = useCallback(() => {
const blob = new Blob([markdown], { type: "text/markdown;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "document.md";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, [markdown]);
const insertSnippet = (prefix: string, suffix = "", placeholder = "") => {
const textarea = document.getElementById("markdown-editor-input") as HTMLTextAreaElement | null;
if (!textarea) {
setMarkdown((prev) => prev + prefix + placeholder + suffix);
return;
}
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selected = markdown.substring(start, end) || placeholder;
const replacement = prefix + selected + suffix;
const updated = markdown.substring(0, start) + replacement + markdown.substring(end);
setMarkdown(updated);
setTimeout(() => {
textarea.focus();
textarea.setSelectionRange(start + prefix.length, start + prefix.length + selected.length);
}, 0);
};
return (
<ToolLayout
title="Markdown Preview - Live Markdown to HTML Renderer"
description="Free online Markdown preview tool. Write or paste Markdown and see the rendered HTML output in real-time."
>
<div className="space-y-4">
{/* Style Block for Rendered HTML Typography */}
<style dangerouslySetInnerHTML={{ __html: `
.markdown-preview-output {
color: inherit;
line-height: 1.7;
}
.markdown-preview-output h1 {
font-size: 1.875rem;
font-weight: 700;
line-height: 1.3;
margin-top: 1.25rem;
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid rgba(156, 163, 175, 0.3);
}
.markdown-preview-output h2 {
font-size: 1.5rem;
font-weight: 700;
line-height: 1.35;
margin-top: 1.25rem;
margin-bottom: 0.6rem;
padding-bottom: 0.35rem;
border-bottom: 1px solid rgba(156, 163, 175, 0.2);
}
.markdown-preview-output h3 {
font-size: 1.25rem;
font-weight: 600;
margin-top: 1rem;
margin-bottom: 0.5rem;
}
.markdown-preview-output h4 {
font-size: 1.1rem;
font-weight: 600;
margin-top: 0.85rem;
margin-bottom: 0.4rem;
}
.markdown-preview-output h5, .markdown-preview-output h6 {
font-size: 0.95rem;
font-weight: 600;
margin-top: 0.75rem;
margin-bottom: 0.35rem;
}
.markdown-preview-output p {
margin-top: 0.6rem;
margin-bottom: 0.6rem;
}
.markdown-preview-output strong {
font-weight: 600;
}
.markdown-preview-output em {
font-style: italic;
}
.markdown-preview-output del {
text-decoration: line-through;
opacity: 0.75;
}
.markdown-preview-output a {
color: #2563eb;
text-decoration: underline;
text-underline-offset: 2px;
}
@media (prefers-color-scheme: dark) {
.markdown-preview-output a {
color: #60a5fa;
}
}
.markdown-preview-output a:hover {
color: #1d4ed8;
}
.markdown-preview-output ul {
list-style-type: disc;
padding-left: 1.5rem;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
.markdown-preview-output ol {
list-style-type: decimal;
padding-left: 1.5rem;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
.markdown-preview-output li {
margin-top: 0.25rem;
margin-bottom: 0.25rem;
}
.markdown-preview-output li > ul,
.markdown-preview-output li > ol {
margin-top: 0.2rem;
margin-bottom: 0.2rem;
}
.markdown-preview-output input[type="checkbox"] {
margin-right: 0.5rem;
border-radius: 0.25rem;
accent-color: #2563eb;
vertical-align: middle;
}
.markdown-preview-output blockquote {
border-left: 4px solid #3b82f6;
padding: 0.5rem 1rem;
margin: 1rem 0;
background: rgba(59, 130, 246, 0.05);
border-top-right-radius: 0.375rem;
border-bottom-right-radius: 0.375rem;
font-style: italic;
}
.markdown-preview-output code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.85em;
padding: 0.15em 0.35em;
border-radius: 0.25rem;
background-color: rgba(156, 163, 175, 0.18);
color: #db2777;
}
@media (prefers-color-scheme: dark) {
.markdown-preview-output code {
color: #f472b6;
}
}
.markdown-preview-output pre {
background-color: #0f172a;
color: #f8fafc;
padding: 1rem;
border-radius: 0.5rem;
overflow-x: auto;
margin: 1rem 0;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.875rem;
line-height: 1.6;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.markdown-preview-output pre code {
background-color: transparent;
color: inherit;
padding: 0;
border-radius: 0;
font-size: inherit;
}
.markdown-preview-output hr {
margin: 1.5rem 0;
border: 0;
border-top: 1px solid rgba(156, 163, 175, 0.3);
}
.markdown-preview-output table {
width: 100%;
border-collapse: collapse;
margin: 1rem 0;
font-size: 0.875rem;
border: 1px solid rgba(156, 163, 175, 0.25);
border-radius: 0.5rem;
overflow: hidden;
}
.markdown-preview-output th,
.markdown-preview-output td {
border: 1px solid rgba(156, 163, 175, 0.25);
padding: 0.5rem 0.75rem;
text-align: left;
}
.markdown-preview-output th {
background-color: rgba(156, 163, 175, 0.12);
font-weight: 600;
}
.markdown-preview-output tr:nth-child(even) {
background-color: rgba(156, 163, 175, 0.05);
}
.markdown-preview-output img {
max-width: 100%;
height: auto;
border-radius: 0.5rem;
margin: 0.75rem 0;
}
` }} />
{/* Top Control Bar */}
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-gray-200 pb-3 dark:border-gray-800">
{/* View Mode Switcher */}
<div className="flex items-center rounded-lg bg-gray-100 p-1 dark:bg-gray-800">
<button
onClick={() => setViewMode("split")}
className={`rounded-md px-3 py-1 text-xs font-medium transition-all ${
viewMode === "split"
? "bg-white text-blue-600 shadow-xs dark:bg-gray-700 dark:text-white"
: "text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200"
}`}
>
Split View
</button>
<button
onClick={() => setViewMode("preview")}
className={`rounded-md px-3 py-1 text-xs font-medium transition-all ${
viewMode === "preview"
? "bg-white text-blue-600 shadow-xs dark:bg-gray-700 dark:text-white"
: "text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200"
}`}
>
Preview
</button>
<button
onClick={() => setViewMode("rawHtml")}
className={`rounded-md px-3 py-1 text-xs font-medium transition-all ${
viewMode === "rawHtml"
? "bg-white text-blue-600 shadow-xs dark:bg-gray-700 dark:text-white"
: "text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200"
}`}
>
HTML Code
</button>
<button
onClick={() => setViewMode("edit")}
className={`rounded-md px-3 py-1 text-xs font-medium transition-all ${
viewMode === "edit"
? "bg-white text-blue-600 shadow-xs dark:bg-gray-700 dark:text-white"
: "text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200"
}`}
>
Editor Only
</button>
</div>
{/* Action Buttons */}
<div className="flex flex-wrap items-center gap-2">
<button
onClick={handleCopyHtml}
className="inline-flex items-center gap-1.5 rounded-lg bg-blue-600 px-3.5 py-1.5 text-xs font-semibold text-white shadow-xs transition-colors hover:bg-blue-700 active:scale-95"
>
{copiedHtml ? (
<>
<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>
HTML 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 HTML
</>
)}
</button>
<button
onClick={handleCopyMarkdown}
className="inline-flex items-center gap-1 rounded-lg bg-gray-200 px-3 py-1.5 text-xs font-medium text-gray-800 transition-colors hover:bg-gray-300 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
>
{copiedMd ? "Markdown Copied!" : "Copy Markdown"}
</button>
<button
onClick={handleDownloadHtml}
className="inline-flex items-center gap-1 rounded-lg border border-gray-300 bg-white px-2.5 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
title="Download standalone HTML document"
>
<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>
.html
</button>
<button
onClick={handleDownloadMd}
className="inline-flex items-center gap-1 rounded-lg border border-gray-300 bg-white px-2.5 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
title="Download markdown 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>
.md
</button>
<button
onClick={() => setMarkdown(SAMPLE_MARKDOWN)}
className="rounded-md px-2.5 py-1 text-xs font-medium text-gray-600 underline-offset-2 hover:text-blue-600 hover:underline dark:text-gray-400 dark:hover:text-blue-400"
>
Reset Sample
</button>
<button
onClick={() => setMarkdown("")}
className="rounded-md px-2.5 py-1 text-xs font-medium text-red-600 underline-offset-2 hover:text-red-700 hover:underline dark:text-red-400 dark:hover:text-red-300"
>
Clear
</button>
</div>
</div>
{/* Quick Markdown Insertion Toolbar */}
<div className="flex flex-wrap items-center gap-1 rounded-lg border border-gray-200 bg-gray-50/70 p-1.5 text-xs dark:border-gray-800 dark:bg-gray-800/40">
<span className="px-2 font-medium text-gray-500 dark:text-gray-400">Insert:</span>
<button
type="button"
onClick={() => insertSnippet("**", "**", "bold text")}
className="rounded px-2 py-1 font-bold text-gray-700 hover:bg-gray-200 dark:text-gray-300 dark:hover:bg-gray-700"
title="Bold"
>
B
</button>
<button
type="button"
onClick={() => insertSnippet("*", "*", "italic text")}
className="rounded px-2 py-1 italic text-gray-700 hover:bg-gray-200 dark:text-gray-300 dark:hover:bg-gray-700"
title="Italic"
>
I
</button>
<button
type="button"
onClick={() => insertSnippet("## ", "\n", "Heading 2")}
className="rounded px-2 py-1 font-semibold text-gray-700 hover:bg-gray-200 dark:text-gray-300 dark:hover:bg-gray-700"
title="Heading 2"
>
H2
</button>
<button
type="button"
onClick={() => insertSnippet("### ", "\n", "Heading 3")}
className="rounded px-2 py-1 font-semibold text-gray-700 hover:bg-gray-200 dark:text-gray-300 dark:hover:bg-gray-700"
title="Heading 3"
>
H3
</button>
<button
type="button"
onClick={() => insertSnippet("[", "](https://example.com)", "link title")}
className="rounded px-2 py-1 text-gray-700 hover:bg-gray-200 dark:text-gray-300 dark:hover:bg-gray-700"
title="Link"
>
🔗 Link
</button>
<button
type="button"
onClick={() => insertSnippet("\n```javascript\n", "\n```\n", "// code here")}
className="rounded px-2 py-1 font-mono text-gray-700 hover:bg-gray-200 dark:text-gray-300 dark:hover:bg-gray-700"
title="Code Block"
>
{"</>"} Code
</button>
<button
type="button"
onClick={() => insertSnippet("\n> ", "\n", "Quote text")}
className="rounded px-2 py-1 text-gray-700 hover:bg-gray-200 dark:text-gray-300 dark:hover:bg-gray-700"
title="Blockquote"
>
❝ Quote
</button>
<button
type="button"
onClick={() => insertSnippet("- ", "\n", "List item")}
className="rounded px-2 py-1 text-gray-700 hover:bg-gray-200 dark:text-gray-300 dark:hover:bg-gray-700"
title="Bullet list"
>
• List
</button>
<button
type="button"
onClick={() => insertSnippet("- [ ] ", "\n", "Todo task")}
className="rounded px-2 py-1 text-gray-700 hover:bg-gray-200 dark:text-gray-300 dark:hover:bg-gray-700"
title="Checklist task"
>
☑ Task
</button>
<button
type="button"
onClick={() => insertSnippet("\n| Column 1 | Column 2 |\n| :--- | :--- |\n| Value 1 | Value 2 |\n", "")}
className="rounded px-2 py-1 text-gray-700 hover:bg-gray-200 dark:text-gray-300 dark:hover:bg-gray-700"
title="Table"
>
▦ Table
</button>
</div>
{/* Content Layout */}
<div
className={`grid gap-4 ${
viewMode === "split"
? "grid-cols-1 lg:grid-cols-2"
: "grid-cols-1"
}`}
>
{/* Left Pane: Markdown Input */}
{(viewMode === "split" || viewMode === "edit") && (
<div className="flex flex-col">
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-semibold text-gray-800 dark:text-gray-200">
Markdown Source
</span>
<span className="text-xs text-gray-500 dark:text-gray-400">
{stats.lines} lines · {stats.words} words · {stats.chars} chars
</span>
</div>
<textarea
id="markdown-editor-input"
value={markdown}
onChange={(e) => setMarkdown(e.target.value)}
placeholder="Type your markdown here..."
rows={22}
spellCheck={false}
className="w-full flex-1 resize-y rounded-lg border border-gray-300 bg-white p-4 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>
)}
{/* Right Pane: Rendered Preview or Raw HTML */}
{(viewMode === "split" || viewMode === "preview") && (
<div className="flex flex-col">
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-semibold text-gray-800 dark:text-gray-200">
Rendered Preview
</span>
<span className="text-xs text-gray-500 dark:text-gray-400">
Estimated read time: ~{stats.readingTimeMinutes} min
</span>
</div>
<div
className="markdown-preview-output min-h-[500px] flex-1 overflow-y-auto rounded-lg border border-gray-300 bg-white p-5 text-gray-900 shadow-xs dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100"
dangerouslySetInnerHTML={{ __html: renderedHtml }}
/>
</div>
)}
{viewMode === "rawHtml" && (
<div className="flex flex-col">
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-semibold text-gray-800 dark:text-gray-200">
Generated HTML Code
</span>
<span className="text-xs text-gray-500 dark:text-gray-400">
{renderedHtml.length} characters
</span>
</div>
<textarea
value={renderedHtml}
readOnly
rows={22}
className="w-full flex-1 resize-y rounded-lg border border-gray-300 bg-gray-50 p-4 font-mono text-xs leading-relaxed text-gray-900 shadow-xs focus:outline-none dark:border-gray-700 dark:bg-gray-900/90 dark:text-gray-200"
/>
</div>
)}
</div>
{/* Informational Footer */}
<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 Live Markdown Preview</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">⚡ Live Rendering</p>
<p className="mt-1 text-xs leading-normal">
Real-time parsing as you type using the fast and robust marked compiler with Github Flavored Markdown (GFM) support.
</p>
</div>
<div>
<p className="font-medium text-gray-800 dark:text-gray-200">📋 Export & Share</p>
<p className="mt-1 text-xs leading-normal">
Instantly copy the generated HTML code or download standalone .html and .md files ready for publishing.
</p>
</div>
<div>
<p className="font-medium text-gray-800 dark:text-gray-200">🔒 100% Private</p>
<p className="mt-1 text-xs leading-normal">
Zero server requests. All markdown processing and parsing occurs strictly within your browser.
</p>
</div>
</div>
</div>
</div>
</ToolLayout>
);
}

View file

@ -0,0 +1,592 @@
"use client";
import { useState, useMemo, useCallback } from "react";
import ToolLayout from "@/components/ToolLayout";
type BaseType = 2 | 8 | 10 | 16;
const BASE_OPTIONS: { label: string; value: BaseType; prefix: string; radixName: string }[] = [
{ label: "Decimal (Base 10)", value: 10, prefix: "", radixName: "Decimal" },
{ label: "Binary (Base 2)", value: 2, prefix: "0b", radixName: "Binary" },
{ label: "Hexadecimal (Base 16)", value: 16, prefix: "0x", radixName: "Hex" },
{ label: "Octal (Base 8)", value: 8, prefix: "0o", radixName: "Octal" },
];
const PRESETS = [
{ label: "0", base: 10, value: "0" },
{ label: "42", base: 10, value: "42" },
{ label: "255 (0xFF)", base: 10, value: "255" },
{ label: "1024 (1K)", base: 10, value: "1024" },
{ label: "65,535 (16-bit max)", base: 10, value: "65535" },
{ label: "4,294,967,295 (32-bit max)", base: 10, value: "4294967295" },
{ label: "0xDEADBEEF", base: 16, value: "DEADBEEF" },
{ label: "0b10101010", base: 2, value: "10101010" },
];
// Helper: validate digit in base
function isValidChar(char: string, base: number): boolean {
const code = char.toLowerCase().charCodeAt(0);
if (code >= 48 && code <= 57) {
// 0-9
return code - 48 < base;
}
if (code >= 97 && code <= 122) {
// a-z
return code - 97 + 10 < base;
}
return false;
}
// Parse string in arbitrary base (2-36) into BigInt
function parseBigIntBase(str: string, base: number): { value: bigint | null; error: string | null; isNegative: boolean } {
let s = str.trim();
if (!s) return { value: null, error: "Please enter a number", isNegative: false };
let isNegative = false;
if (s.startsWith("-")) {
isNegative = true;
s = s.substring(1).trim();
} else if (s.startsWith("+")) {
s = s.substring(1).trim();
}
// Strip prefixes if present
if (base === 2 && s.toLowerCase().startsWith("0b")) s = s.substring(2);
else if (base === 8 && s.toLowerCase().startsWith("0o")) s = s.substring(2);
else if (base === 16 && s.toLowerCase().startsWith("0x")) s = s.substring(2);
// Remove spaces or underscores used as separators
s = s.replace(/[\s_]/g, "");
if (!s) return { value: null, error: "Please enter digits", isNegative };
// Validate each character
for (let i = 0; i < s.length; i++) {
const ch = s[i];
if (!isValidChar(ch, base)) {
return {
value: null,
error: `Invalid character '${ch}' for base ${base}. Allowed: ${
base <= 10 ? `0 to ${base - 1}` : `0-9, A-${String.fromCharCode(65 + base - 11)}`
}`,
isNegative,
};
}
}
try {
const bigBase = BigInt(base);
let result = BigInt(0);
for (let i = 0; i < s.length; i++) {
const ch = s[i].toLowerCase();
let digitVal: bigint;
const code = ch.charCodeAt(0);
if (code >= 48 && code <= 57) {
digitVal = BigInt(code - 48);
} else {
digitVal = BigInt(code - 97 + 10);
}
result = result * bigBase + digitVal;
}
return {
value: isNegative ? -result : result,
error: null,
isNegative,
};
} catch {
return { value: null, error: "Number is too large to parse", isNegative };
}
}
// Format binary string with grouped nibbles/bytes
function formatBinary(binStr: string, groupSize: number = 4): string {
const isNeg = binStr.startsWith("-");
const raw = isNeg ? binStr.substring(1) : binStr;
const rem = raw.length % groupSize;
const padded = rem > 0 ? raw : raw;
const chunks: string[] = [];
let i = padded.length;
while (i > 0) {
const start = Math.max(0, i - groupSize);
chunks.unshift(padded.substring(start, i));
i = start;
}
return (isNeg ? "-" : "") + chunks.join(" ");
}
// Format Hex string with grouped bytes
function formatHex(hexStr: string, groupSize: number = 2): string {
const isNeg = hexStr.startsWith("-");
const raw = isNeg ? hexStr.substring(1) : hexStr;
const chunks: string[] = [];
let i = raw.length;
while (i > 0) {
const start = Math.max(0, i - groupSize);
chunks.unshift(raw.substring(start, i));
i = start;
}
return (isNeg ? "-" : "") + chunks.join(" ");
}
// Format Decimal with commas
function formatDecimal(decStr: string): string {
const isNeg = decStr.startsWith("-");
const raw = isNeg ? decStr.substring(1) : decStr;
return (isNeg ? "-" : "") + raw.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}
export default function NumberBaseConverterPage() {
const [inputVal, setInputVal] = useState<string>("42");
const [inputBase, setInputBase] = useState<BaseType>(10);
const [customBase, setCustomBase] = useState<number>(36);
const [includePrefixes, setIncludePrefixes] = useState<boolean>(false);
const [hexUppercase, setHexUppercase] = useState<boolean>(true);
const [enableGrouping, setEnableGrouping] = useState<boolean>(true);
const [copiedKey, setCopiedKey] = useState<string | null>(null);
// Parse current input
const parseResult = useMemo(() => {
return parseBigIntBase(inputVal, inputBase);
}, [inputVal, inputBase]);
// Copy helper
const copyToClipboard = useCallback(async (text: string, key: string) => {
try {
await navigator.clipboard.writeText(text);
setCopiedKey(key);
setTimeout(() => setCopiedKey((prev) => (prev === key ? null : prev)), 2000);
} catch {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);
setCopiedKey(key);
setTimeout(() => setCopiedKey((prev) => (prev === key ? null : prev)), 2000);
}
}, []);
// Compute conversions
const conversions = useMemo(() => {
if (parseResult.value === null) return null;
const val = parseResult.value;
const isNeg = val < BigInt(0);
const absVal = isNeg ? -val : val;
// Binary
const rawBin = (isNeg ? "-" : "") + absVal.toString(2);
const prefBin = (isNeg ? "-" : "") + (includePrefixes ? "0b" : "") + absVal.toString(2);
const displayBin = enableGrouping ? formatBinary(rawBin, 4) : rawBin;
// Octal
const rawOct = (isNeg ? "-" : "") + absVal.toString(8);
const prefOct = (isNeg ? "-" : "") + (includePrefixes ? "0o" : "") + absVal.toString(8);
const displayOct = enableGrouping ? formatBinary(rawOct, 3) : rawOct;
// Decimal
const rawDec = val.toString(10);
const displayDec = enableGrouping ? formatDecimal(rawDec) : rawDec;
// Hex
const rawHex = (isNeg ? "-" : "") + (hexUppercase ? absVal.toString(16).toUpperCase() : absVal.toString(16).toLowerCase());
const prefHex = (isNeg ? "-" : "") + (includePrefixes ? "0x" : "") + (hexUppercase ? absVal.toString(16).toUpperCase() : absVal.toString(16).toLowerCase());
const displayHex = enableGrouping ? formatHex(rawHex, 2) : rawHex;
// Custom Base (2-36)
let customBaseStr = "";
if (customBase >= 2 && customBase <= 36) {
customBaseStr = (isNeg ? "-" : "") + (hexUppercase ? absVal.toString(customBase).toUpperCase() : absVal.toString(customBase).toLowerCase());
}
// Bit length
const bitLength = absVal === BigInt(0) ? 1 : absVal.toString(2).length;
const byteCount = Math.ceil(bitLength / 8);
// Fixed-width binary representations for positive integers
const absBinStr = absVal.toString(2);
const pad8 = absBinStr.padStart(8, "0");
const pad16 = absBinStr.padStart(16, "0");
const pad32 = absBinStr.padStart(32, "0");
const pad64 = absBinStr.padStart(64, "0");
// ASCII representation (if in 0..127 or 0..255 range)
let asciiChar: string | null = null;
if (val >= BigInt(32) && val <= BigInt(126)) {
asciiChar = String.fromCharCode(Number(val));
} else if (val === BigInt(10)) {
asciiChar = "\\n (LF)";
} else if (val === BigInt(13)) {
asciiChar = "\\r (CR)";
} else if (val === BigInt(9)) {
asciiChar = "\\t (TAB)";
} else if (val === BigInt(0)) {
asciiChar = "\\0 (NUL)";
}
return {
bin: { raw: rawBin, formatted: displayBin, withPrefix: prefBin },
oct: { raw: rawOct, formatted: displayOct, withPrefix: prefOct },
dec: { raw: rawDec, formatted: displayDec, withPrefix: rawDec },
hex: { raw: rawHex, formatted: displayHex, withPrefix: prefHex },
custom: customBaseStr,
bitLength,
byteCount,
pad8,
pad16,
pad32,
pad64,
asciiChar,
isNegative: isNeg,
};
}, [parseResult, includePrefixes, hexUppercase, enableGrouping, customBase]);
return (
<ToolLayout
title="Number Base Converter"
description="Convert numbers seamlessly between Binary, Octal, Decimal, Hexadecimal, and any custom base from 2 to 36 with instant live results."
>
<div className="space-y-8">
{/* Input Configuration Box */}
<div className="rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-800 dark:bg-gray-900/50 sm:p-6 space-y-4">
{/* Base Selector */}
<div>
<label className="text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300 block mb-2">
Input Base:
</label>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{BASE_OPTIONS.map((baseOpt) => (
<button
key={baseOpt.value}
type="button"
onClick={() => setInputBase(baseOpt.value)}
className={`flex items-center justify-center gap-2 rounded-lg py-2.5 px-3 text-sm font-semibold border transition-all ${
inputBase === baseOpt.value
? "border-blue-600 bg-blue-600 text-white shadow-sm"
: "border-gray-300 bg-white text-gray-700 hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
}`}
>
<span>{baseOpt.label}</span>
</button>
))}
</div>
</div>
{/* Number Input Field */}
<div>
<div className="flex items-center justify-between mb-1.5">
<label htmlFor="num-input" className="text-xs font-semibold text-gray-600 dark:text-gray-400">
Number value (supports BigInt & arbitrary precision):
</label>
{inputVal && (
<button
type="button"
onClick={() => setInputVal("")}
className="text-xs text-gray-500 hover:text-red-600 dark:text-gray-400 dark:hover:text-red-400"
>
Clear input
</button>
)}
</div>
<div className="relative">
<input
id="num-input"
type="text"
value={inputVal}
onChange={(e) => setInputVal(e.target.value)}
placeholder={`Enter a ${BASE_OPTIONS.find((b) => b.value === inputBase)?.radixName} number...`}
className={`w-full p-3.5 font-mono text-base sm:text-lg rounded-lg border bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:outline-none ${
parseResult.error
? "border-red-400 focus:ring-red-400 dark:border-red-600"
: "border-gray-300 dark:border-gray-600 focus:ring-blue-500"
}`}
/>
</div>
{/* Error Message banner */}
{parseResult.error && (
<p className="mt-2 text-xs font-medium text-red-600 dark:text-red-400 flex items-center gap-1.5">
<svg className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{parseResult.error}
</p>
)}
</div>
{/* Preset Buttons */}
<div className="flex flex-wrap items-center gap-1.5 pt-1">
<span className="text-xs font-medium text-gray-500 dark:text-gray-400 mr-1">Presets:</span>
{PRESETS.map((p) => (
<button
key={p.label}
type="button"
onClick={() => {
setInputBase(p.base as BaseType);
setInputVal(p.value);
}}
className="rounded bg-white px-2.5 py-1 text-xs font-medium text-gray-700 border border-gray-300 hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-700 transition-colors"
>
{p.label}
</button>
))}
</div>
{/* Display Options / Toggles */}
<div className="flex flex-wrap items-center gap-4 pt-3 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={includePrefixes}
onChange={(e) => setIncludePrefixes(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span>Include prefixes (0b, 0o, 0x)</span>
</label>
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={hexUppercase}
onChange={(e) => setHexUppercase(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span>Uppercase HEX</span>
</label>
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={enableGrouping}
onChange={(e) => setEnableGrouping(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span>Format with digit separators</span>
</label>
</div>
</div>
{/* Conversions Output Cards (All 4 Primary Bases) */}
{conversions ? (
<div className="space-y-6">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{/* Decimal Card */}
<div className="flex flex-col justify-between rounded-xl border border-blue-200 bg-blue-50/40 p-4 dark:border-blue-900/40 dark:bg-blue-950/20">
<div>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="inline-flex h-2 w-2 rounded-full bg-blue-500" />
<span className="text-xs font-bold uppercase tracking-wider text-blue-900 dark:text-blue-300">
Decimal (Base 10)
</span>
</div>
<button
type="button"
onClick={() => copyToClipboard(conversions.dec.raw, "copy-dec")}
className="flex items-center gap-1 text-xs font-medium px-2 py-1 rounded bg-white dark:bg-gray-800 border border-blue-200 dark:border-blue-900 text-blue-700 dark:text-blue-300 hover:bg-blue-100 dark:hover:bg-blue-900/50 transition-colors"
>
{copiedKey === "copy-dec" ? "Copied!" : "Copy"}
</button>
</div>
<div className="font-mono text-lg font-bold text-gray-900 dark:text-white break-all">
{conversions.dec.formatted}
</div>
</div>
<div className="mt-3 pt-2 border-t border-blue-100 dark:border-blue-900/40 text-xs text-gray-500 dark:text-gray-400">
Standard base-10 numerical system
</div>
</div>
{/* Hexadecimal Card */}
<div className="flex flex-col justify-between rounded-xl border border-purple-200 bg-purple-50/40 p-4 dark:border-purple-900/40 dark:bg-purple-950/20">
<div>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="inline-flex h-2 w-2 rounded-full bg-purple-500" />
<span className="text-xs font-bold uppercase tracking-wider text-purple-900 dark:text-purple-300">
Hexadecimal (Base 16)
</span>
</div>
<button
type="button"
onClick={() => copyToClipboard(conversions.hex.withPrefix, "copy-hex")}
className="flex items-center gap-1 text-xs font-medium px-2 py-1 rounded bg-white dark:bg-gray-800 border border-purple-200 dark:border-purple-900 text-purple-700 dark:text-purple-300 hover:bg-purple-100 dark:hover:bg-purple-900/50 transition-colors"
>
{copiedKey === "copy-hex" ? "Copied!" : "Copy"}
</button>
</div>
<div className="font-mono text-lg font-bold text-gray-900 dark:text-white break-all">
{conversions.hex.formatted}
</div>
</div>
<div className="mt-3 pt-2 border-t border-purple-100 dark:border-purple-900/40 text-xs text-gray-500 dark:text-gray-400 flex items-center justify-between">
<span>Prefix: 0x</span>
<span>{conversions.byteCount} Byte{conversions.byteCount > 1 ? "s" : ""}</span>
</div>
</div>
{/* Binary Card */}
<div className="flex flex-col justify-between rounded-xl border border-emerald-200 bg-emerald-50/40 p-4 dark:border-emerald-900/40 dark:bg-emerald-950/20">
<div>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="inline-flex h-2 w-2 rounded-full bg-emerald-500" />
<span className="text-xs font-bold uppercase tracking-wider text-emerald-900 dark:text-emerald-300">
Binary (Base 2)
</span>
</div>
<button
type="button"
onClick={() => copyToClipboard(conversions.bin.withPrefix, "copy-bin")}
className="flex items-center gap-1 text-xs font-medium px-2 py-1 rounded bg-white dark:bg-gray-800 border border-emerald-200 dark:border-emerald-900 text-emerald-700 dark:text-emerald-300 hover:bg-emerald-100 dark:hover:bg-emerald-900/50 transition-colors"
>
{copiedKey === "copy-bin" ? "Copied!" : "Copy"}
</button>
</div>
<div className="font-mono text-base sm:text-lg font-bold text-gray-900 dark:text-white break-all leading-relaxed">
{conversions.bin.formatted}
</div>
</div>
<div className="mt-3 pt-2 border-t border-emerald-100 dark:border-emerald-900/40 text-xs text-gray-500 dark:text-gray-400 flex items-center justify-between">
<span>Prefix: 0b</span>
<span>{conversions.bitLength} Bit{conversions.bitLength > 1 ? "s" : ""}</span>
</div>
</div>
{/* Octal Card */}
<div className="flex flex-col justify-between rounded-xl border border-amber-200 bg-amber-50/40 p-4 dark:border-amber-900/40 dark:bg-amber-950/20">
<div>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="inline-flex h-2 w-2 rounded-full bg-amber-500" />
<span className="text-xs font-bold uppercase tracking-wider text-amber-900 dark:text-amber-300">
Octal (Base 8)
</span>
</div>
<button
type="button"
onClick={() => copyToClipboard(conversions.oct.withPrefix, "copy-oct")}
className="flex items-center gap-1 text-xs font-medium px-2 py-1 rounded bg-white dark:bg-gray-800 border border-amber-200 dark:border-amber-900 text-amber-700 dark:text-amber-300 hover:bg-amber-100 dark:hover:bg-amber-900/50 transition-colors"
>
{copiedKey === "copy-oct" ? "Copied!" : "Copy"}
</button>
</div>
<div className="font-mono text-lg font-bold text-gray-900 dark:text-white break-all">
{conversions.oct.formatted}
</div>
</div>
<div className="mt-3 pt-2 border-t border-amber-100 dark:border-amber-900/40 text-xs text-gray-500 dark:text-gray-400 flex items-center justify-between">
<span>Prefix: 0o</span>
<span>Digits: 0-7</span>
</div>
</div>
</div>
{/* Custom Base Converter & Bit Level Details */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-12">
{/* Custom Base Section */}
<div className="lg:col-span-6 rounded-xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-gray-900 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-bold uppercase tracking-wider text-gray-800 dark:text-gray-200">
Custom Base (Base {customBase})
</h3>
<button
type="button"
onClick={() => copyToClipboard(conversions.custom, "copy-custom")}
className="text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400 font-medium"
>
{copiedKey === "copy-custom" ? "Copied!" : "Copy"}
</button>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-gray-500 dark:text-gray-400">Radix:</span>
<input
type="range"
min="2"
max="36"
value={customBase}
onChange={(e) => setCustomBase(Number(e.target.value))}
className="flex-1 h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-600"
/>
<input
type="number"
min="2"
max="36"
value={customBase}
onChange={(e) => setCustomBase(Math.min(Math.max(Number(e.target.value), 2), 36))}
className="w-14 p-1.5 text-center font-mono text-xs rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800"
/>
</div>
<div className="font-mono text-base font-bold text-gray-900 dark:text-white break-all p-3 rounded-lg bg-gray-50 dark:bg-gray-800/60 border border-gray-200 dark:border-gray-700">
{conversions.custom || "N/A"}
</div>
</div>
{/* Bit Information & ASCII */}
<div className="lg:col-span-6 rounded-xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-gray-900 space-y-3">
<h3 className="text-sm font-bold uppercase tracking-wider text-gray-800 dark:text-gray-200">
Bit-Level Representation
</h3>
<div className="space-y-2 text-xs font-mono">
<div className="flex items-center justify-between p-2 rounded bg-gray-50 dark:bg-gray-800/60 border border-gray-200 dark:border-gray-700">
<span className="text-gray-500 dark:text-gray-400">8-bit (Byte):</span>
<span className="text-gray-900 dark:text-white font-bold">{conversions.pad8.slice(-8)}</span>
</div>
<div className="flex items-center justify-between p-2 rounded bg-gray-50 dark:bg-gray-800/60 border border-gray-200 dark:border-gray-700">
<span className="text-gray-500 dark:text-gray-400">16-bit (Word):</span>
<span className="text-gray-900 dark:text-white font-bold">{formatBinary(conversions.pad16.slice(-16), 4)}</span>
</div>
<div className="flex items-center justify-between p-2 rounded bg-gray-50 dark:bg-gray-800/60 border border-gray-200 dark:border-gray-700">
<span className="text-gray-500 dark:text-gray-400">ASCII / Character:</span>
<span className="text-blue-600 dark:text-blue-400 font-bold">
{conversions.asciiChar !== null ? `'${conversions.asciiChar}'` : "Non-printable / Out of ASCII range"}
</span>
</div>
</div>
</div>
</div>
</div>
) : null}
{/* Quick Reference Table (0 to 15) */}
<div className="rounded-xl border border-gray-200 bg-gray-50 p-5 dark:border-gray-800 dark:bg-gray-900/50">
<h3 className="text-sm font-bold text-gray-900 dark:text-white mb-3">
Quick Reference Table (Values 0 - 15)
</h3>
<div className="overflow-x-auto">
<table className="w-full text-left text-xs font-mono">
<thead>
<tr className="border-b border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400 uppercase">
<th className="pb-2 pr-4">Decimal (10)</th>
<th className="pb-2 pr-4">Binary (2)</th>
<th className="pb-2 pr-4">Octal (8)</th>
<th className="pb-2">Hexadecimal (16)</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800 text-gray-800 dark:text-gray-200">
{[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15].map((val) => (
<tr key={val} className="hover:bg-white dark:hover:bg-gray-800/50">
<td className="py-1.5 pr-4 font-semibold">{val}</td>
<td className="py-1.5 pr-4">{val.toString(2).padStart(4, "0")}</td>
<td className="py-1.5 pr-4">{val.toString(8)}</td>
<td className="py-1.5 font-semibold text-purple-600 dark:text-purple-400">
0x{val.toString(16).toUpperCase()}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</ToolLayout>
);
}

View file

@ -0,0 +1,500 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import ToolLayout from "@/components/ToolLayout";
const CHARSETS = {
uppercase: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
lowercase: "abcdefghijklmnopqrstuvwxyz",
digits: "0123456789",
symbols: "!@#$%^&*()_+-=[]{}|;:,.<>?~",
};
const AMBIGUOUS_CHARS = new Set(["i", "l", "1", "I", "o", "0", "O", "`", "'", '"', "|"]);
function getRandomInt(max: number): number {
const array = new Uint32Array(1);
crypto.getRandomValues(array);
return array[0] % max;
}
function shuffleArray<T>(array: T[]): T[] {
const shuffled = [...array];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = getRandomInt(i + 1);
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
}
interface StrengthInfo {
score: number; // 0 - 4
label: "Very Weak" | "Weak" | "Fair" | "Strong" | "Very Strong";
color: string;
barWidth: string;
entropy: number;
crackTime: string;
}
function calculateStrength(password: string, poolSize: number): StrengthInfo {
if (!password || poolSize === 0) {
return {
score: 0,
label: "Very Weak",
color: "bg-red-500",
barWidth: "10%",
entropy: 0,
crackTime: "Instant",
};
}
const length = password.length;
const entropy = Math.round(length * Math.log2(poolSize));
if (entropy < 35) {
return {
score: 1,
label: "Weak",
color: "bg-red-500",
barWidth: "25%",
entropy,
crackTime: "< 1 second",
};
} else if (entropy < 55) {
return {
score: 2,
label: "Fair",
color: "bg-yellow-500",
barWidth: "50%",
entropy,
crackTime: "Several hours to days",
};
} else if (entropy < 80) {
return {
score: 3,
label: "Strong",
color: "bg-green-500",
barWidth: "75%",
entropy,
crackTime: "Thousands of years",
};
} else {
return {
score: 4,
label: "Very Strong",
color: "bg-emerald-600",
barWidth: "100%",
entropy,
crackTime: "Trillions of centuries",
};
}
}
export default function PasswordGeneratorPage() {
const [length, setLength] = useState<number>(16);
const [useUppercase, setUseUppercase] = useState<boolean>(true);
const [useLowercase, setUseLowercase] = useState<boolean>(true);
const [useDigits, setUseDigits] = useState<boolean>(true);
const [useSymbols, setUseSymbols] = useState<boolean>(true);
const [excludeAmbiguous, setExcludeAmbiguous] = useState<boolean>(false);
const [password, setPassword] = useState<string>("");
const [history, setHistory] = useState<string[]>([]);
const [copied, setCopied] = useState<boolean>(false);
const [historyCopiedIndex, setHistoryCopiedIndex] = useState<number | null>(null);
const generatePassword = useCallback(() => {
let pool = "";
const guaranteedChars: string[] = [];
const getCleanCharset = (chars: string) => {
if (!excludeAmbiguous) return chars;
return chars
.split("")
.filter((c) => !AMBIGUOUS_CHARS.has(c))
.join("");
};
if (useUppercase) {
const set = getCleanCharset(CHARSETS.uppercase);
if (set.length > 0) {
pool += set;
guaranteedChars.push(set[getRandomInt(set.length)]);
}
}
if (useLowercase) {
const set = getCleanCharset(CHARSETS.lowercase);
if (set.length > 0) {
pool += set;
guaranteedChars.push(set[getRandomInt(set.length)]);
}
}
if (useDigits) {
const set = getCleanCharset(CHARSETS.digits);
if (set.length > 0) {
pool += set;
guaranteedChars.push(set[getRandomInt(set.length)]);
}
}
if (useSymbols) {
const set = getCleanCharset(CHARSETS.symbols);
if (set.length > 0) {
pool += set;
guaranteedChars.push(set[getRandomInt(set.length)]);
}
}
if (pool.length === 0) {
setPassword("");
return;
}
const resultChars = [...guaranteedChars];
const remainingCount = Math.max(0, length - guaranteedChars.length);
for (let i = 0; i < remainingCount; i++) {
resultChars.push(pool[getRandomInt(pool.length)]);
}
const shuffled = shuffleArray(resultChars).slice(0, length).join("");
setPassword(shuffled);
// Add to history if unique
setHistory((prev) => {
if (shuffled && !prev.includes(shuffled)) {
return [shuffled, ...prev.slice(0, 4)];
}
return prev;
});
}, [length, useUppercase, useLowercase, useDigits, useSymbols, excludeAmbiguous]);
// Generate on initial load and option changes
useEffect(() => {
generatePassword();
}, [generatePassword]);
const handleCopy = async () => {
if (!password) return;
try {
await navigator.clipboard.writeText(password);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error("Failed to copy password:", err);
}
};
const handleCopyHistory = async (pwd: string, idx: number) => {
try {
await navigator.clipboard.writeText(pwd);
setHistoryCopiedIndex(idx);
setTimeout(() => setHistoryCopiedIndex(null), 2000);
} catch (err) {
console.error("Failed to copy history item:", err);
}
};
// Calculate pool size for entropy
let poolSize = 0;
if (useUppercase) poolSize += 26;
if (useLowercase) poolSize += 26;
if (useDigits) poolSize += 10;
if (useSymbols) poolSize += CHARSETS.symbols.length;
if (excludeAmbiguous) poolSize -= 10;
const strength = calculateStrength(password, poolSize);
const lengthPresets = [8, 12, 16, 24, 32, 64];
// At least one charset should be selected
const atLeastOneSelected = useUppercase || useLowercase || useDigits || useSymbols;
return (
<ToolLayout
title="Password Generator"
description="Generate strong, cryptographically secure random passwords with configurable character sets and live strength analysis."
>
<div className="space-y-6">
{/* Generated Password Display Card */}
<div className="relative rounded-xl border border-gray-200 bg-gray-50/70 p-5 dark:border-gray-700/80 dark:bg-gray-800/50">
<div className="flex flex-col items-center justify-between gap-4 sm:flex-row">
<div className="w-full min-w-0 flex-1 overflow-x-auto py-1">
<span className="mb-1 block text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
Generated Password
</span>
<p className="select-all break-all font-mono text-xl font-bold tracking-wider text-gray-900 dark:text-white sm:text-2xl">
{password || (
<span className="text-sm font-normal text-red-500">
Select at least one character set below
</span>
)}
</p>
</div>
<div className="flex w-full shrink-0 items-center justify-end gap-2 sm:w-auto">
<button
type="button"
onClick={generatePassword}
disabled={!atLeastOneSelected}
title="Regenerate password"
className="flex items-center gap-1.5 rounded-lg border border-gray-300 bg-white px-3 py-2.5 text-sm font-medium text-gray-700 shadow-sm transition-colors hover:bg-gray-50 disabled:opacity-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
<span className="hidden sm:inline">Refresh</span>
</button>
<button
type="button"
onClick={handleCopy}
disabled={!password}
className={`flex items-center gap-2 rounded-lg px-5 py-2.5 text-sm font-medium shadow-sm transition-all disabled:opacity-50 ${
copied
? "bg-green-600 text-white"
: "bg-blue-600 text-white hover:bg-blue-700 active:scale-95"
}`}
>
{copied ? (
<>
<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!
</>
) : (
<>
<svg className="h-4 w-4" 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 Password
</>
)}
</button>
</div>
</div>
{/* Strength Bar */}
<div className="mt-4 pt-3 border-t border-gray-200 dark:border-gray-700">
<div className="mb-1.5 flex items-center justify-between text-xs">
<span className="font-medium text-gray-600 dark:text-gray-400">
Security Strength:{" "}
<strong className="text-gray-900 dark:text-white font-semibold">
{strength.label}
</strong>
</span>
<span className="text-gray-500 dark:text-gray-400">
~{strength.entropy} bits of entropy ({strength.crackTime})
</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div
className={`h-full transition-all duration-300 ${strength.color}`}
style={{ width: strength.barWidth }}
/>
</div>
</div>
</div>
{/* Configuration Controls */}
<div className="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800/40">
<h3 className="mb-4 text-xs font-semibold uppercase tracking-wider text-gray-700 dark:text-gray-300">
Password Settings
</h3>
<div className="space-y-5">
{/* Length Slider & Presets */}
<div>
<div className="mb-2 flex items-center justify-between">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
Password Length: <span className="font-bold text-blue-600 dark:text-blue-400">{length}</span> characters
</label>
<div className="flex gap-1">
{lengthPresets.map((preset) => (
<button
key={preset}
type="button"
onClick={() => setLength(preset)}
className={`rounded px-2 py-0.5 text-xs font-medium transition-colors ${
length === preset
? "bg-blue-600 text-white"
: "bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
}`}
>
{preset}
</button>
))}
</div>
</div>
<div className="flex items-center gap-4">
<input
type="range"
min="8"
max="128"
value={length}
onChange={(e) => setLength(parseInt(e.target.value))}
className="h-2.5 w-full cursor-pointer appearance-none rounded-lg bg-gray-200 accent-blue-600 dark:bg-gray-700"
/>
<input
type="number"
min="8"
max="128"
value={length}
onChange={(e) => {
const val = Math.min(Math.max(8, parseInt(e.target.value) || 8), 128);
setLength(val);
}}
className="w-18 rounded-lg border border-gray-300 bg-white px-2.5 py-1.5 text-center text-sm font-medium text-gray-900 focus:border-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
</div>
</div>
{/* Character Set Checkboxes */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4 pt-2">
<label className="flex cursor-pointer items-center gap-2.5 rounded-lg border border-gray-200 p-3 transition-colors hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800">
<input
type="checkbox"
checked={useUppercase}
onChange={(e) => setUseUppercase(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"
/>
<div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
Uppercase
</div>
<div className="font-mono text-xs text-gray-500 dark:text-gray-400">
A-Z
</div>
</div>
</label>
<label className="flex cursor-pointer items-center gap-2.5 rounded-lg border border-gray-200 p-3 transition-colors hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800">
<input
type="checkbox"
checked={useLowercase}
onChange={(e) => setUseLowercase(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"
/>
<div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
Lowercase
</div>
<div className="font-mono text-xs text-gray-500 dark:text-gray-400">
a-z
</div>
</div>
</label>
<label className="flex cursor-pointer items-center gap-2.5 rounded-lg border border-gray-200 p-3 transition-colors hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800">
<input
type="checkbox"
checked={useDigits}
onChange={(e) => setUseDigits(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"
/>
<div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
Digits
</div>
<div className="font-mono text-xs text-gray-500 dark:text-gray-400">
0-9
</div>
</div>
</label>
<label className="flex cursor-pointer items-center gap-2.5 rounded-lg border border-gray-200 p-3 transition-colors hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800">
<input
type="checkbox"
checked={useSymbols}
onChange={(e) => setUseSymbols(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"
/>
<div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
Symbols
</div>
<div className="font-mono text-xs text-gray-500 dark:text-gray-400">
!@#$%^&amp;*...
</div>
</div>
</label>
</div>
{/* Ambiguous filter */}
<div className="pt-1">
<label className="inline-flex cursor-pointer items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
type="checkbox"
checked={excludeAmbiguous}
onChange={(e) => setExcludeAmbiguous(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>Exclude ambiguous characters (e.g. <code>i, l, 1, I, o, 0, O</code>)</span>
</label>
</div>
</div>
</div>
{/* History of Generated Passwords */}
{history.length > 1 && (
<div className="rounded-lg border border-gray-200 bg-gray-50/50 p-4 dark:border-gray-700/80 dark:bg-gray-800/40">
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wider text-gray-600 dark:text-gray-400">
Recent Passwords (Session History)
</h3>
<div className="divide-y divide-gray-200 dark:divide-gray-700">
{history.map((pwd, idx) => (
<div
key={`${pwd}-${idx}`}
className="flex items-center justify-between py-2 text-xs"
>
<span className="truncate font-mono text-gray-800 dark:text-gray-200">
{pwd}
</span>
<button
type="button"
onClick={() => handleCopyHistory(pwd, idx)}
className="ml-2 shrink-0 rounded bg-white px-2 py-1 font-medium text-gray-700 shadow-xs hover:bg-gray-100 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
>
{historyCopiedIndex === idx ? "Copied!" : "Copy"}
</button>
</div>
))}
</div>
</div>
)}
{/* Information Grid */}
<div className="grid grid-cols-1 gap-4 pt-2 text-xs text-gray-500 dark:text-gray-400 sm:grid-cols-3">
<div className="rounded-lg border border-gray-100 bg-gray-50 p-3.5 dark:border-gray-800 dark:bg-gray-800/40">
<h4 className="font-semibold text-gray-800 dark:text-gray-200">CSPRNG Powered</h4>
<p className="mt-1">
Uses <code>crypto.getRandomValues()</code> for cryptographically secure pseudo-random number generation directly in browser.
</p>
</div>
<div className="rounded-lg border border-gray-100 bg-gray-50 p-3.5 dark:border-gray-800 dark:bg-gray-800/40">
<h4 className="font-semibold text-gray-800 dark:text-gray-200">Zero Transmission</h4>
<p className="mt-1">
Passwords are never transmitted across a network, cached on a server, or stored persistently.
</p>
</div>
<div className="rounded-lg border border-gray-100 bg-gray-50 p-3.5 dark:border-gray-800 dark:bg-gray-800/40">
<h4 className="font-semibold text-gray-800 dark:text-gray-200">Guaranteed Distribution</h4>
<p className="mt-1">
Ensures every active character category is represented and evenly distributed via Fisher-Yates shuffle.
</p>
</div>
</div>
</div>
</ToolLayout>
);
}

View file

@ -0,0 +1,386 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import ToolLayout from "@/components/ToolLayout";
import QRCode from "qrcode";
type SizeOption = "small" | "medium" | "large";
type ErrorCorrectionLevel = "L" | "M" | "Q" | "H";
const SIZE_MAP: Record<SizeOption, { px: number; label: string }> = {
small: { px: 200, label: "Small (200x200)" },
medium: { px: 320, label: "Medium (320x320)" },
large: { px: 500, label: "Large (500x500)" },
};
const ERROR_CORRECTION_DESCRIPTIONS: Record<ErrorCorrectionLevel, string> = {
L: "Low (~7% recovery)",
M: "Medium (~15% recovery)",
Q: "Quartile (~25% recovery)",
H: "High (~30% recovery)",
};
export default function QrCodePage() {
const [inputText, setInputText] = useState<string>("https://devtoolbox.com");
const [size, setSize] = useState<SizeOption>("medium");
const [errorCorrection, setErrorCorrection] = useState<ErrorCorrectionLevel>("M");
const [margin, setMargin] = useState<number>(2);
const [fgColor, setFgColor] = useState<string>("#000000");
const [bgColor, setBgColor] = useState<string>("#ffffff");
const [dataUrl, setDataUrl] = useState<string>("");
const [svgString, setSvgString] = useState<string>("");
const [error, setError] = useState<string | null>(null);
const [copiedDataUrl, setCopiedDataUrl] = useState<boolean>(false);
const generateQRCode = useCallback(async () => {
const textToEncode = inputText.trim();
if (!textToEncode) {
setDataUrl("");
setSvgString("");
setError(null);
return;
}
try {
const options = {
width: SIZE_MAP[size].px,
margin: margin,
errorCorrectionLevel: errorCorrection,
color: {
dark: fgColor,
light: bgColor,
},
};
const [url, svg] = await Promise.all([
QRCode.toDataURL(textToEncode, options),
QRCode.toString(textToEncode, { ...options, type: "svg" }),
]);
setDataUrl(url);
setSvgString(svg);
setError(null);
} catch (err: unknown) {
console.error("QR Code generation error:", err);
const message = err instanceof Error ? err.message : "Failed to generate QR code";
setError(message);
setDataUrl("");
}
}, [inputText, size, errorCorrection, margin, fgColor, bgColor]);
useEffect(() => {
generateQRCode();
}, [generateQRCode]);
const handleDownloadPng = () => {
if (!dataUrl) return;
const link = document.createElement("a");
link.href = dataUrl;
link.download = `qrcode-${Date.now()}.png`;
link.click();
};
const handleDownloadSvg = () => {
if (!svgString) return;
const blob = new Blob([svgString], { type: "image/svg+xml;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `qrcode-${Date.now()}.svg`;
link.click();
URL.revokeObjectURL(url);
};
const handleCopyDataUrl = async () => {
if (!dataUrl) return;
try {
await navigator.clipboard.writeText(dataUrl);
setCopiedDataUrl(true);
setTimeout(() => setCopiedDataUrl(false), 2000);
} catch (err) {
console.error("Failed to copy data url:", err);
}
};
const presetTemplates = [
{ label: "Website URL", value: "https://github.com" },
{ label: "Wi-Fi Network", value: "WIFI:S:MyHomeWiFi;T:WPA;P:MySecretPassword;;" },
{ label: "Email", value: "mailto:support@example.com?subject=Hello&body=Hi there" },
{ label: "Phone", value: "tel:+1234567890" },
{ label: "SMS", value: "smsto:+1234567890:Hello!" },
];
return (
<ToolLayout
title="QR Code Generator"
description="Create high-resolution, custom QR codes for websites, Wi-Fi access, plain text, emails, and contact details with instant download."
>
<div className="space-y-6">
<div className="grid grid-cols-1 gap-8 lg:grid-cols-12">
{/* Controls Column */}
<div className="space-y-5 lg:col-span-7">
{/* Text / Content Input */}
<div>
<div className="mb-2 flex items-center justify-between">
<label
htmlFor="qr-input"
className="text-xs font-semibold uppercase tracking-wider text-gray-700 dark:text-gray-300"
>
Content / URL
</label>
{inputText && (
<button
type="button"
onClick={() => setInputText("")}
className="text-xs text-red-600 hover:text-red-700 dark:text-red-400"
>
Clear
</button>
)}
</div>
<textarea
id="qr-input"
rows={3}
value={inputText}
onChange={(e) => setInputText(e.target.value)}
placeholder="Enter a URL, text, or select a template below..."
className="w-full rounded-lg border border-gray-300 bg-white p-3 font-mono text-sm text-gray-900 shadow-inner focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
/>
{/* Presets */}
<div className="mt-2.5 flex flex-wrap items-center gap-1.5">
<span className="text-xs text-gray-500 dark:text-gray-400">Templates:</span>
{presetTemplates.map((tpl) => (
<button
key={tpl.label}
type="button"
onClick={() => setInputText(tpl.value)}
className="rounded-md bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
{tpl.label}
</button>
))}
</div>
</div>
{/* Configuration Options */}
<div className="rounded-lg border border-gray-200 bg-gray-50/50 p-4 dark:border-gray-700/60 dark:bg-gray-800/40">
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wider text-gray-700 dark:text-gray-300">
Customization Options
</h3>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{/* Size Selector */}
<div>
<label className="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
Export Resolution
</label>
<select
value={size}
onChange={(e) => setSize(e.target.value as SizeOption)}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 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-800 dark:text-gray-100"
>
<option value="small">{SIZE_MAP.small.label}</option>
<option value="medium">{SIZE_MAP.medium.label}</option>
<option value="large">{SIZE_MAP.large.label}</option>
</select>
</div>
{/* Error Correction */}
<div>
<label className="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
Error Correction Level
</label>
<select
value={errorCorrection}
onChange={(e) => setErrorCorrection(e.target.value as ErrorCorrectionLevel)}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 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-800 dark:text-gray-100"
>
<option value="L">L - {ERROR_CORRECTION_DESCRIPTIONS.L}</option>
<option value="M">M - {ERROR_CORRECTION_DESCRIPTIONS.M}</option>
<option value="Q">Q - {ERROR_CORRECTION_DESCRIPTIONS.Q}</option>
<option value="H">H - {ERROR_CORRECTION_DESCRIPTIONS.H}</option>
</select>
</div>
{/* Margin / Quiet Zone */}
<div>
<label className="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
Margin / Border: {margin} module{margin > 1 ? "s" : ""}
</label>
<div className="flex items-center gap-2">
<input
type="range"
min="0"
max="6"
value={margin}
onChange={(e) => setMargin(parseInt(e.target.value))}
className="h-2 w-full cursor-pointer appearance-none rounded-lg bg-gray-200 accent-blue-600 dark:bg-gray-700"
/>
<span className="w-6 text-center text-xs font-medium text-gray-700 dark:text-gray-300">
{margin}
</span>
</div>
</div>
{/* Colors */}
<div>
<label className="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
Colors (Foreground / Background)
</label>
<div className="flex items-center gap-3 pt-1">
<div className="flex items-center gap-1.5">
<input
type="color"
value={fgColor}
onChange={(e) => setFgColor(e.target.value)}
className="h-8 w-8 cursor-pointer rounded border border-gray-300 bg-transparent p-0.5 dark:border-gray-600"
title="Foreground color"
/>
<span className="font-mono text-xs text-gray-700 dark:text-gray-300">
{fgColor}
</span>
</div>
<div className="flex items-center gap-1.5">
<input
type="color"
value={bgColor}
onChange={(e) => setBgColor(e.target.value)}
className="h-8 w-8 cursor-pointer rounded border border-gray-300 bg-transparent p-0.5 dark:border-gray-600"
title="Background color"
/>
<span className="font-mono text-xs text-gray-700 dark:text-gray-300">
{bgColor}
</span>
</div>
{(fgColor !== "#000000" || bgColor !== "#ffffff") && (
<button
type="button"
onClick={() => {
setFgColor("#000000");
setBgColor("#ffffff");
}}
className="text-xs text-blue-600 hover:underline dark:text-blue-400"
>
Reset
</button>
)}
</div>
</div>
</div>
</div>
</div>
{/* QR Code Preview & Download Column */}
<div className="flex flex-col items-center justify-center rounded-xl border border-gray-200 bg-gray-50/50 p-6 dark:border-gray-700/60 dark:bg-gray-800/40 lg:col-span-5">
<h3 className="mb-4 text-xs font-semibold uppercase tracking-wider text-gray-700 dark:text-gray-300">
Live QR Preview
</h3>
{error ? (
<div className="flex h-64 w-full flex-col items-center justify-center rounded-lg border border-red-200 bg-red-50 p-4 text-center dark:border-red-900/50 dark:bg-red-950/20">
<svg className="mb-2 h-8 w-8 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<p className="text-xs font-medium text-red-700 dark:text-red-400">{error}</p>
<p className="mt-1 text-[11px] text-red-600 dark:text-red-500">
Try shortening the text or lowering the error correction level.
</p>
</div>
) : dataUrl ? (
<div className="flex flex-col items-center">
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white p-3 shadow-md dark:border-gray-700 dark:bg-gray-900">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={dataUrl}
alt={`QR Code for ${inputText}`}
className="max-h-64 max-w-full object-contain"
width={SIZE_MAP[size].px}
height={SIZE_MAP[size].px}
/>
</div>
<p className="mt-2 text-center text-xs text-gray-400 dark:text-gray-500">
{SIZE_MAP[size].px} × {SIZE_MAP[size].px} px • Level {errorCorrection}
</p>
{/* Download Actions */}
<div className="mt-5 flex w-full flex-col gap-2">
<button
type="button"
onClick={handleDownloadPng}
className="flex w-full items-center justify-center gap-2 rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
>
<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>
<div className="flex gap-2">
<button
type="button"
onClick={handleDownloadSvg}
className="flex-1 rounded-lg bg-gray-200 px-3 py-2 text-xs font-medium text-gray-800 transition-colors hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
>
Download SVG
</button>
<button
type="button"
onClick={handleCopyDataUrl}
className="flex-1 rounded-lg bg-gray-200 px-3 py-2 text-xs font-medium text-gray-800 transition-colors hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
>
{copiedDataUrl ? "Copied Data URI!" : "Copy Data URI"}
</button>
</div>
</div>
</div>
) : (
<div className="flex h-64 w-full flex-col items-center justify-center rounded-lg border border-dashed border-gray-300 p-4 text-center dark:border-gray-700">
<span className="text-3xl">📱</span>
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
Enter text or a URL above to render a live QR Code.
</p>
</div>
)}
</div>
</div>
{/* Informational Details */}
<div className="grid grid-cols-1 gap-4 pt-4 text-xs text-gray-500 dark:text-gray-400 sm:grid-cols-3">
<div className="rounded-lg border border-gray-100 bg-gray-50 p-3 dark:border-gray-800 dark:bg-gray-800/40">
<h4 className="font-semibold text-gray-800 dark:text-gray-200">Error Correction (Reed-Solomon)</h4>
<p className="mt-1">
Higher error correction allows the QR code to be scanned even if damaged, partially obstructed, or printed on textured materials.
</p>
</div>
<div className="rounded-lg border border-gray-100 bg-gray-50 p-3 dark:border-gray-800 dark:bg-gray-800/40">
<h4 className="font-semibold text-gray-800 dark:text-gray-200">Universal Smartphone Support</h4>
<p className="mt-1">
Scannable natively with standard iOS and Android camera apps without requiring any third-party scanner software.
</p>
</div>
<div className="rounded-lg border border-gray-100 bg-gray-50 p-3 dark:border-gray-800 dark:bg-gray-800/40">
<h4 className="font-semibold text-gray-800 dark:text-gray-200">Zero Server Transfer</h4>
<p className="mt-1">
Your sensitive Wi-Fi passwords, contact cards, and URLs are encoded entirely in client JavaScript.
</p>
</div>
</div>
</div>
</ToolLayout>
);
}

View file

@ -0,0 +1,567 @@
"use client";
import { useState, useMemo, useCallback } from "react";
import ToolLayout from "@/components/ToolLayout";
interface MatchItem {
index: number;
match: string;
start: number;
end: number;
groups: string[];
namedGroups: Record<string, string> | null;
}
const PRESETS = [
{
name: "Email Address",
pattern: "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}",
flags: { g: true, i: true, m: false, s: false, u: false },
test: "Contact our support at support@devtoolbox.co or reach john.doe@example.org. Invalid: @missing.com, test@.com",
},
{
name: "URL / Web Link",
pattern: "https?:\\/\\/(?:www\\.)?[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b(?:[-a-zA-Z0-9()@:%_\\+.~#?&\\/\\/=]*)",
flags: { g: true, i: true, m: false, s: false, u: false },
test: "Visit https://devtoolbox.dev/tools/regex-tester or http://example.com:8080/path?query=1#section for details.",
},
{
name: "IPv4 Address",
pattern: "\\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\b",
flags: { g: true, i: false, m: false, s: false, u: false },
test: "Server IPs: 192.168.1.1, 10.0.0.254, and 127.0.0.1. Invalid: 999.1.1.1, 192.168.1",
},
{
name: "Date (YYYY-MM-DD)",
pattern: "\\b(\\d{4})-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])\\b",
flags: { g: true, i: false, m: false, s: false, u: false },
test: "Event dates: 2026-08-30 (today), 2025-12-31 (New Year), 2024-02-29 (leap). Invalid: 2023-13-40",
},
{
name: "Hex Color",
pattern: "#(?:[0-9a-fA-F]{3}){1,2}\\b",
flags: { g: true, i: true, m: false, s: false, u: false },
test: "Primary colors: #3b82f6 (blue), #ef4444 (red), #10b981 (green), #fff, #000. Not color: #zz1122",
},
{
name: "Phone Number (US)",
pattern: "(?:\\+?1[-. ]?)?\\(?([0-9]{3})\\)?[-. ]?([0-9]{3})[-. ]?([0-9]{4})",
flags: { g: true, i: false, m: false, s: false, u: false },
test: "Call us at (555) 234-5678, +1-800-555-0199, or 555.867.5309 for assistance.",
},
];
function escapeHtml(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
export default function RegexTesterPage() {
const [pattern, setPattern] = useState(
"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}"
);
const [flags, setFlags] = useState<{
g: boolean;
i: boolean;
m: boolean;
s: boolean;
u: boolean;
}>({
g: true,
i: true,
m: false,
s: false,
u: false,
});
const [testString, setTestString] = useState(
"Contact our team at hello@devtoolbox.io or support@example.com! Invalid: @blank.com, admin@."
);
const [replacement, setReplacement] = useState("");
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
const [copiedReplace, setCopiedReplace] = useState(false);
const flagString = useMemo(() => {
let f = "";
if (flags.g) f += "g";
if (flags.i) f += "i";
if (flags.m) f += "m";
if (flags.s) f += "s";
if (flags.u) f += "u";
return f;
}, [flags]);
const { matches, error, highlightedHtml, replacedText } = useMemo(() => {
if (!pattern) {
return {
matches: [],
error: null,
highlightedHtml: escapeHtml(testString),
replacedText: testString,
};
}
try {
const regex = new RegExp(pattern, flagString);
const isGlobal = flags.g;
const foundMatches: MatchItem[] = [];
if (!testString) {
return {
matches: [],
error: null,
highlightedHtml: "",
replacedText: "",
};
}
if (isGlobal) {
let m: RegExpExecArray | null;
let iteration = 0;
const maxMatches = 1000;
while ((m = regex.exec(testString)) !== null) {
iteration++;
if (iteration > maxMatches) break;
const groups = m.slice(1).map((g) => (g === undefined ? "" : g));
const namedGroups = m.groups ? { ...m.groups } : null;
foundMatches.push({
index: foundMatches.length + 1,
match: m[0],
start: m.index,
end: m.index + m[0].length,
groups,
namedGroups,
});
// Prevent infinite loops on zero-length matches (e.g. ^, $, \b, (?=...))
if (m[0].length === 0) {
regex.lastIndex++;
if (regex.lastIndex > testString.length) break;
}
}
} else {
const m = regex.exec(testString);
if (m) {
const groups = m.slice(1).map((g) => (g === undefined ? "" : g));
const namedGroups = m.groups ? { ...m.groups } : null;
foundMatches.push({
index: 1,
match: m[0],
start: m.index,
end: m.index + m[0].length,
groups,
namedGroups,
});
}
}
// Build Highlighted HTML
let html = "";
let lastIndex = 0;
for (let i = 0; i < foundMatches.length; i++) {
const item = foundMatches[i];
if (item.start > lastIndex) {
html += escapeHtml(testString.substring(lastIndex, item.start));
}
if (item.match.length === 0) {
// Zero-length match indicator
html += `<span class="bg-amber-400 text-black font-bold px-0.5 rounded text-xs border border-amber-500" title="Zero-width match at index ${item.start}">|</span>`;
} else {
const colorClass =
i % 2 === 0
? "bg-yellow-200 dark:bg-yellow-800/70 text-yellow-950 dark:text-yellow-100 border-b-2 border-yellow-500"
: "bg-teal-200 dark:bg-teal-800/70 text-teal-950 dark:text-teal-100 border-b-2 border-teal-500";
html += `<mark class="${colorClass} px-0.5 rounded-xs font-mono font-medium">${escapeHtml(
item.match
)}</mark>`;
}
lastIndex = item.end;
}
if (lastIndex < testString.length) {
html += escapeHtml(testString.substring(lastIndex));
}
// Compute replaced text if substitution is used
let repText = "";
try {
repText = testString.replace(new RegExp(pattern, flagString), replacement);
} catch {
repText = testString;
}
return {
matches: foundMatches,
error: null,
highlightedHtml: html || "<span class='text-gray-400 italic'>No content</span>",
replacedText: repText,
};
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Invalid regular expression";
return {
matches: [],
error: msg,
highlightedHtml: escapeHtml(testString),
replacedText: testString,
};
}
}, [pattern, flagString, flags.g, testString, replacement]);
const handleCopyMatch = useCallback(async (matchText: string, idx: number) => {
try {
await navigator.clipboard.writeText(matchText);
setCopiedIndex(idx);
setTimeout(() => setCopiedIndex(null), 2000);
} catch {
// Fallback
}
}, []);
const handleCopyReplaced = useCallback(async () => {
try {
await navigator.clipboard.writeText(replacedText);
setCopiedReplace(true);
setTimeout(() => setCopiedReplace(false), 2000);
} catch {
// Fallback
}
}, [replacedText]);
const loadPreset = (p: (typeof PRESETS)[0]) => {
setPattern(p.pattern);
setFlags(p.flags);
setTestString(p.test);
};
const toggleFlag = (flagKey: keyof typeof flags) => {
setFlags((prev) => ({ ...prev, [flagKey]: !prev[flagKey] }));
};
return (
<ToolLayout
title="Regex Tester & Debugger"
description="Test, validate, and debug regular expressions with real-time match highlighting, captured group extraction, and substitution."
>
<title>Regex Tester &amp; Debugger Online — DevToolbox</title>
<meta
name="description"
content="Free online Regex tester with live match highlighting, capture group breakdown, regex flags (g, i, m, s, u), and substitution support."
/>
<div className="space-y-6">
{/* Preset Selector */}
<div className="flex flex-wrap items-center justify-between gap-3 p-3 bg-gray-50 dark:bg-gray-800/60 rounded-xl border border-gray-200 dark:border-gray-700/80">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
Presets:
</span>
<div className="flex flex-wrap gap-1.5">
{PRESETS.map((p, i) => (
<button
key={i}
type="button"
onClick={() => loadPreset(p)}
className="px-2.5 py-1 text-xs font-medium bg-white dark:bg-gray-850 hover:bg-blue-50 dark:hover:bg-blue-950/40 text-gray-700 dark:text-gray-200 hover:text-blue-600 dark:hover:text-blue-400 border border-gray-200 dark:border-gray-700 rounded-md transition-colors cursor-pointer shadow-xs"
>
{p.name}
</button>
))}
</div>
</div>
<button
type="button"
onClick={() => {
setPattern("");
setTestString("");
setReplacement("");
}}
className="text-xs text-rose-600 dark:text-rose-400 hover:underline cursor-pointer"
>
Clear All
</button>
</div>
{/* Regex Pattern Input & Flags Bar */}
<div className="space-y-3">
<label
htmlFor="regex-pattern-input"
className="text-sm font-semibold text-gray-800 dark:text-gray-200 flex items-center justify-between"
>
<span>Regular Expression Pattern</span>
<span className="text-xs font-normal text-gray-500 dark:text-gray-400 font-mono">
/{pattern}/{flagString}
</span>
</label>
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
<div className="relative flex-1 flex items-center">
<span className="absolute left-3.5 text-gray-400 dark:text-gray-500 font-mono text-base select-none pointer-events-none">
/
</span>
<input
id="regex-pattern-input"
type="text"
value={pattern}
onChange={(e) => setPattern(e.target.value)}
placeholder="Enter regex pattern, e.g. [a-z0-9]+"
className="w-full pl-7 pr-7 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 font-mono text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none transition-shadow"
/>
<span className="absolute right-3.5 text-gray-400 dark:text-gray-500 font-mono text-base select-none pointer-events-none">
/
</span>
</div>
{/* Flags Checkboxes */}
<div className="flex items-center gap-2 bg-gray-50 dark:bg-gray-800/80 p-1.5 rounded-lg border border-gray-200 dark:border-gray-700 text-xs">
<span className="text-gray-500 dark:text-gray-400 font-semibold px-1">Flags:</span>
{[
{ key: "g", label: "g", title: "Global (match all)" },
{ key: "i", label: "i", title: "Case insensitive" },
{ key: "m", label: "m", title: "Multiline (^ and $ match line boundaries)" },
{ key: "s", label: "s", title: "DotAll (. matches newline)" },
{ key: "u", label: "u", title: "Unicode support" },
].map(({ key, label, title }) => {
const k = key as keyof typeof flags;
const active = flags[k];
return (
<button
key={key}
type="button"
title={title}
onClick={() => toggleFlag(k)}
className={`px-2 py-1 rounded font-mono font-semibold transition-colors cursor-pointer ${
active
? "bg-blue-600 text-white shadow-xs"
: "bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600"
}`}
>
{label}
</button>
);
})}
</div>
</div>
{/* Error Message */}
{error && (
<div className="flex items-center gap-2.5 p-3 rounded-lg bg-rose-50 dark:bg-rose-950/40 border border-rose-200 dark:border-rose-800 text-rose-800 dark:text-rose-300 text-xs font-mono">
<svg className="w-4 h-4 shrink-0 text-rose-600" 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>
<span>{error}</span>
</div>
)}
</div>
{/* Test String Input & Highlighted Result */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Test String Textarea */}
<div className="flex flex-col space-y-2">
<div className="flex items-center justify-between">
<label htmlFor="regex-test-string" className="text-sm font-semibold text-gray-700 dark:text-gray-300">
Test String
</label>
<span className="text-xs text-gray-500 dark:text-gray-400 font-mono">
{testString.length} chars
</span>
</div>
<textarea
id="regex-test-string"
rows={8}
value={testString}
onChange={(e) => setTestString(e.target.value)}
placeholder="Paste or type text to match against the pattern..."
className="w-full p-3.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 font-mono text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none resize-y leading-relaxed shadow-inner"
/>
</div>
{/* Highlighted Match Visualizer */}
<div className="flex flex-col space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<label className="text-sm font-semibold text-gray-700 dark:text-gray-300">
Live Match Highlight
</label>
<span
className={`px-2 py-0.5 rounded-full text-xs font-semibold ${
matches.length > 0
? "bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300"
: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
}`}
>
{matches.length} {matches.length === 1 ? "match" : "matches"}
</span>
</div>
<span className="text-xs text-gray-400 dark:text-gray-500">
Alternating highlights
</span>
</div>
<div
className="w-full p-3.5 rounded-lg border border-gray-300 dark:border-gray-700 bg-gray-50/70 dark:bg-gray-900/60 text-gray-900 dark:text-gray-100 font-mono text-sm overflow-auto min-h-[190px] max-h-[300px] whitespace-pre-wrap break-words leading-relaxed select-text"
dangerouslySetInnerHTML={{ __html: highlightedHtml }}
/>
</div>
</div>
{/* Detailed Matches & Capture Groups List */}
<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 flex items-center gap-2">
<span>Match Details &amp; Captured Groups</span>
<span className="text-xs font-normal text-gray-500">
({matches.length} {matches.length === 1 ? "item" : "items"})
</span>
</h3>
</div>
{matches.length === 0 ? (
<div className="p-6 text-center rounded-xl border border-dashed border-gray-300 dark:border-gray-700 text-gray-500 dark:text-gray-400 text-sm">
{pattern ? "No matches found in the test string." : "Enter a regular expression pattern above to see matches."}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 max-h-96 overflow-y-auto pr-1">
{matches.map((item) => (
<div
key={item.index}
className="p-3.5 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800/80 shadow-xs space-y-2"
>
<div className="flex items-center justify-between border-b border-gray-100 dark:border-gray-700 pb-2">
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 rounded text-xs font-bold bg-blue-100 text-blue-800 dark:bg-blue-950 dark:text-blue-300">
Match #{item.index}
</span>
<span className="text-xs text-gray-500 dark:text-gray-400 font-mono">
pos: [{item.start}, {item.end}] ({item.match.length} chars)
</span>
</div>
<button
type="button"
onClick={() => handleCopyMatch(item.match, item.index)}
className="text-xs text-gray-500 hover:text-blue-600 dark:hover:text-blue-400 font-medium cursor-pointer"
>
{copiedIndex === item.index ? "Copied!" : "Copy"}
</button>
</div>
<div className="font-mono text-xs p-2 rounded bg-gray-50 dark:bg-gray-900/80 text-gray-900 dark:text-gray-100 break-all">
{item.match.length === 0 ? (
<span className="italic text-gray-400">&lt;empty match&gt;</span>
) : (
item.match
)}
</div>
{/* Groups */}
{item.groups.length > 0 && (
<div className="space-y-1 pt-1 border-t border-gray-100 dark:border-gray-800 text-xs">
<span className="text-gray-500 dark:text-gray-400 font-medium">
Captured Groups:
</span>
<div className="space-y-1">
{item.groups.map((grp, gIdx) => (
<div
key={gIdx}
className="flex items-start gap-2 font-mono text-xs pl-2 border-l-2 border-indigo-400"
>
<span className="text-indigo-600 dark:text-indigo-400 font-semibold shrink-0">
Group {gIdx + 1}:
</span>
<span className="text-gray-800 dark:text-gray-200 break-all">
{grp !== "" ? `"${grp}"` : <span className="italic text-gray-400">undefined</span>}
</span>
</div>
))}
</div>
</div>
)}
{/* Named Groups */}
{item.namedGroups && Object.keys(item.namedGroups).length > 0 && (
<div className="space-y-1 pt-1 border-t border-gray-100 dark:border-gray-800 text-xs">
<span className="text-gray-500 dark:text-gray-400 font-medium">
Named Groups:
</span>
<div className="space-y-1">
{Object.entries(item.namedGroups).map(([name, val]) => (
<div
key={name}
className="flex items-start gap-2 font-mono text-xs pl-2 border-l-2 border-teal-400"
>
<span className="text-teal-600 dark:text-teal-400 font-semibold shrink-0">
?&lt;{name}&gt;:
</span>
<span className="text-gray-800 dark:text-gray-200 break-all">
&quot;{val}&quot;
</span>
</div>
))}
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
{/* Substitution / Replace Section */}
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-gray-50/50 dark:bg-gray-850 p-4 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-bold text-gray-900 dark:text-gray-100">
Regex Substitution / Replace
</h3>
<span className="text-xs text-gray-500 dark:text-gray-400">
Supports $1, $2 group references
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1.5">
<label
htmlFor="regex-replace-input"
className="text-xs font-semibold text-gray-700 dark:text-gray-300"
>
Replacement String
</label>
<input
id="regex-replace-input"
type="text"
value={replacement}
onChange={(e) => setReplacement(e.target.value)}
placeholder="e.g. [$1] or REDACTED"
className="w-full p-2.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 font-mono text-xs focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<label className="text-xs font-semibold text-gray-700 dark:text-gray-300">
Result After Replacement
</label>
<button
type="button"
onClick={handleCopyReplaced}
disabled={!replacedText}
className="text-xs text-blue-600 dark:text-blue-400 hover:underline disabled:opacity-40 cursor-pointer font-medium"
>
{copiedReplace ? "Copied!" : "Copy Result"}
</button>
</div>
<div className="p-2.5 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 font-mono text-xs min-h-[42px] max-h-32 overflow-y-auto whitespace-pre-wrap break-words select-text">
{replacedText}
</div>
</div>
</div>
</div>
</div>
</ToolLayout>
);
}

View file

@ -0,0 +1,807 @@
"use client";
import { useState, useEffect, useMemo, useCallback } from "react";
import ToolLayout from "@/components/ToolLayout";
// Format relative time
function getRelativeTimeString(date: Date, now: Date): string {
const diffMs = date.getTime() - now.getTime();
const diffSec = Math.round(diffMs / 1000);
const isPast = diffSec < 0;
const absSec = Math.abs(diffSec);
if (absSec < 5) return "just now";
if (absSec < 60) return isPast ? `${absSec} seconds ago` : `in ${absSec} seconds`;
const absMin = Math.round(absSec / 60);
if (absMin < 60) return isPast ? `${absMin} minute${absMin > 1 ? "s" : ""} ago` : `in ${absMin} minute${absMin > 1 ? "s" : ""}`;
const absHours = Math.round(absMin / 60);
if (absHours < 24) return isPast ? `${absHours} hour${absHours > 1 ? "s" : ""} ago` : `in ${absHours} hour${absHours > 1 ? "s" : ""}`;
const absDays = Math.round(absHours / 24);
if (absDays < 30) return isPast ? `${absDays} day${absDays > 1 ? "s" : ""} ago` : `in ${absDays} day${absDays > 1 ? "s" : ""}`;
const absMonths = Math.round(absDays / 30.44);
if (absMonths < 12) return isPast ? `${absMonths} month${absMonths > 1 ? "s" : ""} ago` : `in ${absMonths} month${absMonths > 1 ? "s" : ""}`;
const absYears = Math.round(absDays / 365.25);
return isPast ? `${absYears} year${absYears > 1 ? "s" : ""} ago` : `in ${absYears} year${absYears > 1 ? "s" : ""}`;
}
// Format local datetime string for <input type="datetime-local">
function toDatetimeLocal(date: Date): string {
const pad = (n: number) => n.toString().padStart(2, "0");
const year = date.getFullYear();
const month = pad(date.getMonth() + 1);
const day = pad(date.getDate());
const hours = pad(date.getHours());
const minutes = pad(date.getMinutes());
const seconds = pad(date.getSeconds());
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`;
}
// Get Day of Year
function getDayOfYear(date: Date): number {
const start = new Date(date.getFullYear(), 0, 0);
const diff = date.getTime() - start.getTime() + (start.getTimezoneOffset() - date.getTimezoneOffset()) * 60 * 1000;
const oneDay = 1000 * 60 * 60 * 24;
return Math.floor(diff / oneDay);
}
// Get ISO Week Number
function getWeekNumber(date: Date): number {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
const dayNum = d.getUTCDay() || 7;
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
return Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
}
// Is leap year
function isLeapYear(year: number): boolean {
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
}
export default function TimestampConverterPage() {
// Live current timestamp ticker
const [currentTimestamp, setCurrentTimestamp] = useState<number>(() => Math.floor(Date.now() / 1000));
const [isLivePaused, setIsLivePaused] = useState<boolean>(false);
const [copiedKey, setCopiedKey] = useState<string | null>(null);
// Timestamp -> Date state
const [tsInput, setTsInput] = useState<string>(() => Math.floor(Date.now() / 1000).toString());
const [unitMode, setUnitMode] = useState<"seconds" | "milliseconds" | "auto">("auto");
// Date -> Timestamp state
const [dateInput, setDateInput] = useState<string>(() => toDatetimeLocal(new Date()));
const [dateInputTz, setDateInputTz] = useState<"local" | "utc">("local");
// Active tab
const [activeTab, setActiveTab] = useState<"tsToDate" | "dateToTs">("tsToDate");
// Ticker effect
useEffect(() => {
if (isLivePaused) return;
const interval = setInterval(() => {
setCurrentTimestamp(Math.floor(Date.now() / 1000));
}, 1000);
return () => clearInterval(interval);
}, [isLivePaused]);
// Copy helper
const copyToClipboard = useCallback(async (text: string, key: string) => {
try {
await navigator.clipboard.writeText(text);
setCopiedKey(key);
setTimeout(() => {
setCopiedKey((prev) => (prev === key ? null : prev));
}, 2000);
} catch {
// Fallback
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);
setCopiedKey(key);
setTimeout(() => {
setCopiedKey((prev) => (prev === key ? null : prev));
}, 2000);
}
}, []);
// Parse Timestamp input
const parsedFromTs = useMemo(() => {
const trimmed = tsInput.trim();
if (!trimmed) {
return { valid: false, error: "Please enter a timestamp" };
}
const num = Number(trimmed);
if (isNaN(num)) {
return { valid: false, error: "Invalid numeric timestamp" };
}
let isMs = false;
if (unitMode === "milliseconds") {
isMs = true;
} else if (unitMode === "seconds") {
isMs = false;
} else {
// Auto mode: if 13 digits or > 1e11, treat as milliseconds
isMs = Math.abs(num) > 99999999999;
}
const msValue = isMs ? num : num * 1000;
const date = new Date(msValue);
if (isNaN(date.getTime())) {
return { valid: false, error: "Timestamp out of range for Date object" };
}
return {
valid: true,
date,
seconds: Math.floor(msValue / 1000),
milliseconds: Math.floor(msValue),
isMs,
};
}, [tsInput, unitMode]);
// Parse Date input
const parsedFromDate = useMemo(() => {
if (!dateInput) {
return { valid: false, error: "Please select or enter a date and time" };
}
let date: Date;
if (dateInputTz === "utc") {
// Parse as UTC
const [datePart, timePart = "00:00:00"] = dateInput.split("T");
const [year, month, day] = (datePart || "").split("-").map(Number);
const [hour = 0, min = 0, sec = 0] = (timePart || "").split(":").map(Number);
date = new Date(Date.UTC(year, (month || 1) - 1, day || 1, hour, min, sec));
} else {
date = new Date(dateInput);
}
if (isNaN(date.getTime())) {
return { valid: false, error: "Invalid date or time value" };
}
return {
valid: true,
date,
seconds: Math.floor(date.getTime() / 1000),
milliseconds: date.getTime(),
};
}, [dateInput, dateInputTz]);
// Quick preset actions for Timestamp input
const handleQuickTs = (type: "now" | "todayStart" | "todayEnd" | "plusHour" | "minusHour" | "plusDay" | "minusDay") => {
const now = new Date();
let target = now;
if (type === "now") {
target = new Date();
} else if (type === "todayStart") {
target = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
} else if (type === "todayEnd") {
target = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999);
} else if (type === "plusHour") {
const base = parsedFromTs.valid && parsedFromTs.date ? parsedFromTs.date : now;
target = new Date(base.getTime() + 3600 * 1000);
} else if (type === "minusHour") {
const base = parsedFromTs.valid && parsedFromTs.date ? parsedFromTs.date : now;
target = new Date(base.getTime() - 3600 * 1000);
} else if (type === "plusDay") {
const base = parsedFromTs.valid && parsedFromTs.date ? parsedFromTs.date : now;
target = new Date(base.getTime() + 86400 * 1000);
} else if (type === "minusDay") {
const base = parsedFromTs.valid && parsedFromTs.date ? parsedFromTs.date : now;
target = new Date(base.getTime() - 86400 * 1000);
}
const sec = Math.floor(target.getTime() / 1000);
setTsInput(unitMode === "milliseconds" ? target.getTime().toString() : sec.toString());
};
// Quick preset actions for Date input
const handleQuickDate = (type: "now" | "todayStart" | "todayEnd" | "plusDay" | "minusDay") => {
const now = new Date();
let target = now;
if (type === "now") {
target = new Date();
} else if (type === "todayStart") {
target = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
} else if (type === "todayEnd") {
target = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 0);
} else if (type === "plusDay") {
const base = parsedFromDate.valid && parsedFromDate.date ? parsedFromDate.date : now;
target = new Date(base.getTime() + 86400 * 1000);
} else if (type === "minusDay") {
const base = parsedFromDate.valid && parsedFromDate.date ? parsedFromDate.date : now;
target = new Date(base.getTime() - 86400 * 1000);
}
setDateInput(toDatetimeLocal(target));
};
const nowDate = useMemo(() => new Date(currentTimestamp * 1000), [currentTimestamp]);
return (
<ToolLayout
title="Unix Timestamp Converter"
description="Convert between Unix timestamps and human-readable dates in UTC, ISO 8601, and local timezone formats."
>
<div className="space-y-8">
{/* Live Current Timestamp Banner */}
<div className="rounded-xl border border-blue-200 bg-gradient-to-r from-blue-50 to-indigo-50 p-4 dark:border-blue-900/50 dark:from-blue-950/30 dark:to-indigo-950/30 sm:p-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="flex items-center gap-2">
<span className="relative flex h-3 w-3">
<span
className={`absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75 ${
!isLivePaused ? "animate-ping" : ""
}`}
/>
<span
className={`relative inline-flex h-3 w-3 rounded-full ${
!isLivePaused ? "bg-emerald-500" : "bg-amber-500"
}`}
/>
</span>
<span className="text-xs font-semibold uppercase tracking-wider text-gray-600 dark:text-gray-300">
Current Unix Epoch Time {isLivePaused && "(Paused)"}
</span>
</div>
<div className="mt-2 font-mono text-3xl font-bold tracking-tight text-gray-900 dark:text-white sm:text-4xl">
{currentTimestamp}
</div>
<div className="mt-1 text-xs text-gray-600 dark:text-gray-400 sm:text-sm">
<span className="font-medium text-gray-800 dark:text-gray-200">UTC:</span> {nowDate.toUTCString()}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => copyToClipboard(currentTimestamp.toString(), "live-ts")}
className="flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 active:scale-95 transition-all"
>
{copiedKey === "live-ts" ? (
<>
<svg className="h-4 w-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span>Copied!</span>
</>
) : (
<>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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>
<span>Copy Timestamp</span>
</>
)}
</button>
<button
type="button"
onClick={() => setIsLivePaused(!isLivePaused)}
className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700 transition-colors"
>
{isLivePaused ? "Resume" : "Pause"}
</button>
<button
type="button"
onClick={() => {
setCurrentTimestamp(Math.floor(Date.now() / 1000));
setTsInput(Math.floor(Date.now() / 1000).toString());
setDateInput(toDatetimeLocal(new Date()));
}}
className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700 transition-colors"
title="Reset to current time"
>
Reset to Now
</button>
</div>
</div>
</div>
{/* Tab Navigation */}
<div className="flex border-b border-gray-200 dark:border-gray-800">
<button
type="button"
onClick={() => setActiveTab("tsToDate")}
className={`flex items-center gap-2 border-b-2 px-4 py-3 text-sm font-semibold transition-colors ${
activeTab === "tsToDate"
? "border-blue-600 text-blue-600 dark:border-blue-400 dark:text-blue-400"
: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
}`}
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
Timestamp → Date / Time
</button>
<button
type="button"
onClick={() => setActiveTab("dateToTs")}
className={`flex items-center gap-2 border-b-2 px-4 py-3 text-sm font-semibold transition-colors ${
activeTab === "dateToTs"
? "border-blue-600 text-blue-600 dark:border-blue-400 dark:text-blue-400"
: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
}`}
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
Date / Time Picker → Timestamp
</button>
</div>
{/* Tab 1: Timestamp to Date */}
{activeTab === "tsToDate" && (
<div className="space-y-6">
<div className="rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-800 dark:bg-gray-900/50 sm:p-5">
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<label htmlFor="ts-input" className="text-sm font-semibold text-gray-900 dark:text-gray-100">
Enter Unix Timestamp:
</label>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500 dark:text-gray-400">Unit:</span>
<div className="inline-flex rounded-lg border border-gray-300 p-0.5 dark:border-gray-700 bg-white dark:bg-gray-800">
<button
type="button"
onClick={() => setUnitMode("auto")}
className={`px-2.5 py-1 text-xs font-medium rounded-md transition-colors ${
unitMode === "auto"
? "bg-blue-600 text-white"
: "text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white"
}`}
>
Auto
</button>
<button
type="button"
onClick={() => setUnitMode("seconds")}
className={`px-2.5 py-1 text-xs font-medium rounded-md transition-colors ${
unitMode === "seconds"
? "bg-blue-600 text-white"
: "text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white"
}`}
>
Seconds (s)
</button>
<button
type="button"
onClick={() => setUnitMode("milliseconds")}
className={`px-2.5 py-1 text-xs font-medium rounded-md transition-colors ${
unitMode === "milliseconds"
? "bg-blue-600 text-white"
: "text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white"
}`}
>
Milliseconds (ms)
</button>
</div>
</div>
</div>
<div className="relative">
<input
id="ts-input"
type="text"
value={tsInput}
onChange={(e) => setTsInput(e.target.value)}
placeholder="e.g. 1788114108"
className="w-full p-3 font-mono text-base sm:text-lg rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
{tsInput && (
<button
type="button"
onClick={() => setTsInput("")}
className="absolute right-3 top-1/2 -translate-y-1/2 rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-200"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
{/* Quick Buttons */}
<div className="flex flex-wrap items-center gap-1.5 pt-1">
<span className="text-xs font-medium text-gray-500 dark:text-gray-400 mr-1">Quick presets:</span>
<button
type="button"
onClick={() => handleQuickTs("now")}
className="rounded bg-white px-2.5 py-1 text-xs font-medium text-gray-700 border border-gray-300 hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-700"
>
Now
</button>
<button
type="button"
onClick={() => handleQuickTs("todayStart")}
className="rounded bg-white px-2.5 py-1 text-xs font-medium text-gray-700 border border-gray-300 hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-700"
>
Start of Today
</button>
<button
type="button"
onClick={() => handleQuickTs("todayEnd")}
className="rounded bg-white px-2.5 py-1 text-xs font-medium text-gray-700 border border-gray-300 hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-700"
>
End of Today
</button>
<button
type="button"
onClick={() => handleQuickTs("minusHour")}
className="rounded bg-white px-2.5 py-1 text-xs font-medium text-gray-700 border border-gray-300 hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-700"
>
-1 Hour
</button>
<button
type="button"
onClick={() => handleQuickTs("plusHour")}
className="rounded bg-white px-2.5 py-1 text-xs font-medium text-gray-700 border border-gray-300 hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-700"
>
+1 Hour
</button>
<button
type="button"
onClick={() => handleQuickTs("minusDay")}
className="rounded bg-white px-2.5 py-1 text-xs font-medium text-gray-700 border border-gray-300 hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-700"
>
-1 Day
</button>
<button
type="button"
onClick={() => handleQuickTs("plusDay")}
className="rounded bg-white px-2.5 py-1 text-xs font-medium text-gray-700 border border-gray-300 hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-700"
>
+1 Day
</button>
</div>
</div>
</div>
{/* Results Grid */}
{parsedFromTs.valid && parsedFromTs.date ? (
<FormatResultsGrid
date={parsedFromTs.date}
nowDate={nowDate}
copiedKey={copiedKey}
onCopy={copyToClipboard}
prefix="ts"
/>
) : (
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700 dark:border-red-900/50 dark:bg-red-950/30 dark:text-red-300">
⚠️ {parsedFromTs.error}
</div>
)}
</div>
)}
{/* Tab 2: Date to Timestamp */}
{activeTab === "dateToTs" && (
<div className="space-y-6">
<div className="rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-800 dark:bg-gray-900/50 sm:p-5">
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<label htmlFor="datetime-picker" className="text-sm font-semibold text-gray-900 dark:text-gray-100">
Select Date & Time:
</label>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500 dark:text-gray-400">Interpret as:</span>
<div className="inline-flex rounded-lg border border-gray-300 p-0.5 dark:border-gray-700 bg-white dark:bg-gray-800">
<button
type="button"
onClick={() => setDateInputTz("local")}
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${
dateInputTz === "local"
? "bg-blue-600 text-white"
: "text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white"
}`}
>
Local Timezone
</button>
<button
type="button"
onClick={() => setDateInputTz("utc")}
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${
dateInputTz === "utc"
? "bg-blue-600 text-white"
: "text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white"
}`}
>
UTC Time
</button>
</div>
</div>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div className="sm:col-span-2">
<input
id="datetime-picker"
type="datetime-local"
step="1"
value={dateInput}
onChange={(e) => setDateInput(e.target.value)}
className="w-full p-3 font-mono text-base rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => handleQuickDate("now")}
className="flex-1 py-3 px-3 rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-800 dark:text-gray-200 text-sm font-medium transition-colors"
>
Set to Now
</button>
</div>
</div>
{/* Quick presets for date */}
<div className="flex flex-wrap items-center gap-1.5 pt-1">
<span className="text-xs font-medium text-gray-500 dark:text-gray-400 mr-1">Quick presets:</span>
<button
type="button"
onClick={() => handleQuickDate("todayStart")}
className="rounded bg-white px-2.5 py-1 text-xs font-medium text-gray-700 border border-gray-300 hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-700"
>
Start of Today
</button>
<button
type="button"
onClick={() => handleQuickDate("todayEnd")}
className="rounded bg-white px-2.5 py-1 text-xs font-medium text-gray-700 border border-gray-300 hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-700"
>
End of Today
</button>
<button
type="button"
onClick={() => handleQuickDate("minusDay")}
className="rounded bg-white px-2.5 py-1 text-xs font-medium text-gray-700 border border-gray-300 hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-700"
>
Yesterday
</button>
<button
type="button"
onClick={() => handleQuickDate("plusDay")}
className="rounded bg-white px-2.5 py-1 text-xs font-medium text-gray-700 border border-gray-300 hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-700"
>
Tomorrow
</button>
</div>
</div>
</div>
{/* Results Grid */}
{parsedFromDate.valid && parsedFromDate.date ? (
<FormatResultsGrid
date={parsedFromDate.date}
nowDate={nowDate}
copiedKey={copiedKey}
onCopy={copyToClipboard}
prefix="date"
/>
) : (
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700 dark:border-red-900/50 dark:bg-red-950/30 dark:text-red-300">
⚠️ {parsedFromDate.error}
</div>
)}
</div>
)}
{/* Cheat sheet / Developer Reference */}
<div className="mt-8 rounded-xl border border-gray-200 bg-gray-50 p-5 dark:border-gray-800 dark:bg-gray-900/50">
<h3 className="text-base font-bold text-gray-900 dark:text-white mb-3">
Developer Reference: Getting Current Timestamp
</h3>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{[
{ lang: "JavaScript", code: "Math.floor(Date.now() / 1000)" },
{ lang: "Python", code: "import time; int(time.time())" },
{ lang: "PHP", code: "time()" },
{ lang: "Go", code: "time.Now().Unix()" },
{ lang: "Java", code: "Instant.now().getEpochSecond()" },
{ lang: "C# (.NET)", code: "DateTimeOffset.UtcNow.ToUnixTimeSeconds()" },
{ lang: "Ruby", code: "Time.now.to_i" },
{ lang: "Rust", code: "SystemTime::now().duration_since(UNIX_EPOCH)" },
{ lang: "SQL (Postgres)", code: "EXTRACT(EPOCH FROM NOW())" },
].map((item) => (
<div
key={item.lang}
className="rounded-lg border border-gray-200 bg-white p-3 dark:border-gray-700 dark:bg-gray-800"
>
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-semibold text-gray-500 dark:text-gray-400">{item.lang}</span>
<button
type="button"
onClick={() => copyToClipboard(item.code, `code-${item.lang}`)}
className="text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
{copiedKey === `code-${item.lang}` ? "Copied!" : "Copy"}
</button>
</div>
<code className="block font-mono text-xs text-gray-800 dark:text-gray-200 overflow-x-auto whitespace-nowrap">
{item.code}
</code>
</div>
))}
</div>
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700 flex flex-wrap gap-x-6 gap-y-2 text-xs text-gray-600 dark:text-gray-400">
<div><strong className="text-gray-800 dark:text-gray-200">1 hour:</strong> 3,600 seconds</div>
<div><strong className="text-gray-800 dark:text-gray-200">1 day:</strong> 86,400 seconds</div>
<div><strong className="text-gray-800 dark:text-gray-200">1 week:</strong> 604,800 seconds</div>
<div><strong className="text-gray-800 dark:text-gray-200">30 days:</strong> 2,592,000 seconds</div>
<div><strong className="text-gray-800 dark:text-gray-200">365 days:</strong> 31,536,000 seconds</div>
</div>
</div>
</div>
</ToolLayout>
);
}
// Subcomponent to display formatted results
interface FormatResultsGridProps {
date: Date;
nowDate: Date;
copiedKey: string | null;
onCopy: (text: string, key: string) => void;
prefix: string;
}
function FormatResultsGrid({ date, nowDate, copiedKey, onCopy, prefix }: FormatResultsGridProps) {
const seconds = Math.floor(date.getTime() / 1000);
const milliseconds = date.getTime();
const iso = date.toISOString();
const utc = date.toUTCString();
const local = date.toString();
const relative = getRelativeTimeString(date, nowDate);
const dayOfYear = getDayOfYear(date);
const weekNum = getWeekNumber(date);
const leap = isLeapYear(date.getFullYear());
const formats = [
{
id: "seconds",
label: "Unix Timestamp (Seconds)",
value: seconds.toString(),
highlight: true,
},
{
id: "milliseconds",
label: "Unix Timestamp (Milliseconds)",
value: milliseconds.toString(),
highlight: true,
},
{
id: "utc",
label: "UTC / GMT Format",
value: utc,
},
{
id: "local",
label: "Local Timezone Format",
value: local,
},
{
id: "iso",
label: "ISO 8601 Format",
value: iso,
},
{
id: "relative",
label: "Relative Time",
value: relative,
},
];
return (
<div className="space-y-4">
<h3 className="text-sm font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300">
Converted Formats
</h3>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{formats.map((item) => {
const key = `${prefix}-${item.id}`;
const isCopied = copiedKey === key;
return (
<div
key={item.id}
className={`group flex flex-col justify-between rounded-xl border p-4 transition-all ${
item.highlight
? "border-blue-200 bg-blue-50/50 dark:border-blue-900/40 dark:bg-blue-950/20"
: "border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-800/80"
}`}
>
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{item.label}
</span>
<button
type="button"
onClick={() => onCopy(item.value, key)}
className={`flex items-center gap-1 text-xs font-medium px-2 py-1 rounded transition-colors ${
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-700 dark:hover:bg-gray-600 dark:text-gray-300"
}`}
title="Copy to clipboard"
>
{isCopied ? (
<>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span>Copied!</span>
</>
) : (
<>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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>
<span>Copy</span>
</>
)}
</button>
</div>
<div className="font-mono text-sm font-medium text-gray-900 dark:text-white break-all">
{item.value}
</div>
</div>
);
})}
</div>
{/* Date metadata badges */}
<div className="flex flex-wrap gap-2 pt-2 text-xs">
<span className="inline-flex items-center rounded-md bg-gray-100 px-2.5 py-1 font-medium text-gray-800 dark:bg-gray-800 dark:text-gray-300">
Day of Year: <strong className="ml-1 text-gray-900 dark:text-white">{dayOfYear}</strong>
</span>
<span className="inline-flex items-center rounded-md bg-gray-100 px-2.5 py-1 font-medium text-gray-800 dark:bg-gray-800 dark:text-gray-300">
ISO Week: <strong className="ml-1 text-gray-900 dark:text-white">#{weekNum}</strong>
</span>
<span className="inline-flex items-center rounded-md bg-gray-100 px-2.5 py-1 font-medium text-gray-800 dark:bg-gray-800 dark:text-gray-300">
Leap Year: <strong className="ml-1 text-gray-900 dark:text-white">{leap ? "Yes" : "No"}</strong>
</span>
</div>
</div>
);
}

View file

@ -0,0 +1,414 @@
"use client";
import { useState, useCallback, useId } from "react";
import ToolLayout from "@/components/ToolLayout";
export default function UrlEncoderPage() {
const inputId = useId();
const outputId = useId();
const [input, setInput] = useState("");
const [output, setOutput] = useState("");
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [encodeMode, setEncodeMode] = useState<"component" | "fullUrl">("component");
const [spaceAsPlus, setSpaceAsPlus] = useState(false);
const [decodePlusAsSpace, setDecodePlusAsSpace] = useState(true);
const [lastAction, setLastAction] = useState<"encode" | "decode" | null>(null);
const encodeUrl = useCallback(
(text: string, mode: "component" | "fullUrl", usePlusForSpace: boolean) => {
if (!text) {
setOutput("");
setError(null);
return;
}
try {
let encoded = mode === "component" ? encodeURIComponent(text) : encodeURI(text);
if (usePlusForSpace) {
encoded = encoded.replace(/%20/g, "+");
}
setOutput(encoded);
setError(null);
setLastAction("encode");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to encode URL.");
setOutput("");
}
},
[]
);
const decodeUrl = useCallback(
(text: string, treatPlusAsSpace: boolean) => {
if (!text) {
setOutput("");
setError(null);
return;
}
try {
let toDecode = text;
if (treatPlusAsSpace) {
toDecode = toDecode.replace(/\+/g, " ");
}
const decoded = decodeURIComponent(toDecode);
setOutput(decoded);
setError(null);
setLastAction("decode");
} catch (err) {
setError(
err instanceof URIError
? "Malformed URI sequence: The input contains invalid or incomplete percentage-encoded sequences."
: err instanceof Error
? err.message
: "Failed to decode URL."
);
setOutput("");
}
},
[]
);
const handleEncode = () => {
encodeUrl(input, encodeMode, spaceAsPlus);
};
const handleDecode = () => {
decodeUrl(input, decodePlusAsSpace);
};
const handleCopy = 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);
}
};
const handleSwap = () => {
const temp = output;
setInput(temp);
setOutput(input);
setError(null);
if (lastAction === "encode") {
setLastAction("decode");
} else if (lastAction === "decode") {
setLastAction("encode");
}
};
const handleClear = () => {
setInput("");
setOutput("");
setError(null);
setLastAction(null);
};
const loadSample = (type: "param" | "fullUrl" | "encoded") => {
setError(null);
if (type === "param") {
const sample = "query=Developer Tools & Frameworks=Next.js & React 19! &lang=français";
setInput(sample);
encodeUrl(sample, encodeMode, spaceAsPlus);
} else if (type === "fullUrl") {
const sample = "https://example.com/search?q=hello world & category=web tools#section 1";
setInput(sample);
setEncodeMode("fullUrl");
encodeUrl(sample, "fullUrl", spaceAsPlus);
} else {
const sample = "https%3A%2F%2Fapi.example.com%2Fv1%2Fusers%3Fname%3DJohn%20Doe%26status%3Dactive%20%26%20verified";
setInput(sample);
decodeUrl(sample, decodePlusAsSpace);
}
};
return (
<ToolLayout
title="URL Encode / Decode"
description="Encode special characters into percent-encoded URL components or decode them back to standard text."
>
<title>URL Encode &amp; Decode Online — DevToolbox</title>
<meta
name="description"
content="Free online URL encoder and decoder. Encode and decode URI components, query strings, and complete URLs with support for RFC 3986."
/>
<div className="space-y-6">
{/* Controls Bar */}
<div className="flex flex-wrap items-center justify-between gap-3 pb-2 border-b border-gray-200 dark:border-gray-700">
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={handleEncode}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 active:bg-blue-800 text-white rounded-lg font-medium text-sm transition-colors shadow-sm cursor-pointer 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>
Encode URL
</button>
<button
type="button"
onClick={handleDecode}
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white rounded-lg font-medium text-sm transition-colors shadow-sm cursor-pointer 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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Decode URL
</button>
<button
type="button"
onClick={handleSwap}
disabled={!output && !input}
className="px-3 py-2 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed text-gray-700 dark:text-gray-200 rounded-lg text-sm font-medium transition-colors cursor-pointer flex items-center gap-1"
title="Swap input and output"
>
<svg className="w-4 h-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>
Swap
</button>
<button
type="button"
onClick={handleClear}
disabled={!input && !output}
className="px-3 py-2 bg-gray-100 dark:bg-gray-800 hover:bg-red-50 dark:hover:bg-red-950/30 text-gray-700 dark:text-gray-300 hover:text-red-600 dark:hover:text-red-400 disabled:opacity-40 disabled:cursor-not-allowed rounded-lg text-sm font-medium transition-colors cursor-pointer"
>
Clear
</button>
</div>
{/* Sample buttons */}
<div className="flex items-center gap-2 text-xs">
<span className="text-gray-500 dark:text-gray-400 font-medium">Samples:</span>
<button
type="button"
onClick={() => loadSample("param")}
className="px-2.5 py-1 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded transition-colors cursor-pointer"
>
Query Param
</button>
<button
type="button"
onClick={() => loadSample("fullUrl")}
className="px-2.5 py-1 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded transition-colors cursor-pointer"
>
Full URL
</button>
<button
type="button"
onClick={() => loadSample("encoded")}
className="px-2.5 py-1 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded transition-colors cursor-pointer"
>
Encoded URI
</button>
</div>
</div>
{/* Encoding Options */}
<div className="flex flex-wrap items-center gap-6 text-sm text-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-800/50 p-3 rounded-lg border border-gray-200 dark:border-gray-700/60">
<div className="flex items-center gap-3">
<span className="font-medium text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Encode Mode:</span>
<label className="inline-flex items-center gap-1.5 cursor-pointer">
<input
type="radio"
name="encodeMode"
value="component"
checked={encodeMode === "component"}
onChange={() => {
setEncodeMode("component");
if (lastAction === "encode" && input) {
encodeUrl(input, "component", spaceAsPlus);
}
}}
className="w-4 h-4 text-blue-600 border-gray-300 dark:border-gray-600 focus:ring-blue-500"
/>
<span>encodeURIComponent <span className="text-xs text-gray-500">(for query params &amp; values)</span></span>
</label>
<label className="inline-flex items-center gap-1.5 cursor-pointer">
<input
type="radio"
name="encodeMode"
value="fullUrl"
checked={encodeMode === "fullUrl"}
onChange={() => {
setEncodeMode("fullUrl");
if (lastAction === "encode" && input) {
encodeUrl(input, "fullUrl", spaceAsPlus);
}
}}
className="w-4 h-4 text-blue-600 border-gray-300 dark:border-gray-600 focus:ring-blue-500"
/>
<span>encodeURI <span className="text-xs text-gray-500">(preserves full URL structure)</span></span>
</label>
</div>
<div className="flex items-center gap-4">
<label className="inline-flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={spaceAsPlus}
onChange={(e) => {
const val = e.target.checked;
setSpaceAsPlus(val);
if (lastAction === "encode" && input) {
encodeUrl(input, encodeMode, val);
}
}}
className="w-4 h-4 text-blue-600 rounded border-gray-300 dark:border-gray-600 focus:ring-blue-500"
/>
<span>Encode spaces as <code className="text-xs bg-gray-200 dark:bg-gray-700 px-1 py-0.5 rounded font-mono">+</code></span>
</label>
<label className="inline-flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={decodePlusAsSpace}
onChange={(e) => {
const val = e.target.checked;
setDecodePlusAsSpace(val);
if (lastAction === "decode" && input) {
decodeUrl(input, val);
}
}}
className="w-4 h-4 text-blue-600 rounded border-gray-300 dark:border-gray-600 focus:ring-blue-500"
/>
<span>Decode <code className="text-xs bg-gray-200 dark:bg-gray-700 px-1 py-0.5 rounded font-mono">+</code> as spaces</span>
</label>
</div>
</div>
{/* Error Notification */}
{error && (
<div className="flex items-start gap-3 p-4 bg-red-50 dark:bg-red-950/40 border border-red-200 dark:border-red-800 rounded-lg text-red-800 dark:text-red-300 text-sm">
<svg className="w-5 h-5 text-red-500 shrink-0 mt-0.5" 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">URI Error</p>
<p className="mt-0.5">{error}</p>
</div>
</div>
)}
{/* Two-Column Editor Grid */}
<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="font-medium text-sm text-gray-700 dark:text-gray-300">
Input Text or URL
</label>
<span className="text-xs text-gray-500 dark:text-gray-400 font-mono">
{input.length} chars
</span>
</div>
<textarea
id={inputId}
value={input}
onChange={(e) => {
setInput(e.target.value);
if (error) setError(null);
}}
placeholder="Enter text or URL to encode or decode..."
rows={12}
className="w-full p-3 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 font-mono text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none resize-y placeholder:text-gray-400 dark:placeholder:text-gray-500 leading-relaxed shadow-inner"
/>
</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="font-medium text-sm text-gray-700 dark:text-gray-300">
Output Result
</label>
{lastAction && (
<span className="px-2 py-0.5 text-xs rounded-full font-medium bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300">
{lastAction === "encode" ? "URL Encoded" : "URL Decoded"}
</span>
)}
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-gray-500 dark:text-gray-400 font-mono">
{output.length} chars
</span>
<button
type="button"
onClick={handleCopy}
disabled={!output}
className="px-2.5 py-1 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed text-gray-700 dark:text-gray-300 rounded text-xs font-medium transition-colors cursor-pointer flex items-center gap-1"
>
{copied ? (
<>
<svg className="w-3.5 h-3.5 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-green-600 dark:text-green-400 font-semibold">Copied!</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</span>
</>
)}
</button>
</div>
</div>
<textarea
id={outputId}
value={output}
readOnly
placeholder="Encoded or decoded output will appear here..."
rows={12}
className="w-full p-3 rounded-lg border border-gray-300 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/60 text-gray-900 dark:text-gray-100 font-mono text-sm focus:outline-none resize-y placeholder:text-gray-400 dark:placeholder:text-gray-500 leading-relaxed select-all"
/>
</div>
</div>
{/* Common Reference Table */}
<div className="mt-8 pt-6 border-t border-gray-200 dark:border-gray-800">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-3">Common URL Percent-Encoding Reference</h3>
<div className="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-6 gap-2 text-xs font-mono">
<div className="p-2 bg-gray-50 dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Space:</span>
<span className="font-semibold text-blue-600 dark:text-blue-400">%20 or +</span>
</div>
<div className="p-2 bg-gray-50 dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 flex justify-between">
<span className="text-gray-600 dark:text-gray-400">/ (slash):</span>
<span className="font-semibold text-blue-600 dark:text-blue-400">%2F</span>
</div>
<div className="p-2 bg-gray-50 dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 flex justify-between">
<span className="text-gray-600 dark:text-gray-400">: (colon):</span>
<span className="font-semibold text-blue-600 dark:text-blue-400">%3A</span>
</div>
<div className="p-2 bg-gray-50 dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 flex justify-between">
<span className="text-gray-600 dark:text-gray-400">&amp; (ampersand):</span>
<span className="font-semibold text-blue-600 dark:text-blue-400">%26</span>
</div>
<div className="p-2 bg-gray-50 dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 flex justify-between">
<span className="text-gray-600 dark:text-gray-400">? (question):</span>
<span className="font-semibold text-blue-600 dark:text-blue-400">%3F</span>
</div>
<div className="p-2 bg-gray-50 dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 flex justify-between">
<span className="text-gray-600 dark:text-gray-400">= (equal):</span>
<span className="font-semibold text-blue-600 dark:text-blue-400">%3D</span>
</div>
</div>
</div>
</div>
</ToolLayout>
);
}

View file

@ -0,0 +1,428 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import ToolLayout from "@/components/ToolLayout";
function generateSingleUUID(uppercase: boolean, hyphens: boolean): string {
let uuid: string;
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
uuid = crypto.randomUUID();
} else if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
bytes[6] = (bytes[6] & 0x0f) | 0x40; // Version 4
bytes[8] = (bytes[8] & 0x3f) | 0x80; // Variant 10xx
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
uuid = `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
} else {
uuid = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
if (!hyphens) {
uuid = uuid.replace(/-/g, "");
}
return uppercase ? uuid.toUpperCase() : uuid.toLowerCase();
}
export default function UuidGeneratorPage() {
const [count, setCount] = useState<number>(5);
const [uppercase, setUppercase] = useState<boolean>(false);
const [hyphens, setHyphens] = useState<boolean>(true);
const [uuids, setUuids] = useState<string[]>([]);
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
const [copiedAll, setCopiedAll] = useState<boolean>(false);
const generate = useCallback(
(countToGen: number, isUpper: boolean, hasHyphens: boolean) => {
const validCount = Math.min(Math.max(1, countToGen), 100);
const generated: string[] = [];
for (let i = 0; i < validCount; i++) {
generated.push(generateSingleUUID(isUpper, hasHyphens));
}
setUuids(generated);
setCopiedIndex(null);
setCopiedAll(false);
},
[]
);
// Generate on initial load
useEffect(() => {
generate(count, uppercase, hyphens);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const handleGenerate = () => {
generate(count, uppercase, hyphens);
};
const handleCountChange = (newCount: number) => {
const clamped = Math.min(Math.max(1, newCount), 100);
setCount(clamped);
generate(clamped, uppercase, hyphens);
};
const handleUppercaseToggle = () => {
const next = !uppercase;
setUppercase(next);
setUuids((prev) =>
prev.map((id) => (next ? id.toUpperCase() : id.toLowerCase()))
);
};
const handleHyphensToggle = () => {
const next = !hyphens;
setHyphens(next);
generate(count, uppercase, next);
};
const copyToClipboard = async (text: string, index: number) => {
try {
await navigator.clipboard.writeText(text);
setCopiedIndex(index);
setTimeout(() => setCopiedIndex(null), 2000);
} catch (err) {
console.error("Failed to copy text: ", err);
}
};
const copyAllToClipboard = async () => {
try {
await navigator.clipboard.writeText(uuids.join("\n"));
setCopiedAll(true);
setTimeout(() => setCopiedAll(false), 2000);
} catch (err) {
console.error("Failed to copy all: ", err);
}
};
const downloadAsText = () => {
const blob = new Blob([uuids.join("\n")], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `uuids-${Date.now()}.txt`;
link.click();
URL.revokeObjectURL(url);
};
const downloadAsJson = () => {
const blob = new Blob([JSON.stringify(uuids, null, 2)], {
type: "application/json;charset=utf-8",
});
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `uuids-${Date.now()}.json`;
link.click();
URL.revokeObjectURL(url);
};
const countPresets = [1, 5, 10, 25, 50, 100];
return (
<ToolLayout
title="UUID Generator"
description="Generate cryptographically secure UUID v4 (Universally Unique Identifier) strings in bulk with customization options."
>
<div className="space-y-6">
{/* Controls Bar */}
<div className="rounded-lg border border-gray-200 bg-gray-50/50 p-4 dark:border-gray-700/60 dark:bg-gray-800/40">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{/* Quantity Selector */}
<div>
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-wider text-gray-700 dark:text-gray-300">
Quantity (1 - 100)
</label>
<div className="flex items-center gap-2">
<input
type="number"
min="1"
max="100"
value={count}
onChange={(e) => handleCountChange(parseInt(e.target.value) || 1)}
className="w-24 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium 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-800 dark:text-gray-100"
/>
<div className="flex flex-wrap gap-1">
{countPresets.map((preset) => (
<button
key={preset}
type="button"
onClick={() => handleCountChange(preset)}
className={`rounded px-2 py-1 text-xs font-medium transition-colors ${
count === preset
? "bg-blue-600 text-white"
: "bg-gray-200 text-gray-700 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
}`}
>
{preset}
</button>
))}
</div>
</div>
</div>
{/* Options Toggles */}
<div>
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-wider text-gray-700 dark:text-gray-300">
Formatting Options
</label>
<div className="flex flex-wrap gap-4 pt-1">
<label className="inline-flex cursor-pointer items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
type="checkbox"
checked={uppercase}
onChange={handleUppercaseToggle}
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>Uppercase (A-F)</span>
</label>
<label className="inline-flex cursor-pointer items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
type="checkbox"
checked={hyphens}
onChange={handleHyphensToggle}
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>Include Hyphens</span>
</label>
</div>
</div>
{/* Primary Action Button */}
<div className="flex items-end sm:col-span-2 lg:col-span-1">
<button
type="button"
onClick={handleGenerate}
className="flex w-full items-center justify-center gap-2 rounded-lg bg-blue-600 px-5 py-2.5 font-medium text-white shadow-sm transition-all hover:bg-blue-700 active:scale-[0.99] focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
>
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
Generate New {count > 1 ? `(${count})` : ""}
</button>
</div>
</div>
</div>
{/* Results Header / Bulk Actions */}
<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 items-center gap-2">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
Generated UUIDs
</span>
<span className="inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-semibold text-blue-800 dark:bg-blue-900/40 dark:text-blue-300">
{uuids.length}
</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={copyAllToClipboard}
className={`inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors ${
copiedAll
? "bg-green-600 text-white"
: "bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
}`}
>
{copiedAll ? (
<>
<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>
All 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 All
</>
)}
</button>
<button
type="button"
onClick={downloadAsText}
title="Download as .txt"
className="inline-flex items-center gap-1.5 rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-700 transition-colors hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
>
<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>
.txt
</button>
<button
type="button"
onClick={downloadAsJson}
title="Download as .json"
className="inline-flex items-center gap-1.5 rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-700 transition-colors hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
>
<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>
.json
</button>
</div>
</div>
{/* Featured Single UUID Card when count is 1 */}
{uuids.length === 1 && (
<div className="flex flex-col items-center justify-between gap-4 rounded-xl border border-blue-200 bg-blue-50/50 p-6 dark:border-blue-900/50 dark:bg-blue-950/20 sm:flex-row">
<div className="flex-1 overflow-hidden">
<span className="mb-1 block text-xs font-semibold uppercase tracking-wider text-blue-700 dark:text-blue-400">
Generated UUID v4
</span>
<p className="select-all break-all font-mono text-lg font-bold text-gray-900 dark:text-white sm:text-2xl">
{uuids[0]}
</p>
</div>
<button
type="button"
onClick={() => copyToClipboard(uuids[0], 0)}
className={`flex items-center gap-2 rounded-lg px-5 py-2.5 text-sm font-medium transition-all ${
copiedIndex === 0
? "bg-green-600 text-white"
: "bg-blue-600 text-white hover:bg-blue-700 active:scale-95"
}`}
>
{copiedIndex === 0 ? (
<>
<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!
</>
) : (
<>
<svg className="h-4 w-4" 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
</>
)}
</button>
</div>
)}
{/* UUID List for multiple */}
{uuids.length > 1 && (
<div className="max-h-[500px] overflow-y-auto rounded-lg border border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-900/60">
<div className="divide-y divide-gray-200 dark:divide-gray-800">
{uuids.map((uuid, idx) => (
<div
key={`${uuid}-${idx}`}
className="group flex items-center justify-between gap-4 p-3 transition-colors hover:bg-white dark:hover:bg-gray-800/80"
>
<div className="flex min-w-0 items-center gap-3">
<span className="w-8 select-none text-right font-mono text-xs text-gray-400 dark:text-gray-500">
{idx + 1}.
</span>
<span className="select-all truncate font-mono text-sm font-medium text-gray-900 dark:text-gray-100">
{uuid}
</span>
</div>
<button
type="button"
onClick={() => copyToClipboard(uuid, idx)}
className={`flex shrink-0 items-center gap-1 rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
copiedIndex === idx
? "bg-green-600 text-white"
: "bg-white text-gray-700 opacity-90 shadow-sm hover:bg-gray-100 group-hover:opacity-100 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
}`}
>
{copiedIndex === idx ? (
<>
<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
</>
)}
</button>
</div>
))}
</div>
</div>
)}
{/* Informational Details */}
<div className="grid grid-cols-1 gap-4 pt-4 text-xs text-gray-500 dark:text-gray-400 sm:grid-cols-3">
<div className="rounded-lg border border-gray-100 bg-gray-50 p-3 dark:border-gray-800 dark:bg-gray-800/40">
<h4 className="font-semibold text-gray-800 dark:text-gray-200">128-bit Randomness</h4>
<p className="mt-1">
UUID v4 utilizes 122 cryptographically secure random bits, making collisions statistically virtually impossible.
</p>
</div>
<div className="rounded-lg border border-gray-100 bg-gray-50 p-3 dark:border-gray-800 dark:bg-gray-800/40">
<h4 className="font-semibold text-gray-800 dark:text-gray-200">100% Client-Side</h4>
<p className="mt-1">
All identifiers are created locally in your browser using the Web Crypto API. No server logs or tracking.
</p>
</div>
<div className="rounded-lg border border-gray-100 bg-gray-50 p-3 dark:border-gray-800 dark:bg-gray-800/40">
<h4 className="font-semibold text-gray-800 dark:text-gray-200">RFC 4122 Compliant</h4>
<p className="mt-1">
Generated UUIDs adhere strictly to RFC 4122 specifications with standard version 4 and variant bits.
</p>
</div>
</div>
</div>
</ToolLayout>
);
}

View file

@ -0,0 +1,353 @@
"use client";
import { useState, useMemo } from "react";
import ToolLayout from "@/components/ToolLayout";
const SAMPLE_TEXT = `DevToolbox is a curated collection of fast, privacy-first developer utilities that run entirely in your web browser. None of your sensitive data, tokens, code snippets, or configuration files are sent to any external server.
Whether you need to format complex JSON payloads, test regular expressions with real-time capture groups, calculate precise text differences, or convert identifier cases across programming languages, DevToolbox delivers instant results without friction.
High performance and simplicity make development seamless and productive. Try exploring each utility to streamline your daily workflow!`;
export default function WordCounterPage() {
const [text, setText] = useState("");
const [copied, setCopied] = useState(false);
const stats = useMemo(() => {
const raw = text;
const charCountWithSpaces = raw.length;
const charCountWithoutSpaces = raw.replace(/\s/g, "").length;
const trimmed = raw.trim();
const wordsArray = trimmed ? trimmed.split(/\s+/).filter(Boolean) : [];
const wordCount = wordsArray.length;
// Sentences: split on punctuation followed by whitespace or end of string
const sentencesArray = trimmed
? raw
.split(/[.!?]+(?:\s+|$)/)
.map((s) => s.trim())
.filter((s) => s.length > 0)
: [];
const sentenceCount = sentencesArray.length;
// Paragraphs: split on consecutive newlines
const paragraphsArray = trimmed
? raw
.split(/\n+/)
.map((p) => p.trim())
.filter((p) => p.length > 0)
: [];
const paragraphCount = paragraphsArray.length;
// Lines count
const lineCount = raw ? raw.split(/\r\n|\r|\n/).length : 0;
// Average word length
const totalWordLetters = wordsArray.reduce(
(acc, w) => acc + w.replace(/[^a-zA-Z0-9]/g, "").length,
0
);
const avgWordLength =
wordCount > 0 ? (totalWordLetters / wordCount).toFixed(1) : "0";
// Reading time (200 wpm)
const readingTimeSec = Math.round((wordCount / 200) * 60);
const readingMinutes = Math.floor(readingTimeSec / 60);
const readingSeconds = readingTimeSec % 60;
const readingTimeStr =
wordCount === 0
? "0s"
: readingMinutes === 0
? `${readingSeconds}s`
: `${readingMinutes}m ${readingSeconds}s`;
// Speaking time (130 wpm)
const speakingTimeSec = Math.round((wordCount / 130) * 60);
const speakingMinutes = Math.floor(speakingTimeSec / 60);
const speakingSeconds = speakingTimeSec % 60;
const speakingTimeStr =
wordCount === 0
? "0s"
: readingMinutes === 0
? `${speakingSeconds}s`
: `${speakingMinutes}m ${speakingSeconds}s`;
// Longest word
let longestWord = "";
for (const w of wordsArray) {
const cleanW = w.replace(/[^a-zA-Z0-9_-]/g, "");
if (cleanW.length > longestWord.length) {
longestWord = cleanW;
}
}
// Word frequency / Keyword density (top 6 words)
const freqMap: Record<string, number> = {};
wordsArray.forEach((w) => {
const clean = w.toLowerCase().replace(/[^a-z0-9]/g, "");
if (clean.length >= 2) {
freqMap[clean] = (freqMap[clean] || 0) + 1;
}
});
const topKeywords = Object.entries(freqMap)
.sort((a, b) => b[1] - a[1])
.slice(0, 6)
.map(([word, count]) => ({
word,
count,
percentage:
wordCount > 0 ? ((count / wordCount) * 100).toFixed(1) : "0",
}));
return {
charCountWithSpaces,
charCountWithoutSpaces,
wordCount,
sentenceCount,
paragraphCount,
lineCount,
avgWordLength,
readingTimeStr,
speakingTimeStr,
longestWord,
topKeywords,
};
}, [text]);
const handleCopy = async () => {
if (!text) return;
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Fallback
}
};
const handleClear = () => {
setText("");
};
const handleLoadSample = () => {
setText(SAMPLE_TEXT);
};
const handleCleanWhitespace = () => {
setText((prev) =>
prev
.split("\n")
.map((line) => line.trim().replace(/[ \t]+/g, " "))
.join("\n")
.trim()
);
};
return (
<ToolLayout
title="Word & Character Counter"
description="Count characters, words, sentences, paragraphs, reading time, and analyze keyword frequency in real time."
>
<title>Word &amp; Character Counter — DevToolbox</title>
<meta
name="description"
content="Free online word counter, character counter, sentence counter, paragraph counter, reading time estimator, and keyword density analyzer."
/>
<div className="space-y-6">
{/* Primary Stats Grid */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
<div className="rounded-xl border border-blue-100 bg-blue-50/60 p-4 text-center dark:border-blue-900/40 dark:bg-blue-950/30">
<span className="text-xs font-semibold uppercase tracking-wider text-blue-600 dark:text-blue-400">
Words
</span>
<p className="mt-1 text-2xl font-extrabold text-blue-950 dark:text-blue-100">
{stats.wordCount.toLocaleString()}
</p>
</div>
<div className="rounded-xl border border-indigo-100 bg-indigo-50/60 p-4 text-center dark:border-indigo-900/40 dark:bg-indigo-950/30">
<span className="text-xs font-semibold uppercase tracking-wider text-indigo-600 dark:text-indigo-400">
Characters
</span>
<p className="mt-1 text-2xl font-extrabold text-indigo-950 dark:text-indigo-100">
{stats.charCountWithSpaces.toLocaleString()}
</p>
</div>
<div className="rounded-xl border border-purple-100 bg-purple-50/60 p-4 text-center dark:border-purple-900/40 dark:bg-purple-950/30">
<span className="text-xs font-semibold uppercase tracking-wider text-purple-600 dark:text-purple-400">
No Spaces
</span>
<p className="mt-1 text-2xl font-extrabold text-purple-950 dark:text-purple-100">
{stats.charCountWithoutSpaces.toLocaleString()}
</p>
</div>
<div className="rounded-xl border border-teal-100 bg-teal-50/60 p-4 text-center dark:border-teal-900/40 dark:bg-teal-950/30">
<span className="text-xs font-semibold uppercase tracking-wider text-teal-600 dark:text-teal-400">
Sentences
</span>
<p className="mt-1 text-2xl font-extrabold text-teal-950 dark:text-teal-100">
{stats.sentenceCount.toLocaleString()}
</p>
</div>
<div className="rounded-xl border border-amber-100 bg-amber-50/60 p-4 text-center dark:border-amber-900/40 dark:bg-amber-950/30">
<span className="text-xs font-semibold uppercase tracking-wider text-amber-600 dark:text-amber-400">
Paragraphs
</span>
<p className="mt-1 text-2xl font-extrabold text-amber-950 dark:text-amber-100">
{stats.paragraphCount.toLocaleString()}
</p>
</div>
<div className="rounded-xl border border-emerald-100 bg-emerald-50/60 p-4 text-center dark:border-emerald-900/40 dark:bg-emerald-950/30">
<span className="text-xs font-semibold uppercase tracking-wider text-emerald-600 dark:text-emerald-400">
Reading Time
</span>
<p className="mt-1 text-2xl font-extrabold text-emerald-950 dark:text-emerald-100">
{stats.readingTimeStr}
</p>
</div>
</div>
{/* Input Textarea Area */}
<div className="space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<label
htmlFor="word-counter-input"
className="text-sm font-semibold text-gray-700 dark:text-gray-300"
>
Enter or Paste Your Text
</label>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={handleLoadSample}
className="px-3 py-1.5 text-xs font-medium text-blue-600 hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-blue-950/50 rounded-md transition-colors cursor-pointer"
>
Load Sample
</button>
<button
type="button"
onClick={handleCleanWhitespace}
disabled={!text}
className="px-3 py-1.5 text-xs font-medium text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800 rounded-md disabled:opacity-40 transition-colors cursor-pointer"
>
Clean Spaces
</button>
<button
type="button"
onClick={handleClear}
disabled={!text}
className="px-3 py-1.5 text-xs font-medium text-rose-600 hover:bg-rose-50 dark:text-rose-400 dark:hover:bg-rose-950/50 rounded-md disabled:opacity-40 transition-colors cursor-pointer"
>
Clear
</button>
<button
type="button"
onClick={handleCopy}
disabled={!text}
className="px-3 py-1.5 text-xs font-medium bg-blue-600 hover:bg-blue-700 text-white rounded-md disabled:opacity-40 transition-colors cursor-pointer flex items-center gap-1"
>
{copied ? "Copied!" : "Copy Text"}
</button>
</div>
</div>
<textarea
id="word-counter-input"
rows={10}
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Type or paste your text here to get instant word count, character statistics, sentence counts, and reading time..."
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:border-blue-500 focus:outline-none transition-shadow resize-y shadow-inner leading-relaxed"
/>
</div>
{/* Secondary Detailed Stats */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div className="rounded-lg border border-gray-200 bg-gray-50/70 p-3.5 dark:border-gray-800 dark:bg-gray-800/50">
<span className="text-xs text-gray-500 dark:text-gray-400">
Average Word Length
</span>
<p className="mt-1 text-lg font-bold text-gray-800 dark:text-gray-200">
{stats.avgWordLength}{" "}
<span className="text-xs font-normal text-gray-500">chars</span>
</p>
</div>
<div className="rounded-lg border border-gray-200 bg-gray-50/70 p-3.5 dark:border-gray-800 dark:bg-gray-800/50">
<span className="text-xs text-gray-500 dark:text-gray-400">
Speaking Time (~130 wpm)
</span>
<p className="mt-1 text-lg font-bold text-gray-800 dark:text-gray-200">
{stats.speakingTimeStr}
</p>
</div>
<div className="rounded-lg border border-gray-200 bg-gray-50/70 p-3.5 dark:border-gray-800 dark:bg-gray-800/50">
<span className="text-xs text-gray-500 dark:text-gray-400">
Total Lines
</span>
<p className="mt-1 text-lg font-bold text-gray-800 dark:text-gray-200">
{stats.lineCount.toLocaleString()}
</p>
</div>
<div className="rounded-lg border border-gray-200 bg-gray-50/70 p-3.5 dark:border-gray-800 dark:bg-gray-800/50">
<span className="text-xs text-gray-500 dark:text-gray-400">
Longest Word
</span>
<p
className="mt-1 text-lg font-bold text-gray-800 dark:text-gray-200 truncate"
title={stats.longestWord || "None"}
>
{stats.longestWord || "—"}{" "}
{stats.longestWord ? (
<span className="text-xs font-normal text-gray-500">
({stats.longestWord.length})
</span>
) : null}
</p>
</div>
</div>
{/* Top Keywords / Frequency Section */}
{stats.topKeywords.length > 0 && (
<div className="rounded-xl border border-gray-200 bg-gray-50/40 p-4 dark:border-gray-800 dark:bg-gray-900/40">
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
Top Keywords & Frequency
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
Most frequently occurring terms in your text.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2.5">
{stats.topKeywords.map((kw, i) => (
<div
key={i}
className="flex items-center justify-between p-2.5 rounded-lg border border-gray-200/80 bg-white dark:border-gray-800 dark:bg-gray-800 text-xs shadow-xs"
>
<span className="font-mono font-medium text-gray-800 dark:text-gray-200 truncate mr-2">
{kw.word}
</span>
<div className="flex items-center gap-2 flex-shrink-0">
<span className="px-2 py-0.5 rounded-full bg-blue-100 text-blue-800 dark:bg-blue-950 dark:text-blue-300 font-semibold">
{kw.count}×
</span>
<span className="text-gray-400 dark:text-gray-500">
{kw.percentage}%
</span>
</div>
</div>
))}
</div>
</div>
)}
</div>
</ToolLayout>
);
}

22
src/components/AdSlot.tsx Normal file
View file

@ -0,0 +1,22 @@
"use client";
interface AdSlotProps {
slot: string;
format?: "auto" | "horizontal" | "vertical" | "rectangle";
className?: string;
}
export default function AdSlot({ slot, format = "auto", className = "" }: AdSlotProps) {
return (
<div className={`ad-container my-4 flex justify-center ${className}`}>
<ins
className="adsbygoogle"
style={{ display: "block" }}
data-ad-client="ca-pub-XXXXXXXXXXXXXXXX"
data-ad-slot={slot}
data-ad-format={format}
data-full-width-responsive="true"
/>
</div>
);
}

View file

@ -0,0 +1,7 @@
export default function Footer() {
return (
<footer className="mt-auto border-t border-gray-200 px-4 py-4 text-center text-xs text-gray-400 dark:border-gray-800">
© {new Date().getFullYear()} DevToolbox — Free, private, browser-based developer tools.
</footer>
);
}

18
src/components/Header.tsx Normal file
View file

@ -0,0 +1,18 @@
"use client";
import Link from "next/link";
export default function Header() {
return (
<header className="border-b border-gray-200 dark:border-gray-800">
<div className="mx-auto flex max-w-5xl items-center justify-between px-4 py-3">
<Link href="/" className="text-sm font-semibold text-gray-900 dark:text-white">
DevToolbox
</Link>
<Link href="/" className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
All Tools
</Link>
</div>
</header>
);
}

View file

@ -0,0 +1,16 @@
import Link from "next/link";
import type { Tool } from "@/lib/tools";
export default function ToolCard({ tool }: { tool: Tool }) {
return (
<Link
href={`/tools/${tool.slug}`}
className="block rounded-lg border border-gray-200 px-4 py-3 transition-colors hover:border-gray-400 dark:border-gray-800 dark:hover:border-gray-600"
>
<div className="text-sm font-medium text-gray-900 dark:text-white">
{tool.icon} {tool.name}
</div>
<div className="mt-0.5 text-xs text-gray-400">{tool.description}</div>
</Link>
);
}

View file

@ -0,0 +1,22 @@
import AdSlot from "./AdSlot";
interface ToolLayoutProps {
title: string;
description: string;
children: React.ReactNode;
}
export default function ToolLayout({ title, description, children }: ToolLayoutProps) {
return (
<div className="mx-auto w-full max-w-4xl px-4 py-6">
<AdSlot slot="1234567890" format="horizontal" />
<h1 className="mb-1 text-lg font-semibold text-gray-900 dark:text-white">{title}</h1>
<p className="mb-4 text-sm text-gray-400">{description}</p>
{children}
<AdSlot slot="0987654321" format="horizontal" className="mt-6" />
</div>
);
}

54
src/lib/tools.ts Normal file
View file

@ -0,0 +1,54 @@
export interface Tool {
name: string;
slug: string;
description: string;
category: string;
icon: string;
}
export const tools: Tool[] = [
// Formatters & Validators
{ name: "JSON Formatter", slug: "json-formatter", description: "Format, validate, and beautify JSON data instantly", category: "Formatters", icon: "📋" },
{ name: "Markdown Preview", slug: "markdown-preview", description: "Write Markdown and see a live rendered preview", category: "Formatters", icon: "📝" },
// Encoders & Decoders
{ name: "Base64 Encode/Decode", slug: "base64", description: "Encode and decode Base64 strings", category: "Encoders", icon: "🔤" },
{ name: "URL Encode/Decode", slug: "url-encoder", description: "Encode and decode URL components", category: "Encoders", icon: "🔗" },
{ name: "HTML Entity Encode/Decode", slug: "html-entities", description: "Convert special characters to and from HTML entities", category: "Encoders", icon: "🏷️" },
{ name: "JWT Decoder", slug: "jwt-decoder", description: "Decode and inspect JSON Web Token payloads", category: "Encoders", icon: "🔑" },
// Generators
{ name: "UUID Generator", slug: "uuid-generator", description: "Generate unique UUIDs (v4) instantly", category: "Generators", icon: "🆔" },
{ name: "Lorem Ipsum Generator", slug: "lorem-ipsum", description: "Generate placeholder text for your designs", category: "Generators", icon: "📄" },
{ name: "Hash Generator", slug: "hash-generator", description: "Generate MD5, SHA-1, SHA-256, and SHA-512 hashes", category: "Generators", icon: "🔒" },
{ name: "QR Code Generator", slug: "qr-code", description: "Generate QR codes from any text or URL", category: "Generators", icon: "📱" },
{ name: "Password Generator", slug: "password-generator", description: "Generate strong, secure random passwords", category: "Generators", icon: "🔐" },
// Converters
{ name: "Unix Timestamp Converter", slug: "timestamp-converter", description: "Convert between Unix timestamps and human-readable dates", category: "Converters", icon: "⏰" },
{ name: "Color Converter", slug: "color-converter", description: "Convert colors between HEX, RGB, and HSL formats", category: "Converters", icon: "🎨" },
{ name: "Number Base Converter", slug: "number-base", description: "Convert numbers between binary, octal, decimal, and hex", category: "Converters", icon: "🔢" },
// Text Tools
{ name: "Word & Character Counter", slug: "word-counter", description: "Count words, characters, sentences, and paragraphs", category: "Text Tools", icon: "🔠" },
{ name: "Regex Tester", slug: "regex-tester", description: "Test regular expressions against text with live matching", category: "Text Tools", icon: "🧪" },
{ name: "Text Diff Checker", slug: "diff-checker", description: "Compare two texts and see the differences highlighted", category: "Text Tools", icon: "📊" },
{ name: "Case Converter", slug: "case-converter", description: "Convert text to UPPER, lower, Title, camelCase, and more", category: "Text Tools", icon: "🔡" },
// 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: "🖼️" },
];
export const categories = [...new Set(tools.map((t) => t.category))];
export function getToolsByCategory(): Record<string, Tool[]> {
return tools.reduce(
(acc, tool) => {
if (!acc[tool.category]) acc[tool.category] = [];
acc[tool.category].push(tool);
return acc;
},
{} as Record<string, Tool[]>
);
}

1
toolbox Symbolic link
View file

@ -0,0 +1 @@
/home/oiupoyt/stuff yeah/projects/hustling/toolbox