refactor: minimal sharp JetBrains Mono theme and fix React 19 hook errors

This commit is contained in:
katR 2026-09-15 21:00:43 +05:30
parent 8278c32977
commit d9e0672f4a
22 changed files with 548 additions and 367 deletions

View file

@ -1,30 +1,98 @@
@import "tailwindcss";
* {
box-sizing: border-box;
margin: 0;
padding: 0;
border-radius: 0 !important; /* Square everything out */
}
:root {
--background: #000000;
--background: #0b0b0b;
--foreground: #e5e5e5;
--accent: #60a5fa;
--accent-hover: #3b82f6;
--accent-dim: #1e3a5f;
--surface: #0a0a0a;
--surface-hover: #111111;
--border: #1a1a1a;
--border-hover: #2a2a2a;
--muted: #525252;
--accent: #ffffff;
--accent-hover: #e5e5e5;
--accent-dim: #262626;
--surface: #111111;
--surface-hover: #161616;
--border: #1f1f1f;
--border-hover: #383838;
--muted: #666666;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-accent: var(--accent);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--font-mono: var(--font-mono);
}
body {
background: #000000;
color: var(--foreground);
font-family: var(--font-sans), Arial, Helvetica, sans-serif;
background: #0b0b0b;
color: #e5e5e5;
font-family: var(--font-mono), 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
}
/* Minimal Monospace Form Controls */
input, textarea, select {
font-family: inherit;
border-radius: 0 !important;
}
input[type="text"],
input[type="number"],
input[type="search"],
input[type="password"],
input[type="email"],
input[type="url"],
textarea,
select {
background-color: #0d0d0d;
border: 1px solid #222222;
color: #e5e5e5;
outline: none;
transition: border-color 0.15s ease;
}
input[type="text"]:focus,
input[type="number"]:focus,
input[type="search"]:focus,
input[type="password"]:focus,
input[type="email"]:focus,
input[type="url"]:focus,
textarea:focus,
select:focus {
border-color: #555555;
}
/* Minimal Square Buttons */
button {
border-radius: 0 !important;
font-family: inherit;
user-select: none;
}
/* Scrollbars */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #0b0b0b;
}
::-webkit-scrollbar-thumb {
background: #222222;
}
::-webkit-scrollbar-thumb:hover {
background: #383838;
}
/* Selection */
::selection {
background: #282828;
color: #ffffff;
}
/* Dot pattern background */
@ -36,10 +104,62 @@ body {
content: "";
position: fixed;
inset: 0;
background-image: radial-gradient(circle, rgba(255, 255, 255, 0.03) 1px, transparent 1px);
background-image: radial-gradient(circle, rgba(255, 255, 255, 0.035) 1px, transparent 1px);
background-size: 20px 20px;
pointer-events: none;
mask-image: radial-gradient(ellipse 80% 70% at 50% 50%, black 40%, transparent 100%);
-webkit-mask-image: radial-gradient(ellipse 80% 70% at 50% 50%, black 40%, transparent 100%);
mask-image: radial-gradient(ellipse 85% 75% at 50% 50%, black 40%, transparent 100%);
-webkit-mask-image: radial-gradient(ellipse 85% 75% at 50% 50%, black 40%, transparent 100%);
z-index: 0;
}
/* Minimal Monochrome Overrides for Kat Aesthetic */
.bg-blue-600 {
background-color: #e5e5e5 !important;
color: #0b0b0b !important;
border-color: #ffffff !important;
}
.bg-blue-600:hover,
.hover\:bg-blue-500:hover,
.hover\:bg-blue-700:hover {
background-color: #ffffff !important;
color: #000000 !important;
}
.active\:bg-blue-800:active {
background-color: #cccccc !important;
color: #000000 !important;
}
.bg-blue-700,
.bg-blue-800,
.bg-blue-900,
.bg-blue-900\/20,
.bg-blue-900\/60,
.bg-blue-950\/20 {
background-color: #222222 !important;
}
.text-blue-100,
.text-blue-200,
.text-blue-400,
.text-blue-500,
.text-blue-600 {
color: #e5e5e5 !important;
}
.hover\:text-blue-400:hover {
color: #ffffff !important;
}
.border-blue-500,
.border-blue-500\/60,
.border-blue-600,
.border-blue-700,
.border-blue-900,
.hover\:border-blue-900:hover {
border-color: #383838 !important;
}
.ring-blue-500\/30,
.ring-blue-500\/40,
.ring-blue-900,
.ring-blue-900\/20 {
--tw-ring-color: #444444 !important;
}
.accent-blue-600 {
accent-color: #e5e5e5 !important;
}

View file

@ -1,16 +1,11 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { JetBrains_Mono } from "next/font/google";
import "./globals.css";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
const jetbrainsMono = JetBrains_Mono({
variable: "--font-mono",
subsets: ["latin"],
});
@ -62,7 +57,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
className={`${jetbrainsMono.variable} h-full antialiased font-mono`}
>
<head>
<meta
@ -77,7 +72,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
dangerouslySetInnerHTML={{ __html: "" }}
/>
</head>
<body className="dot-bg-fade flex min-h-full flex-col bg-black text-foreground">
<body className="dot-bg-fade flex min-h-full flex-col bg-[#0b0b0b] text-[#e5e5e5] font-mono">
<Header />
<main className="flex-1">{children}</main>
<Footer />

View file

@ -8,7 +8,7 @@ export default function Home() {
const featuredTools = tools.filter((t) => t.featured);
return (
<div className="relative z-10 mx-auto max-w-7xl px-4 py-8">
<div className="relative z-10 mx-auto max-w-7xl px-4 py-6 font-mono">
<div className="flex justify-center items-start gap-6">
{/* Left Side Rail (Ultra-wide) */}
<div className="hidden 2xl:block sticky top-20 shrink-0">
@ -18,29 +18,32 @@ export default function Home() {
{/* Main Content */}
<div className="flex-1 max-w-5xl min-w-0">
{/* Header intro */}
<div className="mb-8 border border-[#1a1a1a] bg-[#090909] p-6">
<div className="text-xs font-mono text-[#666] mb-1">
Local execution • No uploads
<div className="mb-8 border border-[#1e1e1e] bg-[#0e0e0e] p-6">
<div className="text-[11px] font-mono text-[#666] mb-1.5 flex items-center gap-2">
<span className="inline-block w-1.5 h-1.5 bg-[#4ade80]" />
<span>client-side execution</span>
<span>•</span>
<span>zero server uploads</span>
</div>
<h1 className="text-xl font-semibold text-white tracking-tight">
Browser-based file and developer utilities
<h1 className="text-lg font-bold text-white tracking-tight">
browser-based file &amp; developer utilities
</h1>
<p className="mt-1 text-xs text-gray-400 max-w-2xl leading-relaxed">
Utilities for image resizing, metadata stripping, PDF manipulation, and standard developer conversions. All tasks execute locally in your browser.
<p className="mt-1.5 text-xs text-[#888] max-w-2xl leading-relaxed">
Fast, privacy-focused utilities for image resizing, EXIF metadata scrubbing, PDF manipulation, hashing, formatting, and standard developer conversions. All processing runs entirely in client memory.
</p>
{/* Quick access row */}
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-2">
<div className="mt-5 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-2">
{featuredTools.map((t) => (
<Link
key={t.slug}
href={`/tools/${t.slug}`}
className="group block border border-[#1c1c1c] bg-[#060606] hover:border-blue-900 hover:bg-[#0c0c0c] p-3 transition-colors"
className="group block border border-[#1f1f1f] bg-[#080808] hover:border-[#383838] hover:bg-[#141414] p-3 transition-colors"
>
<div className="text-xs font-medium text-gray-200 group-hover:text-blue-400 transition-colors">
<div className="text-xs font-medium text-gray-200 group-hover:text-white transition-colors">
{t.name}
</div>
<p className="mt-1 text-[11px] text-[#555] line-clamp-2 leading-snug">
<p className="mt-1 text-[11px] text-[#666] line-clamp-2 leading-snug group-hover:text-[#888]">
{t.description}
</p>
</Link>
@ -51,12 +54,12 @@ export default function Home() {
{/* 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-3 bg-blue-900"></div>
<h2 className="text-xs font-mono uppercase tracking-wider text-[#666]">
<div className="flex items-center gap-2.5 mb-3">
<h2 className="text-xs font-mono uppercase tracking-wider text-gray-300 font-semibold">
{category}
</h2>
<span className="text-[10px] text-[#444] font-mono">({categoryTools.length})</span>
<span className="text-[10px] text-[#555] font-mono">[{categoryTools.length}]</span>
<div className="h-px flex-1 bg-[#1a1a1a]" />
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{categoryTools.map((tool) => (

View file

@ -8,7 +8,7 @@ export default function PrivacyPage() {
description="Information on data processing, third-party advertising cookies, and local execution."
showSideAds={false}
>
<div className="space-y-6 text-xs text-gray-400 leading-relaxed font-sans">
<div className="space-y-6 text-xs text-gray-400 leading-relaxed font-mono">
<section className="space-y-2">
<h2 className="text-sm font-semibold text-gray-200">1. Client-Side Data Handling</h2>
<p>
@ -34,7 +34,7 @@ export default function PrivacyPage() {
href="https://www.google.com/settings/ads"
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 hover:underline"
className="text-gray-300 hover:text-white underline"
>
Google Ads Settings
</a>{" "}
@ -43,7 +43,7 @@ export default function PrivacyPage() {
href="https://www.aboutads.info"
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 hover:underline"
className="text-gray-300 hover:text-white underline"
>
aboutads.info
</a>
@ -67,7 +67,7 @@ export default function PrivacyPage() {
href="https://github.com/oiupoyt/kattoolbox"
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 hover:underline"
className="text-gray-300 hover:text-white underline"
>
github.com/oiupoyt/kattoolbox
</a>
@ -76,7 +76,7 @@ export default function PrivacyPage() {
</section>
<div className="pt-4 border-t border-[#1a1a1a]">
<Link href="/" className="text-blue-400 hover:underline">
<Link href="/" className="text-gray-300 hover:text-white transition-colors">
← Return to utilities index
</Link>
</div>

View file

@ -1,6 +1,6 @@
"use client";
import { useState, useId, useCallback, useEffect } from "react";
import { useState, useId, useMemo } from "react";
import ToolLayout from "@/components/ToolLayout";
// Helper to extract words from various formats (camelCase, PascalCase, snake_case, kebab-case, normal text)
@ -219,29 +219,17 @@ export default function CaseConverterPage() {
const outputId = useId();
const [input, setInput] = useState(SAMPLE_TEXT);
const [output, setOutput] = useState("");
const [activeCaseKey, setActiveCaseKey] = useState<string>("titleCase");
const [copied, setCopied] = useState(false);
const [previewCopiedKey, setPreviewCopiedKey] = useState<string | null>(null);
// Auto-convert when input or activeCaseKey changes
const applyConversion = useCallback(
(key: string, textToConvert: string) => {
const converter = CONVERTERS[key];
if (converter) {
setOutput(converter.fn(textToConvert));
}
},
[]
);
useEffect(() => {
applyConversion(activeCaseKey, input);
}, [input, activeCaseKey, applyConversion]);
const output = useMemo(() => {
const converter = CONVERTERS[activeCaseKey];
return converter ? converter.fn(input) : "";
}, [activeCaseKey, input]);
const handleCaseButtonClick = (key: string) => {
setActiveCaseKey(key);
applyConversion(key, input);
};
const handleCopy = async () => {
@ -266,14 +254,11 @@ export default function CaseConverterPage() {
};
const handleSwap = () => {
const temp = output;
setInput(temp);
setOutput(input);
setInput(output);
};
const handleClear = () => {
setInput("");
setOutput("");
};
const handleLoadSample = () => {

View file

@ -1,3 +1,4 @@
/* eslint-disable @next/next/no-img-element */
"use client";
import { useState, useRef, useCallback } from "react";

View file

@ -476,7 +476,6 @@ function computeNextRuns(fields: ParsedField[], count: number = 10, timezone: "l
while (results.length < count && steps < maxSteps) {
steps++;
const getYear = timezone === "utc" ? current.getUTCFullYear() : current.getFullYear();
const getMonth = (timezone === "utc" ? current.getUTCMonth() : current.getMonth()) + 1;
const getDate = timezone === "utc" ? current.getUTCDate() : current.getDate();
const getDay = timezone === "utc" ? current.getUTCDay() : current.getDay();

View file

@ -1,3 +1,4 @@
/* eslint-disable @next/next/no-img-element */
"use client";
import { useState, useRef, useCallback } from "react";
@ -49,7 +50,6 @@ function formatBytes(bytes: number): string {
export default function DiscordEmojiResizerPage() {
const [file, setFile] = useState<File | null>(null);
const [originalUrl, setOriginalUrl] = useState<string | null>(null);
const [originalDimensions, setOriginalDimensions] = useState<{ w: number; h: number } | null>(null);
const [resultBlob, setResultBlob] = useState<Blob | null>(null);
const [resultUrl, setResultUrl] = useState<string | null>(null);
@ -169,12 +169,12 @@ export default function DiscordEmojiResizerPage() {
}
setFile(selected);
const url = URL.createObjectURL(selected);
setOriginalUrl(url);
const img = new Image();
img.src = url;
img.onload = () => {
setOriginalDimensions({ w: img.naturalWidth, h: img.naturalHeight });
URL.revokeObjectURL(url);
};
processImage(selected, activePreset, cropMode);

View file

@ -1,3 +1,4 @@
/* eslint-disable @next/next/no-img-element */
"use client";
import { useState, useRef, useCallback } from "react";

View file

@ -11,7 +11,6 @@ interface GPSData {
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);
@ -29,9 +28,6 @@ export default function ExifScrubberPage() {
setFile(selected);
setScrubbedUrl(null);
setStatusMessage("");
const preview = URL.createObjectURL(selected);
setPreviewUrl(preview);
setIsAnalyzing(true);
try {

View file

@ -1,3 +1,4 @@
/* eslint-disable @next/next/no-img-element */
"use client";
import { useState, useRef, useCallback } from "react";
@ -20,11 +21,21 @@ const ICON_SPECS: IconSize[] = [
{ name: "Android Chrome 512", filename: "android-chrome-512x512.png", size: 512, purpose: "PWA splash screen" },
];
function getHtmlTags(title: string) {
return `<!-- Favicon & App Icons -->
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="manifest" href="/site.webmanifest">
<meta name="apple-mobile-web-app-title" content="${title || "App"}">
<meta name="theme-color" content="#ffffff">`;
}
function createIcoFromPngs(pngBuffers: { size: number; buffer: ArrayBuffer }[]): Blob {
const numImages = pngBuffers.length;
const headerSize = 6;
const dirEntrySize = 16;
let dataOffset = headerSize + dirEntrySize * numImages;
const dataOffset = headerSize + dirEntrySize * numImages;
const totalBytes = dataOffset + pngBuffers.reduce((acc, p) => acc + p.buffer.byteLength, 0);
const out = new Uint8Array(totalBytes);
@ -183,16 +194,6 @@ export default function FaviconGeneratorPage() {
document.body.removeChild(link);
};
const getHtmlTags = (title: string) => {
return `<!-- Favicon & App Icons -->
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="manifest" href="/site.webmanifest">
<meta name="apple-mobile-web-app-title" content="${title || "App"}">
<meta name="theme-color" content="#ffffff">`;
};
const handleCopyHtml = () => {
navigator.clipboard.writeText(getHtmlTags(appName));
setCopiedHtml(true);

View file

@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect } from "react";
import ToolLayout from "@/components/ToolLayout";
// Pure JavaScript MD5 Implementation (RFC 1321)
@ -169,74 +169,80 @@ export default function HashGeneratorPage() {
const [copiedKey, setCopiedKey] = useState<string | null>(null);
const [copiedAll, setCopiedAll] = useState<boolean>(false);
const computeHashes = useCallback(async (text: string) => {
const encoder = new TextEncoder();
const data = encoder.encode(text);
// 1. MD5
const md5Hash = md5(text);
// 2. Web Crypto subtle digests
const getSubtleHash = async (algo: string): Promise<string> => {
try {
const buffer = await crypto.subtle.digest(algo, data);
const hashArray = Array.from(new Uint8Array(buffer));
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
} catch (err) {
console.error(`Failed to calculate ${algo}:`, err);
return "";
}
};
const [sha1Hash, sha256Hash, sha384Hash, sha512Hash] = await Promise.all([
getSubtleHash("SHA-1"),
getSubtleHash("SHA-256"),
getSubtleHash("SHA-384"),
getSubtleHash("SHA-512"),
]);
setHashes([
{
algorithm: "MD5",
name: "MD5 (Message Digest 5)",
bitLength: 128,
hash: md5Hash,
securityNote: "Legacy / Checksums only (vulnerable to collisions)",
},
{
algorithm: "SHA-1",
name: "SHA-1 (Secure Hash Algorithm 1)",
bitLength: 160,
hash: sha1Hash,
securityNote: "Legacy (Git commits, legacy checksums)",
},
{
algorithm: "SHA-256",
name: "SHA-256 (SHA-2 Family)",
bitLength: 256,
hash: sha256Hash,
securityNote: "Industry Standard (High security, SSL, Bitcoin)",
},
{
algorithm: "SHA-384",
name: "SHA-384 (SHA-2 Family)",
bitLength: 384,
hash: sha384Hash,
securityNote: "High Security (Government & enterprise grade)",
},
{
algorithm: "SHA-512",
name: "SHA-512 (SHA-2 Family)",
bitLength: 512,
hash: sha512Hash,
securityNote: "Maximum Security (64-bit optimized systems)",
},
]);
}, []);
useEffect(() => {
computeHashes(inputText);
}, [inputText, computeHashes]);
let isCancelled = false;
async function runCompute() {
const encoder = new TextEncoder();
const data = encoder.encode(inputText);
const md5Hash = md5(inputText);
const getSubtleHash = async (algo: string): Promise<string> => {
try {
const buffer = await crypto.subtle.digest(algo, data);
const hashArray = Array.from(new Uint8Array(buffer));
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
} catch (err) {
console.error(`Failed to calculate ${algo}:`, err);
return "";
}
};
const [sha1Hash, sha256Hash, sha384Hash, sha512Hash] = await Promise.all([
getSubtleHash("SHA-1"),
getSubtleHash("SHA-256"),
getSubtleHash("SHA-384"),
getSubtleHash("SHA-512"),
]);
if (!isCancelled) {
setHashes([
{
algorithm: "MD5",
name: "MD5 (Message Digest 5)",
bitLength: 128,
hash: md5Hash,
securityNote: "Legacy / Checksums only (vulnerable to collisions)",
},
{
algorithm: "SHA-1",
name: "SHA-1 (Secure Hash Algorithm 1)",
bitLength: 160,
hash: sha1Hash,
securityNote: "Legacy (Git commits, legacy checksums)",
},
{
algorithm: "SHA-256",
name: "SHA-256 (SHA-2 Family)",
bitLength: 256,
hash: sha256Hash,
securityNote: "Industry Standard (High security, SSL, Bitcoin)",
},
{
algorithm: "SHA-384",
name: "SHA-384 (SHA-2 Family)",
bitLength: 384,
hash: sha384Hash,
securityNote: "High Security (Government & enterprise grade)",
},
{
algorithm: "SHA-512",
name: "SHA-512 (SHA-2 Family)",
bitLength: 512,
hash: sha512Hash,
securityNote: "Maximum Security (64-bit optimized systems)",
},
]);
}
}
runCompute();
return () => {
isCancelled = true;
};
}, [inputText]);
const copyHash = async (hashValue: string, key: string) => {
const formatted = uppercase ? hashValue.toUpperCase() : hashValue.toLowerCase();

View file

@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useState } from "react";
import ToolLayout from "@/components/ToolLayout";
const LOREM_WORDS = [
@ -86,40 +86,76 @@ const LENGTH_RANGES: Record<ParagraphLength, { min: number; max: number; label:
custom: { min: 20, max: 200, label: "Custom" },
};
function buildLorem(
numParagraphs: number,
lengthPreset: ParagraphLength,
customWords: number,
startWithLorem: boolean,
format: OutputFormat
): string {
const targetWordsPerPara =
lengthPreset === "custom"
? customWords
: Math.floor(
Math.random() * (LENGTH_RANGES[lengthPreset].max - LENGTH_RANGES[lengthPreset].min + 1)
) + LENGTH_RANGES[lengthPreset].min;
const paras: string[] = [];
for (let i = 0; i < numParagraphs; i++) {
paras.push(generateParagraph(targetWordsPerPara, i === 0, startWithLorem));
}
if (format === "html") {
return paras.map((p) => `<p>${p}</p>`).join("\n\n");
}
return paras.join("\n\n");
}
export default function LoremIpsumPage() {
const [numParagraphs, setNumParagraphs] = useState<number>(3);
const [lengthPreset, setLengthPreset] = useState<ParagraphLength>("medium");
const [customWords, setCustomWords] = useState<number>(75);
const [startWithLorem, setStartWithLorem] = useState<boolean>(true);
const [format, setFormat] = useState<OutputFormat>("plain");
const [generatedText, setGeneratedText] = useState<string>("");
const [generatedText, setGeneratedText] = useState<string>(() =>
buildLorem(3, "medium", 75, true, "plain")
);
const [copied, setCopied] = useState<boolean>(false);
const generate = useCallback(() => {
const targetWordsPerPara =
lengthPreset === "custom"
? customWords
: Math.floor(
Math.random() * (LENGTH_RANGES[lengthPreset].max - LENGTH_RANGES[lengthPreset].min + 1)
) + LENGTH_RANGES[lengthPreset].min;
const generate = (
paras = numParagraphs,
preset = lengthPreset,
words = customWords,
start = startWithLorem,
fmt = format
) => {
setGeneratedText(buildLorem(paras, preset, words, start, fmt));
};
const paras: string[] = [];
for (let i = 0; i < numParagraphs; i++) {
paras.push(generateParagraph(targetWordsPerPara, i === 0, startWithLorem));
}
const handleParagraphsChange = (val: number) => {
setNumParagraphs(val);
generate(val, lengthPreset, customWords, startWithLorem, format);
};
if (format === "html") {
setGeneratedText(paras.map((p) => `<p>${p}</p>`).join("\n\n"));
} else if (format === "markdown") {
setGeneratedText(paras.join("\n\n"));
} else {
setGeneratedText(paras.join("\n\n"));
}
}, [numParagraphs, lengthPreset, customWords, startWithLorem, format]);
const handleLengthChange = (preset: ParagraphLength) => {
setLengthPreset(preset);
generate(numParagraphs, preset, customWords, startWithLorem, format);
};
useEffect(() => {
generate();
}, [generate]);
const handleCustomWordsChange = (words: number) => {
setCustomWords(words);
generate(numParagraphs, "custom", words, startWithLorem, format);
};
const handleStartWithLoremChange = (start: boolean) => {
setStartWithLorem(start);
generate(numParagraphs, lengthPreset, customWords, start, format);
};
const handleFormatChange = (fmt: OutputFormat) => {
setFormat(fmt);
generate(numParagraphs, lengthPreset, customWords, startWithLorem, fmt);
};
const handleCopy = async () => {
try {
@ -174,17 +210,17 @@ export default function LoremIpsumPage() {
value={numParagraphs}
onChange={(e) => {
const val = Math.min(Math.max(1, parseInt(e.target.value) || 1), 20);
setNumParagraphs(val);
handleParagraphsChange(val);
}}
className="w-20 border border-[#1a1a1a] bg-[#0a0a0a] px-3 py-2 text-sm font-medium text-gray-200 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-900/20"
className="w-20 border border-[#1a1a1a] bg-[#0a0a0a] px-3 py-2 text-sm font-medium text-gray-200 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-900/20"
/>
<div className="flex flex-wrap gap-1">
{[1, 3, 5, 10].map((count) => (
<button
key={count}
type="button"
onClick={() => setNumParagraphs(count)}
className={`rounded px-2 py-1 text-xs font-medium transition-colors ${
onClick={() => handleParagraphsChange(count)}
className={`px-2 py-1 text-xs font-medium transition-colors ${
numParagraphs === count
? "bg-blue-600 text-white"
: "bg-[#1a1a1a] text-gray-400 hover:bg-[#222]"
@ -204,8 +240,8 @@ export default function LoremIpsumPage() {
</label>
<select
value={lengthPreset}
onChange={(e) => setLengthPreset(e.target.value as ParagraphLength)}
className="w-full border border-[#1a1a1a] bg-[#0a0a0a] px-3 py-2 text-sm font-medium text-gray-200 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-900/20"
onChange={(e) => handleLengthChange(e.target.value as ParagraphLength)}
className="w-full border border-[#1a1a1a] bg-[#0a0a0a] px-3 py-2 text-sm font-medium text-gray-200 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-900/20"
>
<option value="short">Short (~35 words)</option>
<option value="medium">Medium (~75 words)</option>
@ -222,7 +258,7 @@ export default function LoremIpsumPage() {
max="300"
value={customWords}
onChange={(e) =>
setCustomWords(Math.min(Math.max(10, parseInt(e.target.value) || 10), 300))
handleCustomWordsChange(Math.min(Math.max(10, parseInt(e.target.value) || 10), 300))
}
className="w-24 border border-[#1a1a1a] bg-[#0a0a0a] px-2 py-1 text-xs font-medium text-gray-200 focus:outline-none focus:ring-1 focus:ring-blue-900"
/>
@ -235,11 +271,11 @@ export default function LoremIpsumPage() {
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-wider text-gray-400">
Format
</label>
<div className="flex border border-[#1a1a1a] bg-[#111] p-0.5">
<div className="flex border border-[#1a1a1a] bg-[#111] p-0.5">
<button
type="button"
onClick={() => setFormat("plain")}
className={`flex-1 py-1.5 text-xs font-medium transition-colors ${
onClick={() => handleFormatChange("plain")}
className={`flex-1 py-1.5 text-xs font-medium transition-colors ${
format === "plain"
? "bg-[#0a0a0a] text-gray-200 shadow-xs"
: "text-gray-600 hover:text-gray-200"
@ -249,8 +285,8 @@ export default function LoremIpsumPage() {
</button>
<button
type="button"
onClick={() => setFormat("html")}
className={`flex-1 py-1.5 text-xs font-medium transition-colors ${
onClick={() => handleFormatChange("html")}
className={`flex-1 py-1.5 text-xs font-medium transition-colors ${
format === "html"
? "bg-[#0a0a0a] text-gray-200 shadow-xs"
: "text-gray-600 hover:text-gray-200"
@ -270,7 +306,7 @@ export default function LoremIpsumPage() {
<input
type="checkbox"
checked={startWithLorem}
onChange={(e) => setStartWithLorem(e.target.checked)}
onChange={(e) => handleStartWithLoremChange(e.target.checked)}
className="h-4 w-4 border-[#1a1a1a] text-blue-600 focus:ring-blue-900"
/>
<span>Start with &ldquo;Lorem ipsum...&rdquo;</span>
@ -278,7 +314,7 @@ export default function LoremIpsumPage() {
<button
type="button"
onClick={generate}
onClick={() => generate()}
className="mt-3 flex w-full items-center justify-center gap-1.5 bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 focus:outline-none focus:ring-1 focus:ring-blue-900 focus:ring-offset-2"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">

View file

@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useState, useMemo } from "react";
import ToolLayout from "@/components/ToolLayout";
const CHARSETS = {
@ -90,6 +90,68 @@ function calculateStrength(password: string, poolSize: number): StrengthInfo {
}
}
function createPassword(
length: number,
useUppercase: boolean,
useLowercase: boolean,
useDigits: boolean,
useSymbols: boolean,
excludeAmbiguous: boolean
): string {
let pool = "";
const guaranteedChars: string[] = [];
const getCleanCharset = (chars: string) => {
if (!excludeAmbiguous) return chars;
return chars
.split("")
.filter((c) => !AMBIGUOUS_CHARS.has(c))
.join("");
};
if (useUppercase) {
const set = getCleanCharset(CHARSETS.uppercase);
if (set.length > 0) {
pool += set;
guaranteedChars.push(set[getRandomInt(set.length)]);
}
}
if (useLowercase) {
const set = getCleanCharset(CHARSETS.lowercase);
if (set.length > 0) {
pool += set;
guaranteedChars.push(set[getRandomInt(set.length)]);
}
}
if (useDigits) {
const set = getCleanCharset(CHARSETS.digits);
if (set.length > 0) {
pool += set;
guaranteedChars.push(set[getRandomInt(set.length)]);
}
}
if (useSymbols) {
const set = getCleanCharset(CHARSETS.symbols);
if (set.length > 0) {
pool += set;
guaranteedChars.push(set[getRandomInt(set.length)]);
}
}
if (pool.length === 0) {
return "";
}
const resultChars = [...guaranteedChars];
const remainingCount = Math.max(0, length - guaranteedChars.length);
for (let i = 0; i < remainingCount; i++) {
resultChars.push(pool[getRandomInt(pool.length)]);
}
return shuffleArray(resultChars).slice(0, length).join("");
}
export default function PasswordGeneratorPage() {
const [length, setLength] = useState<number>(16);
const [useUppercase, setUseUppercase] = useState<boolean>(true);
@ -97,81 +159,23 @@ export default function PasswordGeneratorPage() {
const [useDigits, setUseDigits] = useState<boolean>(true);
const [useSymbols, setUseSymbols] = useState<boolean>(true);
const [excludeAmbiguous, setExcludeAmbiguous] = useState<boolean>(false);
const [version, setVersion] = useState<number>(0);
const [password, setPassword] = useState<string>("");
const [history, setHistory] = useState<string[]>([]);
const [copied, setCopied] = useState<boolean>(false);
const [historyCopiedIndex, setHistoryCopiedIndex] = useState<number | null>(null);
const generatePassword = useCallback(() => {
let pool = "";
const guaranteedChars: string[] = [];
const password = useMemo(() => {
void version;
return createPassword(length, useUppercase, useLowercase, useDigits, useSymbols, excludeAmbiguous);
}, [length, useUppercase, useLowercase, useDigits, useSymbols, excludeAmbiguous, version]);
const getCleanCharset = (chars: string) => {
if (!excludeAmbiguous) return chars;
return chars
.split("")
.filter((c) => !AMBIGUOUS_CHARS.has(c))
.join("");
};
if (useUppercase) {
const set = getCleanCharset(CHARSETS.uppercase);
if (set.length > 0) {
pool += set;
guaranteedChars.push(set[getRandomInt(set.length)]);
}
const generatePassword = () => {
if (password) {
setHistory((prev) => (!prev.includes(password) ? [password, ...prev.slice(0, 4)] : prev));
}
if (useLowercase) {
const set = getCleanCharset(CHARSETS.lowercase);
if (set.length > 0) {
pool += set;
guaranteedChars.push(set[getRandomInt(set.length)]);
}
}
if (useDigits) {
const set = getCleanCharset(CHARSETS.digits);
if (set.length > 0) {
pool += set;
guaranteedChars.push(set[getRandomInt(set.length)]);
}
}
if (useSymbols) {
const set = getCleanCharset(CHARSETS.symbols);
if (set.length > 0) {
pool += set;
guaranteedChars.push(set[getRandomInt(set.length)]);
}
}
if (pool.length === 0) {
setPassword("");
return;
}
const resultChars = [...guaranteedChars];
const remainingCount = Math.max(0, length - guaranteedChars.length);
for (let i = 0; i < remainingCount; i++) {
resultChars.push(pool[getRandomInt(pool.length)]);
}
const shuffled = shuffleArray(resultChars).slice(0, length).join("");
setPassword(shuffled);
// Add to history if unique
setHistory((prev) => {
if (shuffled && !prev.includes(shuffled)) {
return [shuffled, ...prev.slice(0, 4)];
}
return prev;
});
}, [length, useUppercase, useLowercase, useDigits, useSymbols, excludeAmbiguous]);
// Generate on initial load and option changes
useEffect(() => {
generatePassword();
}, [generatePassword]);
setVersion((v) => v + 1);
};
const handleCopy = async () => {
if (!password) return;

View file

@ -142,7 +142,6 @@ export default function PxToRemPage() {
// Extra units for currently active px
const currentPxNum = parseFloat(pxValue) || 0;
const currentRemNum = parseFloat(remValue) || 0;
const percentageValue = formatNumber((currentPxNum / baseSize) * 100, 2);
const ptValue = formatNumber(currentPxNum * 0.75, 2); // 1px = 0.75pt
const cssDeclaration = `${selectedProperty}: ${remValue || "0"}rem; /* ${pxValue || "0"}px */`;

View file

@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect } from "react";
import ToolLayout from "@/components/ToolLayout";
import QRCode from "qrcode";
@ -21,7 +21,7 @@ const ERROR_CORRECTION_DESCRIPTIONS: Record<ErrorCorrectionLevel, string> = {
};
export default function QrCodePage() {
const [inputText, setInputText] = useState<string>("https://devtoolbox.com");
const [inputText, setInputText] = useState<string>("https://toolbox.oiupoyt.space");
const [size, setSize] = useState<SizeOption>("medium");
const [errorCorrection, setErrorCorrection] = useState<ErrorCorrectionLevel>("M");
const [margin, setMargin] = useState<number>(2);
@ -32,45 +32,57 @@ export default function QrCodePage() {
const [error, setError] = useState<string | null>(null);
const [copiedDataUrl, setCopiedDataUrl] = useState<boolean>(false);
const generateQRCode = useCallback(async () => {
const textToEncode = inputText.trim();
if (!textToEncode) {
setDataUrl("");
setSvgString("");
setError(null);
return;
}
try {
const options = {
width: SIZE_MAP[size].px,
margin: margin,
errorCorrectionLevel: errorCorrection,
color: {
dark: fgColor,
light: bgColor,
},
};
const [url, svg] = await Promise.all([
QRCode.toDataURL(textToEncode, options),
QRCode.toString(textToEncode, { ...options, type: "svg" }),
]);
setDataUrl(url);
setSvgString(svg);
setError(null);
} catch (err: unknown) {
console.error("QR Code generation error:", err);
const message = err instanceof Error ? err.message : "Failed to generate QR code";
setError(message);
setDataUrl("");
}
}, [inputText, size, errorCorrection, margin, fgColor, bgColor]);
useEffect(() => {
generateQRCode();
}, [generateQRCode]);
let active = true;
async function generate() {
const textToEncode = inputText.trim();
if (!textToEncode) {
if (active) {
setDataUrl("");
setSvgString("");
setError(null);
}
return;
}
try {
const options = {
width: SIZE_MAP[size].px,
margin,
errorCorrectionLevel: errorCorrection,
color: {
dark: fgColor,
light: bgColor,
},
};
const [url, svg] = await Promise.all([
QRCode.toDataURL(textToEncode, options),
QRCode.toString(textToEncode, { ...options, type: "svg" }),
]);
if (active) {
setDataUrl(url);
setSvgString(svg);
setError(null);
}
} catch (err: unknown) {
if (active) {
console.error("QR Code generation error:", err);
const message = err instanceof Error ? err.message : "Failed to generate QR code";
setError(message);
setDataUrl("");
}
}
}
generate();
return () => {
active = false;
};
}, [inputText, size, errorCorrection, margin, fgColor, bgColor]);
const handleDownloadPng = () => {
if (!dataUrl) return;

View file

@ -1,8 +1,21 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useState, useMemo, useSyncExternalStore, useCallback } from "react";
import ToolLayout from "@/components/ToolLayout";
function subscribeHash(callback: () => void) {
window.addEventListener("hashchange", callback);
return () => window.removeEventListener("hashchange", callback);
}
function getHashSnapshot() {
return typeof window !== "undefined" ? window.location.hash : "";
}
function getServerSnapshot() {
return "";
}
// Utility helpers for Uint8Array <-> Base64URL
function bufferToBase64Url(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
@ -33,23 +46,20 @@ export default function SecretSharePage() {
const [secretText, setSecretText] = useState("");
const [generatedLink, setGeneratedLink] = useState("");
const [isCopied, setIsCopied] = useState(false);
const [mode, setMode] = useState<"create" | "view">("create");
const hash = useSyncExternalStore(subscribeHash, getHashSnapshot, getServerSnapshot);
const [explicitMode, setExplicitMode] = useState<"create" | "view" | null>(null);
const hasSecretInHash = useMemo(() => {
const raw = hash.startsWith("#") ? hash.substring(1) : hash;
const params = new URLSearchParams(raw);
return Boolean(params.get("d") && params.get("k"));
}, [hash]);
const mode = explicitMode ?? (hasSecretInHash ? "view" : "create");
const [decryptedText, setDecryptedText] = useState<string | null>(null);
const [decryptError, setDecryptError] = useState<string | null>(null);
const [isDecrypting, setIsDecrypting] = useState(false);
// Check URL hash on mount for view mode
useEffect(() => {
if (typeof window === "undefined") return;
const hash = window.location.hash.substring(1);
if (!hash) return;
const params = new URLSearchParams(hash);
if (params.get("d") && params.get("k")) {
setMode("view");
}
}, []);
const handleCreateSecret = async () => {
if (!secretText.trim()) return;
@ -145,8 +155,10 @@ export default function SecretSharePage() {
const handleDestroy = () => {
setDecryptedText(null);
setDecryptError(null);
window.location.hash = "";
setMode("create");
if (typeof window !== "undefined") {
window.location.hash = "";
}
setExplicitMode("create");
setSecretText("");
setGeneratedLink("");
};

View file

@ -1,21 +1,15 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useState, useCallback } from "react";
import ToolLayout from "@/components/ToolLayout";
function generateSingleUUID(uppercase: boolean, hyphens: boolean): string {
let uuid: string;
let id: string;
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
uuid = crypto.randomUUID();
} else if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
bytes[6] = (bytes[6] & 0x0f) | 0x40; // Version 4
bytes[8] = (bytes[8] & 0x3f) | 0x80; // Variant 10xx
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
uuid = `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
id = crypto.randomUUID();
} else {
uuid = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
// RFC4122 v4 fallback
id = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
@ -23,17 +17,19 @@ function generateSingleUUID(uppercase: boolean, hyphens: boolean): string {
}
if (!hyphens) {
uuid = uuid.replace(/-/g, "");
id = id.replace(/-/g, "");
}
return uppercase ? uuid.toUpperCase() : uuid.toLowerCase();
return uppercase ? id.toUpperCase() : id.toLowerCase();
}
export default function UuidGeneratorPage() {
const [count, setCount] = useState<number>(5);
const [uppercase, setUppercase] = useState<boolean>(false);
const [hyphens, setHyphens] = useState<boolean>(true);
const [uuids, setUuids] = useState<string[]>([]);
const [uuids, setUuids] = useState<string[]>(() =>
Array.from({ length: 5 }, () => generateSingleUUID(false, true))
);
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
const [copiedAll, setCopiedAll] = useState<boolean>(false);
@ -51,11 +47,6 @@ export default function UuidGeneratorPage() {
[]
);
// Generate on initial load
useEffect(() => {
generate(count, uppercase, hyphens);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const handleGenerate = () => {
generate(count, uppercase, hyphens);
};

View file

@ -2,19 +2,19 @@ import Link from "next/link";
export default function Footer() {
return (
<footer className="relative z-10 mt-auto border-t border-[#141414] px-6 py-4 flex flex-wrap items-center justify-between gap-4 text-xs text-[#525252] bg-black/80 backdrop-blur-sm max-w-7xl mx-auto w-full">
<span>Free, client-side developer and media utilities.</span>
<footer className="relative z-10 mt-auto border-t border-[#1f1f1f] px-6 py-4 flex flex-wrap items-center justify-between gap-4 text-xs font-mono text-[#555] bg-[#0b0b0b]/90 backdrop-blur-sm max-w-7xl mx-auto w-full">
<span>kattoolbox // zero-knowledge client execution</span>
<div className="flex items-center gap-4">
<Link href="/privacy" className="hover:text-gray-400 transition-colors">
Privacy Policy
<Link href="/privacy" className="hover:text-gray-300 transition-colors">
privacy
</Link>
<a
href="https://github.com/oiupoyt/kattoolbox"
target="_blank"
rel="noopener noreferrer"
className="hover:text-gray-400 transition-colors"
className="hover:text-gray-300 transition-colors"
>
GitHub
github
</a>
</div>
</footer>

View file

@ -18,21 +18,22 @@ export default function Header() {
: [];
return (
<header className="relative z-20 border-b border-[#141414] bg-black/90 backdrop-blur-md sticky top-0">
<header className="relative z-20 border-b border-[#1f1f1f] bg-[#0b0b0b]/90 backdrop-blur-md sticky top-0 font-mono">
<div className="mx-auto max-w-7xl px-4 py-3 flex items-center justify-between gap-4">
{/* Corner Client-Side indicator linking home */}
{/* Brand & Local Status */}
<Link
href="/"
className="text-[10px] font-mono uppercase tracking-widest text-[#333] hover:text-[#555] transition-colors shrink-0 select-none"
title="Return to index"
className="flex items-center gap-2 text-xs font-bold tracking-tight text-white hover:text-gray-300 transition-colors shrink-0 select-none"
title="kattoolbox - client-side utilities"
>
client-side
<span>kattoolbox</span>
<span className="inline-block w-1.5 h-1.5 bg-[#4ade80]" title="local execution" />
</Link>
{/* Centered Search Bar */}
<div className="relative flex-1 max-w-lg mx-auto">
<div className="relative flex-1 max-w-md mx-auto">
<svg
className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-[#444]"
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"
@ -49,31 +50,41 @@ export default function Header() {
}}
onFocus={() => setShowResults(true)}
onBlur={() => setTimeout(() => setShowResults(false), 200)}
className="w-full border border-[#1c1c1c] bg-[#070707] pl-9 pr-3 py-1.5 text-xs text-gray-200 placeholder-[#444] outline-none focus:border-[#333] transition-colors"
className="w-full border border-[#222] bg-[#0d0d0d] pl-8 pr-3 py-1.5 text-xs text-gray-200 placeholder-[#555] outline-none focus:border-[#555] transition-colors font-mono"
/>
{showResults && filtered.length > 0 && (
<div className="absolute left-0 right-0 top-full mt-1 max-h-80 overflow-y-auto border border-[#1f1f1f] bg-[#090909] shadow-2xl z-50 divide-y divide-[#141414]">
<div className="absolute left-0 right-0 top-full mt-1 max-h-80 overflow-y-auto border border-[#262626] bg-[#0e0e0e] shadow-2xl z-50 divide-y divide-[#171717]">
{filtered.map((tool) => (
<Link
key={tool.slug}
href={`/tools/${tool.slug}`}
className="flex items-center justify-between px-3.5 py-2 text-xs text-gray-300 hover:bg-[#121212] hover:text-blue-400 transition-colors"
className="flex items-center justify-between px-3.5 py-2 text-xs text-gray-300 hover:bg-[#181818] hover:text-white transition-colors"
onClick={() => {
setQuery("");
setShowResults(false);
}}
>
<span className="font-medium text-gray-200">{tool.name}</span>
<span className="text-[11px] text-[#555] truncate ml-3">{tool.description}</span>
<span className="text-[11px] text-[#666] truncate ml-3">{tool.description}</span>
</Link>
))}
</div>
)}
</div>
{/* Balancer spacing for desktop layout */}
<div className="w-[70px] hidden sm:block shrink-0" />
{/* Right Info */}
<div className="flex items-center gap-3 shrink-0">
<span className="text-[11px] font-mono text-[#555] hidden sm:inline">{tools.length} tools</span>
<a
href="https://github.com/oiupoyt/kattoolbox"
target="_blank"
rel="noopener noreferrer"
className="text-xs font-mono text-[#666] hover:text-gray-300 transition-colors"
>
github
</a>
</div>
</div>
</header>
);

View file

@ -5,15 +5,17 @@ export default function ToolCard({ tool }: { tool: Tool }) {
return (
<Link
href={`/tools/${tool.slug}`}
className="group block border border-[#181818] bg-[#0a0a0a] p-4 transition-colors hover:bg-[#101010] hover:border-[#282828]"
className="group block border border-[#1e1e1e] bg-[#0e0e0e] p-3.5 transition-all hover:bg-[#151515] hover:border-[#383838]"
>
<div className="flex items-center justify-between gap-2">
<div className="text-sm font-medium text-gray-200 group-hover:text-blue-400 transition-colors">
<div className="text-xs font-semibold text-gray-200 group-hover:text-white transition-colors">
{tool.name}
</div>
<span className="text-xs text-[#444] group-hover:text-gray-300 transition-colors">→</span>
<span className="text-xs font-mono text-[#555] group-hover:text-gray-200 group-hover:translate-x-0.5 transition-all">
→
</span>
</div>
<div className="mt-1 text-xs text-[#666] leading-relaxed">
<div className="mt-1.5 text-[11px] text-[#666] leading-relaxed group-hover:text-[#888] transition-colors">
{tool.description}
</div>
</Link>

View file

@ -1,3 +1,4 @@
import Link from "next/link";
import SidebarAd from "@/components/SidebarAd";
interface ToolLayoutProps {
@ -9,7 +10,7 @@ interface ToolLayoutProps {
export default function ToolLayout({ title, description, children, showSideAds = true }: ToolLayoutProps) {
return (
<div className="relative z-10 mx-auto max-w-7xl px-4 py-8">
<div className="relative z-10 mx-auto max-w-7xl px-4 py-6">
<div className="flex justify-center items-start gap-6">
{/* Left Side Ad (Ultra-wide screens) */}
{showSideAds && (
@ -20,13 +21,19 @@ export default function ToolLayout({ title, description, children, showSideAds =
{/* 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 className="mb-4">
<div className="flex items-center gap-1.5 text-[11px] font-mono text-[#555] mb-1.5">
<Link href="/" className="hover:text-gray-300 transition-colors">
tools
</Link>
<span>/</span>
<span className="text-[#888]">{title.toLowerCase()}</span>
</div>
<h1 className="text-base font-semibold text-white tracking-tight">{title}</h1>
<p className="mt-1 text-xs text-[#666] leading-relaxed">{description}</p>
</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">
<div className="border border-[#1e1e1e] bg-[#0e0e0e] p-5 shadow-2xl">
{children}
</div>
</div>