Add Russian language version of the site

- New i18n system: src/i18n/translations.ts with en/ru translation objects
- LanguageSwitcher component (fixed top-right EN/RU toggle)
- BaseLayout updated with lang prop, hreflang links, auto-redirect for Russian browsers
- All 10 generator components updated to use T.* translations
- GeneratorCard updated to show ruTitle/ruDescription and link to /ru/ routes
- SeoBlock updated with translated section headers
- New /ru/ home page and /ru/generators/[slug] dynamic route (all 10 generators)
- Russian SEO content (howTo/whenTo) for all generator pages
- English pages explicitly pass lang="en"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
emil
2026-05-09 16:49:57 +03:00
co-authored by Claude Sonnet 4.6
parent 76d9233faa
commit 65e42e7d44
29 changed files with 893 additions and 518 deletions
+13 -6
View File
@@ -1,14 +1,21 @@
---
import type { Generator } from '../data/generators';
import { useT } from '../i18n/translations';
import type { Lang } from '../i18n/translations';
interface Props {
generator: Generator;
lang?: Lang;
}
const { generator } = Astro.props;
const { slug, title, description, icon, status } = generator;
const { generator, lang = 'en' } = Astro.props;
const T = useT(lang);
const { slug, icon, status } = generator;
const isLive = status === 'live';
const href = `/generators/${slug}/`;
const isRu = lang === 'ru';
const title = isRu ? generator.ruTitle : generator.title;
const description = isRu ? generator.ruDescription : generator.description;
const href = isRu ? `/ru/generators/${slug}/` : `/generators/${slug}/`;
const icons: Record<string, string> = {
hash: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" x2="20" y1="9" y2="9"/><line x1="4" x2="20" y1="15" y2="15"/><line x1="10" x2="8" y1="3" y2="21"/><line x1="16" x2="14" y1="3" y2="21"/></svg>`,
@@ -32,7 +39,7 @@ const icons: Record<string, string> = {
<div class="flex items-start justify-between gap-3">
<div class="text-[#534AB7]" aria-hidden="true" set:html={icons[icon] ?? icons['hash']} />
<span class="text-xs font-medium text-[#534AB7] bg-[#534AB7]/10 px-2 py-0.5 rounded-full">
Live
{T.live}
</span>
</div>
<h2 class="mt-3 text-base font-semibold text-zinc-100 group-hover:text-[#534AB7] transition-colors duration-150">
@@ -43,12 +50,12 @@ const icons: Record<string, string> = {
) : (
<div
class="block border border-zinc-800/50 rounded-xl p-5 opacity-40 cursor-default"
aria-label={`${title} — coming soon`}
aria-label={`${title} — ${T.comingSoon}`}
>
<div class="flex items-start justify-between gap-3">
<div class="text-zinc-500" aria-hidden="true" set:html={icons[icon] ?? icons['hash']} />
<span class="text-xs font-medium text-zinc-500 bg-zinc-800 px-2 py-0.5 rounded-full">
Coming soon
{T.comingSoon}
</span>
</div>
<h2 class="mt-3 text-base font-semibold text-zinc-400">{title}</h2>
+41
View File
@@ -0,0 +1,41 @@
---
interface Props {
lang: 'en' | 'ru';
alternatePath: string;
}
const { lang, alternatePath } = Astro.props;
const targetLang = lang === 'en' ? 'ru' : 'en';
---
<div class="fixed top-3 right-3 z-50 flex items-center gap-1 bg-zinc-900/80 border border-zinc-800 rounded-lg px-1 py-1 backdrop-blur-sm text-xs font-semibold">
<a
href={lang === 'ru' ? alternatePath : '#'}
id="lang-en"
class={`px-2 py-1 rounded-md transition-colors ${lang === 'en' ? 'bg-[#534AB7] text-white' : 'text-zinc-400 hover:text-zinc-200'}`}
aria-current={lang === 'en' ? 'true' : undefined}
data-target-lang="en"
data-alternate={alternatePath}
>EN</a>
<a
href={lang === 'en' ? alternatePath : '#'}
id="lang-ru"
class={`px-2 py-1 rounded-md transition-colors ${lang === 'ru' ? 'bg-[#534AB7] text-white' : 'text-zinc-400 hover:text-zinc-200'}`}
aria-current={lang === 'ru' ? 'true' : undefined}
data-target-lang="ru"
data-alternate={alternatePath}
>RU</a>
</div>
<script>
document.querySelectorAll<HTMLAnchorElement>('#lang-en, #lang-ru').forEach((el) => {
el.addEventListener('click', (e) => {
const targetLang = el.dataset.targetLang!;
const alternate = el.dataset.alternate!;
if (!alternate || alternate === '#') return;
e.preventDefault();
localStorage.setItem('lang-pref', targetLang);
location.href = alternate;
});
});
</script>
+8 -3
View File
@@ -1,21 +1,26 @@
---
import { useT } from '../i18n/translations';
import type { Lang } from '../i18n/translations';
interface Props {
howTo: string[];
whenTo: string[];
lang?: Lang;
}
const { howTo, whenTo } = Astro.props;
const { howTo, whenTo, lang = 'en' } = Astro.props;
const T = useT(lang);
---
<section class="mt-12 border-t border-zinc-800/60 pt-8 text-sm text-zinc-500 space-y-4">
<div>
<h2 class="text-xs font-semibold uppercase tracking-widest text-zinc-600 mb-2">How to use</h2>
<h2 class="text-xs font-semibold uppercase tracking-widest text-zinc-600 mb-2">{T.howToUse}</h2>
<ol class="space-y-1 list-decimal list-inside">
{howTo.map((step) => <li>{step}</li>)}
</ol>
</div>
<div>
<h2 class="text-xs font-semibold uppercase tracking-widest text-zinc-600 mb-2">When to use</h2>
<h2 class="text-xs font-semibold uppercase tracking-widest text-zinc-600 mb-2">{T.whenToUse}</h2>
<ul class="space-y-1 list-disc list-inside">
{whenTo.map((item) => <li>{item}</li>)}
</ul>
+40 -39
View File
@@ -1,33 +1,35 @@
---
// Interactive card generator — runs on the client via inline script
import { useT } from '../../i18n/translations';
const isRu = Astro.url.pathname.startsWith('/ru');
const T = useT(isRu ? 'ru' : 'en');
---
<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>
<label for="crd-count" class="block text-sm font-medium text-zinc-400 mb-1">{T.cardsToDraw}</label>
<input
id="cg-count"
id="crd-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"
aria-label={T.cardsToDraw}
/>
</div>
<label class="flex items-center gap-2.5 cursor-pointer select-none group pb-2">
<input
id="cg-replace"
id="crd-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>
<span class="text-sm text-zinc-400 group-hover:text-zinc-200 transition-colors">{T.allowDuplicates}</span>
</label>
</div>
<p
id="cg-error"
id="crd-error"
role="alert"
aria-live="polite"
class="mt-3 text-sm text-red-500 hidden"
@@ -35,26 +37,26 @@
<div class="my-10 min-h-32 flex flex-col items-center justify-center gap-4">
<div
id="cg-cards"
id="crd-cards"
class="flex flex-wrap justify-center gap-2"
aria-live="polite"
aria-label="Drawn cards"
></div>
<button
id="cg-copy-btn"
id="crd-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">
<span id="crd-copy-label">{T.copy}</span>
<span id="crd-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">
<span id="crd-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>
@@ -64,25 +66,32 @@
<div class="flex justify-center">
<button
id="cg-btn"
id="crd-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
{T.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 isRu = document.documentElement.lang === 'ru';
const COPY_LABEL = isRu ? 'Копировать' : 'Copy';
const COPIED_LABEL = isRu ? 'Скопировано' : 'Copied';
const ERR_AT_LEAST = isRu ? 'Вытащите хотя бы 1 карту.' : 'Draw at least 1 card.';
const ERR_UNIQUE = isRu ? 'Нельзя вытащить более 52 уникальных карт.' : 'Cannot draw more than 52 unique cards.';
const ERR_MAX52 = isRu ? 'Максимум 52 карты одновременно.' : 'Maximum 52 cards at once.';
const countInput = document.getElementById('crd-count') as HTMLInputElement;
const replaceCb = document.getElementById('crd-replace') as HTMLInputElement;
const btn = document.getElementById('crd-btn') as HTMLButtonElement;
const copyBtn = document.getElementById('crd-copy-btn') as HTMLButtonElement;
const cardsEl = document.getElementById('crd-cards') as HTMLDivElement;
const errorEl = document.getElementById('crd-error') as HTMLParagraphElement;
const copyLabel = document.getElementById('crd-copy-label') as HTMLSpanElement;
const copyIcon = document.getElementById('crd-copy-icon') as HTMLSpanElement;
const checkIcon = document.getElementById('crd-check-icon') as HTMLSpanElement;
const RANKS = ['A','2','3','4','5','6','7','8','9','10','J','Q','K'];
const SUITS = [
@@ -99,30 +108,22 @@
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 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; }
if (!Number.isInteger(count) || count < 1) { showError(ERR_AT_LEAST); return; }
if (!withReplacement && count > 52) { showError(ERR_UNIQUE); return; }
if (count > 52) { showError(ERR_MAX52); 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));
@@ -133,7 +134,7 @@
renderCards();
copyBtn.classList.remove('invisible');
copyLabel.textContent = 'Copy';
copyLabel.textContent = COPY_LABEL;
}
function renderCards() {
@@ -171,13 +172,13 @@
copyIcon.classList.add('hidden');
checkIcon.classList.remove('hidden');
copyLabel.textContent = 'Copied';
copyLabel.textContent = COPIED_LABEL;
if (copyRevertTimer) clearTimeout(copyRevertTimer);
copyRevertTimer = setTimeout(() => {
checkIcon.classList.add('hidden');
copyIcon.classList.remove('hidden');
copyLabel.textContent = 'Copy';
copyLabel.textContent = COPY_LABEL;
}, 1500);
}
+41 -29
View File
@@ -1,21 +1,23 @@
---
// Interactive coin flip generator — runs on the client via inline script
import { useT } from '../../i18n/translations';
const isRu = Astro.url.pathname.startsWith('/ru');
const T = useT(isRu ? 'ru' : 'en');
---
<div id="coin-generator" class="mt-8">
<div>
<div class="flex items-center justify-between mb-1">
<label for="cg-count" class="text-sm font-medium text-zinc-400">Number of coins</label>
<span id="cg-count-val" class="text-sm font-semibold tabular-nums text-zinc-100">1</span>
<label for="coin-count" class="text-sm font-medium text-zinc-400">{T.numberOfCoins}</label>
<span id="coin-count-val" class="text-sm font-semibold tabular-nums text-zinc-100">1</span>
</div>
<input
id="cg-count"
id="coin-count"
type="range"
min="1"
max="20"
value="1"
class="w-full accent-[#534AB7] cursor-pointer"
aria-label="Number of coins"
aria-label={T.numberOfCoins}
/>
<div class="flex justify-between text-xs text-zinc-600 mt-1 select-none">
<span>1</span><span>20</span>
@@ -24,28 +26,28 @@
<div class="my-10 min-h-28 flex flex-col items-center justify-center gap-4">
<div
id="cg-coins"
id="coin-coins"
class="flex flex-wrap justify-center gap-2"
aria-live="polite"
aria-label="Coin results"
></div>
<div id="cg-summary" class="hidden text-sm text-zinc-500 tabular-nums"></div>
<div id="coin-summary" class="hidden text-sm text-zinc-500 tabular-nums"></div>
<button
id="cg-copy-btn"
id="coin-copy-btn"
type="button"
aria-label="Copy results 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">
<span id="coin-copy-label">{T.copy}</span>
<span id="coin-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">
<span id="coin-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>
@@ -55,25 +57,35 @@
<div class="flex justify-center">
<button
id="cg-btn"
id="coin-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"
>
Flip
{T.flip}
</button>
</div>
</div>
<script>
const countInput = document.getElementById('cg-count') as HTMLInputElement;
const countVal = document.getElementById('cg-count-val') as HTMLSpanElement;
const btn = document.getElementById('cg-btn') as HTMLButtonElement;
const copyBtn = document.getElementById('cg-copy-btn') as HTMLButtonElement;
const coinsEl = document.getElementById('cg-coins') as HTMLDivElement;
const summaryEl = document.getElementById('cg-summary') as HTMLDivElement;
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 isRu = document.documentElement.lang === 'ru';
const COPY_LABEL = isRu ? 'Копировать' : 'Copy';
const COPIED_LABEL = isRu ? 'Скопировано' : 'Copied';
const HEADS_LABEL = isRu ? 'О' : 'H';
const TAILS_LABEL = isRu ? 'Р' : 'T';
const HEADS_WORD = isRu ? 'орёл' : 'Heads';
const TAILS_WORD = isRu ? 'решка' : 'Tails';
const HEADS_SUMMARY = isRu ? 'орёл' : 'heads';
const TAILS_SUMMARY = isRu ? 'решка' : 'tails';
const countInput = document.getElementById('coin-count') as HTMLInputElement;
const countVal = document.getElementById('coin-count-val') as HTMLSpanElement;
const btn = document.getElementById('coin-btn') as HTMLButtonElement;
const copyBtn = document.getElementById('coin-copy-btn') as HTMLButtonElement;
const coinsEl = document.getElementById('coin-coins') as HTMLDivElement;
const summaryEl = document.getElementById('coin-summary') as HTMLDivElement;
const copyLabel = document.getElementById('coin-copy-label') as HTMLSpanElement;
const copyIcon = document.getElementById('coin-copy-icon') as HTMLSpanElement;
const checkIcon = document.getElementById('coin-check-icon') as HTMLSpanElement;
let lastResults: boolean[] = [];
let copyRevertTimer: ReturnType<typeof setTimeout> | null = null;
@@ -92,42 +104,42 @@
coinsEl.innerHTML = '';
lastResults.forEach((isHeads) => {
const coin = document.createElement('div');
coin.setAttribute('aria-label', isHeads ? 'Heads' : 'Tails');
coin.setAttribute('aria-label', isHeads ? HEADS_WORD : TAILS_WORD);
coin.className = [
'flex flex-col items-center justify-center w-14 h-14 rounded-full border-2 font-bold text-xs select-none',
isHeads
? 'border-[#534AB7] bg-[#534AB7]/10 text-[#534AB7]'
: 'border-zinc-600 bg-zinc-800 text-zinc-400',
].join(' ');
coin.textContent = isHeads ? 'H' : 'T';
coin.textContent = isHeads ? HEADS_LABEL : TAILS_LABEL;
coinsEl.appendChild(coin);
});
if (count > 1) {
summaryEl.textContent = `${heads} heads · ${tails} tails`;
summaryEl.textContent = `${heads} ${HEADS_SUMMARY} · ${tails} ${TAILS_SUMMARY}`;
summaryEl.classList.remove('hidden');
} else {
summaryEl.classList.add('hidden');
}
copyBtn.classList.remove('invisible');
copyLabel.textContent = 'Copy';
copyLabel.textContent = COPY_LABEL;
}
async function copyResults() {
if (!lastResults.length) return;
const text = lastResults.map((h) => (h ? 'Heads' : 'Tails')).join(', ');
const text = lastResults.map((h) => (h ? HEADS_WORD : TAILS_WORD)).join(', ');
await navigator.clipboard.writeText(text);
copyIcon.classList.add('hidden');
checkIcon.classList.remove('hidden');
copyLabel.textContent = 'Copied';
copyLabel.textContent = COPIED_LABEL;
if (copyRevertTimer) clearTimeout(copyRevertTimer);
copyRevertTimer = setTimeout(() => {
checkIcon.classList.add('hidden');
copyIcon.classList.remove('hidden');
copyLabel.textContent = 'Copy';
copyLabel.textContent = COPY_LABEL;
}, 1500);
}
@@ -1,4 +1,7 @@
---
import { useT } from '../../i18n/translations';
const isRu = Astro.url.pathname.startsWith('/ru');
const T = useT(isRu ? 'ru' : 'en');
---
<div id="color-generator" class="mt-8">
@@ -58,7 +61,7 @@
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
{T.generate}
</button>
</div>
</div>
+23 -22
View File
@@ -1,11 +1,13 @@
---
// Interactive dice generator — runs on the client via inline script
import { useT } from '../../i18n/translations';
const isRu = Astro.url.pathname.startsWith('/ru');
const T = useT(isRu ? 'ru' : 'en');
---
<div id="dice-generator" class="mt-8">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label for="dg-count" class="block text-sm font-medium text-zinc-400 mb-1">Number of dice</label>
<label for="dg-count" class="block text-sm font-medium text-zinc-400 mb-1">{T.numberOfDice}</label>
<input
id="dg-count"
type="number"
@@ -13,12 +15,12 @@
min="1"
max="20"
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 dice to roll"
aria-label={T.numberOfDice}
/>
</div>
<div>
<label class="block text-sm font-medium text-zinc-400 mb-1">Sides</label>
<div class="flex flex-wrap gap-2" role="radiogroup" aria-label="Dice sides">
<label class="block text-sm font-medium text-zinc-400 mb-1">{T.sides}</label>
<div class="flex flex-wrap gap-2" role="radiogroup" aria-label={T.sides}>
{['4','6','8','10','12','20','100'].map((s) => (
<label class="cursor-pointer">
<input type="radio" name="dg-sides" value={s} class="sr-only peer" checked={s === '6'} />
@@ -47,7 +49,7 @@
></div>
<div id="dg-total-wrap" class="hidden flex items-center gap-2">
<span class="text-sm text-zinc-500">Total</span>
<span id="dg-total-label" class="text-sm text-zinc-500">{T.total}</span>
<span id="dg-total" class="text-2xl font-bold tabular-nums text-zinc-100"></span>
</div>
@@ -57,7 +59,7 @@
aria-label="Copy results 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="dg-copy-label">Copy</span>
<span id="dg-copy-label">{T.copy}</span>
<span id="dg-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"/>
@@ -78,12 +80,18 @@
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"
>
Roll
{T.roll}
</button>
</div>
</div>
<script>
const isRu = document.documentElement.lang === 'ru';
const COPY_LABEL = isRu ? 'Копировать' : 'Copy';
const COPIED_LABEL = isRu ? 'Скопировано' : 'Copied';
const ERR_AT_LEAST = isRu ? 'Количество кубиков должно быть не менее 1.' : 'Number of dice must be at least 1.';
const ERR_MAX = isRu ? 'Максимум 20 кубиков одновременно.' : 'Maximum 20 dice at once.';
const countInput = document.getElementById('dg-count') as HTMLInputElement;
const btn = document.getElementById('dg-btn') as HTMLButtonElement;
const copyBtn = document.getElementById('dg-copy-btn') as HTMLButtonElement;
@@ -103,22 +111,15 @@
return parseInt(radio?.value ?? '6', 10);
}
function showError(msg: string) {
errorEl.textContent = msg;
errorEl.classList.remove('hidden');
}
function clearError() {
errorEl.textContent = '';
errorEl.classList.add('hidden');
}
function showError(msg: string) { errorEl.textContent = msg; errorEl.classList.remove('hidden'); }
function clearError() { errorEl.textContent = ''; errorEl.classList.add('hidden'); }
function roll() {
const count = parseInt(countInput.value, 10);
const sides = getSelectedSides();
if (!Number.isInteger(count) || count < 1) { showError('Number of dice must be at least 1.'); return; }
if (count > 20) { showError('Maximum 20 dice at once.'); return; }
if (!Number.isInteger(count) || count < 1) { showError(ERR_AT_LEAST); return; }
if (count > 20) { showError(ERR_MAX); return; }
clearError();
@@ -143,7 +144,7 @@
totalEl.textContent = String(total);
totalWrap.classList.remove('hidden');
copyBtn.classList.remove('invisible');
copyLabel.textContent = 'Copy';
copyLabel.textContent = COPY_LABEL;
}
async function copyResults() {
@@ -154,13 +155,13 @@
copyIcon.classList.add('hidden');
checkIcon.classList.remove('hidden');
copyLabel.textContent = 'Copied';
copyLabel.textContent = COPIED_LABEL;
if (copyRevertTimer) clearTimeout(copyRevertTimer);
copyRevertTimer = setTimeout(() => {
checkIcon.classList.add('hidden');
copyIcon.classList.remove('hidden');
copyLabel.textContent = 'Copy';
copyLabel.textContent = COPY_LABEL;
}, 1500);
}
+48 -43
View File
@@ -1,47 +1,49 @@
---
// Interactive list picker — runs on the client via inline script
import { useT } from '../../i18n/translations';
const isRu = Astro.url.pathname.startsWith('/ru');
const T = useT(isRu ? 'ru' : 'en');
---
<div id="list-generator" class="mt-8">
<div class="flex flex-col gap-4">
<div>
<label for="lg-items" class="block text-sm font-medium text-zinc-400 mb-1">
Items <span class="text-zinc-600">(one per line)</span>
<label for="lst-items" class="block text-sm font-medium text-zinc-400 mb-1">
{T.items} <span class="text-zinc-600">({T.itemsOneLine})</span>
</label>
<textarea
id="lg-items"
id="lst-items"
rows="6"
placeholder="Alice&#10;Bob&#10;Carol&#10;David"
class="w-full bg-zinc-900 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 text-sm font-mono focus:outline-none focus:border-[#534AB7] focus:ring-1 focus:ring-[#534AB7] transition-colors resize-y"
aria-label="List of items to pick from"
aria-label={T.items}
></textarea>
</div>
<div class="flex flex-col sm:flex-row gap-4 items-end">
<div class="flex-1">
<label for="lg-pick" class="block text-sm font-medium text-zinc-400 mb-1">Pick</label>
<label for="lst-pick" class="block text-sm font-medium text-zinc-400 mb-1">{T.pick}</label>
<input
id="lg-pick"
id="lst-pick"
type="number"
value="1"
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 items to pick"
aria-label={T.pick}
/>
</div>
<label class="flex items-center gap-2.5 cursor-pointer select-none group pb-2">
<input
id="lg-replace"
id="lst-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>
<span class="text-sm text-zinc-400 group-hover:text-zinc-200 transition-colors">{T.allowDuplicates}</span>
</label>
</div>
</div>
<p
id="lg-error"
id="lst-error"
role="alert"
aria-live="polite"
class="mt-3 text-sm text-red-500 hidden"
@@ -49,26 +51,26 @@
<div class="my-8 min-h-16 flex flex-col items-center justify-center gap-3">
<div
id="lg-result"
id="lst-result"
class="flex flex-wrap justify-center gap-2"
aria-live="polite"
aria-label="Picked items"
></div>
<button
id="lg-copy-btn"
id="lst-copy-btn"
type="button"
aria-label="Copy picked items 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">
<span id="lst-copy-label">{T.copy}</span>
<span id="lst-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">
<span id="lst-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>
@@ -78,49 +80,52 @@
<div class="flex justify-center">
<button
id="lg-btn"
id="lst-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"
>
Pick
{T.pick}
</button>
</div>
</div>
<script>
const itemsInput = document.getElementById('lg-items') as HTMLTextAreaElement;
const pickInput = document.getElementById('lg-pick') as HTMLInputElement;
const replaceCb = document.getElementById('lg-replace') 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;
const isRu = document.documentElement.lang === 'ru';
const COPY_LABEL = isRu ? 'Копировать' : 'Copy';
const COPIED_LABEL = isRu ? 'Скопировано' : 'Copied';
const ERR_ADD_ONE = isRu ? 'Добавьте хотя бы один элемент в список.' : 'Add at least one item to the list.';
const ERR_PICK_ONE = isRu ? 'Выберите хотя бы 1 элемент.' : 'Pick at least 1 item.';
const itemsInput = document.getElementById('lst-items') as HTMLTextAreaElement;
const pickInput = document.getElementById('lst-pick') as HTMLInputElement;
const replaceCb = document.getElementById('lst-replace') as HTMLInputElement;
const btn = document.getElementById('lst-btn') as HTMLButtonElement;
const copyBtn = document.getElementById('lst-copy-btn') as HTMLButtonElement;
const resultEl = document.getElementById('lst-result') as HTMLDivElement;
const errorEl = document.getElementById('lst-error') as HTMLParagraphElement;
const copyLabel = document.getElementById('lst-copy-label') as HTMLSpanElement;
const copyIcon = document.getElementById('lst-copy-icon') as HTMLSpanElement;
const checkIcon = document.getElementById('lst-check-icon') as HTMLSpanElement;
let lastPicked: string[] = [];
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 showError(msg: string) { errorEl.textContent = msg; errorEl.classList.remove('hidden'); }
function clearError() { errorEl.textContent = ''; errorEl.classList.add('hidden'); }
function pick() {
const items = itemsInput.value.split('\n').map((s) => s.trim()).filter(Boolean);
const count = parseInt(pickInput.value, 10);
const withReplacement = replaceCb.checked;
if (items.length === 0) { showError('Add at least one item to the list.'); return; }
if (!Number.isInteger(count) || count < 1) { showError('Pick at least 1 item.'); return; }
if (items.length === 0) { showError(ERR_ADD_ONE); return; }
if (!Number.isInteger(count) || count < 1) { showError(ERR_PICK_ONE); return; }
if (!withReplacement && count > items.length) {
showError(`Cannot pick ${count} unique items from a list of ${items.length}.`);
showError(
isRu
? `Нельзя выбрать ${count} уникальных элементов из списка ${items.length}.`
: `Cannot pick ${count} unique items from a list of ${items.length}.`
);
return;
}
@@ -147,7 +152,7 @@
});
copyBtn.classList.remove('invisible');
copyLabel.textContent = 'Copy';
copyLabel.textContent = COPY_LABEL;
}
async function copyPicked() {
@@ -156,13 +161,13 @@
copyIcon.classList.add('hidden');
checkIcon.classList.remove('hidden');
copyLabel.textContent = 'Copied';
copyLabel.textContent = COPIED_LABEL;
if (copyRevertTimer) clearTimeout(copyRevertTimer);
copyRevertTimer = setTimeout(() => {
checkIcon.classList.add('hidden');
copyIcon.classList.remove('hidden');
copyLabel.textContent = 'Copy';
copyLabel.textContent = COPY_LABEL;
}, 1500);
}
+48 -111
View File
@@ -1,178 +1,115 @@
---
// Interactive lottery generator — runs on the client via inline script
import { useT } from '../../i18n/translations';
const isRu = Astro.url.pathname.startsWith('/ru');
const T = useT(isRu ? 'ru' : 'en');
---
<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"
/>
<label for="lg-from" class="block text-sm font-medium text-zinc-400 mb-1">{T.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" />
</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"
/>
<label for="lg-to" class="block text-sm font-medium text-zinc-400 mb-1">{T.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" />
</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"
/>
<label for="lg-pick" class="block text-sm font-medium text-zinc-400 mb-1">{T.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" />
</div>
</div>
<p
id="lg-error"
role="alert"
aria-live="polite"
class="mt-3 text-sm text-red-500 hidden"
></p>
<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>
<div id="lg-result" class="flex flex-wrap justify-center gap-2" aria-live="polite"></div>
<button id="lg-copy-btn" type="button" 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">{T.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 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">
{T.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;
const isRu = document.documentElement.lang === 'ru';
const ERR_INTEGERS = isRu ? 'Все значения должны быть целыми числами.' : 'All values must be whole numbers.';
const ERR_FROM_TO = isRu ? '«От» должно быть меньше «До».' : '"From" must be less than "To".';
const ERR_PICK_MIN = isRu ? '«Выбрать» должно быть не менее 1.' : '"Pick" must be at least 1.';
const COPY_LABEL = isRu ? 'Копировать' : 'Copy';
const COPIED_LABEL = isRu ? 'Скопировано' : 'Copied';
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 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;
}
if (!isInteger(fromInput.value) || !isInteger(toInput.value) || !isInteger(pickInput.value)) { showError(ERR_INTEGERS); 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; }
if (min >= max) { showError(ERR_FROM_TO); return; }
if (pick < 1) { showError(ERR_PICK_MIN); return; }
if (pick > pool) {
showError(isRu ? `Нельзя выбрать ${pick} уникальных чисел из диапазона ${pool}.` : `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) => {
picked.forEach((n) => {
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`;
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';
resultEl.appendChild(ball);
});
copyBtn.classList.remove('invisible');
copyLabel.textContent = 'Copy';
copyLabel.textContent = COPY_LABEL;
}
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';
copyLabel.textContent = COPIED_LABEL;
if (copyRevertTimer) clearTimeout(copyRevertTimer);
copyRevertTimer = setTimeout(() => {
checkIcon.classList.add('hidden');
copyIcon.classList.remove('hidden');
copyLabel.textContent = 'Copy';
copyLabel.textContent = COPY_LABEL;
}, 1500);
}
btn.addEventListener('click', draw);
copyBtn.addEventListener('click', copyNumbers);
[fromInput, toInput, pickInput].forEach((input) => {
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') draw(); });
});
+38 -89
View File
@@ -1,43 +1,37 @@
---
// Interactive number generator — runs on the client via inline script
import { useT } from '../../i18n/translations';
const isRu = Astro.url.pathname.startsWith('/ru');
const T = useT(isRu ? 'ru' : 'en');
---
<div id="number-generator" class="mt-8">
<div id="number-generator" class="mt-8" data-ru={isRu ? '1' : '0'}>
<div class="flex flex-col sm:flex-row gap-4">
<div class="flex-1">
<label for="ng-from" class="block text-sm font-medium text-zinc-400 mb-1">From</label>
<label for="ng-from" class="block text-sm font-medium text-zinc-400 mb-1">{T.from}</label>
<input
id="ng-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="Minimum value"
/>
</div>
<div class="flex-1">
<label for="ng-to" class="block text-sm font-medium text-zinc-400 mb-1">To</label>
<label for="ng-to" class="block text-sm font-medium text-zinc-400 mb-1">{T.to}</label>
<input
id="ng-to"
type="number"
value="100"
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="Maximum value"
/>
</div>
</div>
<p
id="ng-error"
role="alert"
aria-live="polite"
class="mt-2 text-sm text-red-600 hidden"
></p>
<p id="ng-error" role="alert" aria-live="polite" class="mt-2 text-sm text-red-600 hidden"></p>
<div class="my-10 min-h-36 flex flex-col items-center justify-center gap-3">
<button
id="ng-copy-btn"
type="button"
aria-label="Copy number to clipboard"
class="group relative invisible cursor-copy rounded-xl px-3 py-1 -mx-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#534AB7]"
aria-live="polite"
>
@@ -46,33 +40,14 @@
class="text-8xl font-bold tabular-nums tracking-tight text-zinc-100 select-none group-hover:text-zinc-300"
style="transition: transform 0.12s cubic-bezier(0.34,1.56,0.64,1), opacity 0.08s ease, color 0.15s ease;"
></span>
<span
id="ng-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 id="ng-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="ng-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 id="ng-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="ng-copied-label"
class="text-xs font-medium text-[#534AB7] opacity-0 transition-opacity duration-200"
aria-live="polite"
aria-atomic="true"
>Copied</span>
<span id="ng-copied-label" class="text-xs font-medium text-[#534AB7] opacity-0 transition-opacity duration-200" aria-live="polite" aria-atomic="true">{T.copied}</span>
</div>
<div class="flex justify-center">
@@ -81,66 +56,46 @@
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
{T.generate}
</button>
</div>
</div>
<script>
const fromInput = document.getElementById('ng-from') as HTMLInputElement;
const toInput = document.getElementById('ng-to') as HTMLInputElement;
const btn = document.getElementById('ng-btn') as HTMLButtonElement;
const copyBtn = document.getElementById('ng-copy-btn') as HTMLButtonElement;
const resultEl = document.getElementById('ng-result') as HTMLSpanElement;
const errorEl = document.getElementById('ng-error') as HTMLParagraphElement;
const copyIcon = document.getElementById('ng-copy-icon') as HTMLSpanElement;
const checkIcon = document.getElementById('ng-check-icon') as HTMLSpanElement;
const copiedLabel = document.getElementById('ng-copied-label') as HTMLSpanElement;
const isRu = document.documentElement.lang === 'ru';
const ERR_INTEGERS = isRu ? 'Оба значения должны быть целыми числами.' : 'Both values must be whole numbers.';
const ERR_FROM_TO = isRu ? '«От» должно быть меньше «До».' : '"From" must be less than "To".';
const fromInput = document.getElementById('ng-from') as HTMLInputElement;
const toInput = document.getElementById('ng-to') as HTMLInputElement;
const btn = document.getElementById('ng-btn') as HTMLButtonElement;
const copyBtn = document.getElementById('ng-copy-btn') as HTMLButtonElement;
const resultEl = document.getElementById('ng-result') as HTMLSpanElement;
const errorEl = document.getElementById('ng-error') as HTMLParagraphElement;
const copyIcon = document.getElementById('ng-copy-icon') as HTMLSpanElement;
const checkIcon = document.getElementById('ng-check-icon') as HTMLSpanElement;
const copiedLabel = document.getElementById('ng-copied-label') as HTMLSpanElement;
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 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 isInteger(val: string) { return /^-?\d+$/.test(val.trim()); }
function pop() {
resultEl.style.transform = 'scale(1.18)';
resultEl.style.opacity = '0.7';
requestAnimationFrame(() => {
requestAnimationFrame(() => {
resultEl.style.transform = 'scale(1)';
resultEl.style.opacity = '1';
});
});
requestAnimationFrame(() => requestAnimationFrame(() => {
resultEl.style.transform = 'scale(1)';
resultEl.style.opacity = '1';
}));
}
function generate() {
const rawFrom = fromInput.value;
const rawTo = toInput.value;
if (!isInteger(rawFrom) || !isInteger(rawTo)) {
showError('Both values must be whole numbers.');
return;
}
const min = parseInt(rawFrom, 10);
const max = parseInt(rawTo, 10);
if (min >= max) {
showError('"From" must be less than "To".');
return;
}
if (!isInteger(fromInput.value) || !isInteger(toInput.value)) { showError(ERR_INTEGERS); return; }
const min = parseInt(fromInput.value, 10);
const max = parseInt(toInput.value, 10);
if (min >= max) { showError(ERR_FROM_TO); return; }
clearError();
const result = Math.floor(Math.random() * (max - min + 1)) + min;
resultEl.textContent = String(result);
@@ -151,13 +106,10 @@
async function copyNumber() {
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');
@@ -168,10 +120,7 @@
btn.addEventListener('click', generate);
copyBtn.addEventListener('click', copyNumber);
[fromInput, toInput].forEach((input) => {
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') generate();
});
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') generate(); });
});
</script>
+46 -124
View File
@@ -1,193 +1,115 @@
---
// Interactive password generator — runs on the client via inline script
import { useT } from '../../i18n/translations';
const isRu = Astro.url.pathname.startsWith('/ru');
const T = useT(isRu ? 'ru' : 'en');
---
<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>
<label for="pg-length" class="text-sm font-medium text-zinc-400">{T.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>
<input id="pg-length" type="range" min="4" max="64" value="16" class="w-full accent-[#534AB7] cursor-pointer" />
<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 (!@#…)' },
{ id: 'pg-upper', label: T.uppercase },
{ id: 'pg-lower', label: T.lowercase },
{ id: 'pg-digits', label: T.digits },
{ id: 'pg-symbols', label: T.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"
/>
<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>
<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>
<button id="pg-copy-btn" type="button" 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 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>
<span id="pg-copied-label" class="text-xs font-medium text-[#534AB7] opacity-0 transition-opacity duration-200" aria-live="polite" aria-atomic="true">{T.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 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">
{T.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 isRu = document.documentElement.lang === 'ru';
const ERR_SELECT_TYPE = isRu ? 'Выберите хотя бы один тип символов.' : 'Select at least one character type.';
const UPPER = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
const LOWER = 'abcdefghijklmnopqrstuvwxyz';
const DIGITS = '0123456789';
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;
});
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 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;
}
if (pools.length === 0) { showError(ERR_SELECT_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 rest = Array.from({ length: length - required.length }, () => combined[Math.floor(Math.random() * combined.length)]);
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'; });
});
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');
+18 -12
View File
@@ -1,12 +1,14 @@
---
// Interactive UUID / token generator — runs on the client via inline script
import { useT } from '../../i18n/translations';
const isRu = Astro.url.pathname.startsWith('/ru');
const T = useT(isRu ? 'ru' : 'en');
---
<div id="uuid-generator" class="mt-8">
<div class="flex flex-col gap-4">
<div>
<label class="block text-sm font-medium text-zinc-400 mb-2">Type</label>
<div class="flex flex-wrap gap-2" role="radiogroup" aria-label="Token type">
<label class="block text-sm font-medium text-zinc-400 mb-2">{T.type}</label>
<div class="flex flex-wrap gap-2" role="radiogroup" aria-label={T.type}>
{[
{ value: 'uuid', label: 'UUID v4' },
{ value: 'hex', label: 'Hex' },
@@ -24,7 +26,7 @@
<div class="flex flex-col sm:flex-row gap-4">
<div id="ug-length-wrap" class="flex-1 hidden">
<label for="ug-length" class="block text-sm font-medium text-zinc-400 mb-1">Length (bytes)</label>
<label for="ug-length" class="block text-sm font-medium text-zinc-400 mb-1">{T.lengthBytes}</label>
<input
id="ug-length"
type="number"
@@ -32,11 +34,11 @@
min="4"
max="64"
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="Token length in bytes"
aria-label={T.lengthBytes}
/>
</div>
<div class="flex-1">
<label for="ug-count" class="block text-sm font-medium text-zinc-400 mb-1">Count</label>
<label for="ug-count" class="block text-sm font-medium text-zinc-400 mb-1">{T.count}</label>
<input
id="ug-count"
type="number"
@@ -44,7 +46,7 @@
min="1"
max="20"
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 tokens to generate"
aria-label={T.count}
/>
</div>
</div>
@@ -59,7 +61,7 @@
aria-label="Copy all tokens to clipboard"
class="invisible self-start 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 mt-1"
>
<span id="ug-copy-label">Copy all</span>
<span id="ug-copy-label">{T.copyAll}</span>
<span id="ug-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"/>
@@ -80,12 +82,16 @@
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
{T.generate}
</button>
</div>
</div>
<script>
const isRu = document.documentElement.lang === 'ru';
const COPY_ALL_LABEL = isRu ? 'Копировать всё' : 'Copy all';
const COPIED_LABEL = isRu ? 'Скопировано' : 'Copied';
const lengthWrap = document.getElementById('ug-length-wrap') as HTMLDivElement;
const lengthInput = document.getElementById('ug-length') as HTMLInputElement;
const countInput = document.getElementById('ug-count') as HTMLInputElement;
@@ -158,7 +164,7 @@
});
copyBtn.classList.remove('invisible');
copyLabel.textContent = 'Copy all';
copyLabel.textContent = COPY_ALL_LABEL;
}
async function copyAll() {
@@ -167,13 +173,13 @@
copyIcon.classList.add('hidden');
checkIcon.classList.remove('hidden');
copyLabel.textContent = 'Copied';
copyLabel.textContent = COPIED_LABEL;
if (copyRevertTimer) clearTimeout(copyRevertTimer);
copyRevertTimer = setTimeout(() => {
checkIcon.classList.add('hidden');
copyIcon.classList.remove('hidden');
copyLabel.textContent = 'Copy all';
copyLabel.textContent = COPY_ALL_LABEL;
}, 1500);
}
+21 -23
View File
@@ -1,5 +1,7 @@
---
// Wheel of Fortune spinner — canvas-based, vanilla JS, no libraries
import { useT } from '../../i18n/translations';
const isRu = Astro.url.pathname.startsWith('/ru');
const T = useT(isRu ? 'ru' : 'en');
---
<div id="wheel-spinner" class="mt-8">
@@ -8,7 +10,7 @@
<div class="flex flex-col gap-3">
<div>
<label for="ws-items" class="block text-sm font-medium text-zinc-400 mb-1">
Items <span class="text-zinc-600">(one per line, 2–24)</span>
{T.items} <span class="text-zinc-600">({T.itemsOneLine224})</span>
</label>
<textarea
id="ws-items"
@@ -46,20 +48,20 @@ Frank</textarea>
type="button"
class="w-full sm:w-auto px-10 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 disabled:opacity-50 disabled:cursor-not-allowed"
>
Spin
{T.spin}
</button>
</div>
<!-- Result card (hidden until first spin) -->
<div id="ws-result-card" class="hidden mt-10 flex flex-col items-center gap-3" aria-live="polite">
<p class="text-xs font-semibold uppercase tracking-widest text-zinc-500">Winner</p>
<p id="ws-winner-label" class="text-xs font-semibold uppercase tracking-widest text-zinc-500">{T.winner}</p>
<span id="ws-result-text" class="text-3xl sm:text-4xl font-bold text-zinc-100 text-center break-words max-w-full"></span>
<button
id="ws-copy-btn"
type="button"
class="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="ws-copy-label">Copy</span>
<span id="ws-copy-label">{T.copy}</span>
<span id="ws-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"/>
@@ -85,6 +87,12 @@ Frank</textarea>
</style>
<script>
const isRu = document.documentElement.lang === 'ru';
const COPY_LABEL = isRu ? 'Копировать' : 'Copy';
const COPIED_LABEL = isRu ? 'Скопировано' : 'Copied';
const ERR_MIN2 = isRu ? 'Добавьте хотя бы 2 элемента.' : 'Add at least 2 items.';
const ERR_MAX24 = isRu ? 'Максимум 24 элемента.' : 'Maximum 24 items.';
const COLORS = ['#534AB7','#0F6E56','#993C1D','#185FA5','#854F0B','#993556','#3B6D11','#A32D2D'];
const TAU = 2 * Math.PI;
@@ -138,7 +146,6 @@ Frank</textarea>
const a0 = -Math.PI / 2 + i * seg + rotation;
const a1 = a0 + seg;
// fill
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.arc(0, 0, r, a0, a1);
@@ -146,12 +153,10 @@ Frank</textarea>
ctx.fillStyle = COLORS[i % COLORS.length];
ctx.fill();
// divider
ctx.strokeStyle = 'rgba(255,255,255,0.13)';
ctx.lineWidth = 1.5;
ctx.stroke();
// label
const mid = a0 + seg / 2;
const fontSize = Math.max(9, Math.min(14, Math.floor(r * seg * 0.4)));
ctx.save();
@@ -164,14 +169,12 @@ Frank</textarea>
ctx.restore();
}
// outer ring
ctx.beginPath();
ctx.arc(0, 0, r, 0, TAU);
ctx.strokeStyle = 'rgba(255,255,255,0.2)';
ctx.lineWidth = 3;
ctx.stroke();
// center hub
ctx.beginPath();
ctx.arc(0, 0, 15, 0, TAU);
ctx.fillStyle = '#18181b';
@@ -186,8 +189,8 @@ Frank</textarea>
// ── Items ────────────────────────────────────────────────────────
function parseItems(): string[] | null {
const lines = textarea.value.split('\n').map(l => l.trim()).filter(Boolean);
if (lines.length < 2) { showError('Add at least 2 items.'); return null; }
if (lines.length > 24) { showError('Maximum 24 items.'); return null; }
if (lines.length < 2) { showError(ERR_MIN2); return null; }
if (lines.length > 24) { showError(ERR_MAX24); return null; }
clearError();
return lines;
}
@@ -218,15 +221,11 @@ Frank</textarea>
const seg = TAU / n;
const winnerIdx = Math.floor(Math.random() * n);
// Desired phase: rotation value (mod TAU) that puts center of winner at top (-π/2)
// midAngle of seg i = -π/2 + (i + 0.5)*seg + rotation → want ≡ -π/2 (mod TAU)
// → rotation ≡ -(i + 0.5)*seg (mod TAU)
const desiredPhase = (-(winnerIdx + 0.5) * seg % TAU + TAU) % TAU;
const currentPhase = ((currentRotation % TAU) + TAU) % TAU;
let delta = (desiredPhase - currentPhase + TAU) % TAU;
if (delta < 0.5) delta += TAU; // avoid near-zero spin
if (delta < 0.5) delta += TAU;
// Small jitter so it doesn't always stop dead-center
const jitter = (Math.random() - 0.5) * seg * 0.55;
const fullTurns = 5 + Math.floor(Math.random() * 3);
const totalDelta = fullTurns * TAU + delta + jitter;
@@ -241,7 +240,7 @@ Frank</textarea>
(function animate(now: number) {
const p = Math.min((now - t0) / duration, 1);
const eased = 1 - Math.pow(1 - p, 3); // ease-out cubic
const eased = 1 - Math.pow(1 - p, 3);
currentRotation = startRot + totalDelta * eased;
draw(currentRotation);
@@ -262,9 +261,9 @@ Frank</textarea>
resultText.textContent = winner;
resultCard.classList.remove('hidden');
resultText.classList.remove('ws-pop');
void resultText.offsetWidth; // reflow to restart animation
void resultText.offsetWidth;
resultText.classList.add('ws-pop');
copyLabel.textContent = 'Copy';
copyLabel.textContent = COPY_LABEL;
copyIcon.classList.remove('hidden');
checkIcon.classList.add('hidden');
}
@@ -275,18 +274,17 @@ Frank</textarea>
await navigator.clipboard.writeText(value);
copyIcon.classList.add('hidden');
checkIcon.classList.remove('hidden');
copyLabel.textContent = 'Copied';
copyLabel.textContent = COPIED_LABEL;
if (copyRevertTimer) clearTimeout(copyRevertTimer);
copyRevertTimer = setTimeout(() => {
checkIcon.classList.add('hidden');
copyIcon.classList.remove('hidden');
copyLabel.textContent = 'Copy';
copyLabel.textContent = COPY_LABEL;
}, 1500);
});
// ── Confetti ─────────────────────────────────────────────────────
function spawnConfetti() {
// Use viewport coordinates + position:fixed so confetti never affects page layout
const cr = canvas.getBoundingClientRect();
const cx = cr.left + cr.width / 2;
const cy = cr.top + cr.height / 2;
+44
View File
@@ -6,6 +6,10 @@ export interface Generator {
status: 'live' | 'coming-soon';
seoTitle: string;
seoDescription: string;
ruTitle: string;
ruDescription: string;
ruSeoTitle: string;
ruSeoDescription: string;
}
export const generators: Generator[] = [
@@ -17,6 +21,10 @@ export const generators: Generator[] = [
status: 'live',
seoTitle: 'Random Number Generator | Randify',
seoDescription: 'Generate a random number between any two values instantly. Free online random number picker — perfect for giveaways, games, and decisions.',
ruTitle: 'Число',
ruDescription: 'Выберите случайное число в любом диапазоне.',
ruSeoTitle: 'Генератор случайных чисел | Randify',
ruSeoDescription: 'Генерируйте случайное число в любом диапазоне мгновенно. Идеально для розыгрышей, игр и принятия решений.',
},
{
slug: 'colors',
@@ -26,6 +34,10 @@ export const generators: Generator[] = [
status: 'live',
seoTitle: 'Random Color Generator — HEX, RGB, HSL | Randify',
seoDescription: 'Get a random color in HEX, RGB or HSL format with one click. Free online color randomizer for designers and developers.',
ruTitle: 'Цвет',
ruDescription: 'Генерируйте случайный цвет в форматах HEX, RGB или HSL.',
ruSeoTitle: 'Генератор случайных цветов — HEX, RGB, HSL | Randify',
ruSeoDescription: 'Получайте случайные цвета в форматах HEX, RGB и HSL одним нажатием. Бесплатный рандомайзер цветов для дизайнеров.',
},
{
slug: 'password',
@@ -35,6 +47,10 @@ export const generators: Generator[] = [
status: 'live',
seoTitle: 'Random Password Generator | Randify',
seoDescription: 'Create a strong random password with custom length, uppercase, lowercase, digits and symbols. Free and secure — runs entirely in your browser.',
ruTitle: 'Пароль',
ruDescription: 'Создайте надёжный случайный пароль с настраиваемыми правилами.',
ruSeoTitle: 'Генератор случайных паролей | Randify',
ruSeoDescription: 'Создавайте надёжные пароли с заглавными буквами, цифрами и символами. Бесплатно и безопасно — работает прямо в браузере.',
},
{
slug: 'lottery',
@@ -44,6 +60,10 @@ export const generators: Generator[] = [
status: 'live',
seoTitle: 'Lottery Number Generator — Random Pick | Randify',
seoDescription: 'Draw a set of unique random lottery numbers from any range. Perfect for lotteries, raffles, and lucky draws. Free online lottery picker.',
ruTitle: 'Лотерея',
ruDescription: 'Вытащите набор уникальных чисел в стиле лотереи.',
ruSeoTitle: 'Генератор лотерейных номеров | Randify',
ruSeoDescription: 'Тяните уникальные случайные числа для лотереи, розыгрыша или жеребьёвки. Бесплатный онлайн-генератор лотерейных номеров.',
},
{
slug: 'dice',
@@ -53,6 +73,10 @@ export const generators: Generator[] = [
status: 'live',
seoTitle: 'Online Dice Roller — d4, d6, d20 and more | Randify',
seoDescription: 'Roll virtual dice online. Supports d4, d6, d8, d10, d12, d20 and d100. Roll multiple dice at once — great for D&D, board games and tabletop RPGs.',
ruTitle: 'Кубик',
ruDescription: 'Бросьте один или несколько кубиков с любым числом граней.',
ruSeoTitle: 'Бросить кубик онлайн — d4, d6, d20 | Randify',
ruSeoDescription: 'Бросайте виртуальные кубики онлайн. Поддержка d4, d6, d8, d10, d12, d20, d100. Отлично для D&D и настольных RPG.',
},
{
slug: 'cards',
@@ -62,6 +86,10 @@ export const generators: Generator[] = [
status: 'live',
seoTitle: 'Random Card Generator — Draw from a Deck | Randify',
seoDescription: 'Draw random playing cards from a standard 52-card deck. Pick any number of cards, with or without replacement. Free online card picker.',
ruTitle: 'Карта',
ruDescription: 'Вытащите случайную карту из стандартной колоды.',
ruSeoTitle: 'Генератор случайных карт | Randify',
ruSeoDescription: 'Тяните случайные карты из колоды 52 карт. С повторениями или без. Бесплатный онлайн-генератор карт.',
},
{
slug: 'coin',
@@ -71,6 +99,10 @@ export const generators: Generator[] = [
status: 'live',
seoTitle: 'Coin Flip Online — Heads or Tails | Randify',
seoDescription: 'Flip a virtual coin online. Flip multiple coins at once and see how many heads and tails you get. Free random coin toss simulator.',
ruTitle: 'Монетка',
ruDescription: 'Подбросьте одну или несколько монет — орёл или решка.',
ruSeoTitle: 'Подбросить монетку онлайн — орёл или решка | Randify',
ruSeoDescription: 'Подбрасывайте виртуальную монету онлайн. Одну или несколько сразу. Бесплатный симулятор подбрасывания монеты.',
},
{
slug: 'list',
@@ -80,6 +112,10 @@ export const generators: Generator[] = [
status: 'live',
seoTitle: 'Random List Picker — Pick a Random Winner | Randify',
seoDescription: 'Paste any list of names or items and pick random winners instantly. Great for giveaways, choosing who goes first, or any random selection.',
ruTitle: 'Список',
ruDescription: 'Вставьте список элементов и выберите случайных победителей.',
ruSeoTitle: 'Случайный выбор из списка | Randify',
ruSeoDescription: 'Вставьте список имён или вариантов и выберите случайных победителей мгновенно. Идеально для розыгрышей и жеребьёвок.',
},
{
slug: 'uuid',
@@ -89,6 +125,10 @@ export const generators: Generator[] = [
status: 'live',
seoTitle: 'UUID Generator — UUID v4, Hex & Base64 Tokens | Randify',
seoDescription: 'Generate random UUID v4, hex tokens, or base64 strings online. Cryptographically secure, runs in your browser. Free UUID and token generator.',
ruTitle: 'UUID / Токен',
ruDescription: 'Генерируйте UUID, hex-токены или случайные Base64-строки.',
ruSeoTitle: 'Генератор UUID и токенов | Randify',
ruSeoDescription: 'Генерируйте UUID v4, hex-токены и Base64-строки онлайн. Криптографически безопасно, работает в браузере.',
},
{
slug: 'wheel',
@@ -98,5 +138,9 @@ export const generators: Generator[] = [
status: 'live',
seoTitle: 'Spin the Wheel — Random Picker | Randify',
seoDescription: 'Spin a customizable wheel of fortune to pick a random winner, choice, or option. Add your own items and let the wheel decide.',
ruTitle: 'Колесо фортуны',
ruDescription: 'Добавьте элементы, крутите колесо и пусть случай решит.',
ruSeoTitle: 'Колесо фортуны онлайн — случайный выбор | Randify',
ruSeoDescription: 'Крутите колесо фортуны онлайн. Добавьте свои варианты и пусть случай решит победителя.',
},
];
+138
View File
@@ -0,0 +1,138 @@
export type Lang = 'en' | 'ru';
export const translations = {
en: {
allGenerators: 'All generators',
live: 'Live',
comingSoon: 'Coming soon',
copy: 'Copy',
copied: 'Copied',
copyAll: 'Copy all',
howToUse: 'How to use',
whenToUse: 'When to use',
generate: 'Generate',
draw: 'Draw',
roll: 'Roll',
flip: 'Flip',
pick: 'Pick',
spin: 'Spin',
from: 'From',
to: 'To',
length: 'Length',
count: 'Count',
winner: 'Winner',
total: 'Total',
sides: 'Sides',
numberOfDice: 'Number of dice',
numberOfCoins: 'Number of coins',
cardsToDraw: 'Cards to draw',
allowDuplicates: 'Allow duplicates',
uppercase: 'Uppercase (A–Z)',
lowercase: 'Lowercase (a–z)',
digits: 'Digits (0–9)',
symbols: 'Symbols (!@#…)',
type: 'Type',
lengthBytes: 'Length (bytes)',
itemsOneLine: 'one per line',
itemsOneLine224: 'one per line, 2–24',
items: 'Items',
headsLabel: 'H',
tailsLabel: 'T',
headsWord: 'heads',
tailsWord: 'tails',
errBothIntegers: 'Both values must be whole numbers.',
errFromLessThanTo: '"From" must be less than "To".',
errSelectOneType: 'Select at least one character type.',
errAllIntegers: 'All values must be whole numbers.',
errPickAtLeastOne: '"Pick" must be at least 1.',
errDiceAtLeast: 'Number of dice must be at least 1.',
errDiceMax: 'Maximum 20 dice at once.',
errDrawAtLeast: 'Draw at least 1 card.',
errMaxUniqueCards: 'Cannot draw more than 52 unique cards.',
errMax52Cards: 'Maximum 52 cards at once.',
errAddAtLeast2: 'Add at least 2 items.',
errMax24: 'Maximum 24 items.',
errAddAtLeast1: 'Add at least one item to the list.',
errPickAtLeast1: 'Pick at least 1 item.',
homeTitle: 'Random generators for every occasion',
homeSubtitle: 'Simple tools for raffles, games, and everything that needs randomness.',
defaultTitle: 'Randify — Random Value Generators',
defaultDesc: 'Simple tools for raffles, games, and everything that needs randomness.',
brandLabel: 'randify',
backToAll: 'All generators',
},
ru: {
allGenerators: 'Все генераторы',
live: 'Активен',
comingSoon: 'Скоро',
copy: 'Копировать',
copied: 'Скопировано',
copyAll: 'Копировать всё',
howToUse: 'Как пользоваться',
whenToUse: 'Когда использовать',
generate: 'Сгенерировать',
draw: 'Тянуть',
roll: 'Бросить',
flip: 'Подбросить',
pick: 'Выбрать',
spin: 'Крутить',
from: 'От',
to: 'До',
length: 'Длина',
count: 'Количество',
winner: 'Победитель',
total: 'Итого',
sides: 'Грани',
numberOfDice: 'Количество кубиков',
numberOfCoins: 'Количество монет',
cardsToDraw: 'Карт вытащить',
allowDuplicates: 'Разрешить повторения',
uppercase: 'Заглавные (A–Z)',
lowercase: 'Строчные (a–z)',
digits: 'Цифры (0–9)',
symbols: 'Символы (!@#…)',
type: 'Тип',
lengthBytes: 'Длина (байты)',
itemsOneLine: 'по одному на строку',
itemsOneLine224: 'по одному на строку, 2–24',
items: 'Элементы',
headsLabel: 'О',
tailsLabel: 'Р',
headsWord: 'орёл',
tailsWord: 'решка',
errBothIntegers: 'Оба значения должны быть целыми числами.',
errFromLessThanTo: '«От» должно быть меньше «До».',
errSelectOneType: 'Выберите хотя бы один тип символов.',
errAllIntegers: 'Все значения должны быть целыми числами.',
errPickAtLeastOne: '«Выбрать» должно быть не менее 1.',
errDiceAtLeast: 'Количество кубиков должно быть не менее 1.',
errDiceMax: 'Максимум 20 кубиков одновременно.',
errDrawAtLeast: 'Вытащите хотя бы 1 карту.',
errMaxUniqueCards: 'Нельзя вытащить более 52 уникальных карт.',
errMax52Cards: 'Максимум 52 карты одновременно.',
errAddAtLeast2: 'Добавьте хотя бы 2 элемента.',
errMax24: 'Максимум 24 элемента.',
errAddAtLeast1: 'Добавьте хотя бы один элемент в список.',
errPickAtLeast1: 'Выберите хотя бы 1 элемент.',
homeTitle: 'Генераторы случайных значений на любой случай',
homeSubtitle: 'Простые инструменты для розыгрышей, игр и всего, что требует случайности.',
defaultTitle: 'Randify — Генераторы случайных значений',
defaultDesc: 'Простые инструменты для розыгрышей, игр и всего, что требует случайности.',
brandLabel: 'randify',
backToAll: 'Все генераторы',
},
} as const;
export type T = typeof translations.en;
export function useT(lang: Lang): T {
return translations[lang] as T;
}
+29 -3
View File
@@ -1,17 +1,30 @@
---
import LanguageSwitcher from '../components/LanguageSwitcher.astro';
import { useT } from '../i18n/translations';
import type { Lang } from '../i18n/translations';
interface Props {
title?: string;
description?: string;
lang?: Lang;
}
const { lang = 'en' } = Astro.props;
const T = useT(lang);
const {
title = 'Randify — Random Value Generators',
description = 'Simple tools for raffles, games, and everything that needs randomness.',
title = T.defaultTitle,
description = T.defaultDesc,
} = Astro.props;
const currentPath = Astro.url.pathname;
const alternatePath = lang === 'ru'
? (currentPath.replace(/^\/ru/, '') || '/')
: ('/ru' + currentPath);
---
<!doctype html>
<html lang="en">
<html lang={lang}>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
@@ -20,6 +33,8 @@ const {
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300..700&display=swap" rel="stylesheet" />
<link rel="alternate" hreflang="en" href={`https://randify.pro${lang === 'ru' ? alternatePath : currentPath}`} />
<link rel="alternate" hreflang="ru" href={`https://randify.pro${lang === 'ru' ? currentPath : alternatePath}`} />
<meta name="verification" content="er9ndnv9ih7agmh8" />
<title>{title}</title>
<!-- Yandex.Metrika counter -->
@@ -36,10 +51,21 @@ const {
<!-- /Yandex.Metrika counter -->
</head>
<body class="bg-zinc-950 text-zinc-100 min-h-screen font-sans antialiased">
<LanguageSwitcher lang={lang} alternatePath={alternatePath} />
<slot />
</body>
</html>
<!-- Auto-detect language on first visit -->
<script>
if (!localStorage.getItem('lang-pref')) {
const browserLang = (navigator.language || '').toLowerCase();
if (browserLang.startsWith('ru') && !location.pathname.startsWith('/ru')) {
location.replace('/ru' + location.pathname);
}
}
</script>
<style is:global>
@import "tailwindcss";
+1 -1
View File
@@ -8,7 +8,7 @@ import SeoBlock from '../../components/SeoBlock.astro';
const generator = generators.find((g) => g.slug === 'cards')!;
---
<BaseLayout
<BaseLayout lang="en"
title={generator.seoTitle}
description={generator.seoDescription}
>
+1 -1
View File
@@ -8,7 +8,7 @@ import SeoBlock from '../../components/SeoBlock.astro';
const generator = generators.find((g) => g.slug === 'coin')!;
---
<BaseLayout
<BaseLayout lang="en"
title={generator.seoTitle}
description={generator.seoDescription}
>
+1 -1
View File
@@ -8,7 +8,7 @@ import SeoBlock from '../../components/SeoBlock.astro';
const generator = generators.find((g) => g.slug === 'colors')!;
---
<BaseLayout
<BaseLayout lang="en"
title={generator.seoTitle}
description={generator.seoDescription}
>
+1 -1
View File
@@ -8,7 +8,7 @@ import SeoBlock from '../../components/SeoBlock.astro';
const generator = generators.find((g) => g.slug === 'dice')!;
---
<BaseLayout
<BaseLayout lang="en"
title={generator.seoTitle}
description={generator.seoDescription}
>
+1 -1
View File
@@ -8,7 +8,7 @@ import SeoBlock from '../../components/SeoBlock.astro';
const generator = generators.find((g) => g.slug === 'list')!;
---
<BaseLayout
<BaseLayout lang="en"
title={generator.seoTitle}
description={generator.seoDescription}
>
+1 -1
View File
@@ -8,7 +8,7 @@ import SeoBlock from '../../components/SeoBlock.astro';
const generator = generators.find((g) => g.slug === 'lottery')!;
---
<BaseLayout
<BaseLayout lang="en"
title={generator.seoTitle}
description={generator.seoDescription}
>
+1 -1
View File
@@ -8,7 +8,7 @@ import SeoBlock from '../../components/SeoBlock.astro';
const generator = generators.find((g) => g.slug === 'numbers')!;
---
<BaseLayout
<BaseLayout lang="en"
title={generator.seoTitle}
description={generator.seoDescription}
>
+1 -1
View File
@@ -8,7 +8,7 @@ import SeoBlock from '../../components/SeoBlock.astro';
const generator = generators.find((g) => g.slug === 'password')!;
---
<BaseLayout
<BaseLayout lang="en"
title={generator.seoTitle}
description={generator.seoDescription}
>
+1 -1
View File
@@ -8,7 +8,7 @@ import SeoBlock from '../../components/SeoBlock.astro';
const generator = generators.find((g) => g.slug === 'uuid')!;
---
<BaseLayout
<BaseLayout lang="en"
title={generator.seoTitle}
description={generator.seoDescription}
>
+1 -1
View File
@@ -8,7 +8,7 @@ import { generators } from '../../data/generators';
const generator = generators.find((g) => g.slug === 'wheel')!;
---
<BaseLayout
<BaseLayout lang="en"
title={generator.seoTitle}
description={generator.seoDescription}
>
+6 -3
View File
@@ -3,9 +3,12 @@ import BaseLayout from '../layouts/BaseLayout.astro';
import GeneratorCard from '../components/GeneratorCard.astro';
import AdBanner from '../components/AdBanner.astro';
import { generators } from '../data/generators';
import { useT } from '../i18n/translations';
const T = useT('en');
---
<BaseLayout>
<BaseLayout lang="en">
<div class="max-w-4xl mx-auto px-4 py-12 sm:py-20">
<header class="mb-12">
<div class="flex items-center gap-2 mb-6">
@@ -16,10 +19,10 @@ import { generators } from '../data/generators';
<span class="text-xl font-bold tracking-tight text-zinc-100">randify</span>
</div>
<h1 class="text-3xl sm:text-4xl font-bold text-zinc-100 leading-tight">
Random generators for<br class="hidden sm:block" /> every occasion
{T.homeTitle}
</h1>
<p class="mt-3 text-base text-zinc-400 max-w-md">
Simple tools for raffles, games, and everything that needs randomness.
{T.homeSubtitle}
</p>
</header>
+227
View File
@@ -0,0 +1,227 @@
---
import { generators } from '../../../data/generators';
import BaseLayout from '../../../layouts/BaseLayout.astro';
import AdBanner from '../../../components/AdBanner.astro';
import SeoBlock from '../../../components/SeoBlock.astro';
import NumberGenerator from '../../../components/generators/NumberGenerator.astro';
import ColorGenerator from '../../../components/generators/ColorGenerator.astro';
import PasswordGenerator from '../../../components/generators/PasswordGenerator.astro';
import LotteryGenerator from '../../../components/generators/LotteryGenerator.astro';
import DiceGenerator from '../../../components/generators/DiceGenerator.astro';
import CardGenerator from '../../../components/generators/CardGenerator.astro';
import CoinGenerator from '../../../components/generators/CoinGenerator.astro';
import ListGenerator from '../../../components/generators/ListGenerator.astro';
import UuidGenerator from '../../../components/generators/UuidGenerator.astro';
import WheelSpinner from '../../../components/generators/WheelSpinner.astro';
export function getStaticPaths() {
return generators.map((g) => ({
params: { slug: g.slug },
props: { generator: g },
}));
}
const { generator } = Astro.props;
const { slug } = Astro.params;
const seoContent: Record<string, { howTo: string[]; whenTo: string[] }> = {
numbers: {
howTo: [
'Введите минимальное значение в поле «От».',
'Введите максимальное значение в поле «До».',
'Нажмите «Сгенерировать» — случайное число появится мгновенно.',
'Нажмите на число, чтобы скопировать его в буфер обмена.',
],
whenTo: [
'Выбор победителя в розыгрыше или конкурсе.',
'Определение случайного первого игрока в настольной игре.',
'Быстрое принятие решения между пронумерованными вариантами.',
'Генерация тестовых данных с числами в заданном диапазоне.',
],
},
colors: {
howTo: [
'Нажмите «Сгенерировать», чтобы получить случайный цвет.',
'Цвет отображается в форматах HEX, RGB и HSL одновременно.',
'Нажмите на иконку копирования рядом с нужным форматом.',
],
whenTo: [
'Поиск вдохновения для цветовой палитры.',
'Случайный выбор цвета для дизайна или иллюстрации.',
'Генерация тестовых данных с цветовыми значениями.',
],
},
password: {
howTo: [
'Задайте длину пароля с помощью ползунка.',
'Выберите типы символов: заглавные, строчные, цифры, спецсимволы.',
'Нажмите «Сгенерировать» — готовый пароль появится на экране.',
'Нажмите на пароль, чтобы скопировать его.',
],
whenTo: [
'Создание надёжного пароля для нового аккаунта.',
'Генерация секретного ключа или токена.',
'Регулярное обновление паролей для повышения безопасности.',
],
},
lottery: {
howTo: [
'Укажите диапазон чисел в полях «От» и «До».',
'Задайте количество чисел для розыгрыша.',
'Нажмите «Тянуть» — выпавшие числа появятся на экране.',
'Нажмите «Копировать», чтобы сохранить результат.',
],
whenTo: [
'Розыгрыш лотерейных номеров.',
'Жеребьёвка участников конкурса.',
'Случайный выбор нескольких уникальных чисел.',
],
},
dice: {
howTo: [
'Укажите количество кубиков (до 20).',
'Выберите количество граней: d4, d6, d8, d10, d12, d20 или d100.',
'Нажмите «Бросить» — результаты отобразятся мгновенно.',
'Нажмите «Копировать», чтобы сохранить результат.',
],
whenTo: [
'Броски в настольных ролевых играх (D&D и другие).',
'Генерация случайных чисел для настольных игр.',
'Проверка вероятностей и симуляции.',
],
},
cards: {
howTo: [
'Укажите количество карт для розыгрыша.',
'Включите «Разрешить повторения», если нужны дубли.',
'Нажмите «Тянуть» — карты появятся на экране.',
'Нажмите «Копировать», чтобы скопировать результат.',
],
whenTo: [
'Случайная раздача карт для карточных игр.',
'Выбор случайных карт для гаданий.',
'Генерация случайных комбинаций карт для тестирования.',
],
},
coin: {
howTo: [
'Выберите количество монет с помощью ползунка.',
'Нажмите «Подбросить» — орёл или решка.',
'При нескольких монетах отображается итоговый счёт.',
'Нажмите «Копировать», чтобы сохранить результат.',
],
whenTo: [
'Принятие решений по принципу «орёл или решка».',
'Определение очерёдности в игре.',
'Симуляция случайных событий с равной вероятностью.',
],
},
list: {
howTo: [
'Введите список элементов — по одному на строку.',
'Укажите, сколько элементов выбрать.',
'При необходимости разрешите повторения.',
'Нажмите «Выбрать» — победители выделятся на экране.',
],
whenTo: [
'Розыгрыш победителей конкурса.',
'Случайное распределение задач между участниками команды.',
'Выбор случайного варианта из списка.',
],
},
uuid: {
howTo: [
'Выберите тип: UUID v4, Hex или Base64.',
'Для Hex и Base64 задайте длину в байтах.',
'Укажите количество токенов.',
'Нажмите «Сгенерировать» и скопируйте результат.',
],
whenTo: [
'Генерация уникальных идентификаторов для баз данных.',
'Создание токенов для API и сессий.',
'Генерация случайных ключей шифрования.',
],
},
wheel: {
howTo: [
'Введите варианты в поле — по одному на строку (от 2 до 24).',
'Колесо обновляется автоматически.',
'Нажмите «Крутить» и дождитесь результата.',
'Победитель выделяется — скопируйте его кнопкой «Копировать».',
],
whenTo: [
'Случайный выбор победителя розыгрыша.',
'Принятие решений в команде.',
'Распределение ролей или задач между участниками.',
],
},
};
const seo = seoContent[slug] ?? { howTo: [], whenTo: [] };
---
<BaseLayout
title={generator.ruSeoTitle}
description={generator.ruSeoDescription}
lang="ru"
>
<div class="max-w-2xl mx-auto px-4 py-12 sm:py-20">
<nav aria-label="Breadcrumb" class="mb-10">
<a
href="/ru/"
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="Все генераторы"
>
<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>
Все генераторы
</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.ruTitle.toLowerCase()}
</h1>
<p class="mt-2 text-base text-zinc-400">{generator.ruDescription}</p>
</header>
<main>
{slug === 'numbers' && <NumberGenerator />}
{slug === 'colors' && <ColorGenerator />}
{slug === 'password' && <PasswordGenerator />}
{slug === 'lottery' && <LotteryGenerator />}
{slug === 'dice' && <DiceGenerator />}
{slug === 'cards' && <CardGenerator />}
{slug === 'coin' && <CoinGenerator />}
{slug === 'list' && <ListGenerator />}
{slug === 'uuid' && <UuidGenerator />}
{slug === 'wheel' && <WheelSpinner />}
</main>
<div class="mt-12 flex justify-center">
<AdBanner size="tile" />
</div>
<SeoBlock howTo={seo.howTo} whenTo={seo.whenTo} lang="ru" />
</div>
</BaseLayout>
+50
View File
@@ -0,0 +1,50 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import GeneratorCard from '../../components/GeneratorCard.astro';
import AdBanner from '../../components/AdBanner.astro';
import { generators } from '../../data/generators';
import { useT } from '../../i18n/translations';
const T = useT('ru');
---
<BaseLayout
title={T.defaultTitle}
description={T.defaultDesc}
lang="ru"
>
<div class="max-w-4xl mx-auto px-4 py-12 sm:py-20">
<header class="mb-12">
<div class="flex items-center gap-2 mb-6">
<span
class="inline-block w-3 h-3 rounded-full bg-[#534AB7]"
aria-hidden="true"
></span>
<span class="text-xl font-bold tracking-tight text-zinc-100">{T.brandLabel}</span>
</div>
<h1 class="text-3xl sm:text-4xl font-bold text-zinc-100 leading-tight">
{T.homeTitle}
</h1>
<p class="mt-3 text-base text-zinc-400 max-w-md">
{T.homeSubtitle}
</p>
</header>
<AdBanner size="leaderboard" />
<main class="mt-8">
<div
class="grid gap-3"
style="grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));"
role="list"
aria-label="Generator catalog"
>
{generators.map((generator) => (
<div role="listitem">
<GeneratorCard generator={generator} lang="ru" />
</div>
))}
</div>
</main>
</div>
</BaseLayout>