refactor: minimal sharp JetBrains Mono theme and fix React 19 hook errors
This commit is contained in:
parent
8278c32977
commit
d9e0672f4a
22 changed files with 548 additions and 367 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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 & 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) => (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
/* eslint-disable @next/next/no-img-element */
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
/* eslint-disable @next/next/no-img-element */
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 “Lorem ipsum...”</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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 */`;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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("");
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue