init
Build and Publish Docker Image / build (push) Successful in 33s

This commit is contained in:
2026-07-28 02:09:46 -04:00
commit ca0074dcad
40 changed files with 5575 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
node_modules
dist
dist-ssr
.git
.gitignore
*.log
npm-debug.log*
.DS_Store
.vscode
.idea
Dockerfile
.dockerignore
README.md
+27
View File
@@ -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
View File
@@ -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
View File
@@ -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;"]
+53
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+3628
View File
File diff suppressed because it is too large Load Diff
+33
View File
@@ -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
}
}
+6
View File
@@ -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

+69
View File
@@ -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
View File
@@ -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>
);
}
+34
View File
@@ -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>
);
}
+40
View File
@@ -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;
}
+35
View File
@@ -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
View File
@@ -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;
}
+41
View File
@@ -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>,
);
+74
View File
@@ -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;
}
+74
View File
@@ -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>
);
}
+13
View File
@@ -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 doesnt exist.
</p>
<Link to="/"> Back to all tools</Link>
</div>
);
}
+82
View File
@@ -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;
}
+107
View File
@@ -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>
</>
);
}
+84
View File
@@ -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>
</>
);
}
+93
View File
@@ -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>
);
}
+107
View File
@@ -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;
}
+185
View File
@@ -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>
)}
</>
);
}
+94
View File
@@ -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]),
);
+68
View File
@@ -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;
}
+82
View File
@@ -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>
</>
);
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+25
View File
@@ -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"]
}
+1
View File
@@ -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"}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+21
View File
@@ -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"]
}
+1
View File
@@ -0,0 +1 @@
{"root":["./vite.config.ts"],"version":"5.9.3"}
+11
View File
@@ -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,
},
});