feat: add card generator
Adds /generators/cards/ — draws 1–52 cards from a standard 52-card deck. Supports unique draws (Fisher-Yates) and with-replacement mode. Cards rendered as mini playing-card tiles with red suits coloured. Clipboard copy in rank+suit format (e.g. A♠, K♥). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
01ddfc8428
commit
d3434bc928
@@ -0,0 +1,187 @@
|
|||||||
|
---
|
||||||
|
// Interactive card generator — runs on the client via inline script
|
||||||
|
---
|
||||||
|
|
||||||
|
<div id="card-generator" class="mt-8">
|
||||||
|
<div class="flex flex-col sm:flex-row gap-4 items-end">
|
||||||
|
<div class="flex-1">
|
||||||
|
<label for="cg-count" class="block text-sm font-medium text-zinc-400 mb-1">Cards to draw</label>
|
||||||
|
<input
|
||||||
|
id="cg-count"
|
||||||
|
type="number"
|
||||||
|
value="1"
|
||||||
|
min="1"
|
||||||
|
max="52"
|
||||||
|
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="Number of cards to draw"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label class="flex items-center gap-2.5 cursor-pointer select-none group pb-2">
|
||||||
|
<input
|
||||||
|
id="cg-replace"
|
||||||
|
type="checkbox"
|
||||||
|
class="w-4 h-4 rounded accent-[#534AB7] cursor-pointer"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-zinc-400 group-hover:text-zinc-200 transition-colors">Allow duplicates</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p
|
||||||
|
id="cg-error"
|
||||||
|
role="alert"
|
||||||
|
aria-live="polite"
|
||||||
|
class="mt-3 text-sm text-red-500 hidden"
|
||||||
|
></p>
|
||||||
|
|
||||||
|
<div class="my-10 min-h-32 flex flex-col items-center justify-center gap-4">
|
||||||
|
<div
|
||||||
|
id="cg-cards"
|
||||||
|
class="flex flex-wrap justify-center gap-2"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-label="Drawn cards"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
id="cg-copy-btn"
|
||||||
|
type="button"
|
||||||
|
aria-label="Copy cards 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="cg-copy-label">Copy</span>
|
||||||
|
<span id="cg-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="cg-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="cg-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 countInput = document.getElementById('cg-count') as HTMLInputElement;
|
||||||
|
const replaceCb = document.getElementById('cg-replace') as HTMLInputElement;
|
||||||
|
const btn = document.getElementById('cg-btn') as HTMLButtonElement;
|
||||||
|
const copyBtn = document.getElementById('cg-copy-btn') as HTMLButtonElement;
|
||||||
|
const cardsEl = document.getElementById('cg-cards') as HTMLDivElement;
|
||||||
|
const errorEl = document.getElementById('cg-error') as HTMLParagraphElement;
|
||||||
|
const copyLabel = document.getElementById('cg-copy-label') as HTMLSpanElement;
|
||||||
|
const copyIcon = document.getElementById('cg-copy-icon') as HTMLSpanElement;
|
||||||
|
const checkIcon = document.getElementById('cg-check-icon') as HTMLSpanElement;
|
||||||
|
|
||||||
|
const RANKS = ['A','2','3','4','5','6','7','8','9','10','J','Q','K'];
|
||||||
|
const SUITS = [
|
||||||
|
{ symbol: '♠', label: 'Spades', red: false },
|
||||||
|
{ symbol: '♥', label: 'Hearts', red: true },
|
||||||
|
{ symbol: '♦', label: 'Diamonds', red: true },
|
||||||
|
{ symbol: '♣', label: 'Clubs', red: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DECK = SUITS.flatMap((suit) =>
|
||||||
|
RANKS.map((rank) => ({ rank, suit }))
|
||||||
|
);
|
||||||
|
|
||||||
|
let lastCards: { rank: string; suit: typeof SUITS[number] }[] = [];
|
||||||
|
let copyRevertTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function showError(msg: string) {
|
||||||
|
errorEl.textContent = msg;
|
||||||
|
errorEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearError() {
|
||||||
|
errorEl.textContent = '';
|
||||||
|
errorEl.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function draw() {
|
||||||
|
const count = parseInt(countInput.value, 10);
|
||||||
|
const withReplacement = replaceCb.checked;
|
||||||
|
|
||||||
|
if (!Number.isInteger(count) || count < 1) { showError('Draw at least 1 card.'); return; }
|
||||||
|
if (!withReplacement && count > 52) { showError('Cannot draw more than 52 unique cards.'); return; }
|
||||||
|
if (count > 52) { showError('Maximum 52 cards at once.'); return; }
|
||||||
|
|
||||||
|
clearError();
|
||||||
|
|
||||||
|
if (withReplacement) {
|
||||||
|
lastCards = Array.from({ length: count }, () => DECK[Math.floor(Math.random() * DECK.length)]);
|
||||||
|
} else {
|
||||||
|
// Fisher-Yates partial shuffle
|
||||||
|
const deck = [...DECK];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const j = i + Math.floor(Math.random() * (deck.length - i));
|
||||||
|
[deck[i], deck[j]] = [deck[j], deck[i]];
|
||||||
|
}
|
||||||
|
lastCards = deck.slice(0, count);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderCards();
|
||||||
|
copyBtn.classList.remove('invisible');
|
||||||
|
copyLabel.textContent = 'Copy';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCards() {
|
||||||
|
cardsEl.innerHTML = '';
|
||||||
|
lastCards.forEach(({ rank, suit }) => {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.setAttribute('aria-label', `${rank} of ${suit.label}`);
|
||||||
|
card.className = [
|
||||||
|
'flex flex-col items-center justify-between w-14 h-20 rounded-lg border px-1.5 py-1.5',
|
||||||
|
'bg-zinc-900 border-zinc-700 select-none',
|
||||||
|
suit.red ? 'text-red-400' : 'text-zinc-100',
|
||||||
|
].join(' ');
|
||||||
|
|
||||||
|
const top = document.createElement('div');
|
||||||
|
top.className = 'w-full text-left text-sm font-bold leading-none tabular-nums';
|
||||||
|
top.textContent = rank;
|
||||||
|
|
||||||
|
const mid = document.createElement('div');
|
||||||
|
mid.className = 'text-2xl leading-none';
|
||||||
|
mid.textContent = suit.symbol;
|
||||||
|
|
||||||
|
const bot = document.createElement('div');
|
||||||
|
bot.className = 'w-full text-right text-sm font-bold leading-none tabular-nums rotate-180';
|
||||||
|
bot.textContent = rank;
|
||||||
|
|
||||||
|
card.append(top, mid, bot);
|
||||||
|
cardsEl.appendChild(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyCards() {
|
||||||
|
if (!lastCards.length) return;
|
||||||
|
const text = lastCards.map(({ rank, suit }) => `${rank}${suit.symbol}`).join(', ');
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
|
||||||
|
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', copyCards);
|
||||||
|
countInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') draw(); });
|
||||||
|
</script>
|
||||||
@@ -47,6 +47,6 @@ export const generators: Generator[] = [
|
|||||||
title: 'Card',
|
title: 'Card',
|
||||||
description: 'Draw a random playing card from a standard deck.',
|
description: 'Draw a random playing card from a standard deck.',
|
||||||
icon: 'square-stack',
|
icon: 'square-stack',
|
||||||
status: 'coming-soon',
|
status: 'live',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
---
|
||||||
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
|
import CardGenerator from '../../components/generators/CardGenerator.astro';
|
||||||
|
import AdBanner from '../../components/AdBanner.astro';
|
||||||
|
import { generators } from '../../data/generators';
|
||||||
|
|
||||||
|
const generator = generators.find((g) => g.slug === 'cards')!;
|
||||||
|
---
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<CardGenerator />
|
||||||
|
</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