init: 20 developer tools
This commit is contained in:
parent
307b957d1a
commit
67aadda3d0
38 changed files with 11637 additions and 115 deletions
|
|
@ -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 -->
|
||||
|
|
@ -1 +0,0 @@
|
|||
@AGENTS.md
|
||||
63
README.md
63
README.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
292
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
4
public/robots.txt
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://devtoolbox.com/sitemap.xml
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
26
src/app/sitemap.ts
Normal 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,
|
||||
];
|
||||
}
|
||||
377
src/app/tools/base64/page.tsx
Normal file
377
src/app/tools/base64/page.tsx
Normal 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 & 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 & 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 & 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>
|
||||
);
|
||||
}
|
||||
726
src/app/tools/box-shadow/page.tsx
Normal file
726
src/app/tools/box-shadow/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
508
src/app/tools/case-converter/page.tsx
Normal file
508
src/app/tools/case-converter/page.tsx
Normal 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 "Apply" or "Copy" 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>
|
||||
);
|
||||
}
|
||||
1015
src/app/tools/color-converter/page.tsx
Normal file
1015
src/app/tools/color-converter/page.tsx
Normal file
File diff suppressed because it is too large
Load diff
723
src/app/tools/css-gradient/page.tsx
Normal file
723
src/app/tools/css-gradient/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
552
src/app/tools/diff-checker/page.tsx
Normal file
552
src/app/tools/diff-checker/page.tsx
Normal 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"> </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>
|
||||
);
|
||||
}
|
||||
468
src/app/tools/hash-generator/page.tsx
Normal file
468
src/app/tools/hash-generator/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
458
src/app/tools/html-entities/page.tsx
Normal file
458
src/app/tools/html-entities/page.tsx
Normal 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> = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
};
|
||||
|
||||
const EXTENDED_ENTITIES_MAP: Record<string, string> = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
"©": "©",
|
||||
"®": "®",
|
||||
"™": "™",
|
||||
"€": "€",
|
||||
"£": "£",
|
||||
"¥": "¥",
|
||||
"¢": "¢",
|
||||
"§": "§",
|
||||
"°": "°",
|
||||
"±": "±",
|
||||
"«": "«",
|
||||
"»": "»",
|
||||
"•": "•",
|
||||
"…": "…",
|
||||
"—": "—",
|
||||
"–": "–",
|
||||
"“": "“",
|
||||
"”": "”",
|
||||
"‘": "‘",
|
||||
"’": "’",
|
||||
"×": "×",
|
||||
"÷": "÷",
|
||||
"µ": "µ",
|
||||
"¶": "¶",
|
||||
"¼": "¼",
|
||||
"½": "½",
|
||||
"¾": "¾",
|
||||
};
|
||||
|
||||
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 = '<div class="card">\n <h2>Hello &amp; Welcome!</h2>\n <p>Price: &euro;50 &copy; 2026</p>\n</div>';
|
||||
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 & 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 & 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">(< > & " ')</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">(&copy;, &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">(&#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">(&#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">< (Less than)</span>
|
||||
<span className="font-semibold text-blue-600 dark:text-blue-400">&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">> (Greater)</span>
|
||||
<span className="font-semibold text-blue-600 dark:text-blue-400">&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">& (Ampersand)</span>
|
||||
<span className="font-semibold text-blue-600 dark:text-blue-400">&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">" (Double quote)</span>
|
||||
<span className="font-semibold text-blue-600 dark:text-blue-400">&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">' (Single quote)</span>
|
||||
<span className="font-semibold text-blue-600 dark:text-blue-400">&#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">© (Copyright)</span>
|
||||
<span className="font-semibold text-blue-600 dark:text-blue-400">&copy;</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ToolLayout>
|
||||
);
|
||||
}
|
||||
563
src/app/tools/json-formatter/page.tsx
Normal file
563
src/app/tools/json-formatter/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
517
src/app/tools/jwt-decoder/page.tsx
Normal file
517
src/app/tools/jwt-decoder/page.tsx
Normal 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 & 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't changed along the way.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ToolLayout>
|
||||
);
|
||||
}
|
||||
394
src/app/tools/lorem-ipsum/page.tsx
Normal file
394
src/app/tools/lorem-ipsum/page.tsx
Normal 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 <p>
|
||||
</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 “Lorem ipsum...”</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's philosophical work <em>“de Finibus Bonorum et Malorum”</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 & 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>
|
||||
);
|
||||
}
|
||||
684
src/app/tools/markdown-preview/page.tsx
Normal file
684
src/app/tools/markdown-preview/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
592
src/app/tools/number-base/page.tsx
Normal file
592
src/app/tools/number-base/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
500
src/app/tools/password-generator/page.tsx
Normal file
500
src/app/tools/password-generator/page.tsx
Normal 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">
|
||||
!@#$%^&*...
|
||||
</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>
|
||||
);
|
||||
}
|
||||
386
src/app/tools/qr-code/page.tsx
Normal file
386
src/app/tools/qr-code/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
567
src/app/tools/regex-tester/page.tsx
Normal file
567
src/app/tools/regex-tester/page.tsx
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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 & 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 & 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"><empty match></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">
|
||||
?<{name}>:
|
||||
</span>
|
||||
<span className="text-gray-800 dark:text-gray-200 break-all">
|
||||
"{val}"
|
||||
</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>
|
||||
);
|
||||
}
|
||||
807
src/app/tools/timestamp-converter/page.tsx
Normal file
807
src/app/tools/timestamp-converter/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
414
src/app/tools/url-encoder/page.tsx
Normal file
414
src/app/tools/url-encoder/page.tsx
Normal 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 & 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 & 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">& (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>
|
||||
);
|
||||
}
|
||||
428
src/app/tools/uuid-generator/page.tsx
Normal file
428
src/app/tools/uuid-generator/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
353
src/app/tools/word-counter/page.tsx
Normal file
353
src/app/tools/word-counter/page.tsx
Normal 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 & 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
22
src/components/AdSlot.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
7
src/components/Footer.tsx
Normal file
7
src/components/Footer.tsx
Normal 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
18
src/components/Header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
src/components/ToolCard.tsx
Normal file
16
src/components/ToolCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
src/components/ToolLayout.tsx
Normal file
22
src/components/ToolLayout.tsx
Normal 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
54
src/lib/tools.ts
Normal 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
1
toolbox
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
/home/oiupoyt/stuff yeah/projects/hustling/toolbox
|
||||
Loading…
Add table
Reference in a new issue