feat: add lottery generator
Adds /generators/lottery/ with configurable range and pick count. Partial Fisher-Yates shuffle guarantees unique draws, results sorted ascending and displayed as numbered balls with clipboard copy. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
9772f04f23
commit
09f593a062
@@ -0,0 +1,179 @@
|
||||
---
|
||||
// Interactive lottery generator — runs on the client via inline script
|
||||
---
|
||||
|
||||
<div id="lottery-generator" class="mt-8">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label for="lg-from" class="block text-sm font-medium text-zinc-400 mb-1">From</label>
|
||||
<input
|
||||
id="lg-from"
|
||||
type="number"
|
||||
value="1"
|
||||
class="w-full bg-zinc-900 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 text-base focus:outline-none focus:border-[#534AB7] focus:ring-1 focus:ring-[#534AB7] transition-colors"
|
||||
aria-label="Range start"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="lg-to" class="block text-sm font-medium text-zinc-400 mb-1">To</label>
|
||||
<input
|
||||
id="lg-to"
|
||||
type="number"
|
||||
value="49"
|
||||
class="w-full bg-zinc-900 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 text-base focus:outline-none focus:border-[#534AB7] focus:ring-1 focus:ring-[#534AB7] transition-colors"
|
||||
aria-label="Range end"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="lg-pick" class="block text-sm font-medium text-zinc-400 mb-1">Pick</label>
|
||||
<input
|
||||
id="lg-pick"
|
||||
type="number"
|
||||
value="6"
|
||||
min="1"
|
||||
class="w-full bg-zinc-900 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 text-base focus:outline-none focus:border-[#534AB7] focus:ring-1 focus:ring-[#534AB7] transition-colors"
|
||||
aria-label="How many numbers to pick"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
id="lg-error"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
class="mt-3 text-sm text-red-500 hidden"
|
||||
></p>
|
||||
|
||||
<div class="my-10 min-h-24 flex flex-col items-center justify-center gap-4">
|
||||
<div
|
||||
id="lg-result"
|
||||
class="flex flex-wrap justify-center gap-2"
|
||||
aria-live="polite"
|
||||
aria-label="Lottery result"
|
||||
></div>
|
||||
|
||||
<button
|
||||
id="lg-copy-btn"
|
||||
type="button"
|
||||
aria-label="Copy numbers to clipboard"
|
||||
class="invisible inline-flex items-center gap-1.5 text-xs font-medium text-zinc-500 hover:text-zinc-200 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[#534AB7] rounded"
|
||||
>
|
||||
<span id="lg-copy-label">Copy</span>
|
||||
<span id="lg-copy-icon" aria-hidden="true">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" 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="lg-check-icon" class="hidden text-[#534AB7]" aria-hidden="true">
|
||||
<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">
|
||||
<path d="M20 6 9 17l-5-5"/>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center">
|
||||
<button
|
||||
id="lg-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"
|
||||
>
|
||||
Draw
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const fromInput = document.getElementById('lg-from') as HTMLInputElement;
|
||||
const toInput = document.getElementById('lg-to') as HTMLInputElement;
|
||||
const pickInput = document.getElementById('lg-pick') as HTMLInputElement;
|
||||
const btn = document.getElementById('lg-btn') as HTMLButtonElement;
|
||||
const copyBtn = document.getElementById('lg-copy-btn') as HTMLButtonElement;
|
||||
const resultEl = document.getElementById('lg-result') as HTMLDivElement;
|
||||
const errorEl = document.getElementById('lg-error') as HTMLParagraphElement;
|
||||
const copyLabel = document.getElementById('lg-copy-label') as HTMLSpanElement;
|
||||
const copyIcon = document.getElementById('lg-copy-icon') as HTMLSpanElement;
|
||||
const checkIcon = document.getElementById('lg-check-icon') as HTMLSpanElement;
|
||||
|
||||
let copyRevertTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function isInteger(val: string) {
|
||||
return /^-?\d+$/.test(val.trim());
|
||||
}
|
||||
|
||||
function showError(msg: string) {
|
||||
errorEl.textContent = msg;
|
||||
errorEl.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function clearError() {
|
||||
errorEl.textContent = '';
|
||||
errorEl.classList.add('hidden');
|
||||
}
|
||||
|
||||
function draw() {
|
||||
if (!isInteger(fromInput.value) || !isInteger(toInput.value) || !isInteger(pickInput.value)) {
|
||||
showError('All values must be whole numbers.');
|
||||
return;
|
||||
}
|
||||
|
||||
const min = parseInt(fromInput.value, 10);
|
||||
const max = parseInt(toInput.value, 10);
|
||||
const pick = parseInt(pickInput.value, 10);
|
||||
const pool = max - min + 1;
|
||||
|
||||
if (min >= max) { showError('"From" must be less than "To".'); return; }
|
||||
if (pick < 1) { showError('"Pick" must be at least 1.'); return; }
|
||||
if (pick > pool) { showError(`Cannot pick ${pick} unique numbers from a range of ${pool}.`); return; }
|
||||
|
||||
clearError();
|
||||
|
||||
// Fisher-Yates partial shuffle — O(pick)
|
||||
const nums = Array.from({ length: pool }, (_, i) => min + i);
|
||||
for (let i = 0; i < pick; i++) {
|
||||
const j = i + Math.floor(Math.random() * (pool - i));
|
||||
[nums[i], nums[j]] = [nums[j], nums[i]];
|
||||
}
|
||||
const picked = nums.slice(0, pick).sort((a, b) => a - b);
|
||||
|
||||
resultEl.innerHTML = '';
|
||||
picked.forEach((n, idx) => {
|
||||
const ball = document.createElement('span');
|
||||
ball.textContent = String(n);
|
||||
ball.className =
|
||||
'inline-flex items-center justify-center min-w-[2.5rem] h-10 px-2 rounded-full bg-[#534AB7]/15 border border-[#534AB7]/40 text-zinc-100 font-bold tabular-nums text-sm';
|
||||
ball.style.animationDelay = `${idx * 40}ms`;
|
||||
resultEl.appendChild(ball);
|
||||
});
|
||||
|
||||
copyBtn.classList.remove('invisible');
|
||||
copyLabel.textContent = 'Copy';
|
||||
}
|
||||
|
||||
async function copyNumbers() {
|
||||
const balls = resultEl.querySelectorAll('span');
|
||||
const value = Array.from(balls).map((b) => b.textContent).join(', ');
|
||||
if (!value) return;
|
||||
|
||||
await navigator.clipboard.writeText(value);
|
||||
|
||||
copyIcon.classList.add('hidden');
|
||||
checkIcon.classList.remove('hidden');
|
||||
copyLabel.textContent = 'Copied';
|
||||
|
||||
if (copyRevertTimer) clearTimeout(copyRevertTimer);
|
||||
copyRevertTimer = setTimeout(() => {
|
||||
checkIcon.classList.add('hidden');
|
||||
copyIcon.classList.remove('hidden');
|
||||
copyLabel.textContent = 'Copy';
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
btn.addEventListener('click', draw);
|
||||
copyBtn.addEventListener('click', copyNumbers);
|
||||
|
||||
[fromInput, toInput, pickInput].forEach((input) => {
|
||||
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') draw(); });
|
||||
});
|
||||
</script>
|
||||
@@ -33,7 +33,7 @@ export const generators: Generator[] = [
|
||||
title: 'Lottery',
|
||||
description: 'Draw a set of unique numbers for lottery-style picks.',
|
||||
icon: 'ticket',
|
||||
status: 'coming-soon',
|
||||
status: 'live',
|
||||
},
|
||||
{
|
||||
slug: 'dice',
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import LotteryGenerator from '../../components/generators/LotteryGenerator.astro';
|
||||
import AdBanner from '../../components/AdBanner.astro';
|
||||
import { generators } from '../../data/generators';
|
||||
|
||||
const generator = generators.find((g) => g.slug === 'lottery')!;
|
||||
---
|
||||
|
||||
<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>
|
||||
<LotteryGenerator />
|
||||
</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