refactor: clean up descriptions, remove emojis, and streamline minimal UI

This commit is contained in:
katR 2026-09-11 21:01:49 +05:30
parent de42f2974a
commit 69ecd11992
10 changed files with 379 additions and 479 deletions

View file

@ -1,60 +1,37 @@
# kattoolbox
> **100% Private, Fast, In-Browser Utilities & File Tools.**
> Zero server uploads. Zero sign-up. Zero data collection. All operations run client-side in your browser.
Browser-based file and developer utilities. All processing occurs locally on the client without uploading files to any external server.
---
## Tools
## 🌟 High-Impact Privacy & Media Tools
### Files & Media
- **File Shrinker**: Compress images to target thresholds (Discord 10MB/8MB, Gmail 25MB, web formats).
- **EXIF & GPS Scrubber**: Inspect camera metadata and remove location tags from photos.
- **PDF Toolkit**: Merge, split, extract pages, rotate documents, and convert images to PDF.
- **Image Redactor**: Black out or pixelate sensitive sections of images.
- **🗜️ Discord & Email File Shrinker (`/tools/discord-image-shrinker`)**
Compress images client-side to fit Discord's 10MB/8MB upload limit or Gmail's 25MB attachment limit. Auto-calculates optimal quality & resolution.
- **🛡️ Photo GPS & EXIF Scrubber (`/tools/exif-scrubber`)**
Inspect hidden GPS coordinates, camera serial numbers, and device tags in photos. One-click sanitization wipes all metadata headers.
- **📑 Private In-Browser PDF Toolkit (`/tools/pdf-tools`)**
Merge multiple PDFs, extract custom page ranges, convert images to PDF, or rotate scans offline using `pdf-lib`.
- **🔒 Document & Image Redactor (`/tools/image-redactor`)**
Draw permanent blackout or pixelate blur boxes over confidential info (SSNs, credit card numbers, IDs, faces) before sharing.
### Developer Utilities
- **Formatters**: JSON, Markdown, SQL, XML
- **Encoders**: Base64, URL, HTML Entities, JWT
- **Generators**: UUID v4, Lorem Ipsum, Hash (MD5, SHA-1, SHA-256, SHA-512), QR Code, Password
- **Converters**: Unix Timestamp, Color (HEX/RGB/HSL), Number Base, JSON / CSV
- **Text**: Word Counter, Regex Tester, Diff Checker, Case Converter
- **CSS**: Gradient Generator, Box Shadow, Px to Rem
- **Web**: Meta Tag Generator, Slug Generator, Cron Parser, Image to Base64, Placeholder Image
---
## 🛠️ Developer & Everyday Utilities (30+)
- **Formatters & Validators:** JSON Formatter, Markdown Preview, SQL Formatter, XML Formatter
- **Encoders & Decoders:** Base64, URL Encoder, HTML Entities, JWT Decoder
- **Generators:** UUID (v4), Lorem Ipsum, Hash (MD5, SHA-1, SHA-256, SHA-512), QR Code, Password
- **Converters:** Unix Timestamp, Color (HEX/RGB/HSL), Number Base, JSON ↔ CSV
- **Text Tools:** Word & Character Counter, Regex Tester, Diff Checker, Case Converter
- **CSS Tools:** Gradient Generator, Box Shadow, Px ↔ Rem Converter
- **Web Tools:** Meta Tag Generator, Slug Generator, Cron Expression Parser, Image to Base64, Placeholder Image
---
## ⚡ Architecture & Design
- **Next.js 16 (Turbopack)** + **React 19** + **TypeScript** + **Tailwind CSS v4**
- **Static Site Generation (SSG):** Pre-rendered static pages for instant load times and optimal SEO.
- **Non-Intrusive Side Ads:** Zero annoying popups or modal overlays. Side ad rails automatically adapt on desktop/laptop viewports and remain unobtrusive.
- **100% Client-Side:** No backend server or database to maintain ($0 hosting forever on Cloudflare Pages, Vercel, or GitHub Pages).
---
## 🚀 Run Locally
## Development
```bash
npm install
npm run dev
```
Open [http://localhost:3000](http://localhost:3000) to view the application.
## 📦 Build Static Output
## Build
```bash
npm run build
```
---
## License
MIT

View file

@ -16,35 +16,32 @@ const geistMono = Geist_Mono({
export const metadata: Metadata = {
title: {
default: "kattoolbox — Private In-Browser Utilities & File Tools",
default: "kattoolbox — Client-Side File & Developer Utilities",
template: "%s | kattoolbox",
},
description:
"Free, fast, and 100% private browser tools. Shrink images for Discord (10MB/8MB) and Gmail (25MB), scrub photo GPS/EXIF metadata, merge & split PDFs offline, redact sensitive images, and 30+ developer utilities. Zero server uploads.",
"Client-side file and developer tools. Compress images to size limits, strip EXIF metadata, merge and extract PDF pages, and run developer utilities directly in the browser.",
keywords: [
"discord image compressor",
"shrink image for discord",
"strip exif metadata online",
"photo gps scrubber",
"private pdf merger",
"extract pdf pages offline",
"image redactor",
"developer toolbox",
"client-side file tools",
"file tools",
"image compression",
"discord image shrinker",
"exif scrubber",
"pdf merge",
"client-side utilities",
"kattoolbox",
],
openGraph: {
type: "website",
title: "kattoolbox — Private In-Browser Utilities & File Tools",
title: "kattoolbox — Client-Side File & Developer Utilities",
description:
"Shrink images for Discord, scrub photo EXIF/GPS, merge/split PDFs, and 30+ dev tools. 100% private in-browser.",
"Client-side file and developer tools running entirely in the browser.",
siteName: "kattoolbox",
},
twitter: {
card: "summary_large_image",
title: "kattoolbox — Private In-Browser Utilities & File Tools",
title: "kattoolbox — Client-Side File & Developer Utilities",
description:
"Shrink images for Discord, scrub photo EXIF/GPS, merge/split PDFs, and 30+ dev tools. 100% private in-browser.",
"Client-side file and developer tools running entirely in the browser.",
},
robots: {
index: true,
@ -59,7 +56,6 @@ export default function RootLayout({ children }: { children: React.ReactNode })
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<head>
{/* Google AdSense — Non-intrusive side placement */}
<script
async
src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-XXXXXXXXXXXXXXXX"

View file

@ -10,56 +10,50 @@ export default function Home() {
return (
<div className="relative z-10 mx-auto max-w-7xl px-4 py-8">
<div className="flex justify-center items-start gap-6">
{/* Left Side Ad (Ultra-wide screens) */}
{/* Left Side Rail (Ultra-wide) */}
<div className="hidden 2xl:block sticky top-20 shrink-0">
<SidebarAd format="vertical" slotId="kat-home-left" />
</div>
{/* Main Content */}
<div className="flex-1 max-w-5xl min-w-0">
{/* Hero / Privacy Banner */}
<section className="mb-10 border border-[#1a1a1a] bg-gradient-to-b from-[#0a0a0a] to-black p-6 sm:p-8">
<div className="flex items-center gap-2 mb-2">
<span className="h-2 w-2 rounded-full bg-emerald-500 animate-pulse"></span>
<span className="text-xs font-mono uppercase tracking-wider text-emerald-400">
100% Client-Side • Zero Server Storage
</span>
{/* Header intro */}
<div className="mb-8 border border-[#1a1a1a] bg-[#090909] p-6">
<div className="text-xs font-mono text-[#666] mb-1">
Local execution • No uploads
</div>
<h1 className="text-2xl sm:text-3xl font-bold text-white tracking-tight">
Fast, Private In-Browser Utilities
<h1 className="text-xl font-semibold text-white tracking-tight">
Browser-based file and developer utilities
</h1>
<p className="mt-2 text-sm text-gray-400 max-w-2xl leading-relaxed">
Clean media, shrink files for Discord and Gmail, redact sensitive data, and manipulate PDFs directly inside your browser. No files ever leave your machine.
<p className="mt-1 text-xs text-gray-400 max-w-2xl leading-relaxed">
Utilities for image resizing, metadata stripping, PDF manipulation, and standard developer conversions. All tasks execute locally in your browser.
</p>
{/* Quick Featured Launchpad */}
<div className="mt-6 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
{/* Quick access row */}
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-2">
{featuredTools.map((t) => (
<Link
key={t.slug}
href={`/tools/${t.slug}`}
className="group block border border-[#222] bg-[#0c0c0c] hover:border-blue-500/50 hover:bg-[#121212] p-3 transition-all"
className="group block border border-[#1c1c1c] bg-[#060606] hover:border-blue-900 hover:bg-[#0c0c0c] p-3 transition-colors"
>
<div className="flex items-center gap-2">
<span className="text-lg">{t.icon}</span>
<span className="text-xs font-semibold text-gray-200 group-hover:text-blue-400 transition-colors">
{t.name}
</span>
<div className="text-xs font-medium text-gray-200 group-hover:text-blue-400 transition-colors">
{t.name}
</div>
<p className="mt-1 text-[11px] text-[#666] line-clamp-2">
<p className="mt-1 text-[11px] text-[#555] line-clamp-2 leading-snug">
{t.description}
</p>
</Link>
))}
</div>
</section>
</div>
{/* Categorized Tools */}
{/* Categorized tools */}
{Object.entries(toolsByCategory).map(([category, categoryTools]) => (
<section key={category} className="mb-8">
<div className="flex items-center gap-2 mb-3">
<div className="h-px w-4 bg-blue-900"></div>
<h2 className="text-xs font-medium uppercase tracking-widest text-[#525252]">
<div className="h-px w-3 bg-blue-900"></div>
<h2 className="text-xs font-mono uppercase tracking-wider text-[#666]">
{category}
</h2>
<span className="text-[10px] text-[#444] font-mono">({categoryTools.length})</span>
@ -73,7 +67,7 @@ export default function Home() {
))}
</div>
{/* Right Side Ad (Desktops & Laptops) */}
{/* Right Side Rail */}
<div className="hidden xl:block sticky top-20 shrink-0">
<SidebarAd format="vertical" slotId="kat-home-right" />
</div>

View file

@ -12,9 +12,9 @@ interface TargetPreset {
const PRESETS: TargetPreset[] = [
{ name: "Discord Free (10 MB)", bytes: 10 * 1024 * 1024, label: "Discord 10MB" },
{ name: "Discord Legacy (8 MB)", bytes: 8 * 1024 * 1024, label: "Discord 8MB" },
{ name: "Email / Gmail (25 MB)", bytes: 24.5 * 1024 * 1024, label: "Email 25MB" },
{ name: "Web / Avatar (1 MB)", bytes: 1 * 1024 * 1024, label: "Web 1MB" },
{ name: "Ultra-Light (500 KB)", bytes: 500 * 1024, label: "Fast 500KB" },
{ name: "Email Attachment (25 MB)", bytes: 24.5 * 1024 * 1024, label: "Email 25MB" },
{ name: "Web (1 MB)", bytes: 1 * 1024 * 1024, label: "Web 1MB" },
{ name: "Optimized (500 KB)", bytes: 500 * 1024, label: "500KB" },
];
function formatBytes(bytes: number): string {
@ -41,7 +41,7 @@ export default function DiscordImageShrinkerPage() {
const compressToTarget = useCallback(
async (sourceFile: File, targetLimit: number, format: "image/webp" | "image/jpeg" | "image/png") => {
setIsCompressing(true);
setStatusMessage("Loading and processing image...");
setStatusMessage("Processing image...");
const img = new Image();
const objectUrl = URL.createObjectURL(sourceFile);
@ -61,11 +61,10 @@ export default function DiscordImageShrinkerPage() {
}
let scale = 1.0;
let minQuality = 0.05;
let maxQuality = 0.95;
const minQuality = 0.05;
const maxQuality = 0.95;
let bestBlob: Blob | null = null;
// Iterative smart compression
for (let iteration = 0; iteration < 6; iteration++) {
canvas.width = Math.max(1, Math.round(img.naturalWidth * scale));
canvas.height = Math.max(1, Math.round(img.naturalHeight * scale));
@ -73,7 +72,6 @@ export default function DiscordImageShrinkerPage() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
// Binary search for quality
let currentBestBlob: Blob | null = null;
let low = minQuality;
let high = maxQuality;
@ -86,9 +84,9 @@ export default function DiscordImageShrinkerPage() {
if (blob.size <= targetLimit) {
currentBestBlob = blob;
low = midQuality; // try higher quality
low = midQuality;
} else {
high = midQuality; // lower quality needed
high = midQuality;
}
}
@ -97,11 +95,9 @@ export default function DiscordImageShrinkerPage() {
break;
}
// If even lowest quality is too big, scale down dimensions by 20%
scale *= 0.8;
}
// Fallback if still over limit
if (!bestBlob) {
canvas.width = Math.max(1, Math.round(img.naturalWidth * 0.5));
canvas.height = Math.max(1, Math.round(img.naturalHeight * 0.5));
@ -121,8 +117,8 @@ export default function DiscordImageShrinkerPage() {
setCompressedSize(bestBlob.size);
setStatusMessage(
bestBlob.size <= targetLimit
? `Successfully shrunk under ${formatBytes(targetLimit)}!`
: `Compressed as much as possible without extreme quality loss.`
? `Compressed to ${formatBytes(bestBlob.size)} (under ${formatBytes(targetLimit)} threshold).`
: `Compressed to lowest viable threshold (${formatBytes(bestBlob.size)}).`
);
}
@ -133,7 +129,7 @@ export default function DiscordImageShrinkerPage() {
const handleFileSelect = (selected: File) => {
if (!selected.type.startsWith("image/")) {
alert("Please upload a valid image file (JPG, PNG, WebP).");
alert("Please upload a valid image file.");
return;
}
setFile(selected);
@ -163,7 +159,7 @@ export default function DiscordImageShrinkerPage() {
const nameWithoutExt = file.name.substring(0, file.name.lastIndexOf(".")) || file.name;
const a = document.createElement("a");
a.href = compressedUrl;
a.download = `${nameWithoutExt}-discord-ready.${ext}`;
a.download = `${nameWithoutExt}-compressed.${ext}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
@ -176,14 +172,14 @@ export default function DiscordImageShrinkerPage() {
return (
<ToolLayout
title="Discord & Email File Shrinker"
description="Compress images client-side to fit Discord's upload limit (10MB/8MB) or Gmail's 25MB attachment limit. 100% private in-browser."
title="File Shrinker"
description="Compress images to target file sizes for Discord, email attachments, or web delivery. Processing runs in browser memory."
>
<div className="space-y-6">
{/* Preset Selector */}
<div>
<label className="text-xs uppercase tracking-wider text-[#666] font-semibold block mb-2">
Select Target Limit
<label className="text-xs font-mono uppercase tracking-wider text-[#666] block mb-2">
Target Size Threshold
</label>
<div className="flex flex-wrap gap-2">
{PRESETS.map((p) => {
@ -192,10 +188,10 @@ export default function DiscordImageShrinkerPage() {
<button
key={p.name}
onClick={() => handlePresetChange(p.bytes)}
className={`px-3 py-1.5 text-xs font-medium border transition-colors cursor-pointer ${
className={`px-3 py-1.5 text-xs font-mono border transition-colors cursor-pointer ${
isSelected
? "bg-blue-600 text-white border-blue-500 shadow-sm"
: "bg-[#111] text-gray-400 border-[#222] hover:bg-[#1a1a1a] hover:text-gray-200"
? "bg-blue-900/60 text-blue-200 border-blue-600"
: "bg-[#0d0d0d] text-gray-400 border-[#222] hover:bg-[#151515] hover:text-gray-200"
}`}
>
{p.label}
@ -207,7 +203,7 @@ export default function DiscordImageShrinkerPage() {
{/* Format Selector */}
<div className="flex items-center gap-4 text-xs text-gray-400">
<span className="text-[#666] uppercase tracking-wider font-semibold">Format:</span>
<span className="text-[#666] font-mono uppercase tracking-wider">Output Format:</span>
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="radio"
@ -216,7 +212,7 @@ export default function DiscordImageShrinkerPage() {
onChange={() => handleFormatChange("image/webp")}
className="text-blue-600"
/>
<span>WebP (Best Compression)</span>
<span>WebP (Optimal compression)</span>
</label>
<label className="flex items-center gap-1.5 cursor-pointer">
<input
@ -240,7 +236,7 @@ export default function DiscordImageShrinkerPage() {
handleFileSelect(e.dataTransfer.files[0]);
}
}}
className="border-2 border-dashed border-[#222] hover:border-blue-600/50 bg-[#080808] p-8 text-center cursor-pointer transition-colors"
className="border border-dashed border-[#222] hover:border-[#3a3a3a] bg-[#070707] p-8 text-center cursor-pointer transition-colors"
>
<input
ref={fileInputRef}
@ -254,69 +250,68 @@ export default function DiscordImageShrinkerPage() {
}}
/>
<div className="flex flex-col items-center justify-center gap-2">
<div className="w-10 h-10 rounded border border-[#222] bg-[#111] flex items-center justify-center text-gray-400">
📁
</div>
<p className="text-sm font-medium text-gray-300">
Drag &amp; drop your image here, or <span className="text-blue-400">browse</span>
<svg className="w-8 h-8 text-[#444]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16v1a2 2 0 002 2h12a2 2 0 002-2v-1M12 12V4m0 0L8 8m4-4l4 4" />
</svg>
<p className="text-xs text-gray-300">
Drag and drop an image, or <span className="text-blue-400">browse</span>
</p>
<p className="text-xs text-[#555]">Supports PNG, JPG, WebP, GIF frames</p>
<p className="text-[11px] text-[#555]">PNG, JPG, WebP supported</p>
</div>
</div>
{/* Processing State */}
{/* Processing Indicator */}
{isCompressing && (
<div className="flex items-center gap-3 p-4 bg-[#0d1525] border border-blue-900 text-blue-300 text-sm">
<div className="w-4 h-4 border-2 border-blue-400 border-t-transparent rounded-full animate-spin" />
<div className="flex items-center gap-3 p-3 bg-[#0c121e] border border-blue-900/60 text-blue-300 text-xs">
<div className="w-3.5 h-3.5 border-2 border-blue-400 border-t-transparent rounded-full animate-spin" />
<span>{statusMessage}</span>
</div>
)}
{/* Results Comparison */}
{/* Results */}
{file && compressedBlob && !isCompressing && (
<div className="space-y-4 border border-[#1a1a1a] bg-[#0c0c0c] p-4">
<div className="flex flex-wrap items-center justify-between gap-4 border-b border-[#1a1a1a] pb-3">
<div className="space-y-4 border border-[#1a1a1a] bg-[#090909] p-4">
<div className="flex flex-wrap items-center justify-between gap-4 border-b border-[#181818] pb-3">
<div>
<span className="text-xs text-[#666] uppercase tracking-wider block">Original Size</span>
<span className="text-sm font-mono text-gray-300">{formatBytes(originalSize)}</span>
<span className="text-[11px] font-mono text-[#555] uppercase block">Original</span>
<span className="text-xs font-mono text-gray-300">{formatBytes(originalSize)}</span>
</div>
<div>
<span className="text-xs text-[#666] uppercase tracking-wider block">Compressed Size</span>
<span className="text-sm font-mono text-green-400 font-bold">
<span className="text-[11px] font-mono text-[#555] uppercase block">Compressed</span>
<span className="text-xs font-mono text-emerald-400 font-medium">
{formatBytes(compressedSize)}
</span>
</div>
<div>
<span className="text-xs text-[#666] uppercase tracking-wider block">Saved</span>
<span className="text-sm font-mono text-blue-400 font-semibold">{savingsPercent}%</span>
<span className="text-[11px] font-mono text-[#555] uppercase block">Reduction</span>
<span className="text-xs font-mono text-blue-400">{savingsPercent}%</span>
</div>
<div>
<span className="text-xs text-[#666] uppercase tracking-wider block">Target Limit</span>
<span className="text-sm font-mono text-gray-400">{formatBytes(targetBytes)}</span>
<span className="text-[11px] font-mono text-[#555] uppercase block">Target</span>
<span className="text-xs font-mono text-gray-400">{formatBytes(targetBytes)}</span>
</div>
<button
onClick={downloadFile}
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium transition-colors cursor-pointer flex items-center gap-2"
className="px-3.5 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-xs font-medium transition-colors cursor-pointer"
>
<span>Download Shrunk Image</span>
Download compressed file
</button>
</div>
{/* Preview Image */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-2">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-1">
<div>
<span className="text-xs text-[#666] block mb-1">Original Preview</span>
<div className="border border-[#1a1a1a] bg-black max-h-60 overflow-hidden flex items-center justify-center">
<span className="text-[11px] text-[#555] block mb-1">Source Preview</span>
<div className="border border-[#181818] bg-black max-h-56 overflow-hidden flex items-center justify-center p-2">
{previewUrl && (
<img src={previewUrl} alt="Original" className="max-h-60 object-contain" />
<img src={previewUrl} alt="Source" className="max-h-52 object-contain" />
)}
</div>
</div>
<div>
<span className="text-xs text-[#666] block mb-1">Compressed Output</span>
<div className="border border-[#1a1a1a] bg-black max-h-60 overflow-hidden flex items-center justify-center">
<span className="text-[11px] text-[#555] block mb-1">Result Preview</span>
<div className="border border-[#181818] bg-black max-h-56 overflow-hidden flex items-center justify-center p-2">
{compressedUrl && (
<img src={compressedUrl} alt="Compressed" className="max-h-60 object-contain" />
<img src={compressedUrl} alt="Result" className="max-h-52 object-contain" />
)}
</div>
</div>
@ -324,19 +319,19 @@ export default function DiscordImageShrinkerPage() {
</div>
)}
{/* Feature Explainer */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 pt-4 border-t border-[#1a1a1a] text-xs text-[#666]">
<div className="border border-[#181818] p-3 bg-black">
<h4 className="text-gray-300 font-medium mb-1">Never Fails Discord Limits</h4>
<p>Automatically reduces image resolution and quality iteratively until it falls safely below the target file size.</p>
{/* Technical details */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 pt-4 border-t border-[#1a1a1a] text-xs text-[#555]">
<div className="border border-[#141414] p-3 bg-black">
<h4 className="text-gray-300 font-medium mb-1">Target Size Convergence</h4>
<p>Iteratively scales dimensions and adjusts encoder quality until file size conforms to selected limit.</p>
</div>
<div className="border border-[#181818] p-3 bg-black">
<h4 className="text-gray-300 font-medium mb-1">Zero Server Uploads</h4>
<p>All compression runs locally via HTML5 Canvas. Your images are never sent across the internet.</p>
<div className="border border-[#141414] p-3 bg-black">
<h4 className="text-gray-300 font-medium mb-1">In-Memory Execution</h4>
<p>Processing occurs via HTML5 Canvas in local browser memory. Files are not transmitted externally.</p>
</div>
<div className="border border-[#181818] p-3 bg-black">
<h4 className="text-gray-300 font-medium mb-1">Metadata Stripped</h4>
<p>Canvas re-encoding inherently purges hidden GPS, camera, and device tracking markers.</p>
<div className="border border-[#141414] p-3 bg-black">
<h4 className="text-gray-300 font-medium mb-1">Metadata Stripping</h4>
<p>Canvas rasterization automatically removes embedded camera metadata and GPS coordinates.</p>
</div>
</div>
</div>

View file

@ -17,19 +17,17 @@ export default function ExifScrubberPage() {
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [isScrubbing, setIsScrubbing] = useState(false);
const [scrubbedUrl, setScrubbedUrl] = useState<string | null>(null);
const [scrubbedSize, setScrubbedSize] = useState<number | null>(null);
const [statusMessage, setStatusMessage] = useState<string>("");
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileSelect = async (selected: File) => {
if (!selected.type.startsWith("image/")) {
alert("Please upload a valid image file.");
alert("Please upload an image file.");
return;
}
setFile(selected);
setScrubbedUrl(null);
setScrubbedSize(null);
setStatusMessage("");
const preview = URL.createObjectURL(selected);
@ -37,7 +35,6 @@ export default function ExifScrubberPage() {
setIsAnalyzing(true);
try {
// Parse all available metadata
const rawExif = await exifr.parse(selected, {
gps: true,
tiff: true,
@ -70,7 +67,7 @@ export default function ExifScrubberPage() {
const scrubMetadata = async () => {
if (!file) return;
setIsScrubbing(true);
setStatusMessage("Stripping EXIF, GPS, and device markers...");
setStatusMessage("Removing metadata headers...");
try {
const img = new Image();
@ -91,7 +88,6 @@ export default function ExifScrubberPage() {
ctx.drawImage(img, 0, 0);
// Export as fresh JPEG/PNG with zero metadata headers
const outputType = file.type === "image/png" ? "image/png" : "image/jpeg";
const blob: Blob = await new Promise((resolve) => {
canvas.toBlob((b) => resolve(b || new Blob()), outputType, 0.95);
@ -101,8 +97,7 @@ export default function ExifScrubberPage() {
const cleanUrl = URL.createObjectURL(blob);
setScrubbedUrl(cleanUrl);
setScrubbedSize(blob.size);
setStatusMessage("All metadata wiped successfully! The file is now 100% anonymous.");
setStatusMessage("All EXIF tags and GPS data stripped.");
} catch (err) {
setStatusMessage(err instanceof Error ? err.message : "Failed to scrub metadata");
} finally {
@ -124,8 +119,8 @@ export default function ExifScrubberPage() {
return (
<ToolLayout
title="Photo GPS &amp; EXIF Metadata Scrubber"
description="Inspect hidden location tags, camera serial numbers, and device data in your photos. Wipe all tracking tags locally before sharing online."
title="EXIF & GPS Scrubber"
description="Inspect embedded camera parameters, device identifiers, and location tags. Strip metadata client-side before sharing."
>
<div className="space-y-6">
{/* Dropzone */}
@ -138,7 +133,7 @@ export default function ExifScrubberPage() {
handleFileSelect(e.dataTransfer.files[0]);
}
}}
className="border-2 border-dashed border-[#222] hover:border-blue-600/50 bg-[#080808] p-8 text-center cursor-pointer transition-colors"
className="border border-dashed border-[#222] hover:border-[#3a3a3a] bg-[#070707] p-8 text-center cursor-pointer transition-colors"
>
<input
ref={fileInputRef}
@ -152,82 +147,82 @@ export default function ExifScrubberPage() {
}}
/>
<div className="flex flex-col items-center justify-center gap-2">
<div className="w-10 h-10 rounded border border-[#222] bg-[#111] flex items-center justify-center text-gray-400">
📷
</div>
<p className="text-sm font-medium text-gray-300">
Drag &amp; drop a photo to inspect, or <span className="text-blue-400">browse files</span>
<svg className="w-8 h-8 text-[#444]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<p className="text-xs text-gray-300">
Drag and drop an image to inspect, or <span className="text-blue-400">browse</span>
</p>
<p className="text-xs text-[#555]">Inspects GPS, Make, Model, Serial, Lens, Timestamp</p>
<p className="text-[11px] text-[#555]">Extracts GPS, camera model, lens parameters, and timestamps</p>
</div>
</div>
{isAnalyzing && (
<div className="flex items-center gap-3 p-4 bg-[#0d1525] border border-blue-900 text-blue-300 text-sm">
<div className="w-4 h-4 border-2 border-blue-400 border-t-transparent rounded-full animate-spin" />
<span>Analyzing image headers for hidden location and device tags...</span>
<div className="flex items-center gap-3 p-3 bg-[#0c121e] border border-blue-900/60 text-blue-300 text-xs">
<div className="w-3.5 h-3.5 border-2 border-blue-400 border-t-transparent rounded-full animate-spin" />
<span>Inspecting metadata headers...</span>
</div>
)}
{/* GPS Threat Warning */}
{/* GPS alert banner */}
{gpsData && (
<div className="border border-red-900/80 bg-[#1f0a0a] p-4 text-red-300 space-y-2">
<div className="flex items-center gap-2 font-semibold text-sm text-red-200">
<span className="text-base">⚠️</span>
<span>Physical Location Coordinates Detected!</span>
<div className="border border-red-900/60 bg-[#160a0a] p-4 text-xs space-y-1.5">
<div className="font-medium text-red-300">
Embedded GPS Coordinates Detected
</div>
<p className="text-xs text-red-400/90 leading-relaxed">
This photo contains precise embedded GPS coordinates. Anyone with this file can find exactly where it was taken.
<p className="text-red-400/80 leading-relaxed">
This file contains geographic coordinates identifying where the photo was taken.
</p>
<div className="flex flex-wrap items-center gap-4 text-xs font-mono pt-1 text-red-200">
<div className="flex flex-wrap items-center gap-4 font-mono pt-1 text-red-200 text-[11px]">
<span>Latitude: {gpsData.latitude.toFixed(6)}</span>
<span>Longitude: {gpsData.longitude.toFixed(6)}</span>
<a
href={`https://www.google.com/maps?q=${gpsData.latitude},${gpsData.longitude}`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 underline hover:text-blue-300 font-sans"
className="text-blue-400 hover:underline"
>
View on Google Maps →
Google Maps
</a>
<a
href={`https://www.openstreetmap.org/?mlat=${gpsData.latitude}&mlon=${gpsData.longitude}#map=16/${gpsData.latitude}/${gpsData.longitude}`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 underline hover:text-blue-300 font-sans"
className="text-blue-400 hover:underline"
>
View on OpenStreetMap →
OpenStreetMap
</a>
</div>
</div>
)}
{/* Action Bar & Scrubbing Button */}
{/* File Actions */}
{file && !isAnalyzing && (
<div className="flex flex-wrap items-center justify-between gap-4 border border-[#1a1a1a] bg-[#0c0c0c] p-4">
<div className="flex flex-wrap items-center justify-between gap-4 border border-[#1a1a1a] bg-[#090909] p-4">
<div>
<span className="text-xs text-[#666] uppercase tracking-wider block">File Loaded</span>
<span className="text-sm font-medium text-gray-200">{file.name}</span>
<span className="text-xs text-[#555] block">
<span className="text-[11px] font-mono text-[#555] uppercase block">Loaded File</span>
<span className="text-xs font-medium text-gray-200">{file.name}</span>
<span className="text-[11px] text-[#555] block">
{(file.size / (1024 * 1024)).toFixed(2)} MB • {file.type}
</span>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
{!scrubbedUrl ? (
<button
onClick={scrubMetadata}
disabled={isScrubbing}
className="px-5 py-2.5 bg-red-600 hover:bg-red-500 disabled:opacity-50 text-white font-medium text-sm transition-colors cursor-pointer flex items-center gap-2"
className="px-4 py-2 bg-red-900/80 hover:bg-red-800 disabled:opacity-50 text-red-100 text-xs font-medium transition-colors cursor-pointer"
>
<span>🛡️ Wipe All Metadata</span>
Strip Metadata
</button>
) : (
<button
onClick={downloadCleanFile}
className="px-5 py-2.5 bg-green-600 hover:bg-green-500 text-white font-medium text-sm transition-colors cursor-pointer flex items-center gap-2"
className="px-4 py-2 bg-emerald-900/80 hover:bg-emerald-800 text-emerald-100 text-xs font-medium transition-colors cursor-pointer"
>
<span>✓ Download Sanitized Image</span>
Download sanitized image
</button>
)}
</div>
@ -235,33 +230,30 @@ export default function ExifScrubberPage() {
)}
{statusMessage && (
<div className="p-3 bg-[#0d1c12] border border-green-900/60 text-green-300 text-xs">
<div className="p-3 bg-[#0a140f] border border-emerald-950 text-emerald-400 text-xs">
{statusMessage}
</div>
)}
{/* EXIF Metadata Table */}
{/* Metadata Table */}
{file && !isAnalyzing && (
<div className="border border-[#1a1a1a] bg-[#080808]">
<div className="px-4 py-2.5 border-b border-[#1a1a1a] flex items-center justify-between">
<span className="text-xs font-semibold uppercase tracking-wider text-gray-400">
Detected Metadata Tags ({exifData ? Object.keys(exifData).length : 0})
<div className="px-4 py-2 border-b border-[#181818] flex items-center justify-between">
<span className="text-xs font-mono uppercase tracking-wider text-[#666]">
Metadata Tags ({exifData ? Object.keys(exifData).length : 0})
</span>
{exifData && (
<span className="text-[11px] text-amber-500 font-mono">Contains identifiable tags</span>
)}
</div>
{exifData && Object.keys(exifData).length > 0 ? (
<div className="max-h-80 overflow-y-auto divide-y divide-[#141414]">
<div className="max-h-72 overflow-y-auto divide-y divide-[#121212]">
{Object.entries(exifData).map(([key, value]) => {
const displayValue =
typeof value === "object"
? JSON.stringify(value)
: String(value);
return (
<div key={key} className="grid grid-cols-3 px-4 py-2 text-xs font-mono hover:bg-[#0f0f0f]">
<span className="text-[#777] truncate pr-2">{key}</span>
<div key={key} className="grid grid-cols-3 px-4 py-1.5 text-xs font-mono hover:bg-[#0c0c0c]">
<span className="text-[#666] truncate pr-2">{key}</span>
<span className="col-span-2 text-gray-300 break-all">{displayValue}</span>
</div>
);
@ -269,25 +261,25 @@ export default function ExifScrubberPage() {
</div>
) : (
<div className="p-6 text-center text-xs text-[#555]">
No EXIF metadata tags found in this file (or already cleaned).
No EXIF metadata tags detected in this file.
</div>
)}
</div>
)}
{/* Why Strip EXIF Section */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 pt-4 border-t border-[#1a1a1a] text-xs text-[#666]">
<div className="border border-[#181818] p-3 bg-black">
<h4 className="text-gray-300 font-medium mb-1">Prevent Stalking &amp; Doxxing</h4>
<p>Smartphones automatically write GPS coordinates into every photo. Sanitizing removes your home location.</p>
{/* Explanatory notes */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 pt-4 border-t border-[#1a1a1a] text-xs text-[#555]">
<div className="border border-[#141414] p-3 bg-black">
<h4 className="text-gray-300 font-medium mb-1">Geotagging Removal</h4>
<p>Smartphones and digital cameras store GPS coordinates in EXIF headers. Stripping purges this data.</p>
</div>
<div className="border border-[#181818] p-3 bg-black">
<h4 className="text-gray-300 font-medium mb-1">Remove Device Fingerprints</h4>
<p>Strips camera serial numbers, iPhone/Android hardware identifiers, and internal software versions.</p>
<div className="border border-[#141414] p-3 bg-black">
<h4 className="text-gray-300 font-medium mb-1">Hardware Fingerprints</h4>
<p>Removes device serial numbers, lens specifications, and software version identifiers.</p>
</div>
<div className="border border-[#181818] p-3 bg-black">
<h4 className="text-gray-300 font-medium mb-1">100% Client-Side Privacy</h4>
<p>Processing happens entirely inside your browser. Your images are never sent to any server.</p>
<div className="border border-[#141414] p-3 bg-black">
<h4 className="text-gray-300 font-medium mb-1">Local Rasterization</h4>
<p>Images are re-encoded locally via HTML5 canvas, ensuring stripped headers are physically unrecoverable.</p>
</div>
</div>
</div>

View file

@ -31,11 +31,9 @@ export default function ImageRedactorPage() {
const ctx = canvas.getContext("2d");
if (!ctx) return;
// Draw base image
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
// Helper to draw box
const drawSingleBox = (box: RedactBox) => {
const rx = Math.min(box.x, box.x + box.w);
const ry = Math.min(box.y, box.y + box.h);
@ -48,7 +46,6 @@ export default function ImageRedactorPage() {
ctx.fillStyle = "#000000";
ctx.fillRect(rx, ry, rw, rh);
} else {
// Pixelate
try {
const pixelSize = Math.max(8, Math.round(rw / 10));
const subW = Math.max(1, Math.round(rw / pixelSize));
@ -71,19 +68,16 @@ export default function ImageRedactorPage() {
}
};
// Draw committed boxes
boxes.forEach(drawSingleBox);
// Draw preview box while dragging
if (currentBox) {
drawSingleBox(currentBox);
// Border outline for visibility
const rx = Math.min(currentBox.x, currentBox.x + currentBox.w);
const ry = Math.min(currentBox.y, currentBox.y + currentBox.h);
const rw = Math.abs(currentBox.w);
const rh = Math.abs(currentBox.h);
ctx.strokeStyle = "#3b82f6";
ctx.lineWidth = 2;
ctx.lineWidth = 1.5;
ctx.strokeRect(rx, ry, rw, rh);
}
}, [boxes, currentBox]);
@ -186,8 +180,8 @@ export default function ImageRedactorPage() {
return (
<ToolLayout
title="Private Document &amp; Image Redactor"
description="Draw solid blackout or pixelate boxes over sensitive names, credit card numbers, faces, and addresses before sharing. 100% offline."
title="Image Redactor"
description="Apply permanent blackout or pixelated blur regions over confidential details before exporting."
>
<div className="space-y-6">
{/* Controls */}
@ -195,46 +189,46 @@ export default function ImageRedactorPage() {
<div className="flex items-center gap-2">
<button
onClick={() => setMode("blackout")}
className={`px-3 py-1.5 text-xs font-medium border transition-colors cursor-pointer ${
className={`px-3 py-1.5 text-xs font-mono border transition-colors cursor-pointer ${
mode === "blackout"
? "bg-blue-600 border-blue-500 text-white"
: "bg-[#111] border-[#222] text-gray-400 hover:text-gray-200"
? "bg-blue-900/60 border-blue-600 text-blue-200"
: "bg-[#0c0c0c] border-[#222] text-gray-400 hover:text-gray-200"
}`}
>
⬛ Blackout Box
Blackout
</button>
<button
onClick={() => setMode("pixelate")}
className={`px-3 py-1.5 text-xs font-medium border transition-colors cursor-pointer ${
className={`px-3 py-1.5 text-xs font-mono border transition-colors cursor-pointer ${
mode === "pixelate"
? "bg-blue-600 border-blue-500 text-white"
: "bg-[#111] border-[#222] text-gray-400 hover:text-gray-200"
? "bg-blue-900/60 border-blue-600 text-blue-200"
: "bg-[#0c0c0c] border-[#222] text-gray-400 hover:text-gray-200"
}`}
>
🏁 Pixelate Blur
Pixelate
</button>
<button
onClick={handleUndo}
disabled={boxes.length === 0}
className="px-3 py-1.5 bg-[#111] border border-[#222] hover:bg-[#1a1a1a] disabled:opacity-40 text-gray-300 text-xs font-medium transition-colors cursor-pointer"
className="px-3 py-1.5 bg-[#0c0c0c] border border-[#222] hover:bg-[#151515] disabled:opacity-40 text-gray-400 text-xs font-mono transition-colors cursor-pointer"
>
↩ Undo
Undo
</button>
<button
onClick={handleClear}
disabled={boxes.length === 0}
className="px-3 py-1.5 bg-[#111] border border-[#222] hover:bg-[#1a1a1a] disabled:opacity-40 text-red-400 text-xs font-medium transition-colors cursor-pointer"
className="px-3 py-1.5 bg-[#0c0c0c] border border-[#222] hover:bg-[#151515] disabled:opacity-40 text-red-400 text-xs font-mono transition-colors cursor-pointer"
>
Clear All
Clear
</button>
</div>
{file && (
<button
onClick={handleDownload}
className="px-4 py-1.5 bg-green-600 hover:bg-green-500 text-white text-xs font-semibold transition-colors cursor-pointer flex items-center gap-1.5"
className="px-3.5 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-xs font-medium transition-colors cursor-pointer"
>
<span>Download Redacted Image</span>
Download redacted image
</button>
)}
</div>
@ -250,7 +244,7 @@ export default function ImageRedactorPage() {
handleFileSelect(e.dataTransfer.files[0]);
}
}}
className="border-2 border-dashed border-[#222] hover:border-blue-600/50 bg-[#080808] p-8 text-center cursor-pointer transition-colors"
className="border border-dashed border-[#222] hover:border-[#3a3a3a] bg-[#070707] p-8 text-center cursor-pointer transition-colors"
>
<input
ref={fileInputRef}
@ -264,36 +258,34 @@ export default function ImageRedactorPage() {
}}
/>
<div className="flex flex-col items-center gap-2">
<div className="w-10 h-10 rounded border border-[#222] bg-[#111] flex items-center justify-center text-gray-400">
🔒
</div>
<p className="text-sm font-medium text-gray-300">
Upload image to redact (screenshots, IDs, receipts, documents)
<svg className="w-8 h-8 text-[#444]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
<p className="text-xs text-gray-300">
Select an image to redact
</p>
<p className="text-xs text-[#555]">Click and drag boxes to cover confidential sections</p>
<p className="text-[11px] text-[#555]">Click and drag boxes over confidential fields</p>
</div>
</div>
)}
{/* Interactive Redaction Canvas */}
{/* Canvas editor */}
{file && (
<div className="space-y-3">
<div className="flex items-center justify-between text-xs text-[#777]">
<span>
Draw boxes over sensitive areas ({boxes.length} redactions applied)
</span>
<div className="flex items-center justify-between text-xs text-[#666] font-mono">
<span>{boxes.length} regions redacted</span>
<button
onClick={() => {
setFile(null);
setBoxes([]);
}}
className="text-xs text-red-400 hover:text-red-300 cursor-pointer"
className="text-red-400 hover:text-red-300 cursor-pointer"
>
Change Image
Reset Image
</button>
</div>
<div className="border border-[#1a1a1a] bg-[#050505] p-2 flex justify-center overflow-auto max-h-[650px]">
<div className="border border-[#1a1a1a] bg-[#050505] p-2 flex justify-center overflow-auto max-h-[600px]">
<canvas
ref={canvasRef}
onMouseDown={handleMouseDown}
@ -305,19 +297,19 @@ export default function ImageRedactorPage() {
</div>
)}
{/* Security & Use Case Highlights */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 pt-4 border-t border-[#1a1a1a] text-xs text-[#666]">
<div className="border border-[#181818] p-3 bg-black">
<h4 className="text-gray-300 font-medium mb-1">Permanent Blackout</h4>
<p>Pixels are physically overwritten on the raw image canvas, making recovery impossible.</p>
{/* Technical overview */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 pt-4 border-t border-[#1a1a1a] text-xs text-[#555]">
<div className="border border-[#141414] p-3 bg-black">
<h4 className="text-gray-300 font-medium mb-1">Pixel Overwrite</h4>
<p>Target pixel values are directly overwritten in canvas memory, preventing layer extraction.</p>
</div>
<div className="border border-[#181818] p-3 bg-black">
<h4 className="text-gray-300 font-medium mb-1">Safe Sharing</h4>
<p>Ideal for redacting government IDs, social security numbers, banking details, or names.</p>
<div className="border border-[#141414] p-3 bg-black">
<h4 className="text-gray-300 font-medium mb-1">Local Processing</h4>
<p>Redactions are applied strictly in the browser. Zero image data is sent to external servers.</p>
</div>
<div className="border border-[#181818] p-3 bg-black">
<h4 className="text-gray-300 font-medium mb-1">100% Offline</h4>
<p>Runs entirely inside your browser memory. No files are transmitted to any server.</p>
<div className="border border-[#141414] p-3 bg-black">
<h4 className="text-gray-300 font-medium mb-1">Clean Export</h4>
<p>Export produces a flattened PNG output without proprietary revision histories.</p>
</div>
</div>
</div>

View file

@ -35,9 +35,7 @@ export default function PdfToolsPage() {
const imageInputRef = useRef<HTMLInputElement>(null);
const rotateInputRef = useRef<HTMLInputElement>(null);
// Helper download
const triggerDownload = (bytes: Uint8Array, filename: string) => {
// Copy to standard Uint8Array to avoid ArrayBuffer detachment issues
const safeBytes = new Uint8Array(bytes.length);
safeBytes.set(bytes);
const blob = new Blob([safeBytes.buffer], { type: "application/pdf" });
@ -51,14 +49,14 @@ export default function PdfToolsPage() {
URL.revokeObjectURL(url);
};
// 1. Merge Handler
// 1. Merge
const handleMerge = async () => {
if (mergeFiles.length < 2) {
alert("Please upload at least 2 PDF files to merge.");
alert("Select at least 2 PDF files to merge.");
return;
}
setIsMerging(true);
setStatusMessage("Merging PDF files offline in browser...");
setStatusMessage("Merging PDF files...");
try {
const mergedPdf = await PDFDocument.create();
@ -72,7 +70,7 @@ export default function PdfToolsPage() {
const mergedPdfBytes = await mergedPdf.save();
triggerDownload(mergedPdfBytes, "merged-document.pdf");
setStatusMessage("Merged PDF downloaded successfully!");
setStatusMessage("Merged PDF downloaded.");
} catch (err) {
setStatusMessage(err instanceof Error ? err.message : "Error merging PDFs");
} finally {
@ -80,7 +78,7 @@ export default function PdfToolsPage() {
}
};
// 2. Split Handler
// 2. Split
const handleSplitLoad = async (file: File) => {
setSplitFile(file);
try {
@ -97,14 +95,13 @@ export default function PdfToolsPage() {
const handleSplit = async () => {
if (!splitFile) return;
setIsSplitting(true);
setStatusMessage("Extracting requested pages...");
setStatusMessage("Extracting pages...");
try {
const arrayBuffer = await splitFile.arrayBuffer();
const srcPdf = await PDFDocument.load(arrayBuffer);
const total = srcPdf.getPageCount();
// Parse range string (e.g. "1, 3-5")
const pagesToExtract = new Set<number>();
const parts = pageRange.split(",");
@ -116,7 +113,7 @@ export default function PdfToolsPage() {
const end = parseInt(endStr, 10);
if (!isNaN(start) && !isNaN(end)) {
for (let i = Math.max(1, start); i <= Math.min(total, end); i++) {
pagesToExtract.add(i - 1); // 0-indexed
pagesToExtract.add(i - 1);
}
}
} else {
@ -138,7 +135,7 @@ export default function PdfToolsPage() {
const pdfBytes = await newPdf.save();
triggerDownload(pdfBytes, `${splitFile.name.replace(".pdf", "")}-extracted.pdf`);
setStatusMessage(`Extracted ${copied.length} pages successfully!`);
setStatusMessage(`Extracted ${copied.length} pages.`);
} catch (err) {
setStatusMessage(err instanceof Error ? err.message : "Error extracting pages");
} finally {
@ -150,7 +147,7 @@ export default function PdfToolsPage() {
const handleImagesToPdf = async () => {
if (imageFiles.length === 0) return;
setIsConvertingImages(true);
setStatusMessage("Converting images to PDF pages...");
setStatusMessage("Converting images to PDF...");
try {
const pdfDoc = await PDFDocument.create();
@ -177,7 +174,7 @@ export default function PdfToolsPage() {
const pdfBytes = await pdfDoc.save();
triggerDownload(pdfBytes, "converted-images.pdf");
setStatusMessage("Images converted to PDF and downloaded!");
setStatusMessage("Converted PDF downloaded.");
} catch (err) {
setStatusMessage(err instanceof Error ? err.message : "Error converting images to PDF");
} finally {
@ -185,7 +182,7 @@ export default function PdfToolsPage() {
}
};
// 4. Rotate PDF
// 4. Rotate
const handleRotate = async () => {
if (!rotateFile) return;
setIsRotating(true);
@ -203,7 +200,7 @@ export default function PdfToolsPage() {
const pdfBytes = await pdf.save();
triggerDownload(pdfBytes, `${rotateFile.name.replace(".pdf", "")}-rotated.pdf`);
setStatusMessage("Rotated PDF downloaded successfully!");
setStatusMessage("Rotated PDF downloaded.");
} catch (err) {
setStatusMessage(err instanceof Error ? err.message : "Error rotating PDF");
} finally {
@ -213,64 +210,31 @@ export default function PdfToolsPage() {
return (
<ToolLayout
title="Private In-Browser PDF Toolkit"
description="Merge, split, extract pages, convert images, or rotate PDFs 100% locally. Your sensitive documents never leave your device."
title="PDF Toolkit"
description="Merge, extract pages, convert images, or rotate PDF documents. Operations execute locally via WebAssembly."
>
<div className="space-y-6">
{/* Navigation Tabs */}
<div className="flex border-b border-[#1a1a1a] gap-2">
<button
onClick={() => {
setActiveTab("merge");
setStatusMessage("");
}}
className={`px-4 py-2 text-xs font-semibold uppercase tracking-wider transition-colors cursor-pointer border-b-2 ${
activeTab === "merge"
? "border-blue-500 text-blue-400 bg-[#0e1626]"
: "border-transparent text-[#666] hover:text-gray-300"
}`}
>
Merge PDFs
</button>
<button
onClick={() => {
setActiveTab("split");
setStatusMessage("");
}}
className={`px-4 py-2 text-xs font-semibold uppercase tracking-wider transition-colors cursor-pointer border-b-2 ${
activeTab === "split"
? "border-blue-500 text-blue-400 bg-[#0e1626]"
: "border-transparent text-[#666] hover:text-gray-300"
}`}
>
Split / Extract
</button>
<button
onClick={() => {
setActiveTab("images");
setStatusMessage("");
}}
className={`px-4 py-2 text-xs font-semibold uppercase tracking-wider transition-colors cursor-pointer border-b-2 ${
activeTab === "images"
? "border-blue-500 text-blue-400 bg-[#0e1626]"
: "border-transparent text-[#666] hover:text-gray-300"
}`}
>
Images to PDF
</button>
<button
onClick={() => {
setActiveTab("rotate");
setStatusMessage("");
}}
className={`px-4 py-2 text-xs font-semibold uppercase tracking-wider transition-colors cursor-pointer border-b-2 ${
activeTab === "rotate"
? "border-blue-500 text-blue-400 bg-[#0e1626]"
: "border-transparent text-[#666] hover:text-gray-300"
}`}
>
Rotate Pages
</button>
<div className="flex border-b border-[#1a1a1a] gap-1">
{(["merge", "split", "images", "rotate"] as ActiveTab[]).map((tab) => (
<button
key={tab}
onClick={() => {
setActiveTab(tab);
setStatusMessage("");
}}
className={`px-3 py-1.5 text-xs font-mono uppercase tracking-wider transition-colors cursor-pointer border-b-2 ${
activeTab === tab
? "border-blue-500 text-blue-400 bg-[#0d121c]"
: "border-transparent text-[#666] hover:text-gray-300"
}`}
>
{tab === "merge" && "Merge"}
{tab === "split" && "Split / Extract"}
{tab === "images" && "Images to PDF"}
{tab === "rotate" && "Rotate"}
</button>
))}
</div>
{/* Tab 1: Merge */}
@ -278,7 +242,7 @@ export default function PdfToolsPage() {
<div className="space-y-4">
<div
onClick={() => mergeInputRef.current?.click()}
className="border-2 border-dashed border-[#222] hover:border-blue-600/50 bg-[#080808] p-8 text-center cursor-pointer transition-colors"
className="border border-dashed border-[#222] hover:border-[#3a3a3a] bg-[#070707] p-8 text-center cursor-pointer transition-colors"
>
<input
ref={mergeInputRef}
@ -293,38 +257,38 @@ export default function PdfToolsPage() {
}}
/>
<div className="flex flex-col items-center gap-2">
<div className="w-10 h-10 rounded border border-[#222] bg-[#111] flex items-center justify-center text-gray-400">
📑
</div>
<p className="text-sm font-medium text-gray-300">
Select 2 or more PDFs to combine
<svg className="w-8 h-8 text-[#444]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p className="text-xs text-gray-300">
Select 2 or more PDF files to merge
</p>
<p className="text-xs text-[#555]">All processing happens locally in your memory</p>
<p className="text-[11px] text-[#555]">Merged in browser memory without server upload</p>
</div>
</div>
{mergeFiles.length > 0 && (
<div className="border border-[#1a1a1a] bg-[#0c0c0c] p-4 space-y-3">
<div className="border border-[#1a1a1a] bg-[#090909] p-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold uppercase tracking-wider text-gray-400">
Files to Merge ({mergeFiles.length})
<span className="text-xs font-mono uppercase tracking-wider text-[#666]">
Queued Files ({mergeFiles.length})
</span>
<button
onClick={() => setMergeFiles([])}
className="text-xs text-red-400 hover:text-red-300 cursor-pointer"
>
Clear All
Clear
</button>
</div>
<div className="divide-y divide-[#181818]">
<div className="divide-y divide-[#141414]">
{mergeFiles.map((f, idx) => (
<div key={idx} className="py-2 flex items-center justify-between text-xs">
<span className="text-gray-300 font-mono truncate max-w-sm">
<div key={idx} className="py-1.5 flex items-center justify-between text-xs font-mono">
<span className="text-gray-300 truncate max-w-sm">
{idx + 1}. {f.name}
</span>
<div className="flex items-center gap-3">
<span className="text-[#666]">{(f.size / 1024).toFixed(1)} KB</span>
<span className="text-[#555]">{(f.size / 1024).toFixed(1)} KB</span>
<button
onClick={() => setMergeFiles((prev) => prev.filter((_, i) => i !== idx))}
className="text-gray-500 hover:text-red-400 cursor-pointer"
@ -339,9 +303,9 @@ export default function PdfToolsPage() {
<button
onClick={handleMerge}
disabled={isMerging || mergeFiles.length < 2}
className="w-full py-2.5 bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white font-medium text-sm transition-colors cursor-pointer flex items-center justify-center gap-2"
className="w-full py-2 bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white font-medium text-xs transition-colors cursor-pointer"
>
{isMerging ? "Merging..." : "Merge & Download Single PDF"}
{isMerging ? "Merging..." : "Merge and download PDF"}
</button>
</div>
)}
@ -353,7 +317,7 @@ export default function PdfToolsPage() {
<div className="space-y-4">
<div
onClick={() => splitInputRef.current?.click()}
className="border-2 border-dashed border-[#222] hover:border-blue-600/50 bg-[#080808] p-8 text-center cursor-pointer transition-colors"
className="border border-dashed border-[#222] hover:border-[#3a3a3a] bg-[#070707] p-8 text-center cursor-pointer transition-colors"
>
<input
ref={splitInputRef}
@ -367,42 +331,42 @@ export default function PdfToolsPage() {
}}
/>
<div className="flex flex-col items-center gap-2">
<div className="w-10 h-10 rounded border border-[#222] bg-[#111] flex items-center justify-center text-gray-400">
✂️
</div>
<p className="text-sm font-medium text-gray-300">
Select a PDF file to extract or split
<svg className="w-8 h-8 text-[#444]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 7v8a2 2 0 002 2h6M8 7V5a2 2 0 012-2h4.586a1 1 0 01.707.293l4.414 4.414a1 1 0 01.293.707V15a2 2 0 01-2 2h-2M8 7H6a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2v-2" />
</svg>
<p className="text-xs text-gray-300">
Select a PDF file to extract pages
</p>
<p className="text-xs text-[#555]">Extract individual pages or custom ranges</p>
<p className="text-[11px] text-[#555]">Extract single pages or ranges</p>
</div>
</div>
{splitFile && (
<div className="border border-[#1a1a1a] bg-[#0c0c0c] p-4 space-y-4">
<div className="border border-[#1a1a1a] bg-[#090909] p-4 space-y-3">
<div className="flex justify-between items-center text-xs">
<span className="text-gray-300 font-medium">{splitFile.name}</span>
<span className="text-blue-400 font-mono">{splitTotalPages} total pages</span>
<span className="text-blue-400 font-mono">{splitTotalPages} pages</span>
</div>
<div>
<label className="text-xs text-[#777] block mb-1">
Enter page range to extract (e.g. <code className="text-gray-300 font-mono">1, 3-5, 8</code>):
<label className="text-[11px] text-[#666] block mb-1">
Page range (e.g. <code className="text-gray-300 font-mono">1, 3-5</code>):
</label>
<input
type="text"
value={pageRange}
onChange={(e) => setPageRange(e.target.value)}
placeholder="1-3"
className="w-full p-2.5 bg-[#050505] border border-[#222] text-sm font-mono text-gray-200 focus:outline-none focus:border-blue-500"
className="w-full p-2 bg-[#050505] border border-[#222] text-xs font-mono text-gray-200 focus:outline-none focus:border-blue-600"
/>
</div>
<button
onClick={handleSplit}
disabled={isSplitting}
className="w-full py-2.5 bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white font-medium text-sm transition-colors cursor-pointer"
className="w-full py-2 bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white font-medium text-xs transition-colors cursor-pointer"
>
{isSplitting ? "Extracting..." : "Extract & Download Pages"}
{isSplitting ? "Extracting..." : "Extract pages"}
</button>
</div>
)}
@ -414,7 +378,7 @@ export default function PdfToolsPage() {
<div className="space-y-4">
<div
onClick={() => imageInputRef.current?.click()}
className="border-2 border-dashed border-[#222] hover:border-blue-600/50 bg-[#080808] p-8 text-center cursor-pointer transition-colors"
className="border border-dashed border-[#222] hover:border-[#3a3a3a] bg-[#070707] p-8 text-center cursor-pointer transition-colors"
>
<input
ref={imageInputRef}
@ -429,33 +393,33 @@ export default function PdfToolsPage() {
}}
/>
<div className="flex flex-col items-center gap-2">
<div className="w-10 h-10 rounded border border-[#222] bg-[#111] flex items-center justify-center text-gray-400">
🖼️
</div>
<p className="text-sm font-medium text-gray-300">
Select JPG or PNG images to bundle into a PDF
<svg className="w-8 h-8 text-[#444]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} 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>
<p className="text-xs text-gray-300">
Select JPG or PNG images to compile into a PDF
</p>
<p className="text-xs text-[#555]">Each image will be placed onto its own page</p>
<p className="text-[11px] text-[#555]">Each image creates one PDF page</p>
</div>
</div>
{imageFiles.length > 0 && (
<div className="border border-[#1a1a1a] bg-[#0c0c0c] p-4 space-y-3">
<div className="border border-[#1a1a1a] bg-[#090909] p-4 space-y-3">
<div className="flex items-center justify-between text-xs">
<span className="font-semibold text-gray-400 uppercase tracking-wider">
Images Selected ({imageFiles.length})
<span className="font-mono text-[#666] uppercase">
Images ({imageFiles.length})
</span>
<button
onClick={() => setImageFiles([])}
className="text-red-400 hover:text-red-300 cursor-pointer"
>
Clear All
Clear
</button>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{imageFiles.map((img, i) => (
<div key={i} className="border border-[#1a1a1a] p-2 bg-black text-center text-xs">
<div key={i} className="border border-[#181818] p-2 bg-black text-center text-[11px]">
<span className="text-gray-400 truncate block font-mono">{img.name}</span>
</div>
))}
@ -464,9 +428,9 @@ export default function PdfToolsPage() {
<button
onClick={handleImagesToPdf}
disabled={isConvertingImages}
className="w-full py-2.5 bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white font-medium text-sm transition-colors cursor-pointer"
className="w-full py-2 bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white font-medium text-xs transition-colors cursor-pointer"
>
{isConvertingImages ? "Converting..." : "Convert All to Single PDF"}
{isConvertingImages ? "Converting..." : "Convert images to PDF"}
</button>
</div>
)}
@ -478,7 +442,7 @@ export default function PdfToolsPage() {
<div className="space-y-4">
<div
onClick={() => rotateInputRef.current?.click()}
className="border-2 border-dashed border-[#222] hover:border-blue-600/50 bg-[#080808] p-8 text-center cursor-pointer transition-colors"
className="border border-dashed border-[#222] hover:border-[#3a3a3a] bg-[#070707] p-8 text-center cursor-pointer transition-colors"
>
<input
ref={rotateInputRef}
@ -492,29 +456,29 @@ export default function PdfToolsPage() {
}}
/>
<div className="flex flex-col items-center gap-2">
<div className="w-10 h-10 rounded border border-[#222] bg-[#111] flex items-center justify-center text-gray-400">
🔄
</div>
<p className="text-sm font-medium text-gray-300">
Select a PDF to rotate its orientation
<svg className="w-8 h-8 text-[#444]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} 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>
<p className="text-xs text-gray-300">
Select a PDF to rotate
</p>
<p className="text-xs text-[#555]">Fix upside-down or sideways scans locally</p>
<p className="text-[11px] text-[#555]">Adjust page orientation in 90-degree steps</p>
</div>
</div>
{rotateFile && (
<div className="border border-[#1a1a1a] bg-[#0c0c0c] p-4 space-y-4">
<div className="border border-[#1a1a1a] bg-[#090909] p-4 space-y-3">
<div className="text-xs text-gray-300 font-medium">{rotateFile.name}</div>
<div className="flex items-center gap-3 text-xs">
<span className="text-[#666]">Rotation:</span>
<div className="flex items-center gap-2 text-xs">
<span className="text-[#666] font-mono">Rotation:</span>
{[90, 180, 270].map((deg) => (
<button
key={deg}
onClick={() => setRotateAngle(deg)}
className={`px-3 py-1.5 border transition-colors cursor-pointer ${
className={`px-2.5 py-1 font-mono border transition-colors cursor-pointer ${
rotateAngle === deg
? "bg-blue-600 border-blue-500 text-white"
: "bg-[#111] border-[#222] text-gray-400"
? "bg-blue-900/60 border-blue-600 text-blue-200"
: "bg-[#0c0c0c] border-[#222] text-gray-400"
}`}
>
+{deg}°
@ -525,9 +489,9 @@ export default function PdfToolsPage() {
<button
onClick={handleRotate}
disabled={isRotating}
className="w-full py-2.5 bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white font-medium text-sm transition-colors cursor-pointer"
className="w-full py-2 bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white font-medium text-xs transition-colors cursor-pointer"
>
{isRotating ? "Rotating..." : `Rotate +${rotateAngle}° & Download`}
{isRotating ? "Rotating..." : `Rotate +${rotateAngle}° and download`}
</button>
</div>
)}
@ -535,24 +499,24 @@ export default function PdfToolsPage() {
)}
{statusMessage && (
<div className="p-3 bg-[#0d1c12] border border-green-900/60 text-green-300 text-xs">
<div className="p-3 bg-[#0a140f] border border-emerald-950 text-emerald-400 text-xs">
{statusMessage}
</div>
)}
{/* Security / Privacy Highlights */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 pt-4 border-t border-[#1a1a1a] text-xs text-[#666]">
<div className="border border-[#181818] p-3 bg-black">
<h4 className="text-gray-300 font-medium mb-1">Confidential &amp; Safe</h4>
<p>Financial statements, tax documents, and legal contracts never leave your browser.</p>
{/* Notes */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 pt-4 border-t border-[#1a1a1a] text-xs text-[#555]">
<div className="border border-[#141414] p-3 bg-black">
<h4 className="text-gray-300 font-medium mb-1">Local Processing</h4>
<p>Documents are parsed using pdf-lib inside browser memory without network requests.</p>
</div>
<div className="border border-[#181818] p-3 bg-black">
<h4 className="text-gray-300 font-medium mb-1">Zero File Size Limits</h4>
<p>Process documents of any length without cloud queue times or paywalls.</p>
<div className="border border-[#141414] p-3 bg-black">
<h4 className="text-gray-300 font-medium mb-1">No File Constraints</h4>
<p>Operates without arbitrary page caps or upload queue restrictions.</p>
</div>
<div className="border border-[#181818] p-3 bg-black">
<h4 className="text-gray-300 font-medium mb-1">No Sign-up Required</h4>
<p>Free, instant utility without logins, tracking cookies, or subscriptions.</p>
<div className="border border-[#141414] p-3 bg-black">
<h4 className="text-gray-300 font-medium mb-1">Zero Accounts</h4>
<p>Stateless architecture requires no login, telemetry, or storage.</p>
</div>
</div>
</div>

View file

@ -20,20 +20,17 @@ export default function Header() {
return (
<header className="relative z-20 border-b border-[#1a1a1a] bg-black/90 backdrop-blur-md sticky top-0">
<div className="mx-auto max-w-7xl px-4 py-3 flex items-center justify-between gap-4">
{/* Logo / Brand */}
<Link href="/" className="flex items-center gap-2 shrink-0 group">
<span className="w-7 h-7 rounded bg-blue-600/20 border border-blue-500/40 flex items-center justify-center text-sm group-hover:border-blue-400 transition-colors">
🧰
{/* Brand */}
<Link href="/" className="flex items-center gap-2.5 shrink-0 group">
<span className="w-6 h-6 border border-[#262626] bg-[#0c0c0c] flex items-center justify-center text-xs font-mono font-semibold text-gray-300 group-hover:border-blue-500 transition-colors">
k
</span>
<span className="font-mono text-sm font-bold text-white tracking-tight group-hover:text-blue-400 transition-colors">
<span className="font-mono text-sm font-medium text-white tracking-tight group-hover:text-blue-400 transition-colors">
kattoolbox
</span>
<span className="hidden sm:inline-block px-1.5 py-0.5 rounded text-[10px] font-mono bg-[#161616] text-[#666] border border-[#222]">
v2.0
</span>
</Link>
{/* Search Bar */}
{/* Search */}
<div className="relative flex-1 max-w-xl">
<svg
className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-[#555]"
@ -45,7 +42,7 @@ export default function Header() {
</svg>
<input
type="text"
placeholder={`Search ${tools.length} private tools...`}
placeholder={`Search ${tools.length} utilities...`}
value={query}
onChange={(e) => {
setQuery(e.target.value);
@ -62,27 +59,23 @@ export default function Header() {
<Link
key={tool.slug}
href={`/tools/${tool.slug}`}
className="flex items-center gap-3 px-3.5 py-2 text-xs text-gray-300 hover:bg-[#161616] hover:text-blue-400 transition-colors"
className="flex items-center justify-between px-3.5 py-2 text-xs text-gray-300 hover:bg-[#161616] hover:text-blue-400 transition-colors"
onClick={() => {
setQuery("");
setShowResults(false);
}}
>
<span className="text-base">{tool.icon}</span>
<div className="truncate">
<span className="font-medium text-gray-200">{tool.name}</span>
<span className="ml-2 text-[11px] text-[#666]">{tool.description}</span>
</div>
<span className="font-medium text-gray-200">{tool.name}</span>
<span className="text-[11px] text-[#666] truncate ml-3">{tool.description}</span>
</Link>
))}
</div>
)}
</div>
{/* Quick Privacy Badge */}
<div className="hidden md:flex items-center gap-2 text-[11px] text-[#666] font-mono">
<span className="inline-block w-1.5 h-1.5 rounded-full bg-emerald-500"></span>
<span>100% In-Browser</span>
{/* Subtitle label */}
<div className="hidden md:flex items-center text-[11px] text-[#555] font-mono">
<span>Client-side</span>
</div>
</div>
</header>

View file

@ -5,14 +5,16 @@ export default function ToolCard({ tool }: { tool: Tool }) {
return (
<Link
href={`/tools/${tool.slug}`}
className="group flex items-start gap-3 border border-[#1a1a1a] bg-[#0a0a0a]/80 px-5 py-4 transition-all hover:bg-[#111] hover:border-[#2a2a2a]"
className="group block border border-[#181818] bg-[#0a0a0a] p-4 transition-colors hover:bg-[#101010] hover:border-[#282828]"
>
<span className="text-lg mt-0.5 grayscale group-hover:grayscale-0 transition-all">{tool.icon}</span>
<div>
<div className="text-sm font-medium text-gray-300 group-hover:text-blue-400 transition-colors">
<div className="flex items-center justify-between gap-2">
<div className="text-sm font-medium text-gray-200 group-hover:text-blue-400 transition-colors">
{tool.name}
</div>
<div className="mt-0.5 text-xs text-[#525252] leading-relaxed">{tool.description}</div>
<span className="text-xs text-[#444] group-hover:text-gray-300 transition-colors">→</span>
</div>
<div className="mt-1 text-xs text-[#666] leading-relaxed">
{tool.description}
</div>
</Link>
);

View file

@ -3,90 +3,85 @@ export interface Tool {
slug: string;
description: string;
category: string;
icon: string;
featured?: boolean;
}
export const tools: Tool[] = [
// Privacy & Media (High-Impact & High Search Intent)
// Files & Media
{
name: "Discord & Email File Shrinker",
name: "File Shrinker",
slug: "discord-image-shrinker",
description: "Compress images under 10MB (Discord), 8MB, or 25MB (Gmail) with zero server uploads",
category: "Privacy & Media",
icon: "🗜️",
description: "Compress images to target sizes for Discord, email, or web",
category: "Files & Media",
featured: true,
},
{
name: "Photo GPS & EXIF Scrubber",
name: "EXIF & GPS Scrubber",
slug: "exif-scrubber",
description: "Inspect hidden location tags, camera serials, and wipe all tracking metadata locally",
category: "Privacy & Media",
icon: "🛡️",
description: "Inspect camera metadata and strip GPS location tags from photos",
category: "Files & Media",
featured: true,
},
{
name: "Private In-Browser PDF Toolkit",
name: "PDF Toolkit",
slug: "pdf-tools",
description: "Merge, split, extract pages, convert images, or rotate PDFs 100% offline",
category: "Privacy & Media",
icon: "📑",
description: "Merge, split, extract pages, rotate, and convert images to PDF",
category: "Files & Media",
featured: true,
},
{
name: "Document & Image Redactor",
name: "Image Redactor",
slug: "image-redactor",
description: "Permanently blackout or pixelate sensitive text, IDs, and credit card numbers",
category: "Privacy & Media",
icon: "🔒",
description: "Black out or pixelate sensitive regions in images",
category: "Files & Media",
featured: true,
},
// 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: "📝" },
{ name: "JSON Formatter", slug: "json-formatter", description: "Format, validate, and beautify JSON data", category: "Formatters" },
{ name: "Markdown Preview", slug: "markdown-preview", description: "Write Markdown with live preview rendering", category: "Formatters" },
// 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: "🔑" },
{ name: "Base64 Encode/Decode", slug: "base64", description: "Encode and decode Base64 strings with UTF-8 support", category: "Encoders" },
{ name: "URL Encode/Decode", slug: "url-encoder", description: "Encode and decode URL component strings", category: "Encoders" },
{ name: "HTML Entities", slug: "html-entities", description: "Convert characters to and from HTML entities", category: "Encoders" },
{ name: "JWT Decoder", slug: "jwt-decoder", description: "Decode and inspect JSON Web Token payloads", category: "Encoders" },
// 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: "🔐" },
{ name: "UUID Generator", slug: "uuid-generator", description: "Generate random UUID v4 identifiers", category: "Generators" },
{ name: "Lorem Ipsum", slug: "lorem-ipsum", description: "Generate placeholder text by words or paragraphs", category: "Generators" },
{ name: "Hash Generator", slug: "hash-generator", description: "Compute MD5, SHA-1, SHA-256, and SHA-512 hashes", category: "Generators" },
{ name: "QR Code Generator", slug: "qr-code", description: "Create QR codes from URLs or plain text", category: "Generators" },
{ name: "Password Generator", slug: "password-generator", description: "Generate randomized secure passwords", category: "Generators" },
// 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: "🔢" },
{ name: "Unix Timestamp", slug: "timestamp-converter", description: "Convert between Unix epoch and standard date formats", category: "Converters" },
{ name: "Color Converter", slug: "color-converter", description: "Convert colors between HEX, RGB, and HSL formats", category: "Converters" },
{ name: "Number Base", slug: "number-base", description: "Convert numbers across binary, octal, decimal, and hex", category: "Converters" },
// 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: "🔡" },
{ name: "Word Counter", slug: "word-counter", description: "Count words, characters, sentences, and reading time", category: "Text Tools" },
{ name: "Regex Tester", slug: "regex-tester", description: "Test regular expressions with real-time match highlighting", category: "Text Tools" },
{ name: "Diff Checker", slug: "diff-checker", description: "Compare two text blocks and highlight line differences", category: "Text Tools" },
{ name: "Case Converter", slug: "case-converter", description: "Transform text casing (camelCase, snake_case, uppercase)", category: "Text Tools" },
// CSS Tools
{ name: "CSS Gradient Generator", slug: "css-gradient", description: "Create beautiful CSS gradients with a visual editor", category: "CSS Tools", icon: "🌈" },
{ name: "CSS Box Shadow Generator", slug: "box-shadow", description: "Design and preview CSS box shadows visually", category: "CSS Tools", icon: "🖼️" },
{ name: "Px to Rem Converter", slug: "px-to-rem", description: "Convert pixels to rem units and vice versa", category: "CSS Tools", icon: "📐" },
{ name: "CSS Gradient", slug: "css-gradient", description: "Visual linear and radial CSS gradient builder", category: "CSS Tools" },
{ name: "Box Shadow", slug: "box-shadow", description: "Visual CSS box-shadow generator with code export", category: "CSS Tools" },
{ name: "Px to Rem", slug: "px-to-rem", description: "Convert pixel values to rem units using a configurable base", category: "CSS Tools" },
// Data Tools
{ name: "JSON to CSV", slug: "json-to-csv", description: "Convert JSON arrays to downloadable CSV files", category: "Data Tools", icon: "📊" },
{ name: "CSV to JSON", slug: "csv-to-json", description: "Convert CSV data to structured JSON", category: "Data Tools", icon: "📋" },
{ name: "SQL Formatter", slug: "sql-formatter", description: "Format and beautify SQL queries instantly", category: "Data Tools", icon: "🗄️" },
{ name: "XML Formatter", slug: "xml-formatter", description: "Format, validate, and beautify XML data", category: "Data Tools", icon: "📰" },
{ name: "JSON to CSV", slug: "json-to-csv", description: "Convert JSON array data to CSV tables", category: "Data Tools" },
{ name: "CSV to JSON", slug: "csv-to-json", description: "Parse CSV tables into JSON structures", category: "Data Tools" },
{ name: "SQL Formatter", slug: "sql-formatter", description: "Format and indent standard SQL queries", category: "Data Tools" },
{ name: "XML Formatter", slug: "xml-formatter", description: "Format, indent, and validate XML documents", category: "Data Tools" },
// Web Tools
{ name: "Meta Tag Generator", slug: "meta-tag-generator", description: "Generate HTML meta tags for SEO and social sharing", category: "Web Tools", icon: "🏷️" },
{ name: "Slug Generator", slug: "slug-generator", description: "Convert text to clean, URL-friendly slugs", category: "Web Tools", icon: "🔗" },
{ name: "Cron Expression Parser", slug: "cron-parser", description: "Parse and explain cron expressions in plain English", category: "Web Tools", icon: "⏱️" },
{ name: "Image to Base64", slug: "image-to-base64", description: "Convert images to Base64 encoded strings", category: "Web Tools", icon: "🖼️" },
{ name: "Placeholder Image Generator", slug: "placeholder-image", description: "Generate placeholder images with custom size and colors", category: "Web Tools", icon: "🎨" },
{ name: "Meta Tag Generator", slug: "meta-tag-generator", description: "Generate OpenGraph, Twitter, and SEO HTML tags", category: "Web Tools" },
{ name: "Slug Generator", slug: "slug-generator", description: "Create URL-safe slugs from plain text strings", category: "Web Tools" },
{ name: "Cron Parser", slug: "cron-parser", description: "Parse and explain 5-field cron schedules in plain text", category: "Web Tools" },
{ name: "Image to Base64", slug: "image-to-base64", description: "Encode image files into data URIs", category: "Web Tools" },
{ name: "Placeholder Image", slug: "placeholder-image", description: "Generate SVG placeholder images with custom dimensions", category: "Web Tools" },
];
export const categories = [...new Set(tools.map((t) => t.category))];