feat: add high-impact privacy & media suite (discord shrinker, exif scrubber, pdf tools, redactor) with side-ad rails
This commit is contained in:
parent
35d84857e3
commit
de42f2974a
13 changed files with 1900 additions and 87 deletions
54
README.md
54
README.md
|
|
@ -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
60
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
345
src/app/tools/discord-image-shrinker/page.tsx
Normal file
345
src/app/tools/discord-image-shrinker/page.tsx
Normal 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 & 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>
|
||||
);
|
||||
}
|
||||
296
src/app/tools/exif-scrubber/page.tsx
Normal file
296
src/app/tools/exif-scrubber/page.tsx
Normal 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 & 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 & 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 & 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>
|
||||
);
|
||||
}
|
||||
326
src/app/tools/image-redactor/page.tsx
Normal file
326
src/app/tools/image-redactor/page.tsx
Normal 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 & 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>
|
||||
);
|
||||
}
|
||||
561
src/app/tools/pdf-tools/page.tsx
Normal file
561
src/app/tools/pdf-tools/page.tsx
Normal 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 & 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
56
src/components/SidebarAd.tsx
Normal file
56
src/components/SidebarAd.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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: "📝" },
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue