feat: add password generator
Adds /generators/password/ with length slider (4–64), uppercase/lowercase/digits/symbols toggles, guaranteed character-type coverage, Fisher-Yates shuffle, and clipboard copy. Sets password status to live in the generator catalog. Also adds CLAUDE.md with project architecture notes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
6a8630a00d
commit
3b7b1382b4
@@ -0,0 +1,37 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## What this is
|
||||
|
||||
Randify is a static site (Astro 4 + Tailwind CSS 4) that hosts random-value generators. Only the number generator is live; the rest are catalogued in `src/data/generators.ts` with `status: 'coming-soon'`.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm run dev # Dev server (localhost:4321)
|
||||
npm run build # Static build → dist/
|
||||
npm run preview # Serve the built dist/ locally
|
||||
```
|
||||
|
||||
**Docker (production):** multi-stage build compiles with Node 20 then serves via `nginx:alpine`. The nginx config handles pretty URLs via `try_files $uri $uri/index.html`.
|
||||
|
||||
```bash
|
||||
docker build -t randify .
|
||||
docker run -p 8080:80 randify
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
**Adding a new generator** involves three steps:
|
||||
|
||||
1. Add an entry to `src/data/generators.ts` (set `status: 'live'` when ready).
|
||||
2. Create `src/components/generators/<Name>Generator.astro` — interactive logic goes in an inline `<script>` tag (no framework, plain TypeScript compiled by Vite).
|
||||
3. Create `src/pages/generators/<slug>.astro` — follows the same layout pattern as `numbers.astro`.
|
||||
|
||||
**Key conventions:**
|
||||
- Accent color is `#534AB7` (also exposed as `--accent` CSS custom property in `BaseLayout.astro`).
|
||||
- All SVG icons are inlined strings inside `GeneratorCard.astro`; add new ones to the `icons` map there.
|
||||
- `AdBanner.astro` is a placeholder component with three size variants (`leaderboard`, `rectangle`, `tile`) — not wired to any ad network yet.
|
||||
- No client-side router — each generator is a separate static HTML page.
|
||||
- No test runner or linter is configured.
|
||||
@@ -0,0 +1,201 @@
|
||||
---
|
||||
// Interactive password generator — runs on the client via inline script
|
||||
---
|
||||
|
||||
<div id="password-generator" class="mt-8">
|
||||
<div class="flex flex-col gap-5">
|
||||
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<label for="pg-length" class="text-sm font-medium text-zinc-400">Length</label>
|
||||
<span id="pg-length-val" class="text-sm font-semibold tabular-nums text-zinc-100">16</span>
|
||||
</div>
|
||||
<input
|
||||
id="pg-length"
|
||||
type="range"
|
||||
min="4"
|
||||
max="64"
|
||||
value="16"
|
||||
class="w-full accent-[#534AB7] cursor-pointer"
|
||||
aria-label="Password length"
|
||||
/>
|
||||
<div class="flex justify-between text-xs text-zinc-600 mt-1 select-none">
|
||||
<span>4</span><span>64</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
{[
|
||||
{ id: 'pg-upper', label: 'Uppercase (A–Z)' },
|
||||
{ id: 'pg-lower', label: 'Lowercase (a–z)' },
|
||||
{ id: 'pg-digits', label: 'Digits (0–9)' },
|
||||
{ id: 'pg-symbols', label: 'Symbols (!@#…)' },
|
||||
].map(({ id, label }) => (
|
||||
<label class="flex items-center gap-2.5 cursor-pointer select-none group">
|
||||
<input
|
||||
id={id}
|
||||
type="checkbox"
|
||||
checked
|
||||
class="w-4 h-4 rounded accent-[#534AB7] cursor-pointer"
|
||||
/>
|
||||
<span class="text-sm text-zinc-400 group-hover:text-zinc-200 transition-colors">{label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
id="pg-error"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
class="mt-4 text-sm text-red-500 hidden"
|
||||
></p>
|
||||
|
||||
<div class="my-10 min-h-24 flex flex-col items-center justify-center gap-3">
|
||||
<button
|
||||
id="pg-copy-btn"
|
||||
type="button"
|
||||
aria-label="Copy password to clipboard"
|
||||
class="group relative invisible cursor-copy rounded-xl px-3 py-2 -mx-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#534AB7]"
|
||||
>
|
||||
<span
|
||||
id="pg-result"
|
||||
class="font-mono text-2xl sm:text-3xl font-bold tracking-wide text-zinc-100 break-all select-none group-hover:text-zinc-300"
|
||||
style="transition: opacity 0.08s ease, color 0.15s ease;"
|
||||
></span>
|
||||
<span
|
||||
id="pg-copy-icon"
|
||||
class="absolute -right-7 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 text-zinc-500"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/>
|
||||
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span
|
||||
id="pg-check-icon"
|
||||
class="absolute -right-7 top-1/2 -translate-y-1/2 hidden text-[#534AB7]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M20 6 9 17l-5-5"/>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<span
|
||||
id="pg-copied-label"
|
||||
class="text-xs font-medium text-[#534AB7] opacity-0 transition-opacity duration-200"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>Copied</span>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center">
|
||||
<button
|
||||
id="pg-btn"
|
||||
type="button"
|
||||
class="w-full sm:w-auto px-8 py-3 bg-[#534AB7] text-white font-semibold rounded-lg hover:bg-[#4740a0] active:bg-[#3d3990] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#534AB7] focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors duration-100 cursor-pointer"
|
||||
>
|
||||
Generate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const lengthInput = document.getElementById('pg-length') as HTMLInputElement;
|
||||
const lengthVal = document.getElementById('pg-length-val') as HTMLSpanElement;
|
||||
const upperCb = document.getElementById('pg-upper') as HTMLInputElement;
|
||||
const lowerCb = document.getElementById('pg-lower') as HTMLInputElement;
|
||||
const digitsCb = document.getElementById('pg-digits') as HTMLInputElement;
|
||||
const symbolsCb = document.getElementById('pg-symbols') as HTMLInputElement;
|
||||
const btn = document.getElementById('pg-btn') as HTMLButtonElement;
|
||||
const copyBtn = document.getElementById('pg-copy-btn') as HTMLButtonElement;
|
||||
const resultEl = document.getElementById('pg-result') as HTMLSpanElement;
|
||||
const errorEl = document.getElementById('pg-error') as HTMLParagraphElement;
|
||||
const copyIcon = document.getElementById('pg-copy-icon') as HTMLSpanElement;
|
||||
const checkIcon = document.getElementById('pg-check-icon') as HTMLSpanElement;
|
||||
const copiedLabel = document.getElementById('pg-copied-label') as HTMLSpanElement;
|
||||
|
||||
const UPPER = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
const LOWER = 'abcdefghijklmnopqrstuvwxyz';
|
||||
const DIGITS = '0123456789';
|
||||
const SYMBOLS = '!@#$%^&*()-_=+[]{}|;:,.<>?';
|
||||
|
||||
let copyRevertTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
lengthInput.addEventListener('input', () => {
|
||||
lengthVal.textContent = lengthInput.value;
|
||||
});
|
||||
|
||||
function showError(msg: string) {
|
||||
errorEl.textContent = msg;
|
||||
errorEl.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function clearError() {
|
||||
errorEl.textContent = '';
|
||||
errorEl.classList.add('hidden');
|
||||
}
|
||||
|
||||
function generate() {
|
||||
const length = parseInt(lengthInput.value, 10);
|
||||
|
||||
const pools: string[] = [];
|
||||
if (upperCb.checked) pools.push(UPPER);
|
||||
if (lowerCb.checked) pools.push(LOWER);
|
||||
if (digitsCb.checked) pools.push(DIGITS);
|
||||
if (symbolsCb.checked) pools.push(SYMBOLS);
|
||||
|
||||
if (pools.length === 0) {
|
||||
showError('Select at least one character type.');
|
||||
return;
|
||||
}
|
||||
|
||||
clearError();
|
||||
|
||||
// Guarantee at least one char from each selected pool, then fill randomly
|
||||
const required = pools.map((p) => p[Math.floor(Math.random() * p.length)]);
|
||||
const combined = pools.join('');
|
||||
const rest = Array.from(
|
||||
{ length: length - required.length },
|
||||
() => combined[Math.floor(Math.random() * combined.length)],
|
||||
);
|
||||
|
||||
// Fisher-Yates shuffle
|
||||
const chars = [...required, ...rest];
|
||||
for (let i = chars.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[chars[i], chars[j]] = [chars[j], chars[i]];
|
||||
}
|
||||
|
||||
resultEl.textContent = chars.join('');
|
||||
resultEl.style.opacity = '0.5';
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => { resultEl.style.opacity = '1'; });
|
||||
});
|
||||
copyBtn.classList.remove('invisible');
|
||||
}
|
||||
|
||||
async function copyPassword() {
|
||||
const value = resultEl.textContent?.trim();
|
||||
if (!value) return;
|
||||
|
||||
await navigator.clipboard.writeText(value);
|
||||
|
||||
copyIcon.classList.add('hidden');
|
||||
checkIcon.classList.remove('hidden');
|
||||
copiedLabel.style.opacity = '1';
|
||||
|
||||
if (copyRevertTimer) clearTimeout(copyRevertTimer);
|
||||
copyRevertTimer = setTimeout(() => {
|
||||
checkIcon.classList.add('hidden');
|
||||
copyIcon.classList.remove('hidden');
|
||||
copiedLabel.style.opacity = '0';
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
btn.addEventListener('click', generate);
|
||||
copyBtn.addEventListener('click', copyPassword);
|
||||
</script>
|
||||
@@ -26,7 +26,7 @@ export const generators: Generator[] = [
|
||||
title: 'Password',
|
||||
description: 'Create a strong, random password with custom rules.',
|
||||
icon: 'lock',
|
||||
status: 'coming-soon',
|
||||
status: 'live',
|
||||
},
|
||||
{
|
||||
slug: 'lottery',
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import PasswordGenerator from '../../components/generators/PasswordGenerator.astro';
|
||||
import AdBanner from '../../components/AdBanner.astro';
|
||||
import { generators } from '../../data/generators';
|
||||
|
||||
const generator = generators.find((g) => g.slug === 'password')!;
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={`${generator.title} Generator — Randify`}
|
||||
description={generator.description}
|
||||
>
|
||||
<div class="max-w-2xl mx-auto px-4 py-12 sm:py-20">
|
||||
<nav aria-label="Breadcrumb" class="mb-10">
|
||||
<a
|
||||
href="/"
|
||||
class="inline-flex items-center gap-1.5 text-sm text-zinc-500 hover:text-zinc-200 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[#534AB7] rounded"
|
||||
aria-label="Back to all generators"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="m15 18-6-6 6-6"/>
|
||||
</svg>
|
||||
All generators
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<header class="mb-2">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<span
|
||||
class="inline-block w-2.5 h-2.5 rounded-full bg-[#534AB7]"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span class="text-sm font-medium text-zinc-400 uppercase tracking-widest">randify</span>
|
||||
</div>
|
||||
<h1 class="text-3xl sm:text-4xl font-bold text-zinc-100">
|
||||
{generator.title} Generator
|
||||
</h1>
|
||||
<p class="mt-2 text-base text-zinc-400">{generator.description}</p>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<PasswordGenerator />
|
||||
</main>
|
||||
|
||||
<div class="mt-12 grid grid-cols-3 gap-3">
|
||||
<AdBanner size="tile" />
|
||||
<AdBanner size="tile" />
|
||||
<AdBanner size="tile" />
|
||||
</div>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
Reference in New Issue
Block a user