feat: add high-impact privacy & media suite (discord shrinker, exif scrubber, pdf tools, redactor) with side-ad rails

This commit is contained in:
katR 2026-09-11 20:11:08 +05:30
parent 35d84857e3
commit de42f2974a
13 changed files with 1900 additions and 87 deletions

View file

@ -1,36 +1,60 @@
# kattoolbox
Free, fast, browser-based developer tools. No sign-up. No data collection.
> **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.
**Live →** *coming soon*
---
## Tools (30)
## 🌟 High-Impact Privacy & Media Tools
**Formatters** — JSON Formatter, Markdown Preview, SQL Formatter, XML Formatter
- **🗜️ 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.
**Encoders** — Base64, URL Encoder, HTML Entities, JWT Decoder
---
**Generators** — UUID, Lorem Ipsum, Hash, QR Code, Password
## 🛠️ Developer & Everyday Utilities (30+)
**Converters** — Timestamp, Color, Number Base, JSON→CSV, CSV→JSON
- **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
**Text Tools** — Word Counter, Regex Tester, Diff Checker, Case Converter
---
**CSS Tools** — Gradient Generator, Box Shadow, Px↔Rem Converter
## ⚡ Architecture & Design
**Web Tools** — Meta Tag Generator, Slug Generator, Cron Parser, Image→Base64, Placeholder Image
- **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).
## Stack
---
Next.js · TypeScript · Tailwind CSS · Static Export
## Run locally
## 🚀 Run Locally
```bash
npm install
npm run dev
```
## License
Open [http://localhost:3000](http://localhost:3000) to view the application.
## 📦 Build Static Output
```bash
npm run build
```
---
## License
MIT

60
package-lock.json generated
View file

@ -8,8 +8,11 @@
"name": "toolbox",
"version": "0.1.0",
"dependencies": {
"exifr": "^7.1.3",
"lucide-react": "^1.45.0",
"marked": "^18.0.11",
"next": "16.3.3",
"pdf-lib": "^1.17.1",
"qrcode": "^1.5.4",
"react": "19.2.8",
"react-dom": "19.2.8"
@ -1380,6 +1383,24 @@
"node": ">=12.4.0"
}
},
"node_modules/@pdf-lib/standard-fonts": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
"integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==",
"license": "MIT",
"dependencies": {
"pako": "^1.0.6"
}
},
"node_modules/@pdf-lib/upng": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz",
"integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==",
"license": "MIT",
"dependencies": {
"pako": "^1.0.10"
}
},
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@ -3780,6 +3801,12 @@
"node": ">=0.10.0"
}
},
"node_modules/exifr": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/exifr/-/exifr-7.1.3.tgz",
"integrity": "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@ -5228,6 +5255,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-react": {
"version": "1.45.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.45.0.tgz",
"integrity": "sha512-yH1ubCAduho9UR7oJhRXIQXogksRILBiTuZC4/bQIGeB9JOkxMlSuEHyyZpo1Z3S0yWJO2KTSUZbjiNvVxeOUw==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@ -5666,6 +5702,12 @@
"node": ">=6"
}
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@ -5705,6 +5747,24 @@
"dev": true,
"license": "MIT"
},
"node_modules/pdf-lib": {
"version": "1.17.1",
"resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz",
"integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==",
"license": "MIT",
"dependencies": {
"@pdf-lib/standard-fonts": "^1.0.0",
"@pdf-lib/upng": "^1.0.1",
"pako": "^1.0.11",
"tslib": "^1.11.1"
}
},
"node_modules/pdf-lib/node_modules/tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"license": "0BSD"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",

View file

@ -9,8 +9,11 @@
"lint": "eslint"
},
"dependencies": {
"exifr": "^7.1.3",
"lucide-react": "^1.45.0",
"marked": "^18.0.11",
"next": "16.3.3",
"pdf-lib": "^1.17.1",
"qrcode": "^1.5.4",
"react": "19.2.8",
"react-dom": "19.2.8"

View file

@ -16,33 +16,35 @@ const geistMono = Geist_Mono({
export const metadata: Metadata = {
title: {
default: "DevToolbox — Free Online Developer Tools",
template: "%s | DevToolbox",
default: "kattoolbox — Private In-Browser Utilities & File Tools",
template: "%s | kattoolbox",
},
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.",
"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.",
keywords: [
"developer tools",
"online tools",
"JSON formatter",
"Base64 encoder",
"UUID generator",
"hash generator",
"regex tester",
"free dev tools",
"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",
"kattoolbox",
],
openGraph: {
type: "website",
title: "DevToolbox — Free Online Developer Tools",
title: "kattoolbox — Private In-Browser Utilities & File Tools",
description:
"20+ free, fast, privacy-first developer tools. JSON formatter, Base64 encoder, UUID generator, and more.",
siteName: "DevToolbox",
"Shrink images for Discord, scrub photo EXIF/GPS, merge/split PDFs, and 30+ dev tools. 100% private in-browser.",
siteName: "kattoolbox",
},
twitter: {
card: "summary_large_image",
title: "DevToolbox — Free Online Developer Tools",
title: "kattoolbox — Private In-Browser Utilities & File Tools",
description:
"20+ free, fast, privacy-first developer tools. JSON formatter, Base64 encoder, UUID generator, and more.",
"Shrink images for Discord, scrub photo EXIF/GPS, merge/split PDFs, and 30+ dev tools. 100% private in-browser.",
},
robots: {
index: true,
@ -57,7 +59,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<head>
{/* Google AdSense — replace ca-pub-XXXXXXXXXXXXXXXX with your publisher ID */}
{/* Google AdSense — Non-intrusive side placement */}
<script
async
src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-XXXXXXXXXXXXXXXX"

View file

@ -1,26 +1,83 @@
import ToolCard from "@/components/ToolCard";
import { getToolsByCategory } from "@/lib/tools";
import SidebarAd from "@/components/SidebarAd";
import { getToolsByCategory, tools } from "@/lib/tools";
import Link from "next/link";
export default function Home() {
const toolsByCategory = getToolsByCategory();
const featuredTools = tools.filter((t) => t.featured);
return (
<div className="relative z-10 mx-auto max-w-6xl px-6 py-8">
{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]">
{category}
</h2>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{categoryTools.map((tool) => (
<ToolCard key={tool.slug} tool={tool} />
))}
</div>
</section>
))}
<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) */}
<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>
</div>
<h1 className="text-2xl sm:text-3xl font-bold text-white tracking-tight">
Fast, Private In-Browser 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>
{/* Quick Featured Launchpad */}
<div className="mt-6 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
{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"
>
<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>
<p className="mt-1 text-[11px] text-[#666] line-clamp-2">
{t.description}
</p>
</Link>
))}
</div>
</section>
{/* 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]">
{category}
</h2>
<span className="text-[10px] text-[#444] font-mono">({categoryTools.length})</span>
</div>
<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>
))}
</div>
{/* Right Side Ad (Desktops & Laptops) */}
<div className="hidden xl:block sticky top-20 shrink-0">
<SidebarAd format="vertical" slotId="kat-home-right" />
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,345 @@
"use client";
import { useState, useRef, useCallback } from "react";
import ToolLayout from "@/components/ToolLayout";
interface TargetPreset {
name: string;
bytes: number;
label: string;
}
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" },
];
function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
}
export default function DiscordImageShrinkerPage() {
const [file, setFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [compressedBlob, setCompressedBlob] = useState<Blob | null>(null);
const [compressedUrl, setCompressedUrl] = useState<string | null>(null);
const [originalSize, setOriginalSize] = useState<number>(0);
const [compressedSize, setCompressedSize] = useState<number>(0);
const [isCompressing, setIsCompressing] = useState(false);
const [targetBytes, setTargetBytes] = useState<number>(PRESETS[0].bytes);
const [selectedFormat, setSelectedFormat] = useState<"image/webp" | "image/jpeg" | "image/png">("image/webp");
const [statusMessage, setStatusMessage] = useState<string>("");
const fileInputRef = useRef<HTMLInputElement>(null);
const compressToTarget = useCallback(
async (sourceFile: File, targetLimit: number, format: "image/webp" | "image/jpeg" | "image/png") => {
setIsCompressing(true);
setStatusMessage("Loading and processing image...");
const img = new Image();
const objectUrl = URL.createObjectURL(sourceFile);
img.src = objectUrl;
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = () => reject(new Error("Failed to load image"));
});
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) {
setIsCompressing(false);
setStatusMessage("Canvas context unavailable");
return;
}
let scale = 1.0;
let minQuality = 0.05;
let 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));
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;
for (let qStep = 0; qStep < 5; qStep++) {
const midQuality = (low + high) / 2;
const blob: Blob = await new Promise((res) => {
canvas.toBlob((b) => res(b || new Blob()), format, midQuality);
});
if (blob.size <= targetLimit) {
currentBestBlob = blob;
low = midQuality; // try higher quality
} else {
high = midQuality; // lower quality needed
}
}
if (currentBestBlob && currentBestBlob.size <= targetLimit) {
bestBlob = currentBestBlob;
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));
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
bestBlob = await new Promise((res) => {
canvas.toBlob((b) => res(b || new Blob()), format, 0.4);
});
}
URL.revokeObjectURL(objectUrl);
if (bestBlob) {
if (compressedUrl) URL.revokeObjectURL(compressedUrl);
const newUrl = URL.createObjectURL(bestBlob);
setCompressedBlob(bestBlob);
setCompressedUrl(newUrl);
setCompressedSize(bestBlob.size);
setStatusMessage(
bestBlob.size <= targetLimit
? `Successfully shrunk under ${formatBytes(targetLimit)}!`
: `Compressed as much as possible without extreme quality loss.`
);
}
setIsCompressing(false);
},
[compressedUrl]
);
const handleFileSelect = (selected: File) => {
if (!selected.type.startsWith("image/")) {
alert("Please upload a valid image file (JPG, PNG, WebP).");
return;
}
setFile(selected);
setOriginalSize(selected.size);
const url = URL.createObjectURL(selected);
setPreviewUrl(url);
compressToTarget(selected, targetBytes, selectedFormat);
};
const handlePresetChange = (presetBytes: number) => {
setTargetBytes(presetBytes);
if (file) {
compressToTarget(file, presetBytes, selectedFormat);
}
};
const handleFormatChange = (fmt: "image/webp" | "image/jpeg" | "image/png") => {
setSelectedFormat(fmt);
if (file) {
compressToTarget(file, targetBytes, fmt);
}
};
const downloadFile = () => {
if (!compressedBlob || !compressedUrl || !file) return;
const ext = selectedFormat === "image/webp" ? "webp" : selectedFormat === "image/jpeg" ? "jpg" : "png";
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}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
const savingsPercent =
originalSize > 0 && compressedSize > 0
? Math.max(0, Math.round(((originalSize - compressedSize) / originalSize) * 100))
: 0;
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."
>
<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>
<div className="flex flex-wrap gap-2">
{PRESETS.map((p) => {
const isSelected = targetBytes === p.bytes;
return (
<button
key={p.name}
onClick={() => handlePresetChange(p.bytes)}
className={`px-3 py-1.5 text-xs font-medium 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"
}`}
>
{p.label}
</button>
);
})}
</div>
</div>
{/* 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>
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="radio"
name="format"
checked={selectedFormat === "image/webp"}
onChange={() => handleFormatChange("image/webp")}
className="text-blue-600"
/>
<span>WebP (Best Compression)</span>
</label>
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="radio"
name="format"
checked={selectedFormat === "image/jpeg"}
onChange={() => handleFormatChange("image/jpeg")}
className="text-blue-600"
/>
<span>JPEG (Universal)</span>
</label>
</div>
{/* Dropzone */}
<div
onClick={() => fileInputRef.current?.click()}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
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"
>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
if (e.target.files && e.target.files[0]) {
handleFileSelect(e.target.files[0]);
}
}}
/>
<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>
</p>
<p className="text-xs text-[#555]">Supports PNG, JPG, WebP, GIF frames</p>
</div>
</div>
{/* Processing State */}
{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" />
<span>{statusMessage}</span>
</div>
)}
{/* Results Comparison */}
{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>
<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>
</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">
{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>
</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>
</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"
>
<span>Download Shrunk Image</span>
</button>
</div>
{/* Preview Image */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-2">
<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">
{previewUrl && (
<img src={previewUrl} alt="Original" className="max-h-60 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">
{compressedUrl && (
<img src={compressedUrl} alt="Compressed" className="max-h-60 object-contain" />
)}
</div>
</div>
</div>
</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>
</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>
<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>
</div>
</div>
</ToolLayout>
);
}

View file

@ -0,0 +1,296 @@
"use client";
import { useState, useRef } from "react";
import ToolLayout from "@/components/ToolLayout";
import exifr from "exifr";
interface GPSData {
latitude: number;
longitude: number;
}
export default function ExifScrubberPage() {
const [file, setFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [exifData, setExifData] = useState<Record<string, unknown> | null>(null);
const [gpsData, setGpsData] = useState<GPSData | null>(null);
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.");
return;
}
setFile(selected);
setScrubbedUrl(null);
setScrubbedSize(null);
setStatusMessage("");
const preview = URL.createObjectURL(selected);
setPreviewUrl(preview);
setIsAnalyzing(true);
try {
// Parse all available metadata
const rawExif = await exifr.parse(selected, {
gps: true,
tiff: true,
xmp: true,
iptc: true,
jfif: true,
});
const gps = await exifr.gps(selected);
if (rawExif && Object.keys(rawExif).length > 0) {
setExifData(rawExif);
} else {
setExifData(null);
}
if (gps && typeof gps.latitude === "number" && typeof gps.longitude === "number") {
setGpsData({ latitude: gps.latitude, longitude: gps.longitude });
} else {
setGpsData(null);
}
} catch {
setExifData(null);
setGpsData(null);
} finally {
setIsAnalyzing(false);
}
};
const scrubMetadata = async () => {
if (!file) return;
setIsScrubbing(true);
setStatusMessage("Stripping EXIF, GPS, and device markers...");
try {
const img = new Image();
const objectUrl = URL.createObjectURL(file);
img.src = objectUrl;
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = () => reject(new Error("Failed to load image"));
});
const canvas = document.createElement("canvas");
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Canvas context failed");
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);
});
URL.revokeObjectURL(objectUrl);
const cleanUrl = URL.createObjectURL(blob);
setScrubbedUrl(cleanUrl);
setScrubbedSize(blob.size);
setStatusMessage("All metadata wiped successfully! The file is now 100% anonymous.");
} catch (err) {
setStatusMessage(err instanceof Error ? err.message : "Failed to scrub metadata");
} finally {
setIsScrubbing(false);
}
};
const downloadCleanFile = () => {
if (!scrubbedUrl || !file) return;
const a = document.createElement("a");
a.href = scrubbedUrl;
const ext = file.type === "image/png" ? "png" : "jpg";
const baseName = file.name.substring(0, file.name.lastIndexOf(".")) || file.name;
a.download = `${baseName}-sanitized.${ext}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
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."
>
<div className="space-y-6">
{/* Dropzone */}
<div
onClick={() => fileInputRef.current?.click()}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
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"
>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
if (e.target.files && e.target.files[0]) {
handleFileSelect(e.target.files[0]);
}
}}
/>
<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>
</p>
<p className="text-xs text-[#555]">Inspects GPS, Make, Model, Serial, Lens, Timestamp</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>
)}
{/* GPS Threat Warning */}
{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>
<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>
<div className="flex flex-wrap items-center gap-4 text-xs font-mono pt-1 text-red-200">
<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"
>
View on 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"
>
View on OpenStreetMap →
</a>
</div>
</div>
)}
{/* Action Bar & Scrubbing Button */}
{file && !isAnalyzing && (
<div className="flex flex-wrap items-center justify-between gap-4 border border-[#1a1a1a] bg-[#0c0c0c] 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">
{(file.size / (1024 * 1024)).toFixed(2)} MB • {file.type}
</span>
</div>
<div className="flex items-center gap-3">
{!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"
>
<span>🛡️ Wipe All Metadata</span>
</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"
>
<span>✓ Download Sanitized Image</span>
</button>
)}
</div>
</div>
)}
{statusMessage && (
<div className="p-3 bg-[#0d1c12] border border-green-900/60 text-green-300 text-xs">
{statusMessage}
</div>
)}
{/* EXIF 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})
</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]">
{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>
<span className="col-span-2 text-gray-300 break-all">{displayValue}</span>
</div>
);
})}
</div>
) : (
<div className="p-6 text-center text-xs text-[#555]">
No EXIF metadata tags found in this file (or already cleaned).
</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>
</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>
<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>
</div>
</div>
</ToolLayout>
);
}

View file

@ -0,0 +1,326 @@
"use client";
import { useState, useRef, useEffect, useCallback } from "react";
import ToolLayout from "@/components/ToolLayout";
interface RedactBox {
x: number;
y: number;
w: number;
h: number;
style: "blackout" | "pixelate";
}
export default function ImageRedactorPage() {
const [file, setFile] = useState<File | null>(null);
const [boxes, setBoxes] = useState<RedactBox[]>([]);
const [mode, setMode] = useState<"blackout" | "pixelate">("blackout");
const [isDrawing, setIsDrawing] = useState(false);
const [startPos, setStartPos] = useState<{ x: number; y: number } | null>(null);
const [currentBox, setCurrentBox] = useState<RedactBox | null>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const imageObjRef = useRef<HTMLImageElement | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const renderCanvas = useCallback(() => {
const canvas = canvasRef.current;
const img = imageObjRef.current;
if (!canvas || !img) return;
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);
const rw = Math.abs(box.w);
const rh = Math.abs(box.h);
if (rw === 0 || rh === 0) return;
if (box.style === "blackout") {
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));
const subH = Math.max(1, Math.round(rh / pixelSize));
const offCanvas = document.createElement("canvas");
offCanvas.width = subW;
offCanvas.height = subH;
const offCtx = offCanvas.getContext("2d");
if (offCtx) {
offCtx.drawImage(canvas, rx, ry, rw, rh, 0, 0, subW, subH);
ctx.imageSmoothingEnabled = false;
ctx.drawImage(offCanvas, 0, 0, subW, subH, rx, ry, rw, rh);
ctx.imageSmoothingEnabled = true;
}
} catch {
ctx.fillStyle = "#111111";
ctx.fillRect(rx, ry, rw, rh);
}
}
};
// 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.strokeRect(rx, ry, rw, rh);
}
}, [boxes, currentBox]);
useEffect(() => {
renderCanvas();
}, [renderCanvas]);
const handleFileSelect = (selected: File) => {
if (!selected.type.startsWith("image/")) {
alert("Please upload a valid image file.");
return;
}
setFile(selected);
setBoxes([]);
setCurrentBox(null);
const img = new Image();
const url = URL.createObjectURL(selected);
img.src = url;
img.onload = () => {
imageObjRef.current = img;
if (canvasRef.current) {
canvasRef.current.width = img.naturalWidth;
canvasRef.current.height = img.naturalHeight;
}
renderCanvas();
URL.revokeObjectURL(url);
};
};
const getCanvasCoords = (e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas) return { x: 0, y: 0 };
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
return {
x: (e.clientX - rect.left) * scaleX,
y: (e.clientY - rect.top) * scaleY,
};
};
const handleMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (!imageObjRef.current) return;
const coords = getCanvasCoords(e);
setIsDrawing(true);
setStartPos(coords);
setCurrentBox({
x: coords.x,
y: coords.y,
w: 0,
h: 0,
style: mode,
});
};
const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (!isDrawing || !startPos) return;
const coords = getCanvasCoords(e);
setCurrentBox({
x: startPos.x,
y: startPos.y,
w: coords.x - startPos.x,
h: coords.y - startPos.y,
style: mode,
});
};
const handleMouseUp = () => {
if (!isDrawing || !currentBox) return;
setIsDrawing(false);
if (Math.abs(currentBox.w) > 5 && Math.abs(currentBox.h) > 5) {
setBoxes((prev) => [...prev, currentBox]);
}
setCurrentBox(null);
setStartPos(null);
};
const handleUndo = () => {
setBoxes((prev) => prev.slice(0, -1));
};
const handleClear = () => {
setBoxes([]);
};
const handleDownload = () => {
const canvas = canvasRef.current;
if (!canvas || !file) return;
const cleanUrl = canvas.toDataURL("image/png");
const a = document.createElement("a");
a.href = cleanUrl;
const baseName = file.name.substring(0, file.name.lastIndexOf(".")) || file.name;
a.download = `${baseName}-redacted.png`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
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."
>
<div className="space-y-6">
{/* Controls */}
<div className="flex flex-wrap items-center justify-between gap-3 pb-2 border-b border-[#1a1a1a]">
<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 ${
mode === "blackout"
? "bg-blue-600 border-blue-500 text-white"
: "bg-[#111] border-[#222] text-gray-400 hover:text-gray-200"
}`}
>
⬛ Blackout Box
</button>
<button
onClick={() => setMode("pixelate")}
className={`px-3 py-1.5 text-xs font-medium 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"
}`}
>
🏁 Pixelate Blur
</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"
>
↩ 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"
>
Clear All
</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"
>
<span>Download Redacted Image</span>
</button>
)}
</div>
{/* Upload Box */}
{!file && (
<div
onClick={() => fileInputRef.current?.click()}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
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"
>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
if (e.target.files && e.target.files[0]) {
handleFileSelect(e.target.files[0]);
}
}}
/>
<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)
</p>
<p className="text-xs text-[#555]">Click and drag boxes to cover confidential sections</p>
</div>
</div>
)}
{/* Interactive Redaction Canvas */}
{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>
<button
onClick={() => {
setFile(null);
setBoxes([]);
}}
className="text-xs text-red-400 hover:text-red-300 cursor-pointer"
>
Change Image
</button>
</div>
<div className="border border-[#1a1a1a] bg-[#050505] p-2 flex justify-center overflow-auto max-h-[650px]">
<canvas
ref={canvasRef}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
className="cursor-crosshair max-w-full h-auto object-contain border border-[#181818]"
/>
</div>
</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>
</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>
<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>
</div>
</div>
</ToolLayout>
);
}

View file

@ -0,0 +1,561 @@
"use client";
import { useState, useRef } from "react";
import ToolLayout from "@/components/ToolLayout";
import { PDFDocument, degrees } from "pdf-lib";
type ActiveTab = "merge" | "split" | "images" | "rotate";
export default function PdfToolsPage() {
const [activeTab, setActiveTab] = useState<ActiveTab>("merge");
// Merge state
const [mergeFiles, setMergeFiles] = useState<File[]>([]);
const [isMerging, setIsMerging] = useState(false);
// Split state
const [splitFile, setSplitFile] = useState<File | null>(null);
const [splitTotalPages, setSplitTotalPages] = useState<number>(0);
const [pageRange, setPageRange] = useState<string>("1-2");
const [isSplitting, setIsSplitting] = useState(false);
// Images state
const [imageFiles, setImageFiles] = useState<File[]>([]);
const [isConvertingImages, setIsConvertingImages] = useState(false);
// Rotate state
const [rotateFile, setRotateFile] = useState<File | null>(null);
const [rotateAngle, setRotateAngle] = useState<number>(90);
const [isRotating, setIsRotating] = useState(false);
const [statusMessage, setStatusMessage] = useState<string>("");
const mergeInputRef = useRef<HTMLInputElement>(null);
const splitInputRef = useRef<HTMLInputElement>(null);
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" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
// 1. Merge Handler
const handleMerge = async () => {
if (mergeFiles.length < 2) {
alert("Please upload at least 2 PDF files to merge.");
return;
}
setIsMerging(true);
setStatusMessage("Merging PDF files offline in browser...");
try {
const mergedPdf = await PDFDocument.create();
for (const file of mergeFiles) {
const arrayBuffer = await file.arrayBuffer();
const pdf = await PDFDocument.load(arrayBuffer);
const copiedPages = await mergedPdf.copyPages(pdf, pdf.getPageIndices());
copiedPages.forEach((page) => mergedPdf.addPage(page));
}
const mergedPdfBytes = await mergedPdf.save();
triggerDownload(mergedPdfBytes, "merged-document.pdf");
setStatusMessage("Merged PDF downloaded successfully!");
} catch (err) {
setStatusMessage(err instanceof Error ? err.message : "Error merging PDFs");
} finally {
setIsMerging(false);
}
};
// 2. Split Handler
const handleSplitLoad = async (file: File) => {
setSplitFile(file);
try {
const arrayBuffer = await file.arrayBuffer();
const pdf = await PDFDocument.load(arrayBuffer);
const count = pdf.getPageCount();
setSplitTotalPages(count);
setPageRange(count > 1 ? `1-${Math.min(count, 3)}` : "1");
} catch {
alert("Failed to load PDF structure.");
}
};
const handleSplit = async () => {
if (!splitFile) return;
setIsSplitting(true);
setStatusMessage("Extracting requested 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(",");
for (const part of parts) {
const trimmed = part.trim();
if (trimmed.includes("-")) {
const [startStr, endStr] = trimmed.split("-");
const start = parseInt(startStr, 10);
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
}
}
} else {
const p = parseInt(trimmed, 10);
if (!isNaN(p) && p >= 1 && p <= total) {
pagesToExtract.add(p - 1);
}
}
}
if (pagesToExtract.size === 0) {
throw new Error("No valid page numbers found in specified range.");
}
const newPdf = await PDFDocument.create();
const pageIndices = Array.from(pagesToExtract).sort((a, b) => a - b);
const copied = await newPdf.copyPages(srcPdf, pageIndices);
copied.forEach((p) => newPdf.addPage(p));
const pdfBytes = await newPdf.save();
triggerDownload(pdfBytes, `${splitFile.name.replace(".pdf", "")}-extracted.pdf`);
setStatusMessage(`Extracted ${copied.length} pages successfully!`);
} catch (err) {
setStatusMessage(err instanceof Error ? err.message : "Error extracting pages");
} finally {
setIsSplitting(false);
}
};
// 3. Images to PDF
const handleImagesToPdf = async () => {
if (imageFiles.length === 0) return;
setIsConvertingImages(true);
setStatusMessage("Converting images to PDF pages...");
try {
const pdfDoc = await PDFDocument.create();
for (const imgFile of imageFiles) {
const arrayBuffer = await imgFile.arrayBuffer();
let embeddedImage;
if (imgFile.type === "image/png") {
embeddedImage = await pdfDoc.embedPng(arrayBuffer);
} else {
embeddedImage = await pdfDoc.embedJpg(arrayBuffer);
}
const { width, height } = embeddedImage;
const page = pdfDoc.addPage([width, height]);
page.drawImage(embeddedImage, {
x: 0,
y: 0,
width,
height,
});
}
const pdfBytes = await pdfDoc.save();
triggerDownload(pdfBytes, "converted-images.pdf");
setStatusMessage("Images converted to PDF and downloaded!");
} catch (err) {
setStatusMessage(err instanceof Error ? err.message : "Error converting images to PDF");
} finally {
setIsConvertingImages(false);
}
};
// 4. Rotate PDF
const handleRotate = async () => {
if (!rotateFile) return;
setIsRotating(true);
setStatusMessage("Rotating PDF pages...");
try {
const arrayBuffer = await rotateFile.arrayBuffer();
const pdf = await PDFDocument.load(arrayBuffer);
const pages = pdf.getPages();
pages.forEach((page) => {
const currentRotation = page.getRotation().angle;
page.setRotation(degrees((currentRotation + rotateAngle) % 360));
});
const pdfBytes = await pdf.save();
triggerDownload(pdfBytes, `${rotateFile.name.replace(".pdf", "")}-rotated.pdf`);
setStatusMessage("Rotated PDF downloaded successfully!");
} catch (err) {
setStatusMessage(err instanceof Error ? err.message : "Error rotating PDF");
} finally {
setIsRotating(false);
}
};
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."
>
<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>
{/* Tab 1: Merge */}
{activeTab === "merge" && (
<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"
>
<input
ref={mergeInputRef}
type="file"
multiple
accept="application/pdf"
className="hidden"
onChange={(e) => {
if (e.target.files) {
setMergeFiles((prev) => [...prev, ...Array.from(e.target.files!)]);
}
}}
/>
<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
</p>
<p className="text-xs text-[#555]">All processing happens locally in your memory</p>
</div>
</div>
{mergeFiles.length > 0 && (
<div className="border border-[#1a1a1a] bg-[#0c0c0c] 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>
<button
onClick={() => setMergeFiles([])}
className="text-xs text-red-400 hover:text-red-300 cursor-pointer"
>
Clear All
</button>
</div>
<div className="divide-y divide-[#181818]">
{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">
{idx + 1}. {f.name}
</span>
<div className="flex items-center gap-3">
<span className="text-[#666]">{(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"
>
✕
</button>
</div>
</div>
))}
</div>
<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"
>
{isMerging ? "Merging..." : "Merge & Download Single PDF"}
</button>
</div>
)}
</div>
)}
{/* Tab 2: Split / Extract */}
{activeTab === "split" && (
<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"
>
<input
ref={splitInputRef}
type="file"
accept="application/pdf"
className="hidden"
onChange={(e) => {
if (e.target.files && e.target.files[0]) {
handleSplitLoad(e.target.files[0]);
}
}}
/>
<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
</p>
<p className="text-xs text-[#555]">Extract individual pages or custom ranges</p>
</div>
</div>
{splitFile && (
<div className="border border-[#1a1a1a] bg-[#0c0c0c] p-4 space-y-4">
<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>
</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>
<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"
/>
</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"
>
{isSplitting ? "Extracting..." : "Extract & Download Pages"}
</button>
</div>
)}
</div>
)}
{/* Tab 3: Images to PDF */}
{activeTab === "images" && (
<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"
>
<input
ref={imageInputRef}
type="file"
multiple
accept="image/png, image/jpeg, image/jpg"
className="hidden"
onChange={(e) => {
if (e.target.files) {
setImageFiles((prev) => [...prev, ...Array.from(e.target.files!)]);
}
}}
/>
<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
</p>
<p className="text-xs text-[#555]">Each image will be placed onto its own page</p>
</div>
</div>
{imageFiles.length > 0 && (
<div className="border border-[#1a1a1a] bg-[#0c0c0c] 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>
<button
onClick={() => setImageFiles([])}
className="text-red-400 hover:text-red-300 cursor-pointer"
>
Clear All
</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">
<span className="text-gray-400 truncate block font-mono">{img.name}</span>
</div>
))}
</div>
<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"
>
{isConvertingImages ? "Converting..." : "Convert All to Single PDF"}
</button>
</div>
)}
</div>
)}
{/* Tab 4: Rotate */}
{activeTab === "rotate" && (
<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"
>
<input
ref={rotateInputRef}
type="file"
accept="application/pdf"
className="hidden"
onChange={(e) => {
if (e.target.files && e.target.files[0]) {
setRotateFile(e.target.files[0]);
}
}}
/>
<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
</p>
<p className="text-xs text-[#555]">Fix upside-down or sideways scans locally</p>
</div>
</div>
{rotateFile && (
<div className="border border-[#1a1a1a] bg-[#0c0c0c] p-4 space-y-4">
<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>
{[90, 180, 270].map((deg) => (
<button
key={deg}
onClick={() => setRotateAngle(deg)}
className={`px-3 py-1.5 border transition-colors cursor-pointer ${
rotateAngle === deg
? "bg-blue-600 border-blue-500 text-white"
: "bg-[#111] border-[#222] text-gray-400"
}`}
>
+{deg}°
</button>
))}
</div>
<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"
>
{isRotating ? "Rotating..." : `Rotate +${rotateAngle}° & Download`}
</button>
</div>
)}
</div>
)}
{statusMessage && (
<div className="p-3 bg-[#0d1c12] border border-green-900/60 text-green-300 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>
</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>
<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>
</div>
</div>
</ToolLayout>
);
}

View file

@ -12,52 +12,78 @@ export default function Header() {
? tools.filter(
(t) =>
t.name.toLowerCase().includes(query.toLowerCase()) ||
t.description.toLowerCase().includes(query.toLowerCase())
t.description.toLowerCase().includes(query.toLowerCase()) ||
t.category.toLowerCase().includes(query.toLowerCase())
)
: [];
return (
<header className="relative z-10 border-b border-[#1a1a1a] bg-black/80 backdrop-blur-sm">
<div className="mx-auto max-w-6xl px-6 py-3 relative">
<div className="relative">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[#525252]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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">
🧰
</span>
<span className="font-mono text-sm font-bold 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 */}
<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]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="square" strokeLinejoin="miter" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
placeholder="Search 30 tools..."
placeholder={`Search ${tools.length} private tools...`}
value={query}
onChange={(e) => {
setQuery(e.target.value);
setShowResults(true);
}}
onFocus={() => setShowResults(true)}
onBlur={() => setTimeout(() => setShowResults(false), 150)}
className="w-full border border-[#1a1a1a] bg-[#0a0a0a] pl-10 pr-3 py-2 text-sm text-gray-300 placeholder-[#525252] outline-none focus:border-blue-900 transition-colors"
onBlur={() => setTimeout(() => setShowResults(false), 200)}
className="w-full border border-[#222] bg-[#0a0a0a] pl-9 pr-3 py-1.5 text-xs text-gray-200 placeholder-[#555] outline-none focus:border-blue-600 transition-colors"
/>
{showResults && filtered.length > 0 && (
<div className="absolute left-0 right-0 top-full mt-1 max-h-80 overflow-y-auto border border-[#222] bg-[#0c0c0c] shadow-2xl z-50 divide-y divide-[#161616]">
{filtered.map((tool) => (
<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"
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>
</Link>
))}
</div>
)}
</div>
{showResults && filtered.length > 0 && (
<div className="absolute left-6 right-6 top-full z-50 max-h-72 overflow-y-auto border border-[#1a1a1a] border-t-0 bg-black/95 backdrop-blur-md shadow-2xl shadow-black/80">
{filtered.map((tool) => (
<Link
key={tool.slug}
href={`/tools/${tool.slug}`}
className="flex items-center gap-3 border-b border-[#111] px-4 py-2.5 text-sm text-gray-400 hover:bg-[#111] hover:text-blue-400 transition-colors"
onClick={() => {
setQuery("");
setShowResults(false);
}}
>
<span className="text-base">{tool.icon}</span>
<div>
<span className="text-gray-200">{tool.name}</span>
<span className="ml-2 text-xs text-[#525252]">{tool.description}</span>
</div>
</Link>
))}
</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>
</div>
</div>
</header>
);

View file

@ -0,0 +1,56 @@
"use client";
import { useEffect } from "react";
interface SidebarAdProps {
slotId?: string;
format?: "vertical" | "rectangle";
className?: string;
}
export default function SidebarAd({ slotId, format = "vertical", className = "" }: SidebarAdProps) {
useEffect(() => {
try {
if (typeof window !== "undefined") {
// @ts-expect-error Google ads window object
(window.adsbygoogle = window.adsbygoogle || []).push({});
}
} catch {
// Ignore if AdSense blocked by user or not configured
}
}, []);
return (
<aside
aria-label="Advertisement"
className={`relative flex flex-col items-center justify-center border border-[#1a1a1a] bg-[#070707] p-3 text-center transition-colors hover:border-[#262626] ${
format === "vertical" ? "w-[170px] min-h-[500px]" : "w-full min-h-[250px]"
} ${className}`}
>
<span className="absolute top-1.5 right-2 text-[10px] uppercase tracking-wider text-[#404040]">
Ad
</span>
{/* Actual AdSense Slot (active when ca-pub and slot ID are configured) */}
<ins
className="adsbygoogle block w-full h-full"
style={{ display: "block" }}
data-ad-client="ca-pub-XXXXXXXXXXXXXXXX"
data-ad-slot={slotId || "1234567890"}
data-ad-format={format === "vertical" ? "vertical" : "auto"}
data-full-width-responsive="true"
/>
{/* Elegant Minimalist Placeholder shown when ads are not yet loaded */}
<div className="flex flex-col items-center justify-center gap-2 text-xs text-[#444] pointer-events-none select-none my-auto">
<div className="w-8 h-8 rounded border border-[#1f1f1f] flex items-center justify-center text-[10px] text-[#555]">
ADS
</div>
<span className="text-[11px] text-[#4a4a4a] font-mono">Sponsor Space</span>
<span className="text-[10px] text-[#333] max-w-[130px] leading-tight">
Non-intrusive side placement
</span>
</div>
</aside>
);
}

View file

@ -1,20 +1,42 @@
import SidebarAd from "@/components/SidebarAd";
interface ToolLayoutProps {
title: string;
description: string;
children: React.ReactNode;
showSideAds?: boolean;
}
export default function ToolLayout({ title, description, children }: ToolLayoutProps) {
export default function ToolLayout({ title, description, children, showSideAds = true }: ToolLayoutProps) {
return (
<div className="relative z-10 mx-auto max-w-5xl px-6 py-8">
<div className="flex items-center gap-2 mb-1">
<div className="h-px w-4 bg-blue-900"></div>
<h1 className="text-lg font-semibold text-white">{title}</h1>
</div>
<p className="mb-5 text-sm text-[#525252] ml-6">{description}</p>
<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) */}
{showSideAds && (
<div className="hidden 2xl:block sticky top-20 shrink-0">
<SidebarAd format="vertical" slotId="kat-tool-left" />
</div>
)}
<div className="border border-[#1a1a1a] bg-[#0a0a0a]/80 p-5">
{children}
{/* Main Tool Content */}
<div className="flex-1 max-w-5xl min-w-0">
<div className="flex items-center gap-2 mb-1">
<div className="h-px w-4 bg-blue-900"></div>
<h1 className="text-lg font-semibold text-white">{title}</h1>
</div>
<p className="mb-5 text-sm text-[#525252] ml-6">{description}</p>
<div className="border border-[#1a1a1a] bg-[#0a0a0a]/90 p-5 shadow-2xl">
{children}
</div>
</div>
{/* Right Side Ad (Desktop & Laptops) */}
{showSideAds && (
<div className="hidden xl:block sticky top-20 shrink-0">
<SidebarAd format="vertical" slotId="kat-tool-right" />
</div>
)}
</div>
</div>
);

View file

@ -4,9 +4,44 @@ export interface Tool {
description: string;
category: string;
icon: string;
featured?: boolean;
}
export const tools: Tool[] = [
// Privacy & Media (High-Impact & High Search Intent)
{
name: "Discord & Email File Shrinker",
slug: "discord-image-shrinker",
description: "Compress images under 10MB (Discord), 8MB, or 25MB (Gmail) with zero server uploads",
category: "Privacy & Media",
icon: "🗜️",
featured: true,
},
{
name: "Photo GPS & EXIF Scrubber",
slug: "exif-scrubber",
description: "Inspect hidden location tags, camera serials, and wipe all tracking metadata locally",
category: "Privacy & Media",
icon: "🛡️",
featured: true,
},
{
name: "Private In-Browser PDF Toolkit",
slug: "pdf-tools",
description: "Merge, split, extract pages, convert images, or rotate PDFs 100% offline",
category: "Privacy & Media",
icon: "📑",
featured: true,
},
{
name: "Document & Image Redactor",
slug: "image-redactor",
description: "Permanently blackout or pixelate sensitive text, IDs, and credit card numbers",
category: "Privacy & Media",
icon: "🔒",
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: "📝" },