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:
co-authored by
Claude Sonnet 4.6
parent
76d9233faa
commit
65e42e7d44
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 Bob Carol 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(); });
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: 'Крутите колесо фортуны онлайн. Добавьте свои варианты и пусть случай решит победителя.',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user