@@ -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