@@ -0,0 +1,13 @@
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
.git
|
||||
.gitignore
|
||||
*.log
|
||||
npm-debug.log*
|
||||
.DS_Store
|
||||
.vscode
|
||||
.idea
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
README.md
|
||||
@@ -0,0 +1,27 @@
|
||||
name: Build and Publish Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Build Docker Image
|
||||
run: |
|
||||
docker build -t git.alexav.gg/${{ gitea.repository }}:latest .
|
||||
docker build -t git.alexav.gg/${{ gitea.repository }}:${{ gitea.sha }} .
|
||||
|
||||
- name: Login to Gitea Registry
|
||||
run: |
|
||||
echo "${{ secrets.RUNNER_TOKEN }}" | docker login git.alexav.gg -u ${{ gitea.actor }} --password-stdin
|
||||
|
||||
- name: Push Docker Image
|
||||
run: |
|
||||
docker push git.alexav.gg/${{ gitea.repository }}:latest
|
||||
docker push git.alexav.gg/${{ gitea.repository }}:${{ gitea.sha }}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# --- Build stage ---------------------------------------------------------
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies first for better layer caching.
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Build the static site (outputs to /app/dist).
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# --- Serve stage ---------------------------------------------------------
|
||||
FROM nginx:alpine AS serve
|
||||
|
||||
# SPA-aware nginx config (client-side routing fallback).
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Copy the built static assets.
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,53 @@
|
||||
# Tools
|
||||
|
||||
A collection of small, self-contained developer tools built with **React + TypeScript + Vite**.
|
||||
Everything runs entirely in the browser — no backend, no data leaves your machine.
|
||||
|
||||
## Tools included
|
||||
|
||||
**Encoders**
|
||||
- **Base64** — encode/decode with full UTF-8 support
|
||||
- **URL Encode/Decode** — component (`encodeURIComponent`) or full-URL (`encodeURI`) modes
|
||||
- **JSON Formatter** — pretty-print, minify, and validate
|
||||
- **JWT Decoder** — decode header & payload (does not verify signatures)
|
||||
|
||||
**Dev Utilities**
|
||||
- **Regex Tester** — live match highlighting, flags, and capture groups
|
||||
- **Diff Viewer** — line-by-line comparison of two texts
|
||||
- **Markdown Preview** — live GitHub-Flavored Markdown rendering
|
||||
- **QR Code Generator** — encode text/URLs, download as PNG
|
||||
|
||||
## Architecture
|
||||
|
||||
Tools are driven by a single registry at [`src/tools/registry.ts`](src/tools/registry.ts).
|
||||
Routes ([`src/main.tsx`](src/main.tsx)) and the home grid ([`src/pages/Home.tsx`](src/pages/Home.tsx))
|
||||
are both generated from it, and each tool is lazy-loaded.
|
||||
|
||||
**To add a tool:** create a component under `src/tools/<name>/`, then append one entry to the
|
||||
`tools` array in `registry.ts`. No routing or navigation changes needed.
|
||||
|
||||
## Local development
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # start Vite dev server (http://localhost:5173)
|
||||
```
|
||||
|
||||
Other scripts:
|
||||
|
||||
```bash
|
||||
npm run build # type-check + production build into dist/
|
||||
npm run preview # serve the production build locally
|
||||
npm run typecheck # type-check only
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
Multi-stage build: Vite builds the static site, then nginx serves it with an SPA routing fallback.
|
||||
|
||||
```bash
|
||||
docker build -t tools .
|
||||
docker run --rm -p 8080:80 tools
|
||||
```
|
||||
|
||||
Then open <http://localhost:8080>.
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="A collection of small, self-contained developer tools." />
|
||||
<title>Tools</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Cache hashed static assets aggressively.
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# Client-side routing: fall back to index.html for unknown paths.
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Basic gzip for text assets.
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
|
||||
gzip_min_length 1024;
|
||||
}
|
||||
Generated
+3628
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "tools",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc -b --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"diff": "^7.0.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"remark-gfm": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/diff": "^5.2.3",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^18.3.10",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"typescript": "^5.6.2",
|
||||
"vite": "^5.4.8"
|
||||
},
|
||||
"allowScripts": {
|
||||
"esbuild@0.21.5": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="7" fill="#6366f1" />
|
||||
<path d="M11.5 10.5 7 16l4.5 5.5M20.5 10.5 25 16l-4.5 5.5M18 8l-4 16"
|
||||
fill="none" stroke="#fff" stroke-width="2.2"
|
||||
stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 307 B |
@@ -0,0 +1,69 @@
|
||||
.shell {
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.85rem 1.5rem;
|
||||
background: rgba(15, 17, 23, 0.85);
|
||||
backdrop-filter: blur(8px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
font-weight: 700;
|
||||
font-size: 1.05rem;
|
||||
color: var(--text);
|
||||
}
|
||||
.brand:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.brandMark {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.headerLink {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.headerLink:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1.5rem 4rem;
|
||||
}
|
||||
|
||||
.footer {
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 1.25rem 1.5rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.loading {
|
||||
padding: 3rem 0;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import { Link, Outlet } from "react-router-dom";
|
||||
import styles from "./App.module.css";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div className={styles.shell}>
|
||||
<header className={styles.header}>
|
||||
<Link to="/" className={styles.brand}>
|
||||
<img src="/favicon.svg" alt="" className={styles.brandMark} />
|
||||
<span>Alex's Tools</span>
|
||||
</Link>
|
||||
<div className={styles.spacer} />
|
||||
<a
|
||||
className={styles.headerLink}
|
||||
href="https://github.com"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
</header>
|
||||
|
||||
<main className={styles.main}>
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
<footer className={styles.footer}>
|
||||
A collection of small, self-contained developer tools. Runs entirely in
|
||||
your browser.
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useState } from "react";
|
||||
|
||||
interface CopyButtonProps {
|
||||
/** Text to copy, or a getter for it. */
|
||||
value: string | (() => string);
|
||||
label?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function CopyButton({
|
||||
value,
|
||||
label = "Copy",
|
||||
disabled,
|
||||
}: CopyButtonProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function handleCopy() {
|
||||
const text = typeof value === "function" ? value() : value;
|
||||
if (!text) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
// Clipboard API unavailable (e.g. non-secure context) — fail silently.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" onClick={handleCopy} disabled={disabled}>
|
||||
{copied ? "Copied ✓" : label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
.wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.back {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.back:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 1.9rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.desc {
|
||||
margin: 0.15rem 0 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import styles from "./ToolLayout.module.css";
|
||||
|
||||
interface ToolLayoutProps {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function ToolLayout({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
children,
|
||||
}: ToolLayoutProps) {
|
||||
return (
|
||||
<div className={styles.wrap}>
|
||||
<Link to="/" className={styles.back}>
|
||||
← All tools
|
||||
</Link>
|
||||
<div className={styles.head}>
|
||||
<span className={styles.icon} aria-hidden="true">
|
||||
{icon}
|
||||
</span>
|
||||
<div>
|
||||
<h1 className={styles.title}>{title}</h1>
|
||||
<p className={styles.desc}>{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.body}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
:root {
|
||||
--bg: #0f1117;
|
||||
--bg-elevated: #171a23;
|
||||
--bg-input: #1e222c;
|
||||
--border: #2a2f3a;
|
||||
--border-strong: #3a4150;
|
||||
--text: #e6e8ee;
|
||||
--text-muted: #9aa2b1;
|
||||
--accent: #6366f1;
|
||||
--accent-hover: #7c7ff5;
|
||||
--accent-soft: rgba(99, 102, 241, 0.15);
|
||||
--danger: #ef4444;
|
||||
--success: #22c55e;
|
||||
--radius: 10px;
|
||||
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
|
||||
"Liberation Mono", monospace;
|
||||
--font-sans: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial,
|
||||
sans-serif;
|
||||
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font-sans);
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
a:hover {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
line-height: 1.2;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--bg-input);
|
||||
color: var(--text);
|
||||
padding: 0.5rem 0.9rem;
|
||||
border-radius: 8px;
|
||||
transition: background 0.12s ease, border-color 0.12s ease;
|
||||
}
|
||||
button:hover {
|
||||
background: var(--bg-elevated);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
button:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
button.primary:hover {
|
||||
background: var(--accent-hover);
|
||||
border-color: var(--accent-hover);
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
textarea,
|
||||
select {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.875rem;
|
||||
width: 100%;
|
||||
background: var(--bg-input);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.6rem 0.75rem;
|
||||
resize: vertical;
|
||||
}
|
||||
input[type="text"]:focus,
|
||||
textarea:focus,
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 8rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { StrictMode, Suspense } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { createBrowserRouter, RouterProvider } from "react-router-dom";
|
||||
import App from "./App";
|
||||
import Home from "./pages/Home";
|
||||
import NotFound from "./pages/NotFound";
|
||||
import ToolLayout from "./components/ToolLayout";
|
||||
import { tools } from "./tools/registry";
|
||||
import styles from "./App.module.css";
|
||||
import "./index.css";
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: "/",
|
||||
element: <App />,
|
||||
children: [
|
||||
{ index: true, element: <Home /> },
|
||||
...tools.map((tool) => ({
|
||||
path: tool.slug,
|
||||
element: (
|
||||
<ToolLayout
|
||||
title={tool.title}
|
||||
description={tool.description}
|
||||
icon={tool.icon}
|
||||
>
|
||||
<Suspense fallback={<div className={styles.loading}>Loading…</div>}>
|
||||
<tool.Component />
|
||||
</Suspense>
|
||||
</ToolLayout>
|
||||
),
|
||||
})),
|
||||
{ path: "*", element: <NotFound /> },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<RouterProvider router={router} />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,74 @@
|
||||
.hero {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.heroSub {
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 1.25rem;
|
||||
}
|
||||
|
||||
.search {
|
||||
max-width: 22rem;
|
||||
}
|
||||
|
||||
.category {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.categoryLabel {
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 1.1rem;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
transition: border-color 0.12s ease, transform 0.12s ease, background 0.12s ease;
|
||||
}
|
||||
.card:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--bg-input);
|
||||
transform: translateY(-2px);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.cardIcon {
|
||||
font-size: 1.6rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.cardDesc {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--text-muted);
|
||||
padding: 2rem 0;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { tools } from "../tools/registry";
|
||||
import type { ToolCategory } from "../tools/registry";
|
||||
import styles from "./Home.module.css";
|
||||
|
||||
const CATEGORY_ORDER: ToolCategory[] = ["Dev Utilities", "Encoders"];
|
||||
|
||||
export default function Home() {
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return tools;
|
||||
return tools.filter(
|
||||
(t) =>
|
||||
t.title.toLowerCase().includes(q) ||
|
||||
t.description.toLowerCase().includes(q) ||
|
||||
t.category.toLowerCase().includes(q),
|
||||
);
|
||||
}, [query]);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
return CATEGORY_ORDER.map((category) => ({
|
||||
category,
|
||||
items: filtered.filter((t) => t.category === category),
|
||||
})).filter((g) => g.items.length > 0);
|
||||
}, [filtered]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<section className={styles.hero}>
|
||||
<h1 className={styles.heroTitle}>Alex's Tools</h1>
|
||||
<p className={styles.heroSub}>
|
||||
A grab bag of small developer utilities — everything runs locally in
|
||||
your browser.
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.search}
|
||||
placeholder="Search tools…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</section>
|
||||
|
||||
{grouped.length === 0 ? (
|
||||
<p className={styles.empty}>No tools match “{query}”.</p>
|
||||
) : (
|
||||
grouped.map(({ category, items }) => (
|
||||
<section key={category} className={styles.category}>
|
||||
<h2 className={styles.categoryLabel}>{category}</h2>
|
||||
<div className={styles.grid}>
|
||||
{items.map((tool) => (
|
||||
<Link
|
||||
key={tool.slug}
|
||||
to={`/${tool.slug}`}
|
||||
className={styles.card}
|
||||
>
|
||||
<span className={styles.cardIcon} aria-hidden="true">
|
||||
{tool.icon}
|
||||
</span>
|
||||
<span className={styles.cardTitle}>{tool.title}</span>
|
||||
<p className={styles.cardDesc}>{tool.description}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div style={{ textAlign: "center", padding: "3rem 0" }}>
|
||||
<h1 style={{ fontSize: "2.5rem", marginBottom: "0.5rem" }}>404</h1>
|
||||
<p style={{ color: "var(--text-muted)", marginBottom: "1.5rem" }}>
|
||||
That page doesn’t exist.
|
||||
</p>
|
||||
<Link to="/">← Back to all tools</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import CopyButton from "../../components/CopyButton";
|
||||
import s from "../tools.module.css";
|
||||
|
||||
type Mode = "encode" | "decode";
|
||||
|
||||
function encode(input: string): string {
|
||||
// btoa only handles Latin-1; go through UTF-8 bytes for full Unicode support.
|
||||
const bytes = new TextEncoder().encode(input);
|
||||
let binary = "";
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function decode(input: string): string {
|
||||
const binary = atob(input.trim());
|
||||
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
export default function Base64() {
|
||||
const [mode, setMode] = useState<Mode>("encode");
|
||||
const [input, setInput] = useState("");
|
||||
|
||||
const { output, error } = useMemo(() => {
|
||||
if (!input) return { output: "", error: "" };
|
||||
try {
|
||||
return {
|
||||
output: mode === "encode" ? encode(input) : decode(input),
|
||||
error: "",
|
||||
};
|
||||
} catch {
|
||||
return { output: "", error: "Invalid Base64 input." };
|
||||
}
|
||||
}, [input, mode]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={s.row}>
|
||||
<button
|
||||
className={mode === "encode" ? "primary" : ""}
|
||||
onClick={() => setMode("encode")}
|
||||
>
|
||||
Encode
|
||||
</button>
|
||||
<button
|
||||
className={mode === "decode" ? "primary" : ""}
|
||||
onClick={() => setMode("decode")}
|
||||
>
|
||||
Decode
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={s.split}>
|
||||
<div className={s.field}>
|
||||
<label>Input</label>
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder={
|
||||
mode === "encode" ? "Text to encode…" : "Base64 to decode…"
|
||||
}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
<div className={s.field}>
|
||||
<label>Output</label>
|
||||
{error ? (
|
||||
<pre className={s.output}>
|
||||
<span className={s.error}>{error}</span>
|
||||
</pre>
|
||||
) : (
|
||||
<pre className={s.output}>{output}</pre>
|
||||
)}
|
||||
<div className={s.row}>
|
||||
<CopyButton value={output} disabled={!output} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
.result {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.55;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.line {
|
||||
display: flex;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
padding: 0 0.75rem;
|
||||
}
|
||||
|
||||
.added {
|
||||
background: rgba(34, 197, 94, 0.14);
|
||||
}
|
||||
.added .sign {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.removed {
|
||||
background: rgba(239, 68, 68, 0.14);
|
||||
}
|
||||
.removed .sign {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.sign {
|
||||
width: 1.2rem;
|
||||
flex: 0 0 1.2rem;
|
||||
color: var(--text-muted);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--text-muted);
|
||||
padding: 0.75rem;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { diffLines } from "diff";
|
||||
import s from "../tools.module.css";
|
||||
import m from "./DiffViewer.module.css";
|
||||
|
||||
type Kind = "added" | "removed" | "common";
|
||||
|
||||
interface Row {
|
||||
kind: Kind;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export default function DiffViewer() {
|
||||
const [left, setLeft] = useState("");
|
||||
const [right, setRight] = useState("");
|
||||
|
||||
const rows = useMemo<Row[]>(() => {
|
||||
if (!left && !right) return [];
|
||||
const parts = diffLines(left, right);
|
||||
const out: Row[] = [];
|
||||
for (const part of parts) {
|
||||
const kind: Kind = part.added
|
||||
? "added"
|
||||
: part.removed
|
||||
? "removed"
|
||||
: "common";
|
||||
// Split into lines, dropping the trailing empty entry from a final newline.
|
||||
const lines = part.value.split("\n");
|
||||
if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
|
||||
for (const text of lines) out.push({ kind, text });
|
||||
}
|
||||
return out;
|
||||
}, [left, right]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
let added = 0;
|
||||
let removed = 0;
|
||||
for (const r of rows) {
|
||||
if (r.kind === "added") added++;
|
||||
else if (r.kind === "removed") removed++;
|
||||
}
|
||||
return { added, removed };
|
||||
}, [rows]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={s.split}>
|
||||
<div className={s.field}>
|
||||
<label>Original</label>
|
||||
<textarea
|
||||
value={left}
|
||||
onChange={(e) => setLeft(e.target.value)}
|
||||
placeholder="Paste the original text…"
|
||||
spellCheck={false}
|
||||
style={{ minHeight: "12rem" }}
|
||||
/>
|
||||
</div>
|
||||
<div className={s.field}>
|
||||
<label>Changed</label>
|
||||
<textarea
|
||||
value={right}
|
||||
onChange={(e) => setRight(e.target.value)}
|
||||
placeholder="Paste the changed text…"
|
||||
spellCheck={false}
|
||||
style={{ minHeight: "12rem" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={s.field}>
|
||||
<label>
|
||||
Diff{" "}
|
||||
<span className={s.hint}>
|
||||
(+{stats.added} / −{stats.removed})
|
||||
</span>
|
||||
</label>
|
||||
<div className={m.result}>
|
||||
{rows.length === 0 ? (
|
||||
<div className={m.empty}>Enter text on both sides to compare.</div>
|
||||
) : (
|
||||
rows.map((row, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`${m.line} ${
|
||||
row.kind === "added"
|
||||
? m.added
|
||||
: row.kind === "removed"
|
||||
? m.removed
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<span className={m.sign}>
|
||||
{row.kind === "added"
|
||||
? "+"
|
||||
: row.kind === "removed"
|
||||
? "−"
|
||||
: " "}
|
||||
</span>
|
||||
<span className={m.content}>{row.text || " "}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import CopyButton from "../../components/CopyButton";
|
||||
import s from "../tools.module.css";
|
||||
|
||||
type Indent = "2" | "4" | "tab" | "min";
|
||||
|
||||
function indentValue(indent: Indent): string | number {
|
||||
switch (indent) {
|
||||
case "2":
|
||||
return 2;
|
||||
case "4":
|
||||
return 4;
|
||||
case "tab":
|
||||
return "\t";
|
||||
case "min":
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export default function JsonFormat() {
|
||||
const [input, setInput] = useState("");
|
||||
const [indent, setIndent] = useState<Indent>("2");
|
||||
|
||||
const { output, error } = useMemo(() => {
|
||||
if (!input.trim()) return { output: "", error: "" };
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(input);
|
||||
const space = indentValue(indent);
|
||||
const out =
|
||||
indent === "min"
|
||||
? JSON.stringify(parsed)
|
||||
: JSON.stringify(parsed, null, space);
|
||||
return { output: out, error: "" };
|
||||
} catch (e) {
|
||||
return {
|
||||
output: "",
|
||||
error: e instanceof Error ? e.message : "Invalid JSON.",
|
||||
};
|
||||
}
|
||||
}, [input, indent]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={s.row}>
|
||||
<label>Indent</label>
|
||||
<select
|
||||
value={indent}
|
||||
onChange={(e) => setIndent(e.target.value as Indent)}
|
||||
style={{ width: "auto" }}
|
||||
>
|
||||
<option value="2">2 spaces</option>
|
||||
<option value="4">4 spaces</option>
|
||||
<option value="tab">Tabs</option>
|
||||
<option value="min">Minify</option>
|
||||
</select>
|
||||
<span className={s.grow} />
|
||||
<CopyButton value={output} disabled={!output} label="Copy output" />
|
||||
</div>
|
||||
|
||||
<div className={s.split}>
|
||||
<div className={s.field}>
|
||||
<label>Input</label>
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder='{"hello": "world"}'
|
||||
spellCheck={false}
|
||||
style={{ minHeight: "16rem" }}
|
||||
/>
|
||||
{error && <span className={s.error}>{error}</span>}
|
||||
{!error && input.trim() && (
|
||||
<span className={s.hint}>Valid JSON ✓</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={s.field}>
|
||||
<label>Output</label>
|
||||
<pre className={s.output} style={{ minHeight: "16rem" }}>
|
||||
{output}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import CopyButton from "../../components/CopyButton";
|
||||
import s from "../tools.module.css";
|
||||
|
||||
function base64UrlDecode(segment: string): string {
|
||||
const padded = segment.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const binary = atob(padded);
|
||||
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
function prettyJson(raw: string): string {
|
||||
return JSON.stringify(JSON.parse(raw), null, 2);
|
||||
}
|
||||
|
||||
function formatDate(seconds: unknown): string | null {
|
||||
if (typeof seconds !== "number") return null;
|
||||
return new Date(seconds * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
export default function JwtDecode() {
|
||||
const [token, setToken] = useState("");
|
||||
|
||||
const result = useMemo(() => {
|
||||
const value = token.trim();
|
||||
if (!value) return null;
|
||||
const parts = value.split(".");
|
||||
if (parts.length < 2) {
|
||||
return { error: "A JWT must have at least two dot-separated parts." };
|
||||
}
|
||||
try {
|
||||
const header = prettyJson(base64UrlDecode(parts[0]));
|
||||
const payloadRaw = base64UrlDecode(parts[1]);
|
||||
const payload = prettyJson(payloadRaw);
|
||||
const claims = JSON.parse(payloadRaw) as Record<string, unknown>;
|
||||
return {
|
||||
header,
|
||||
payload,
|
||||
exp: formatDate(claims.exp),
|
||||
iat: formatDate(claims.iat),
|
||||
error: "",
|
||||
};
|
||||
} catch {
|
||||
return { error: "Could not decode — is this a valid JWT?" };
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={s.field}>
|
||||
<label>JWT</label>
|
||||
<textarea
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
placeholder="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.signature"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className={s.notice}>
|
||||
This decoder does <strong>not</strong> verify the signature. Never trust
|
||||
a token's contents without validating it server-side.
|
||||
</p>
|
||||
|
||||
{result?.error && <span className={s.error}>{result.error}</span>}
|
||||
|
||||
{result && !result.error && (
|
||||
<div className={s.split}>
|
||||
<div className={s.field}>
|
||||
<label>Header</label>
|
||||
<pre className={s.output}>{result.header}</pre>
|
||||
<div className={s.row}>
|
||||
<CopyButton value={result.header ?? ""} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={s.field}>
|
||||
<label>Payload</label>
|
||||
<pre className={s.output}>{result.payload}</pre>
|
||||
<div className={s.row}>
|
||||
<CopyButton value={result.payload ?? ""} />
|
||||
</div>
|
||||
{(result.iat || result.exp) && (
|
||||
<span className={s.hint}>
|
||||
{result.iat && <>Issued: {result.iat}. </>}
|
||||
{result.exp && <>Expires: {result.exp}.</>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
.preview {
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1rem;
|
||||
min-height: 20rem;
|
||||
overflow-x: auto;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.preview :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.preview h1,
|
||||
.preview h2,
|
||||
.preview h3 {
|
||||
margin: 1.2rem 0 0.6rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.preview h1 {
|
||||
font-size: 1.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding-bottom: 0.3rem;
|
||||
}
|
||||
.preview h2 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
.preview p {
|
||||
margin: 0.6rem 0;
|
||||
}
|
||||
.preview a {
|
||||
color: var(--accent);
|
||||
}
|
||||
.preview code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85em;
|
||||
background: var(--bg);
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.preview pre {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.preview pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
.preview blockquote {
|
||||
margin: 0.6rem 0;
|
||||
padding-left: 0.9rem;
|
||||
border-left: 3px solid var(--border-strong);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.preview table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 0.6rem 0;
|
||||
}
|
||||
.preview th,
|
||||
.preview td {
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.4rem 0.6rem;
|
||||
text-align: left;
|
||||
}
|
||||
.preview ul,
|
||||
.preview ol {
|
||||
padding-left: 1.4rem;
|
||||
}
|
||||
.preview img {
|
||||
max-width: 100%;
|
||||
}
|
||||
.preview hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 1.2rem 0;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import s from "../tools.module.css";
|
||||
import m from "./MarkdownPreview.module.css";
|
||||
|
||||
const SAMPLE = `# Hello, Markdown
|
||||
|
||||
Type on the left, see it rendered on the right.
|
||||
|
||||
- Supports **GitHub-Flavored Markdown**
|
||||
- Task lists, tables, ~~strikethrough~~
|
||||
|
||||
| Feature | Supported |
|
||||
| ------- | :-------: |
|
||||
| Tables | ✓ |
|
||||
| Code | ✓ |
|
||||
|
||||
\`\`\`ts
|
||||
const greet = (name: string) => \`Hi, \${name}\`;
|
||||
\`\`\`
|
||||
|
||||
> Everything runs locally in your browser.
|
||||
`;
|
||||
|
||||
export default function MarkdownPreview() {
|
||||
const [text, setText] = useState(SAMPLE);
|
||||
|
||||
return (
|
||||
<div className={s.split}>
|
||||
<div className={s.field}>
|
||||
<label>Markdown</label>
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
spellCheck={false}
|
||||
style={{ minHeight: "20rem" }}
|
||||
/>
|
||||
</div>
|
||||
<div className={s.field}>
|
||||
<label>Preview</label>
|
||||
<div className={m.preview}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{text}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import QRCode from "qrcode";
|
||||
import s from "../tools.module.css";
|
||||
|
||||
type Level = "L" | "M" | "Q" | "H";
|
||||
|
||||
export default function QrCode() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [text, setText] = useState("https://example.com");
|
||||
const [level, setLevel] = useState<Level>("M");
|
||||
const [error, setError] = useState("");
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
if (!text) {
|
||||
setReady(false);
|
||||
setError("");
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx?.clearRect(0, 0, canvas.width, canvas.height);
|
||||
return;
|
||||
}
|
||||
QRCode.toCanvas(
|
||||
canvas,
|
||||
text,
|
||||
{
|
||||
errorCorrectionLevel: level,
|
||||
width: 256,
|
||||
margin: 2,
|
||||
color: { dark: "#0f1117", light: "#ffffff" },
|
||||
},
|
||||
(err) => {
|
||||
if (err) {
|
||||
setError("Text is too long for a QR code.");
|
||||
setReady(false);
|
||||
} else {
|
||||
setError("");
|
||||
setReady(true);
|
||||
}
|
||||
},
|
||||
);
|
||||
}, [text, level]);
|
||||
|
||||
function download() {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || !ready) return;
|
||||
const url = canvas.toDataURL("image/png");
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = "qr-code.png";
|
||||
link.click();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={s.field}>
|
||||
<label>Text or URL</label>
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder="Anything you want to encode…"
|
||||
spellCheck={false}
|
||||
style={{ minHeight: "6rem" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={s.row}>
|
||||
<label>Error correction</label>
|
||||
<select
|
||||
value={level}
|
||||
onChange={(e) => setLevel(e.target.value as Level)}
|
||||
style={{ width: "auto" }}
|
||||
>
|
||||
<option value="L">Low (7%)</option>
|
||||
<option value="M">Medium (15%)</option>
|
||||
<option value="Q">Quartile (25%)</option>
|
||||
<option value="H">High (30%)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && <span className={s.error}>{error}</span>}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: "1rem",
|
||||
padding: "1rem 0",
|
||||
}}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
background: "#fff",
|
||||
borderRadius: 8,
|
||||
display: ready ? "block" : "none",
|
||||
}}
|
||||
/>
|
||||
<button className="primary" onClick={download} disabled={!ready}>
|
||||
Download PNG
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
.highlight {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.6rem 0.75rem;
|
||||
min-height: 8rem;
|
||||
}
|
||||
|
||||
.mark {
|
||||
background: var(--accent-soft);
|
||||
border-radius: 3px;
|
||||
outline: 1px solid var(--accent);
|
||||
}
|
||||
|
||||
.flagRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.flag {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.flag input {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.matches {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.match {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.6rem;
|
||||
}
|
||||
|
||||
.groups {
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { Fragment, useMemo, useState } from "react";
|
||||
import s from "../tools.module.css";
|
||||
import m from "./RegexTester.module.css";
|
||||
|
||||
const FLAGS = [
|
||||
{ flag: "g", label: "global" },
|
||||
{ flag: "i", label: "ignore case" },
|
||||
{ flag: "m", label: "multiline" },
|
||||
{ flag: "s", label: "dotall" },
|
||||
{ flag: "u", label: "unicode" },
|
||||
{ flag: "y", label: "sticky" },
|
||||
] as const;
|
||||
|
||||
interface MatchInfo {
|
||||
index: number;
|
||||
length: number;
|
||||
value: string;
|
||||
groups: string[];
|
||||
}
|
||||
|
||||
const SAMPLE_TEXT =
|
||||
"Contact us at hello@example.com or support@example.org.";
|
||||
|
||||
export default function RegexTester() {
|
||||
const [pattern, setPattern] = useState("\\w+@\\w+\\.\\w+");
|
||||
const [flags, setFlags] = useState<string[]>(["g"]);
|
||||
const [text, setText] = useState(SAMPLE_TEXT);
|
||||
|
||||
function toggleFlag(flag: string) {
|
||||
setFlags((cur) =>
|
||||
cur.includes(flag) ? cur.filter((f) => f !== flag) : [...cur, flag],
|
||||
);
|
||||
}
|
||||
|
||||
const { matches, error, regex } = useMemo(() => {
|
||||
if (!pattern) return { matches: [], error: "", regex: null };
|
||||
let re: RegExp;
|
||||
try {
|
||||
re = new RegExp(pattern, flags.join(""));
|
||||
} catch (e) {
|
||||
return {
|
||||
matches: [],
|
||||
error: e instanceof Error ? e.message : "Invalid regular expression.",
|
||||
regex: null,
|
||||
};
|
||||
}
|
||||
|
||||
const found: MatchInfo[] = [];
|
||||
if (re.global || re.sticky) {
|
||||
let match: RegExpExecArray | null;
|
||||
let guard = 0;
|
||||
while ((match = re.exec(text)) !== null) {
|
||||
found.push({
|
||||
index: match.index,
|
||||
length: match[0].length,
|
||||
value: match[0],
|
||||
groups: match.slice(1).map((g) => g ?? ""),
|
||||
});
|
||||
if (match[0] === "") re.lastIndex++; // avoid infinite loop on empty match
|
||||
if (++guard > 10000) break;
|
||||
}
|
||||
} else {
|
||||
const match = re.exec(text);
|
||||
if (match) {
|
||||
found.push({
|
||||
index: match.index,
|
||||
length: match[0].length,
|
||||
value: match[0],
|
||||
groups: match.slice(1).map((g) => g ?? ""),
|
||||
});
|
||||
}
|
||||
}
|
||||
return { matches: found, error: "", regex: re };
|
||||
}, [pattern, flags, text]);
|
||||
|
||||
// Build highlighted segments from match ranges.
|
||||
const segments = useMemo(() => {
|
||||
if (!regex || matches.length === 0) return null;
|
||||
const out: { text: string; hit: boolean }[] = [];
|
||||
let cursor = 0;
|
||||
for (const match of matches) {
|
||||
if (match.index > cursor) {
|
||||
out.push({ text: text.slice(cursor, match.index), hit: false });
|
||||
}
|
||||
out.push({ text: match.value, hit: true });
|
||||
cursor = match.index + match.length;
|
||||
}
|
||||
if (cursor < text.length) {
|
||||
out.push({ text: text.slice(cursor), hit: false });
|
||||
}
|
||||
return out;
|
||||
}, [matches, regex, text]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={s.field}>
|
||||
<label>Pattern</label>
|
||||
<div className={s.row}>
|
||||
<span style={{ fontFamily: "var(--font-mono)" }}>/</span>
|
||||
<input
|
||||
type="text"
|
||||
className={s.grow}
|
||||
value={pattern}
|
||||
onChange={(e) => setPattern(e.target.value)}
|
||||
spellCheck={false}
|
||||
placeholder="pattern"
|
||||
/>
|
||||
<span style={{ fontFamily: "var(--font-mono)" }}>
|
||||
/{flags.join("")}
|
||||
</span>
|
||||
</div>
|
||||
{error && <span className={s.error}>{error}</span>}
|
||||
</div>
|
||||
|
||||
<div className={s.field}>
|
||||
<label>Flags</label>
|
||||
<div className={m.flagRow}>
|
||||
{FLAGS.map(({ flag, label }) => (
|
||||
<label key={flag} className={m.flag}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={flags.includes(flag)}
|
||||
onChange={() => toggleFlag(flag)}
|
||||
/>
|
||||
{flag} <span className={s.hint}>({label})</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={s.field}>
|
||||
<label>Test string</label>
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={s.field}>
|
||||
<label>
|
||||
Highlighted matches{" "}
|
||||
<span className={s.hint}>
|
||||
({matches.length} match{matches.length === 1 ? "" : "es"})
|
||||
</span>
|
||||
</label>
|
||||
<div className={m.highlight}>
|
||||
{segments
|
||||
? segments.map((seg, i) =>
|
||||
seg.hit ? (
|
||||
<mark key={i} className={m.mark}>
|
||||
{seg.text}
|
||||
</mark>
|
||||
) : (
|
||||
<Fragment key={i}>{seg.text}</Fragment>
|
||||
),
|
||||
)
|
||||
: text}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{matches.some((mt) => mt.groups.length > 0) && (
|
||||
<div className={s.field}>
|
||||
<label>Capture groups</label>
|
||||
<div className={m.matches}>
|
||||
{matches.map((mt, i) => (
|
||||
<div key={i} className={m.match}>
|
||||
Match {i + 1}: <strong>{mt.value || "∅"}</strong> @ {mt.index}
|
||||
{mt.groups.length > 0 && (
|
||||
<div className={m.groups}>
|
||||
{mt.groups.map((g, gi) => (
|
||||
<div key={gi}>
|
||||
Group {gi + 1}: {g || "∅"}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { lazy } from "react";
|
||||
import type { ComponentType, LazyExoticComponent } from "react";
|
||||
|
||||
export type ToolCategory = "Encoders" | "Dev Utilities";
|
||||
|
||||
export interface Tool {
|
||||
/** URL path segment, e.g. "base64" -> /base64 */
|
||||
slug: string;
|
||||
/** Display title used on the card and tool page. */
|
||||
title: string;
|
||||
/** Short blurb shown on the home card. */
|
||||
description: string;
|
||||
category: ToolCategory;
|
||||
/** Single emoji/text glyph used as the card icon. */
|
||||
icon: string;
|
||||
/** Lazy-loaded tool component. */
|
||||
Component: LazyExoticComponent<ComponentType>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The single source of truth for every tool. To add a tool: create its
|
||||
* component under `src/tools/<name>/` and append one entry here. Routing
|
||||
* (main.tsx) and the home grid (Home.tsx) are both generated from this array.
|
||||
*/
|
||||
export const tools: Tool[] = [
|
||||
{
|
||||
slug: "base64",
|
||||
title: "Base64 Encode / Decode",
|
||||
description: "Convert text to and from Base64, with full UTF-8 support.",
|
||||
category: "Encoders",
|
||||
icon: "🔡",
|
||||
Component: lazy(() => import("./base64/Base64")),
|
||||
},
|
||||
{
|
||||
slug: "url-encode",
|
||||
title: "URL Encode / Decode",
|
||||
description: "Percent-encode or decode text and URL components.",
|
||||
category: "Encoders",
|
||||
icon: "🔗",
|
||||
Component: lazy(() => import("./url-encode/UrlEncode")),
|
||||
},
|
||||
{
|
||||
slug: "json-format",
|
||||
title: "JSON Formatter",
|
||||
description: "Pretty-print, minify, and validate JSON.",
|
||||
category: "Encoders",
|
||||
icon: "📦",
|
||||
Component: lazy(() => import("./json-format/JsonFormat")),
|
||||
},
|
||||
{
|
||||
slug: "jwt-decode",
|
||||
title: "JWT Decoder",
|
||||
description: "Decode a JWT's header and payload (no signature check).",
|
||||
category: "Encoders",
|
||||
icon: "🎫",
|
||||
Component: lazy(() => import("./jwt-decode/JwtDecode")),
|
||||
},
|
||||
{
|
||||
slug: "regex-tester",
|
||||
title: "Regex Tester",
|
||||
description: "Test a regular expression against sample text, live.",
|
||||
category: "Dev Utilities",
|
||||
icon: "✳️",
|
||||
Component: lazy(() => import("./regex-tester/RegexTester")),
|
||||
},
|
||||
{
|
||||
slug: "diff-viewer",
|
||||
title: "Diff Viewer",
|
||||
description: "Compare two blocks of text line by line.",
|
||||
category: "Dev Utilities",
|
||||
icon: "🔀",
|
||||
Component: lazy(() => import("./diff-viewer/DiffViewer")),
|
||||
},
|
||||
{
|
||||
slug: "markdown-preview",
|
||||
title: "Markdown Preview",
|
||||
description: "Write Markdown and see it rendered live (GFM).",
|
||||
category: "Dev Utilities",
|
||||
icon: "📝",
|
||||
Component: lazy(() => import("./markdown-preview/MarkdownPreview")),
|
||||
},
|
||||
{
|
||||
slug: "qr-code",
|
||||
title: "QR Code Generator",
|
||||
description: "Turn any text or URL into a downloadable QR code.",
|
||||
category: "Dev Utilities",
|
||||
icon: "🔳",
|
||||
Component: lazy(() => import("./qr-code/QrCode")),
|
||||
},
|
||||
];
|
||||
|
||||
export const toolsBySlug: Record<string, Tool> = Object.fromEntries(
|
||||
tools.map((t) => [t.slug, t]),
|
||||
);
|
||||
@@ -0,0 +1,68 @@
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Side-by-side input/output panes that stack on narrow screens. */
|
||||
.split {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.split {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.output {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.6rem 0.75rem;
|
||||
min-height: 8rem;
|
||||
margin: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--danger);
|
||||
font-size: 0.85rem;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.notice {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
background: var(--accent-soft);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 0.75rem;
|
||||
}
|
||||
|
||||
.inlineInput {
|
||||
width: auto;
|
||||
flex: 1;
|
||||
min-width: 8rem;
|
||||
}
|
||||
|
||||
.grow {
|
||||
flex: 1;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import CopyButton from "../../components/CopyButton";
|
||||
import s from "../tools.module.css";
|
||||
|
||||
type Mode = "encode" | "decode";
|
||||
type Scope = "component" | "full";
|
||||
|
||||
export default function UrlEncode() {
|
||||
const [mode, setMode] = useState<Mode>("encode");
|
||||
const [scope, setScope] = useState<Scope>("component");
|
||||
const [input, setInput] = useState("");
|
||||
|
||||
const { output, error } = useMemo(() => {
|
||||
if (!input) return { output: "", error: "" };
|
||||
try {
|
||||
let out: string;
|
||||
if (mode === "encode") {
|
||||
out =
|
||||
scope === "component"
|
||||
? encodeURIComponent(input)
|
||||
: encodeURI(input);
|
||||
} else {
|
||||
out =
|
||||
scope === "component"
|
||||
? decodeURIComponent(input)
|
||||
: decodeURI(input);
|
||||
}
|
||||
return { output: out, error: "" };
|
||||
} catch {
|
||||
return { output: "", error: "Malformed input — cannot decode." };
|
||||
}
|
||||
}, [input, mode, scope]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={s.row}>
|
||||
<button
|
||||
className={mode === "encode" ? "primary" : ""}
|
||||
onClick={() => setMode("encode")}
|
||||
>
|
||||
Encode
|
||||
</button>
|
||||
<button
|
||||
className={mode === "decode" ? "primary" : ""}
|
||||
onClick={() => setMode("decode")}
|
||||
>
|
||||
Decode
|
||||
</button>
|
||||
<span className={s.grow} />
|
||||
<select
|
||||
value={scope}
|
||||
onChange={(e) => setScope(e.target.value as Scope)}
|
||||
style={{ width: "auto" }}
|
||||
>
|
||||
<option value="component">Component (encodeURIComponent)</option>
|
||||
<option value="full">Full URL (encodeURI)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className={s.split}>
|
||||
<div className={s.field}>
|
||||
<label>Input</label>
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder={mode === "encode" ? "Text or URL…" : "Encoded text…"}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
<div className={s.field}>
|
||||
<label>Output</label>
|
||||
<pre className={s.output}>
|
||||
{error ? <span className={s.error}>{error}</span> : output}
|
||||
</pre>
|
||||
<div className={s.row}>
|
||||
<CopyButton value={output} disabled={!output} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/copybutton.tsx","./src/components/toollayout.tsx","./src/pages/home.tsx","./src/pages/notfound.tsx","./src/tools/registry.ts","./src/tools/base64/base64.tsx","./src/tools/diff-viewer/diffviewer.tsx","./src/tools/json-format/jsonformat.tsx","./src/tools/jwt-decode/jwtdecode.tsx","./src/tools/markdown-preview/markdownpreview.tsx","./src/tools/qr-code/qrcode.tsx","./src/tools/regex-tester/regextester.tsx","./src/tools/url-encode/urlencode.tsx"],"version":"5.9.3"}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"root":["./vite.config.ts"],"version":"5.9.3"}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: true,
|
||||
port: 5173,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user