refactor: extract shared client utilities
- Add src/lib/client/clipboard.ts with CopyFeedback class - Add src/lib/client/validation.ts with createErrorDisplay and isInteger - Add src/lib/client/animations.ts with popElement - Refactor NumberGenerator, LotteryGenerator, PasswordGenerator, ListGenerator, CardGenerator, CoinGenerator, UuidGenerator, WheelSpinner to use shared utilities - Leave DiceGenerator and ColorGenerator untouched (complex patterns)
This commit is contained in:
@@ -1,188 +1,272 @@
|
|||||||
---
|
---
|
||||||
import { useT } from '../../i18n/translations';
|
import { useT } from "../../i18n/translations";
|
||||||
const isRu = Astro.url.pathname.startsWith('/ru');
|
const isRu = Astro.url.pathname.startsWith("/ru");
|
||||||
const T = useT(isRu ? 'ru' : 'en');
|
const T = useT(isRu ? "ru" : "en");
|
||||||
---
|
---
|
||||||
|
|
||||||
<div id="card-generator" class="mt-8">
|
<div id="card-generator" class="mt-8">
|
||||||
<div class="flex flex-col sm:flex-row gap-4 items-end">
|
<div class="flex flex-col sm:flex-row gap-4 items-end">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<label for="crd-count" class="block text-sm font-medium text-zinc-400 mb-1">{T.cardsToDraw}</label>
|
<label
|
||||||
<input
|
for="crd-count"
|
||||||
id="crd-count"
|
class="block text-sm font-medium text-zinc-400 mb-1"
|
||||||
type="number"
|
>{T.cardsToDraw}</label
|
||||||
value="1"
|
>
|
||||||
min="1"
|
<input
|
||||||
max="52"
|
id="crd-count"
|
||||||
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"
|
type="number"
|
||||||
aria-label={T.cardsToDraw}
|
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={T.cardsToDraw}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label
|
||||||
|
class="flex items-center gap-2.5 cursor-pointer select-none group pb-2"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
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"
|
||||||
|
>{T.allowDuplicates}</span
|
||||||
|
>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<label class="flex items-center gap-2.5 cursor-pointer select-none group pb-2">
|
|
||||||
<input
|
|
||||||
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">{T.allowDuplicates}</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p
|
<p
|
||||||
id="crd-error"
|
id="crd-error"
|
||||||
role="alert"
|
role="alert"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
class="mt-3 text-sm text-red-500 hidden"
|
class="mt-3 text-sm text-red-500 hidden"
|
||||||
></p>
|
|
||||||
|
|
||||||
<div class="my-10 min-h-32 flex flex-col items-center justify-center gap-4">
|
|
||||||
<div
|
|
||||||
id="crd-cards"
|
|
||||||
class="flex flex-wrap justify-center gap-2"
|
|
||||||
aria-live="polite"
|
|
||||||
aria-label="Drawn cards"
|
|
||||||
></div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
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="crd-copy-label">{T.copy}</span>
|
</p>
|
||||||
<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="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>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-center">
|
<div class="my-10 min-h-32 flex flex-col items-center justify-center gap-4">
|
||||||
<button
|
<div
|
||||||
id="crd-btn"
|
id="crd-cards"
|
||||||
type="button"
|
class="flex flex-wrap justify-center gap-2"
|
||||||
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"
|
aria-live="polite"
|
||||||
>
|
aria-label="Drawn cards"
|
||||||
{T.draw}
|
>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
|
||||||
|
<button
|
||||||
|
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="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"
|
||||||
|
></rect>
|
||||||
|
<path
|
||||||
|
d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<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"></path>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<button
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{T.draw}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const isRu = document.documentElement.lang === 'ru';
|
import { CopyFeedback } from "@/lib/client/clipboard";
|
||||||
const COPY_LABEL = isRu ? 'Копировать' : 'Copy';
|
import { createErrorDisplay } from "@/lib/client/validation";
|
||||||
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 isRu = document.documentElement.lang === "ru";
|
||||||
const replaceCb = document.getElementById('crd-replace') as HTMLInputElement;
|
const COPY_LABEL = isRu ? "Копировать" : "Copy";
|
||||||
const btn = document.getElementById('crd-btn') as HTMLButtonElement;
|
const COPIED_LABEL = isRu ? "Скопировано" : "Copied";
|
||||||
const copyBtn = document.getElementById('crd-copy-btn') as HTMLButtonElement;
|
const ERR_AT_LEAST = isRu
|
||||||
const cardsEl = document.getElementById('crd-cards') as HTMLDivElement;
|
? "Вытащите хотя бы 1 карту."
|
||||||
const errorEl = document.getElementById('crd-error') as HTMLParagraphElement;
|
: "Draw at least 1 card.";
|
||||||
const copyLabel = document.getElementById('crd-copy-label') as HTMLSpanElement;
|
const ERR_UNIQUE = isRu
|
||||||
const copyIcon = document.getElementById('crd-copy-icon') as HTMLSpanElement;
|
? "Нельзя вытащить более 52 уникальных карт."
|
||||||
const checkIcon = document.getElementById('crd-check-icon') as HTMLSpanElement;
|
: "Cannot draw more than 52 unique cards.";
|
||||||
|
const ERR_MAX52 = isRu
|
||||||
|
? "Максимум 52 карты одновременно."
|
||||||
|
: "Maximum 52 cards at once.";
|
||||||
|
|
||||||
const RANKS = ['A','2','3','4','5','6','7','8','9','10','J','Q','K'];
|
const countInput = document.getElementById("crd-count") as HTMLInputElement;
|
||||||
const SUITS = [
|
const replaceCb = document.getElementById(
|
||||||
{ symbol: '♠', label: 'Spades', red: false },
|
"crd-replace",
|
||||||
{ symbol: '♥', label: 'Hearts', red: true },
|
) as HTMLInputElement;
|
||||||
{ symbol: '♦', label: 'Diamonds', red: true },
|
const btn = document.getElementById("crd-btn") as HTMLButtonElement;
|
||||||
{ symbol: '♣', label: 'Clubs', red: false },
|
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 DECK = SUITS.flatMap((suit) =>
|
const RANKS = [
|
||||||
RANKS.map((rank) => ({ rank, suit }))
|
"A",
|
||||||
);
|
"2",
|
||||||
|
"3",
|
||||||
|
"4",
|
||||||
|
"5",
|
||||||
|
"6",
|
||||||
|
"7",
|
||||||
|
"8",
|
||||||
|
"9",
|
||||||
|
"10",
|
||||||
|
"J",
|
||||||
|
"Q",
|
||||||
|
"K",
|
||||||
|
];
|
||||||
|
const SUITS = [
|
||||||
|
{ symbol: "♠", label: "Spades", red: false },
|
||||||
|
{ symbol: "♥", label: "Hearts", red: true },
|
||||||
|
{ symbol: "♦", label: "Diamonds", red: true },
|
||||||
|
{ symbol: "♣", label: "Clubs", red: false },
|
||||||
|
];
|
||||||
|
|
||||||
let lastCards: { rank: string; suit: typeof SUITS[number] }[] = [];
|
const DECK = SUITS.flatMap((suit) => RANKS.map((rank) => ({ rank, suit })));
|
||||||
let copyRevertTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
function showError(msg: string) { errorEl.textContent = msg; errorEl.classList.remove('hidden'); }
|
let lastCards: { rank: string; suit: (typeof SUITS)[number] }[] = [];
|
||||||
function clearError() { errorEl.textContent = ''; errorEl.classList.add('hidden'); }
|
|
||||||
|
|
||||||
function draw() {
|
const errors = createErrorDisplay(errorEl);
|
||||||
const count = parseInt(countInput.value, 10);
|
const clipboard = new CopyFeedback(copyIcon, checkIcon);
|
||||||
const withReplacement = replaceCb.checked;
|
|
||||||
|
|
||||||
if (!Number.isInteger(count) || count < 1) { showError(ERR_AT_LEAST); return; }
|
function draw() {
|
||||||
if (!withReplacement && count > 52) { showError(ERR_UNIQUE); return; }
|
const count = parseInt(countInput.value, 10);
|
||||||
if (count > 52) { showError(ERR_MAX52); return; }
|
const withReplacement = replaceCb.checked;
|
||||||
|
|
||||||
clearError();
|
if (!Number.isInteger(count) || count < 1) {
|
||||||
|
errors.show(ERR_AT_LEAST);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!withReplacement && count > 52) {
|
||||||
|
errors.show(ERR_UNIQUE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (count > 52) {
|
||||||
|
errors.show(ERR_MAX52);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (withReplacement) {
|
errors.clear();
|
||||||
lastCards = Array.from({ length: count }, () => DECK[Math.floor(Math.random() * DECK.length)]);
|
|
||||||
} else {
|
if (withReplacement) {
|
||||||
const deck = [...DECK];
|
lastCards = Array.from(
|
||||||
for (let i = 0; i < count; i++) {
|
{ length: count },
|
||||||
const j = i + Math.floor(Math.random() * (deck.length - i));
|
() => DECK[Math.floor(Math.random() * DECK.length)],
|
||||||
[deck[i], deck[j]] = [deck[j], deck[i]];
|
);
|
||||||
}
|
} else {
|
||||||
lastCards = deck.slice(0, count);
|
const deck = [...DECK];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const j = i + Math.floor(Math.random() * (deck.length - i));
|
||||||
|
[deck[i], deck[j]] = [deck[j], deck[i]];
|
||||||
|
}
|
||||||
|
lastCards = deck.slice(0, count);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderCards();
|
||||||
|
copyBtn.classList.remove("invisible");
|
||||||
|
copyLabel.textContent = COPY_LABEL;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderCards();
|
function renderCards() {
|
||||||
copyBtn.classList.remove('invisible');
|
cardsEl.innerHTML = "";
|
||||||
copyLabel.textContent = COPY_LABEL;
|
lastCards.forEach(({ rank, suit }) => {
|
||||||
}
|
const card = document.createElement("div");
|
||||||
|
card.setAttribute("aria-label", `${rank} of ${suit.label}`);
|
||||||
|
card.className = [
|
||||||
|
"flex flex-col items-center justify-between w-14 h-20 rounded-lg border px-1.5 py-1.5",
|
||||||
|
"bg-zinc-900 border-zinc-700 select-none",
|
||||||
|
suit.red ? "text-red-400" : "text-zinc-100",
|
||||||
|
].join(" ");
|
||||||
|
|
||||||
function renderCards() {
|
const top = document.createElement("div");
|
||||||
cardsEl.innerHTML = '';
|
top.className =
|
||||||
lastCards.forEach(({ rank, suit }) => {
|
"w-full text-left text-sm font-bold leading-none tabular-nums";
|
||||||
const card = document.createElement('div');
|
top.textContent = rank;
|
||||||
card.setAttribute('aria-label', `${rank} of ${suit.label}`);
|
|
||||||
card.className = [
|
|
||||||
'flex flex-col items-center justify-between w-14 h-20 rounded-lg border px-1.5 py-1.5',
|
|
||||||
'bg-zinc-900 border-zinc-700 select-none',
|
|
||||||
suit.red ? 'text-red-400' : 'text-zinc-100',
|
|
||||||
].join(' ');
|
|
||||||
|
|
||||||
const top = document.createElement('div');
|
const mid = document.createElement("div");
|
||||||
top.className = 'w-full text-left text-sm font-bold leading-none tabular-nums';
|
mid.className = "text-2xl leading-none";
|
||||||
top.textContent = rank;
|
mid.textContent = suit.symbol;
|
||||||
|
|
||||||
const mid = document.createElement('div');
|
const bot = document.createElement("div");
|
||||||
mid.className = 'text-2xl leading-none';
|
bot.className =
|
||||||
mid.textContent = suit.symbol;
|
"w-full text-right text-sm font-bold leading-none tabular-nums rotate-180";
|
||||||
|
bot.textContent = rank;
|
||||||
|
|
||||||
const bot = document.createElement('div');
|
card.append(top, mid, bot);
|
||||||
bot.className = 'w-full text-right text-sm font-bold leading-none tabular-nums rotate-180';
|
cardsEl.appendChild(card);
|
||||||
bot.textContent = rank;
|
});
|
||||||
|
}
|
||||||
|
|
||||||
card.append(top, mid, bot);
|
async function copyCards() {
|
||||||
cardsEl.appendChild(card);
|
if (!lastCards.length) return;
|
||||||
|
const text = lastCards
|
||||||
|
.map(({ rank, suit }) => `${rank}${suit.symbol}`)
|
||||||
|
.join(", ");
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
|
||||||
|
clipboard.showCopied();
|
||||||
|
copyLabel.textContent = COPIED_LABEL;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
copyLabel.textContent = COPY_LABEL;
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.addEventListener("click", draw);
|
||||||
|
copyBtn.addEventListener("click", copyCards);
|
||||||
|
countInput.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Enter") draw();
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
async function copyCards() {
|
|
||||||
if (!lastCards.length) return;
|
|
||||||
const text = lastCards.map(({ rank, suit }) => `${rank}${suit.symbol}`).join(', ');
|
|
||||||
await navigator.clipboard.writeText(text);
|
|
||||||
|
|
||||||
copyIcon.classList.add('hidden');
|
|
||||||
checkIcon.classList.remove('hidden');
|
|
||||||
copyLabel.textContent = COPIED_LABEL;
|
|
||||||
|
|
||||||
if (copyRevertTimer) clearTimeout(copyRevertTimer);
|
|
||||||
copyRevertTimer = setTimeout(() => {
|
|
||||||
checkIcon.classList.add('hidden');
|
|
||||||
copyIcon.classList.remove('hidden');
|
|
||||||
copyLabel.textContent = COPY_LABEL;
|
|
||||||
}, 1500);
|
|
||||||
}
|
|
||||||
|
|
||||||
btn.addEventListener('click', draw);
|
|
||||||
copyBtn.addEventListener('click', copyCards);
|
|
||||||
countInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') draw(); });
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,148 +1,200 @@
|
|||||||
---
|
---
|
||||||
import { useT } from '../../i18n/translations';
|
import { useT } from "../../i18n/translations";
|
||||||
const isRu = Astro.url.pathname.startsWith('/ru');
|
const isRu = Astro.url.pathname.startsWith("/ru");
|
||||||
const T = useT(isRu ? 'ru' : 'en');
|
const T = useT(isRu ? "ru" : "en");
|
||||||
---
|
---
|
||||||
|
|
||||||
<div id="coin-generator" class="mt-8">
|
<div id="coin-generator" class="mt-8">
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center justify-between mb-1">
|
<div class="flex items-center justify-between mb-1">
|
||||||
<label for="coin-count" class="text-sm font-medium text-zinc-400">{T.numberOfCoins}</label>
|
<label for="coin-count" class="text-sm font-medium text-zinc-400"
|
||||||
<span id="coin-count-val" class="text-sm font-semibold tabular-nums text-zinc-100">1</span>
|
>{T.numberOfCoins}</label
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
id="coin-count-val"
|
||||||
|
class="text-sm font-semibold tabular-nums text-zinc-100">1</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="coin-count"
|
||||||
|
type="range"
|
||||||
|
min="1"
|
||||||
|
max="20"
|
||||||
|
value="1"
|
||||||
|
class="w-full accent-[#534AB7] cursor-pointer"
|
||||||
|
aria-label={T.numberOfCoins}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="flex justify-between text-xs text-zinc-600 mt-1 select-none"
|
||||||
|
>
|
||||||
|
<span>1</span><span>20</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<input
|
|
||||||
id="coin-count"
|
<div class="my-10 min-h-28 flex flex-col items-center justify-center gap-4">
|
||||||
type="range"
|
<div
|
||||||
min="1"
|
id="coin-coins"
|
||||||
max="20"
|
class="flex flex-wrap justify-center gap-2"
|
||||||
value="1"
|
aria-live="polite"
|
||||||
class="w-full accent-[#534AB7] cursor-pointer"
|
aria-label="Coin results"
|
||||||
aria-label={T.numberOfCoins}
|
>
|
||||||
/>
|
</div>
|
||||||
<div class="flex justify-between text-xs text-zinc-600 mt-1 select-none">
|
|
||||||
<span>1</span><span>20</span>
|
<div
|
||||||
|
id="coin-summary"
|
||||||
|
class="hidden text-sm text-zinc-500 tabular-nums"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
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="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"
|
||||||
|
></rect>
|
||||||
|
<path
|
||||||
|
d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<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"></path>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="my-10 min-h-28 flex flex-col items-center justify-center gap-4">
|
<div class="flex justify-center">
|
||||||
<div
|
<button
|
||||||
id="coin-coins"
|
id="coin-btn"
|
||||||
class="flex flex-wrap justify-center gap-2"
|
type="button"
|
||||||
aria-live="polite"
|
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"
|
||||||
aria-label="Coin results"
|
>
|
||||||
></div>
|
{T.flip}
|
||||||
|
</button>
|
||||||
<div id="coin-summary" class="hidden text-sm text-zinc-500 tabular-nums"></div>
|
</div>
|
||||||
|
|
||||||
<button
|
|
||||||
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="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="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>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-center">
|
|
||||||
<button
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
{T.flip}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const isRu = document.documentElement.lang === 'ru';
|
import { CopyFeedback } from "@/lib/client/clipboard";
|
||||||
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 isRu = document.documentElement.lang === "ru";
|
||||||
const countVal = document.getElementById('coin-count-val') as HTMLSpanElement;
|
const COPY_LABEL = isRu ? "Копировать" : "Copy";
|
||||||
const btn = document.getElementById('coin-btn') as HTMLButtonElement;
|
const COPIED_LABEL = isRu ? "Скопировано" : "Copied";
|
||||||
const copyBtn = document.getElementById('coin-copy-btn') as HTMLButtonElement;
|
const HEADS_LABEL = isRu ? "О" : "H";
|
||||||
const coinsEl = document.getElementById('coin-coins') as HTMLDivElement;
|
const TAILS_LABEL = isRu ? "Р" : "T";
|
||||||
const summaryEl = document.getElementById('coin-summary') as HTMLDivElement;
|
const HEADS_WORD = isRu ? "орёл" : "Heads";
|
||||||
const copyLabel = document.getElementById('coin-copy-label') as HTMLSpanElement;
|
const TAILS_WORD = isRu ? "решка" : "Tails";
|
||||||
const copyIcon = document.getElementById('coin-copy-icon') as HTMLSpanElement;
|
const HEADS_SUMMARY = isRu ? "орёл" : "heads";
|
||||||
const checkIcon = document.getElementById('coin-check-icon') as HTMLSpanElement;
|
const TAILS_SUMMARY = isRu ? "решка" : "tails";
|
||||||
|
|
||||||
let lastResults: boolean[] = [];
|
const countInput = document.getElementById(
|
||||||
let copyRevertTimer: ReturnType<typeof setTimeout> | null = null;
|
"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;
|
||||||
|
|
||||||
countInput.addEventListener('input', () => {
|
let lastResults: boolean[] = [];
|
||||||
countVal.textContent = countInput.value;
|
|
||||||
});
|
|
||||||
|
|
||||||
function flip() {
|
const clipboard = new CopyFeedback(copyIcon, checkIcon);
|
||||||
const count = parseInt(countInput.value, 10);
|
|
||||||
lastResults = Array.from({ length: count }, () => Math.random() < 0.5);
|
|
||||||
|
|
||||||
const heads = lastResults.filter(Boolean).length;
|
countInput.addEventListener("input", () => {
|
||||||
const tails = count - heads;
|
countVal.textContent = countInput.value;
|
||||||
|
|
||||||
coinsEl.innerHTML = '';
|
|
||||||
lastResults.forEach((isHeads) => {
|
|
||||||
const coin = document.createElement('div');
|
|
||||||
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 ? HEADS_LABEL : TAILS_LABEL;
|
|
||||||
coinsEl.appendChild(coin);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (count > 1) {
|
function flip() {
|
||||||
summaryEl.textContent = `${heads} ${HEADS_SUMMARY} · ${tails} ${TAILS_SUMMARY}`;
|
const count = parseInt(countInput.value, 10);
|
||||||
summaryEl.classList.remove('hidden');
|
lastResults = Array.from({ length: count }, () => Math.random() < 0.5);
|
||||||
} else {
|
|
||||||
summaryEl.classList.add('hidden');
|
const heads = lastResults.filter(Boolean).length;
|
||||||
|
const tails = count - heads;
|
||||||
|
|
||||||
|
coinsEl.innerHTML = "";
|
||||||
|
lastResults.forEach((isHeads) => {
|
||||||
|
const coin = document.createElement("div");
|
||||||
|
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 ? HEADS_LABEL : TAILS_LABEL;
|
||||||
|
coinsEl.appendChild(coin);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (count > 1) {
|
||||||
|
summaryEl.textContent = `${heads} ${HEADS_SUMMARY} · ${tails} ${TAILS_SUMMARY}`;
|
||||||
|
summaryEl.classList.remove("hidden");
|
||||||
|
} else {
|
||||||
|
summaryEl.classList.add("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
copyBtn.classList.remove("invisible");
|
||||||
|
copyLabel.textContent = COPY_LABEL;
|
||||||
}
|
}
|
||||||
|
|
||||||
copyBtn.classList.remove('invisible');
|
async function copyResults() {
|
||||||
copyLabel.textContent = COPY_LABEL;
|
if (!lastResults.length) return;
|
||||||
}
|
const text = lastResults
|
||||||
|
.map((h) => (h ? HEADS_WORD : TAILS_WORD))
|
||||||
|
.join(", ");
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
|
||||||
async function copyResults() {
|
clipboard.showCopied();
|
||||||
if (!lastResults.length) return;
|
copyLabel.textContent = COPIED_LABEL;
|
||||||
const text = lastResults.map((h) => (h ? HEADS_WORD : TAILS_WORD)).join(', ');
|
|
||||||
await navigator.clipboard.writeText(text);
|
|
||||||
|
|
||||||
copyIcon.classList.add('hidden');
|
setTimeout(() => {
|
||||||
checkIcon.classList.remove('hidden');
|
copyLabel.textContent = COPY_LABEL;
|
||||||
copyLabel.textContent = COPIED_LABEL;
|
}, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
if (copyRevertTimer) clearTimeout(copyRevertTimer);
|
btn.addEventListener("click", flip);
|
||||||
copyRevertTimer = setTimeout(() => {
|
copyBtn.addEventListener("click", copyResults);
|
||||||
checkIcon.classList.add('hidden');
|
|
||||||
copyIcon.classList.remove('hidden');
|
|
||||||
copyLabel.textContent = COPY_LABEL;
|
|
||||||
}, 1500);
|
|
||||||
}
|
|
||||||
|
|
||||||
btn.addEventListener('click', flip);
|
|
||||||
copyBtn.addEventListener('click', copyResults);
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,179 +1,251 @@
|
|||||||
---
|
---
|
||||||
import { useT } from '../../i18n/translations';
|
import { useT } from "../../i18n/translations";
|
||||||
const isRu = Astro.url.pathname.startsWith('/ru');
|
const isRu = Astro.url.pathname.startsWith("/ru");
|
||||||
const T = useT(isRu ? 'ru' : 'en');
|
const T = useT(isRu ? "ru" : "en");
|
||||||
---
|
---
|
||||||
|
|
||||||
<div id="list-generator" class="mt-8">
|
<div id="list-generator" class="mt-8">
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label for="lst-items" class="block text-sm font-medium text-zinc-400 mb-1">
|
<label
|
||||||
{T.items} <span class="text-zinc-600">({T.itemsOneLine})</span>
|
for="lst-items"
|
||||||
</label>
|
class="block text-sm font-medium text-zinc-400 mb-1"
|
||||||
<textarea
|
>
|
||||||
id="lst-items"
|
{T.items}
|
||||||
rows="6"
|
<span class="text-zinc-600">({T.itemsOneLine})</span>
|
||||||
placeholder="Alice Bob Carol David"
|
</label>
|
||||||
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"
|
<textarea
|
||||||
aria-label={T.items}
|
id="lst-items"
|
||||||
></textarea>
|
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={T.items}></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col sm:flex-row gap-4 items-end">
|
||||||
|
<div class="flex-1">
|
||||||
|
<label
|
||||||
|
for="lst-pick"
|
||||||
|
class="block text-sm font-medium text-zinc-400 mb-1"
|
||||||
|
>{T.pick}</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
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={T.pick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label
|
||||||
|
class="flex items-center gap-2.5 cursor-pointer select-none group pb-2"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
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"
|
||||||
|
>{T.allowDuplicates}</span
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col sm:flex-row gap-4 items-end">
|
<p
|
||||||
<div class="flex-1">
|
id="lst-error"
|
||||||
<label for="lst-pick" class="block text-sm font-medium text-zinc-400 mb-1">{T.pick}</label>
|
role="alert"
|
||||||
<input
|
aria-live="polite"
|
||||||
id="lst-pick"
|
class="mt-3 text-sm text-red-500 hidden"
|
||||||
type="number"
|
>
|
||||||
value="1"
|
</p>
|
||||||
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 class="my-8 min-h-16 flex flex-col items-center justify-center gap-3">
|
||||||
aria-label={T.pick}
|
<div
|
||||||
/>
|
id="lst-result"
|
||||||
</div>
|
class="flex flex-wrap justify-center gap-2"
|
||||||
<label class="flex items-center gap-2.5 cursor-pointer select-none group pb-2">
|
aria-live="polite"
|
||||||
<input
|
aria-label="Picked items"
|
||||||
id="lst-replace"
|
>
|
||||||
type="checkbox"
|
</div>
|
||||||
class="w-4 h-4 rounded accent-[#534AB7] cursor-pointer"
|
|
||||||
/>
|
<button
|
||||||
<span class="text-sm text-zinc-400 group-hover:text-zinc-200 transition-colors">{T.allowDuplicates}</span>
|
id="lst-copy-btn"
|
||||||
</label>
|
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="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"
|
||||||
|
></rect>
|
||||||
|
<path
|
||||||
|
d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<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"></path>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<p
|
<div class="flex justify-center">
|
||||||
id="lst-error"
|
<button
|
||||||
role="alert"
|
id="lst-btn"
|
||||||
aria-live="polite"
|
type="button"
|
||||||
class="mt-3 text-sm text-red-500 hidden"
|
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"
|
||||||
></p>
|
>
|
||||||
|
{T.pick}
|
||||||
<div class="my-8 min-h-16 flex flex-col items-center justify-center gap-3">
|
</button>
|
||||||
<div
|
</div>
|
||||||
id="lst-result"
|
|
||||||
class="flex flex-wrap justify-center gap-2"
|
|
||||||
aria-live="polite"
|
|
||||||
aria-label="Picked items"
|
|
||||||
></div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
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="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="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>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-center">
|
|
||||||
<button
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
{T.pick}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const isRu = document.documentElement.lang === 'ru';
|
import { CopyFeedback } from "@/lib/client/clipboard";
|
||||||
const COPY_LABEL = isRu ? 'Копировать' : 'Copy';
|
import { createErrorDisplay } from "@/lib/client/validation";
|
||||||
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 isRu = document.documentElement.lang === "ru";
|
||||||
const pickInput = document.getElementById('lst-pick') as HTMLInputElement;
|
const COPY_LABEL = isRu ? "Копировать" : "Copy";
|
||||||
const replaceCb = document.getElementById('lst-replace') as HTMLInputElement;
|
const COPIED_LABEL = isRu ? "Скопировано" : "Copied";
|
||||||
const btn = document.getElementById('lst-btn') as HTMLButtonElement;
|
const ERR_ADD_ONE = isRu
|
||||||
const copyBtn = document.getElementById('lst-copy-btn') as HTMLButtonElement;
|
? "Добавьте хотя бы один элемент в список."
|
||||||
const resultEl = document.getElementById('lst-result') as HTMLDivElement;
|
: "Add at least one item to the list.";
|
||||||
const errorEl = document.getElementById('lst-error') as HTMLParagraphElement;
|
const ERR_PICK_ONE = isRu
|
||||||
const copyLabel = document.getElementById('lst-copy-label') as HTMLSpanElement;
|
? "Выберите хотя бы 1 элемент."
|
||||||
const copyIcon = document.getElementById('lst-copy-icon') as HTMLSpanElement;
|
: "Pick at least 1 item.";
|
||||||
const checkIcon = document.getElementById('lst-check-icon') as HTMLSpanElement;
|
|
||||||
|
|
||||||
let lastPicked: string[] = [];
|
const itemsInput = document.getElementById(
|
||||||
let copyRevertTimer: ReturnType<typeof setTimeout> | null = null;
|
"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;
|
||||||
|
|
||||||
function showError(msg: string) { errorEl.textContent = msg; errorEl.classList.remove('hidden'); }
|
let lastPicked: string[] = [];
|
||||||
function clearError() { errorEl.textContent = ''; errorEl.classList.add('hidden'); }
|
|
||||||
|
|
||||||
function pick() {
|
const errors = createErrorDisplay(errorEl);
|
||||||
const items = itemsInput.value.split('\n').map((s) => s.trim()).filter(Boolean);
|
const clipboard = new CopyFeedback(copyIcon, checkIcon);
|
||||||
const count = parseInt(pickInput.value, 10);
|
|
||||||
const withReplacement = replaceCb.checked;
|
|
||||||
|
|
||||||
if (items.length === 0) { showError(ERR_ADD_ONE); return; }
|
function pick() {
|
||||||
if (!Number.isInteger(count) || count < 1) { showError(ERR_PICK_ONE); return; }
|
const items = itemsInput.value
|
||||||
if (!withReplacement && count > items.length) {
|
.split("\n")
|
||||||
showError(
|
.map((s) => s.trim())
|
||||||
isRu
|
.filter(Boolean);
|
||||||
? `Нельзя выбрать ${count} уникальных элементов из списка ${items.length}.`
|
const count = parseInt(pickInput.value, 10);
|
||||||
: `Cannot pick ${count} unique items from a list of ${items.length}.`
|
const withReplacement = replaceCb.checked;
|
||||||
);
|
|
||||||
return;
|
if (items.length === 0) {
|
||||||
|
errors.show(ERR_ADD_ONE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(count) || count < 1) {
|
||||||
|
errors.show(ERR_PICK_ONE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!withReplacement && count > items.length) {
|
||||||
|
errors.show(
|
||||||
|
isRu
|
||||||
|
? `Нельзя выбрать ${count} уникальных элементов из списка ${items.length}.`
|
||||||
|
: `Cannot pick ${count} unique items from a list of ${items.length}.`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
errors.clear();
|
||||||
|
|
||||||
|
if (withReplacement) {
|
||||||
|
lastPicked = Array.from(
|
||||||
|
{ length: count },
|
||||||
|
() => items[Math.floor(Math.random() * items.length)],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const pool = [...items];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const j = i + Math.floor(Math.random() * (pool.length - i));
|
||||||
|
[pool[i], pool[j]] = [pool[j], pool[i]];
|
||||||
|
}
|
||||||
|
lastPicked = pool.slice(0, count);
|
||||||
|
}
|
||||||
|
|
||||||
|
resultEl.innerHTML = "";
|
||||||
|
lastPicked.forEach((item) => {
|
||||||
|
const chip = document.createElement("span");
|
||||||
|
chip.textContent = item;
|
||||||
|
chip.className =
|
||||||
|
"inline-flex items-center px-3 py-1.5 rounded-full border border-[#534AB7]/40 bg-[#534AB7]/10 text-zinc-100 text-sm font-medium";
|
||||||
|
resultEl.appendChild(chip);
|
||||||
|
});
|
||||||
|
|
||||||
|
copyBtn.classList.remove("invisible");
|
||||||
|
copyLabel.textContent = COPY_LABEL;
|
||||||
}
|
}
|
||||||
|
|
||||||
clearError();
|
async function copyPicked() {
|
||||||
|
if (!lastPicked.length) return;
|
||||||
|
await navigator.clipboard.writeText(lastPicked.join(", "));
|
||||||
|
|
||||||
if (withReplacement) {
|
clipboard.showCopied();
|
||||||
lastPicked = Array.from({ length: count }, () => items[Math.floor(Math.random() * items.length)]);
|
copyLabel.textContent = COPIED_LABEL;
|
||||||
} else {
|
|
||||||
const pool = [...items];
|
setTimeout(() => {
|
||||||
for (let i = 0; i < count; i++) {
|
copyLabel.textContent = COPY_LABEL;
|
||||||
const j = i + Math.floor(Math.random() * (pool.length - i));
|
}, 1500);
|
||||||
[pool[i], pool[j]] = [pool[j], pool[i]];
|
|
||||||
}
|
|
||||||
lastPicked = pool.slice(0, count);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resultEl.innerHTML = '';
|
btn.addEventListener("click", pick);
|
||||||
lastPicked.forEach((item) => {
|
copyBtn.addEventListener("click", copyPicked);
|
||||||
const chip = document.createElement('span');
|
itemsInput.addEventListener("keydown", (e) => {
|
||||||
chip.textContent = item;
|
if (e.key === "Enter" && e.ctrlKey) pick();
|
||||||
chip.className =
|
|
||||||
'inline-flex items-center px-3 py-1.5 rounded-full border border-[#534AB7]/40 bg-[#534AB7]/10 text-zinc-100 text-sm font-medium';
|
|
||||||
resultEl.appendChild(chip);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
copyBtn.classList.remove('invisible');
|
|
||||||
copyLabel.textContent = COPY_LABEL;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copyPicked() {
|
|
||||||
if (!lastPicked.length) return;
|
|
||||||
await navigator.clipboard.writeText(lastPicked.join(', '));
|
|
||||||
|
|
||||||
copyIcon.classList.add('hidden');
|
|
||||||
checkIcon.classList.remove('hidden');
|
|
||||||
copyLabel.textContent = COPIED_LABEL;
|
|
||||||
|
|
||||||
if (copyRevertTimer) clearTimeout(copyRevertTimer);
|
|
||||||
copyRevertTimer = setTimeout(() => {
|
|
||||||
checkIcon.classList.add('hidden');
|
|
||||||
copyIcon.classList.remove('hidden');
|
|
||||||
copyLabel.textContent = COPY_LABEL;
|
|
||||||
}, 1500);
|
|
||||||
}
|
|
||||||
|
|
||||||
btn.addEventListener('click', pick);
|
|
||||||
copyBtn.addEventListener('click', copyPicked);
|
|
||||||
itemsInput.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Enter' && e.ctrlKey) pick();
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,116 +1,224 @@
|
|||||||
---
|
---
|
||||||
import { useT } from '../../i18n/translations';
|
import { useT } from "../../i18n/translations";
|
||||||
const isRu = Astro.url.pathname.startsWith('/ru');
|
const isRu = Astro.url.pathname.startsWith("/ru");
|
||||||
const T = useT(isRu ? 'ru' : 'en');
|
const T = useT(isRu ? "ru" : "en");
|
||||||
---
|
---
|
||||||
|
|
||||||
<div id="lottery-generator" class="mt-8">
|
<div id="lottery-generator" class="mt-8">
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label for="lg-from" class="block text-sm font-medium text-zinc-400 mb-1">{T.from}</label>
|
<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" />
|
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"
|
||||||
|
>{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"
|
||||||
|
>{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>
|
</div>
|
||||||
<div>
|
|
||||||
<label for="lg-to" class="block text-sm font-medium text-zinc-400 mb-1">{T.to}</label>
|
<p
|
||||||
<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" />
|
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"
|
||||||
|
>
|
||||||
|
</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"
|
||||||
|
></rect><path
|
||||||
|
d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"
|
||||||
|
></path></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"></path></svg
|
||||||
|
></span
|
||||||
|
>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label for="lg-pick" class="block text-sm font-medium text-zinc-400 mb-1">{T.pick}</label>
|
<div class="flex justify-center">
|
||||||
<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" />
|
<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>
|
||||||
</div>
|
|
||||||
|
|
||||||
<p id="lg-error" role="alert" aria-live="polite" class="mt-3 text-sm text-red-500 hidden"></p>
|
|
||||||
|
|
||||||
<div class="my-10 min-h-24 flex flex-col items-center justify-center gap-4">
|
|
||||||
<div id="lg-result" class="flex flex-wrap justify-center gap-2" aria-live="polite"></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">
|
|
||||||
{T.draw}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const isRu = document.documentElement.lang === 'ru';
|
import { CopyFeedback } from "@/lib/client/clipboard";
|
||||||
const ERR_INTEGERS = isRu ? 'Все значения должны быть целыми числами.' : 'All values must be whole numbers.';
|
import { createErrorDisplay, isInteger } from "@/lib/client/validation";
|
||||||
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 isRu = document.documentElement.lang === "ru";
|
||||||
const toInput = document.getElementById('lg-to') as HTMLInputElement;
|
const ERR_INTEGERS = isRu
|
||||||
const pickInput = document.getElementById('lg-pick') as HTMLInputElement;
|
? "Все значения должны быть целыми числами."
|
||||||
const btn = document.getElementById('lg-btn') as HTMLButtonElement;
|
: "All values must be whole numbers.";
|
||||||
const copyBtn = document.getElementById('lg-copy-btn') as HTMLButtonElement;
|
const ERR_FROM_TO = isRu
|
||||||
const resultEl = document.getElementById('lg-result') as HTMLDivElement;
|
? "«От» должно быть меньше «До»."
|
||||||
const errorEl = document.getElementById('lg-error') as HTMLParagraphElement;
|
: '"From" must be less than "To".';
|
||||||
const copyLabel = document.getElementById('lg-copy-label') as HTMLSpanElement;
|
const ERR_PICK_MIN = isRu
|
||||||
const copyIcon = document.getElementById('lg-copy-icon') as HTMLSpanElement;
|
? "«Выбрать» должно быть не менее 1."
|
||||||
const checkIcon = document.getElementById('lg-check-icon') as HTMLSpanElement;
|
: '"Pick" must be at least 1.';
|
||||||
let copyRevertTimer: ReturnType<typeof setTimeout> | null = null;
|
const COPY_LABEL = isRu ? "Копировать" : "Copy";
|
||||||
|
const COPIED_LABEL = isRu ? "Скопировано" : "Copied";
|
||||||
|
|
||||||
function isInteger(val: string) { return /^-?\d+$/.test(val.trim()); }
|
const fromInput = document.getElementById("lg-from") as HTMLInputElement;
|
||||||
function showError(msg: string) { errorEl.textContent = msg; errorEl.classList.remove('hidden'); }
|
const toInput = document.getElementById("lg-to") as HTMLInputElement;
|
||||||
function clearError() { errorEl.textContent = ''; errorEl.classList.add('hidden'); }
|
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;
|
||||||
|
|
||||||
function draw() {
|
const errors = createErrorDisplay(errorEl);
|
||||||
if (!isInteger(fromInput.value) || !isInteger(toInput.value) || !isInteger(pickInput.value)) { showError(ERR_INTEGERS); return; }
|
const clipboard = new CopyFeedback(copyIcon, checkIcon);
|
||||||
const min = parseInt(fromInput.value, 10);
|
|
||||||
const max = parseInt(toInput.value, 10);
|
function draw() {
|
||||||
const pick = parseInt(pickInput.value, 10);
|
if (
|
||||||
const pool = max - min + 1;
|
!isInteger(fromInput.value) ||
|
||||||
if (min >= max) { showError(ERR_FROM_TO); return; }
|
!isInteger(toInput.value) ||
|
||||||
if (pick < 1) { showError(ERR_PICK_MIN); return; }
|
!isInteger(pickInput.value)
|
||||||
if (pick > pool) {
|
) {
|
||||||
showError(isRu ? `Нельзя выбрать ${pick} уникальных чисел из диапазона ${pool}.` : `Cannot pick ${pick} unique numbers from a range of ${pool}.`);
|
errors.show(ERR_INTEGERS);
|
||||||
return;
|
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) {
|
||||||
|
errors.show(ERR_FROM_TO);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pick < 1) {
|
||||||
|
errors.show(ERR_PICK_MIN);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pick > pool) {
|
||||||
|
errors.show(
|
||||||
|
isRu
|
||||||
|
? `Нельзя выбрать ${pick} уникальных чисел из диапазона ${pool}.`
|
||||||
|
: `Cannot pick ${pick} unique numbers from a range of ${pool}.`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
errors.clear();
|
||||||
|
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) => {
|
||||||
|
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";
|
||||||
|
resultEl.appendChild(ball);
|
||||||
|
});
|
||||||
|
copyBtn.classList.remove("invisible");
|
||||||
|
copyLabel.textContent = COPY_LABEL;
|
||||||
}
|
}
|
||||||
clearError();
|
|
||||||
const nums = Array.from({ length: pool }, (_, i) => min + i);
|
async function copyNumbers() {
|
||||||
for (let i = 0; i < pick; i++) {
|
const balls = resultEl.querySelectorAll("span");
|
||||||
const j = i + Math.floor(Math.random() * (pool - i));
|
const value = Array.from(balls)
|
||||||
[nums[i], nums[j]] = [nums[j], nums[i]];
|
.map((b) => b.textContent)
|
||||||
|
.join(", ");
|
||||||
|
if (!value) return;
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
clipboard.showCopied();
|
||||||
|
copyLabel.textContent = COPIED_LABEL;
|
||||||
|
setTimeout(() => {
|
||||||
|
copyLabel.textContent = COPY_LABEL;
|
||||||
|
}, 1500);
|
||||||
}
|
}
|
||||||
const picked = nums.slice(0, pick).sort((a, b) => a - b);
|
|
||||||
resultEl.innerHTML = '';
|
btn.addEventListener("click", draw);
|
||||||
picked.forEach((n) => {
|
copyBtn.addEventListener("click", copyNumbers);
|
||||||
const ball = document.createElement('span');
|
[fromInput, toInput, pickInput].forEach((input) => {
|
||||||
ball.textContent = String(n);
|
input.addEventListener("keydown", (e) => {
|
||||||
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';
|
if (e.key === "Enter") draw();
|
||||||
resultEl.appendChild(ball);
|
});
|
||||||
});
|
});
|
||||||
copyBtn.classList.remove('invisible');
|
|
||||||
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_LABEL;
|
|
||||||
if (copyRevertTimer) clearTimeout(copyRevertTimer);
|
|
||||||
copyRevertTimer = setTimeout(() => {
|
|
||||||
checkIcon.classList.add('hidden');
|
|
||||||
copyIcon.classList.remove('hidden');
|
|
||||||
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(); });
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,126 +1,182 @@
|
|||||||
---
|
---
|
||||||
import { useT } from '../../i18n/translations';
|
import { useT } from "../../i18n/translations";
|
||||||
const isRu = Astro.url.pathname.startsWith('/ru');
|
const isRu = Astro.url.pathname.startsWith("/ru");
|
||||||
const T = useT(isRu ? 'ru' : 'en');
|
const T = useT(isRu ? "ru" : "en");
|
||||||
---
|
---
|
||||||
|
|
||||||
<div id="number-generator" class="mt-8" data-ru={isRu ? '1' : '0'}>
|
<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 flex-col sm:flex-row gap-4">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<label for="ng-from" class="block text-sm font-medium text-zinc-400 mb-1">{T.from}</label>
|
<label
|
||||||
<input
|
for="ng-from"
|
||||||
id="ng-from"
|
class="block text-sm font-medium text-zinc-400 mb-1"
|
||||||
type="number"
|
>{T.from}</label
|
||||||
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"
|
<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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
|
||||||
<label for="ng-to" class="block text-sm font-medium text-zinc-400 mb-1">{T.to}</label>
|
<p
|
||||||
<input
|
id="ng-error"
|
||||||
id="ng-to"
|
role="alert"
|
||||||
type="number"
|
aria-live="polite"
|
||||||
value="100"
|
class="mt-2 text-sm text-red-600 hidden"
|
||||||
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"
|
>
|
||||||
/>
|
</p>
|
||||||
|
|
||||||
|
<div class="my-10 min-h-36 flex flex-col items-center justify-center gap-3">
|
||||||
|
<button
|
||||||
|
id="ng-copy-btn"
|
||||||
|
type="button"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
id="ng-result"
|
||||||
|
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"
|
||||||
|
></rect><path
|
||||||
|
d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"
|
||||||
|
></path></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"></path></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">{T.copied}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<p id="ng-error" role="alert" aria-live="polite" class="mt-2 text-sm text-red-600 hidden"></p>
|
<div class="flex justify-center">
|
||||||
|
<button
|
||||||
<div class="my-10 min-h-36 flex flex-col items-center justify-center gap-3">
|
id="ng-btn"
|
||||||
<button
|
type="button"
|
||||||
id="ng-copy-btn"
|
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"
|
||||||
type="button"
|
>
|
||||||
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]"
|
{T.generate}
|
||||||
aria-live="polite"
|
</button>
|
||||||
>
|
</div>
|
||||||
<span
|
|
||||||
id="ng-result"
|
|
||||||
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>
|
|
||||||
<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">{T.copied}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-center">
|
|
||||||
<button
|
|
||||||
id="ng-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>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const isRu = document.documentElement.lang === 'ru';
|
import { CopyFeedback } from "@/lib/client/clipboard";
|
||||||
const ERR_INTEGERS = isRu ? 'Оба значения должны быть целыми числами.' : 'Both values must be whole numbers.';
|
import { createErrorDisplay, isInteger } from "@/lib/client/validation";
|
||||||
const ERR_FROM_TO = isRu ? '«От» должно быть меньше «До».' : '"From" must be less than "To".';
|
import { popElement } from "@/lib/client/animations";
|
||||||
|
|
||||||
const fromInput = document.getElementById('ng-from') as HTMLInputElement;
|
const isRu = document.documentElement.lang === "ru";
|
||||||
const toInput = document.getElementById('ng-to') as HTMLInputElement;
|
const ERR_INTEGERS = isRu
|
||||||
const btn = document.getElementById('ng-btn') as HTMLButtonElement;
|
? "Оба значения должны быть целыми числами."
|
||||||
const copyBtn = document.getElementById('ng-copy-btn') as HTMLButtonElement;
|
: "Both values must be whole numbers.";
|
||||||
const resultEl = document.getElementById('ng-result') as HTMLSpanElement;
|
const ERR_FROM_TO = isRu
|
||||||
const errorEl = document.getElementById('ng-error') as HTMLParagraphElement;
|
? "«От» должно быть меньше «До»."
|
||||||
const copyIcon = document.getElementById('ng-copy-icon') as HTMLSpanElement;
|
: '"From" must be less than "To".';
|
||||||
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;
|
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;
|
||||||
|
|
||||||
function showError(msg: string) { errorEl.textContent = msg; errorEl.classList.remove('hidden'); }
|
const errors = createErrorDisplay(errorEl);
|
||||||
function clearError() { errorEl.textContent = ''; errorEl.classList.add('hidden'); }
|
const clipboard = new CopyFeedback(copyIcon, checkIcon);
|
||||||
function isInteger(val: string) { return /^-?\d+$/.test(val.trim()); }
|
|
||||||
|
|
||||||
function pop() {
|
function generate() {
|
||||||
resultEl.style.transform = 'scale(1.18)';
|
if (!isInteger(fromInput.value) || !isInteger(toInput.value)) {
|
||||||
resultEl.style.opacity = '0.7';
|
errors.show(ERR_INTEGERS);
|
||||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
return;
|
||||||
resultEl.style.transform = 'scale(1)';
|
}
|
||||||
resultEl.style.opacity = '1';
|
const min = parseInt(fromInput.value, 10);
|
||||||
}));
|
const max = parseInt(toInput.value, 10);
|
||||||
}
|
if (min >= max) {
|
||||||
|
errors.show(ERR_FROM_TO);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
errors.clear();
|
||||||
|
const result = Math.floor(Math.random() * (max - min + 1)) + min;
|
||||||
|
resultEl.textContent = String(result);
|
||||||
|
copyBtn.classList.remove("invisible");
|
||||||
|
popElement(resultEl);
|
||||||
|
}
|
||||||
|
|
||||||
function generate() {
|
async function copyNumber() {
|
||||||
if (!isInteger(fromInput.value) || !isInteger(toInput.value)) { showError(ERR_INTEGERS); return; }
|
const value = resultEl.textContent?.trim();
|
||||||
const min = parseInt(fromInput.value, 10);
|
if (!value) return;
|
||||||
const max = parseInt(toInput.value, 10);
|
await navigator.clipboard.writeText(value);
|
||||||
if (min >= max) { showError(ERR_FROM_TO); return; }
|
clipboard.showCopied();
|
||||||
clearError();
|
copiedLabel.style.opacity = "1";
|
||||||
const result = Math.floor(Math.random() * (max - min + 1)) + min;
|
setTimeout(() => {
|
||||||
resultEl.textContent = String(result);
|
copiedLabel.style.opacity = "0";
|
||||||
copyBtn.classList.remove('invisible');
|
}, 1500);
|
||||||
pop();
|
}
|
||||||
}
|
|
||||||
|
|
||||||
async function copyNumber() {
|
btn.addEventListener("click", generate);
|
||||||
const value = resultEl.textContent?.trim();
|
copyBtn.addEventListener("click", copyNumber);
|
||||||
if (!value) return;
|
[fromInput, toInput].forEach((input) => {
|
||||||
await navigator.clipboard.writeText(value);
|
input.addEventListener("keydown", (e) => {
|
||||||
copyIcon.classList.add('hidden');
|
if (e.key === "Enter") generate();
|
||||||
checkIcon.classList.remove('hidden');
|
});
|
||||||
copiedLabel.style.opacity = '1';
|
});
|
||||||
if (copyRevertTimer) clearTimeout(copyRevertTimer);
|
|
||||||
copyRevertTimer = setTimeout(() => {
|
|
||||||
checkIcon.classList.add('hidden');
|
|
||||||
copyIcon.classList.remove('hidden');
|
|
||||||
copiedLabel.style.opacity = '0';
|
|
||||||
}, 1500);
|
|
||||||
}
|
|
||||||
|
|
||||||
btn.addEventListener('click', generate);
|
|
||||||
copyBtn.addEventListener('click', copyNumber);
|
|
||||||
[fromInput, toInput].forEach((input) => {
|
|
||||||
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') generate(); });
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,123 +1,227 @@
|
|||||||
---
|
---
|
||||||
import { useT } from '../../i18n/translations';
|
import { useT } from "../../i18n/translations";
|
||||||
const isRu = Astro.url.pathname.startsWith('/ru');
|
const isRu = Astro.url.pathname.startsWith("/ru");
|
||||||
const T = useT(isRu ? 'ru' : 'en');
|
const T = useT(isRu ? "ru" : "en");
|
||||||
---
|
---
|
||||||
|
|
||||||
<div id="password-generator" class="mt-8">
|
<div id="password-generator" class="mt-8">
|
||||||
<div class="flex flex-col gap-5">
|
<div class="flex flex-col gap-5">
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center justify-between mb-1">
|
<div class="flex items-center justify-between mb-1">
|
||||||
<label for="pg-length" class="text-sm font-medium text-zinc-400">{T.length}</label>
|
<label for="pg-length" class="text-sm font-medium text-zinc-400"
|
||||||
<span id="pg-length-val" class="text-sm font-semibold tabular-nums text-zinc-100">16</span>
|
>{T.length}</label
|
||||||
</div>
|
>
|
||||||
<input id="pg-length" type="range" min="4" max="64" value="16" class="w-full accent-[#534AB7] cursor-pointer" />
|
<span
|
||||||
<div class="flex justify-between text-xs text-zinc-600 mt-1 select-none"><span>4</span><span>64</span></div>
|
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"
|
||||||
|
/>
|
||||||
|
<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: 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"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-zinc-400 group-hover:text-zinc-200 transition-colors">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-3">
|
|
||||||
{[
|
<p
|
||||||
{ id: 'pg-upper', label: T.uppercase },
|
id="pg-error"
|
||||||
{ id: 'pg-lower', label: T.lowercase },
|
role="alert"
|
||||||
{ id: 'pg-digits', label: T.digits },
|
aria-live="polite"
|
||||||
{ id: 'pg-symbols', label: T.symbols },
|
class="mt-4 text-sm text-red-500 hidden"
|
||||||
].map(({ id, label }) => (
|
>
|
||||||
<label class="flex items-center gap-2.5 cursor-pointer select-none group">
|
</p>
|
||||||
<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>
|
<div class="my-10 min-h-24 flex flex-col items-center justify-center gap-3">
|
||||||
</label>
|
<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"
|
||||||
|
></rect><path
|
||||||
|
d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"
|
||||||
|
></path></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"></path></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">{T.copied}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<p id="pg-error" role="alert" aria-live="polite" class="mt-4 text-sm text-red-500 hidden"></p>
|
<div class="flex justify-center">
|
||||||
|
<button
|
||||||
<div class="my-10 min-h-24 flex flex-col items-center justify-center gap-3">
|
id="pg-btn"
|
||||||
<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]">
|
type="button"
|
||||||
<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>
|
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"
|
||||||
<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>
|
{T.generate}
|
||||||
</span>
|
</button>
|
||||||
<span id="pg-check-icon" class="absolute -right-7 top-1/2 -translate-y-1/2 hidden text-[#534AB7]" aria-hidden="true">
|
</div>
|
||||||
<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">{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">
|
|
||||||
{T.generate}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const isRu = document.documentElement.lang === 'ru';
|
import { CopyFeedback } from "@/lib/client/clipboard";
|
||||||
const ERR_SELECT_TYPE = isRu ? 'Выберите хотя бы один тип символов.' : 'Select at least one character type.';
|
import { createErrorDisplay } from "@/lib/client/validation";
|
||||||
|
|
||||||
const lengthInput = document.getElementById('pg-length') as HTMLInputElement;
|
const isRu = document.documentElement.lang === "ru";
|
||||||
const lengthVal = document.getElementById('pg-length-val') as HTMLSpanElement;
|
const ERR_SELECT_TYPE = isRu
|
||||||
const upperCb = document.getElementById('pg-upper') as HTMLInputElement;
|
? "Выберите хотя бы один тип символов."
|
||||||
const lowerCb = document.getElementById('pg-lower') as HTMLInputElement;
|
: "Select at least one character type.";
|
||||||
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 lengthInput = document.getElementById(
|
||||||
const LOWER = 'abcdefghijklmnopqrstuvwxyz';
|
"pg-length",
|
||||||
const DIGITS = '0123456789';
|
) as HTMLInputElement;
|
||||||
const SYMBOLS = '!@#$%^&*()-_=+[]{}|;:,.<>?';
|
const lengthVal = document.getElementById(
|
||||||
let copyRevertTimer: ReturnType<typeof setTimeout> | null = null;
|
"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;
|
||||||
|
|
||||||
lengthInput.addEventListener('input', () => { lengthVal.textContent = lengthInput.value; });
|
const UPPER = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||||
|
const LOWER = "abcdefghijklmnopqrstuvwxyz";
|
||||||
|
const DIGITS = "0123456789";
|
||||||
|
const SYMBOLS = "!@#$%^&*()-_=+[]{}|;:,.<>?";
|
||||||
|
|
||||||
function showError(msg: string) { errorEl.textContent = msg; errorEl.classList.remove('hidden'); }
|
lengthInput.addEventListener("input", () => {
|
||||||
function clearError() { errorEl.textContent = ''; errorEl.classList.add('hidden'); }
|
lengthVal.textContent = lengthInput.value;
|
||||||
|
});
|
||||||
|
|
||||||
function generate() {
|
const errors = createErrorDisplay(errorEl);
|
||||||
const length = parseInt(lengthInput.value, 10);
|
const clipboard = new CopyFeedback(copyIcon, checkIcon);
|
||||||
const pools: string[] = [];
|
|
||||||
if (upperCb.checked) pools.push(UPPER);
|
function generate() {
|
||||||
if (lowerCb.checked) pools.push(LOWER);
|
const length = parseInt(lengthInput.value, 10);
|
||||||
if (digitsCb.checked) pools.push(DIGITS);
|
const pools: string[] = [];
|
||||||
if (symbolsCb.checked) pools.push(SYMBOLS);
|
if (upperCb.checked) pools.push(UPPER);
|
||||||
if (pools.length === 0) { showError(ERR_SELECT_TYPE); return; }
|
if (lowerCb.checked) pools.push(LOWER);
|
||||||
clearError();
|
if (digitsCb.checked) pools.push(DIGITS);
|
||||||
const required = pools.map((p) => p[Math.floor(Math.random() * p.length)]);
|
if (symbolsCb.checked) pools.push(SYMBOLS);
|
||||||
const combined = pools.join('');
|
if (pools.length === 0) {
|
||||||
const rest = Array.from({ length: length - required.length }, () => combined[Math.floor(Math.random() * combined.length)]);
|
errors.show(ERR_SELECT_TYPE);
|
||||||
const chars = [...required, ...rest];
|
return;
|
||||||
for (let i = chars.length - 1; i > 0; i--) {
|
}
|
||||||
const j = Math.floor(Math.random() * (i + 1));
|
errors.clear();
|
||||||
[chars[i], chars[j]] = [chars[j], chars[i]];
|
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)],
|
||||||
|
);
|
||||||
|
const chars = [...required, ...rest];
|
||||||
|
for (let i = chars.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[chars[i], chars[j]] = [chars[j], chars[i]];
|
||||||
|
}
|
||||||
|
resultEl.textContent = chars.join("");
|
||||||
|
resultEl.style.opacity = "0.5";
|
||||||
|
requestAnimationFrame(() =>
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
resultEl.style.opacity = "1";
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
copyBtn.classList.remove("invisible");
|
||||||
}
|
}
|
||||||
resultEl.textContent = chars.join('');
|
|
||||||
resultEl.style.opacity = '0.5';
|
|
||||||
requestAnimationFrame(() => requestAnimationFrame(() => { resultEl.style.opacity = '1'; }));
|
|
||||||
copyBtn.classList.remove('invisible');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copyPassword() {
|
async function copyPassword() {
|
||||||
const value = resultEl.textContent?.trim();
|
const value = resultEl.textContent?.trim();
|
||||||
if (!value) return;
|
if (!value) return;
|
||||||
await navigator.clipboard.writeText(value);
|
await navigator.clipboard.writeText(value);
|
||||||
copyIcon.classList.add('hidden');
|
clipboard.showCopied();
|
||||||
checkIcon.classList.remove('hidden');
|
copiedLabel.style.opacity = "1";
|
||||||
copiedLabel.style.opacity = '1';
|
setTimeout(() => {
|
||||||
if (copyRevertTimer) clearTimeout(copyRevertTimer);
|
copiedLabel.style.opacity = "0";
|
||||||
copyRevertTimer = setTimeout(() => {
|
}, 1500);
|
||||||
checkIcon.classList.add('hidden');
|
}
|
||||||
copyIcon.classList.remove('hidden');
|
|
||||||
copiedLabel.style.opacity = '0';
|
|
||||||
}, 1500);
|
|
||||||
}
|
|
||||||
|
|
||||||
btn.addEventListener('click', generate);
|
btn.addEventListener("click", generate);
|
||||||
copyBtn.addEventListener('click', copyPassword);
|
copyBtn.addEventListener("click", copyPassword);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,191 +1,263 @@
|
|||||||
---
|
---
|
||||||
import { useT } from '../../i18n/translations';
|
import { useT } from "../../i18n/translations";
|
||||||
const isRu = Astro.url.pathname.startsWith('/ru');
|
const isRu = Astro.url.pathname.startsWith("/ru");
|
||||||
const T = useT(isRu ? 'ru' : 'en');
|
const T = useT(isRu ? "ru" : "en");
|
||||||
---
|
---
|
||||||
|
|
||||||
<div id="uuid-generator" class="mt-8">
|
<div id="uuid-generator" class="mt-8">
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-zinc-400 mb-2">{T.type}</label>
|
<label class="block text-sm font-medium text-zinc-400 mb-2"
|
||||||
<div class="flex flex-wrap gap-2" role="radiogroup" aria-label={T.type}>
|
>{T.type}</label
|
||||||
{[
|
>
|
||||||
{ value: 'uuid', label: 'UUID v4' },
|
<div
|
||||||
{ value: 'hex', label: 'Hex' },
|
class="flex flex-wrap gap-2"
|
||||||
{ value: 'base64', label: 'Base64' },
|
role="radiogroup"
|
||||||
].map(({ value, label }) => (
|
aria-label={T.type}
|
||||||
<label class="cursor-pointer">
|
>
|
||||||
<input type="radio" name="ug-type" value={value} class="sr-only peer" checked={value === 'uuid'} />
|
{
|
||||||
<span class="inline-flex items-center justify-center px-4 h-9 rounded-lg border border-zinc-700 text-sm font-medium text-zinc-400 peer-checked:border-[#534AB7] peer-checked:text-[#534AB7] peer-checked:bg-[#534AB7]/10 hover:border-zinc-500 transition-colors select-none cursor-pointer">
|
[
|
||||||
{label}
|
{ value: "uuid", label: "UUID v4" },
|
||||||
|
{ value: "hex", label: "Hex" },
|
||||||
|
{ value: "base64", label: "Base64" },
|
||||||
|
].map(({ value, label }) => (
|
||||||
|
<label class="cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="ug-type"
|
||||||
|
value={value}
|
||||||
|
class="sr-only peer"
|
||||||
|
checked={value === "uuid"}
|
||||||
|
/>
|
||||||
|
<span class="inline-flex items-center justify-center px-4 h-9 rounded-lg border border-zinc-700 text-sm font-medium text-zinc-400 peer-checked:border-[#534AB7] peer-checked:text-[#534AB7] peer-checked:bg-[#534AB7]/10 hover:border-zinc-500 transition-colors select-none cursor-pointer">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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"
|
||||||
|
>{T.lengthBytes}</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="ug-length"
|
||||||
|
type="number"
|
||||||
|
value="16"
|
||||||
|
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={T.lengthBytes}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<label
|
||||||
|
for="ug-count"
|
||||||
|
class="block text-sm font-medium text-zinc-400 mb-1"
|
||||||
|
>{T.count}</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="ug-count"
|
||||||
|
type="number"
|
||||||
|
value="1"
|
||||||
|
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={T.count}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="my-8 min-h-16 flex flex-col gap-2"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-label="Generated tokens"
|
||||||
|
>
|
||||||
|
<div id="ug-result" class="flex flex-col gap-2"></div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
id="ug-copy-btn"
|
||||||
|
type="button"
|
||||||
|
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">{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"
|
||||||
|
></rect>
|
||||||
|
<path
|
||||||
|
d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
<span
|
||||||
))}
|
id="ug-check-icon"
|
||||||
</div>
|
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"></path>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col sm:flex-row gap-4">
|
<div class="flex justify-center">
|
||||||
<div id="ug-length-wrap" class="flex-1 hidden">
|
<button
|
||||||
<label for="ug-length" class="block text-sm font-medium text-zinc-400 mb-1">{T.lengthBytes}</label>
|
id="ug-btn"
|
||||||
<input
|
type="button"
|
||||||
id="ug-length"
|
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"
|
||||||
type="number"
|
>
|
||||||
value="16"
|
{T.generate}
|
||||||
min="4"
|
</button>
|
||||||
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={T.lengthBytes}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1">
|
|
||||||
<label for="ug-count" class="block text-sm font-medium text-zinc-400 mb-1">{T.count}</label>
|
|
||||||
<input
|
|
||||||
id="ug-count"
|
|
||||||
type="number"
|
|
||||||
value="1"
|
|
||||||
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={T.count}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="my-8 min-h-16 flex flex-col gap-2" aria-live="polite" aria-label="Generated tokens">
|
|
||||||
<div id="ug-result" class="flex flex-col gap-2"></div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
id="ug-copy-btn"
|
|
||||||
type="button"
|
|
||||||
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">{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"/>
|
|
||||||
<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="ug-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="ug-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>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const isRu = document.documentElement.lang === 'ru';
|
import { CopyFeedback } from "@/lib/client/clipboard";
|
||||||
const COPY_ALL_LABEL = isRu ? 'Копировать всё' : 'Copy all';
|
|
||||||
const COPIED_LABEL = isRu ? 'Скопировано' : 'Copied';
|
|
||||||
|
|
||||||
const lengthWrap = document.getElementById('ug-length-wrap') as HTMLDivElement;
|
const isRu = document.documentElement.lang === "ru";
|
||||||
const lengthInput = document.getElementById('ug-length') as HTMLInputElement;
|
const COPY_ALL_LABEL = isRu ? "Копировать всё" : "Copy all";
|
||||||
const countInput = document.getElementById('ug-count') as HTMLInputElement;
|
const COPIED_LABEL = isRu ? "Скопировано" : "Copied";
|
||||||
const btn = document.getElementById('ug-btn') as HTMLButtonElement;
|
|
||||||
const copyBtn = document.getElementById('ug-copy-btn') as HTMLButtonElement;
|
|
||||||
const resultEl = document.getElementById('ug-result') as HTMLDivElement;
|
|
||||||
const copyLabel = document.getElementById('ug-copy-label') as HTMLSpanElement;
|
|
||||||
const copyIcon = document.getElementById('ug-copy-icon') as HTMLSpanElement;
|
|
||||||
const checkIcon = document.getElementById('ug-check-icon') as HTMLSpanElement;
|
|
||||||
|
|
||||||
let lastTokens: string[] = [];
|
const lengthWrap = document.getElementById(
|
||||||
let copyRevertTimer: ReturnType<typeof setTimeout> | null = null;
|
"ug-length-wrap",
|
||||||
|
) as HTMLDivElement;
|
||||||
|
const lengthInput = document.getElementById(
|
||||||
|
"ug-length",
|
||||||
|
) as HTMLInputElement;
|
||||||
|
const countInput = document.getElementById("ug-count") as HTMLInputElement;
|
||||||
|
const btn = document.getElementById("ug-btn") as HTMLButtonElement;
|
||||||
|
const copyBtn = document.getElementById("ug-copy-btn") as HTMLButtonElement;
|
||||||
|
const resultEl = document.getElementById("ug-result") as HTMLDivElement;
|
||||||
|
const copyLabel = document.getElementById(
|
||||||
|
"ug-copy-label",
|
||||||
|
) as HTMLSpanElement;
|
||||||
|
const copyIcon = document.getElementById("ug-copy-icon") as HTMLSpanElement;
|
||||||
|
const checkIcon = document.getElementById(
|
||||||
|
"ug-check-icon",
|
||||||
|
) as HTMLSpanElement;
|
||||||
|
|
||||||
function getType(): string {
|
let lastTokens: string[] = [];
|
||||||
return (document.querySelector('input[name="ug-type"]:checked') as HTMLInputElement)?.value ?? 'uuid';
|
|
||||||
}
|
|
||||||
|
|
||||||
document.querySelectorAll('input[name="ug-type"]').forEach((radio) => {
|
const clipboard = new CopyFeedback(copyIcon, checkIcon);
|
||||||
radio.addEventListener('change', () => {
|
|
||||||
const type = getType();
|
|
||||||
lengthWrap.classList.toggle('hidden', type === 'uuid');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function uuidV4(): string {
|
function getType(): string {
|
||||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
return (
|
||||||
const r = (crypto.getRandomValues(new Uint8Array(1))[0] & 0xf);
|
(
|
||||||
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
|
document.querySelector(
|
||||||
});
|
'input[name="ug-type"]:checked',
|
||||||
}
|
) as HTMLInputElement
|
||||||
|
)?.value ?? "uuid"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function randomBytes(n: number): Uint8Array {
|
document.querySelectorAll('input[name="ug-type"]').forEach((radio) => {
|
||||||
const buf = new Uint8Array(n);
|
radio.addEventListener("change", () => {
|
||||||
crypto.getRandomValues(buf);
|
const type = getType();
|
||||||
return buf;
|
lengthWrap.classList.toggle("hidden", type === "uuid");
|
||||||
}
|
});
|
||||||
|
|
||||||
function toHex(bytes: Uint8Array): string {
|
|
||||||
return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function toBase64(bytes: Uint8Array): string {
|
|
||||||
return btoa(String.fromCharCode(...bytes))
|
|
||||||
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
function generate() {
|
|
||||||
const type = getType();
|
|
||||||
const count = parseInt(countInput.value, 10);
|
|
||||||
const length = parseInt(lengthInput.value, 10);
|
|
||||||
|
|
||||||
lastTokens = Array.from({ length: count }, () => {
|
|
||||||
if (type === 'uuid') return uuidV4();
|
|
||||||
if (type === 'hex') return toHex(randomBytes(length));
|
|
||||||
return toBase64(randomBytes(length));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
resultEl.innerHTML = '';
|
function uuidV4(): string {
|
||||||
lastTokens.forEach((token) => {
|
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
||||||
const row = document.createElement('div');
|
const r = crypto.getRandomValues(new Uint8Array(1))[0] & 0xf;
|
||||||
row.className =
|
return (c === "x" ? r : (r & 0x3) | 0x8).toString(16);
|
||||||
'flex items-center justify-between gap-3 bg-zinc-900 border border-zinc-700 rounded-lg px-3 py-2';
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const code = document.createElement('code');
|
function randomBytes(n: number): Uint8Array {
|
||||||
code.textContent = token;
|
const buf = new Uint8Array(n);
|
||||||
code.className = 'text-sm font-mono text-zinc-100 break-all';
|
crypto.getRandomValues(buf);
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
row.appendChild(code);
|
function toHex(bytes: Uint8Array): string {
|
||||||
resultEl.appendChild(row);
|
return Array.from(bytes)
|
||||||
|
.map((b) => b.toString(16).padStart(2, "0"))
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBase64(bytes: Uint8Array): string {
|
||||||
|
return btoa(String.fromCharCode(...bytes))
|
||||||
|
.replace(/\+/g, "-")
|
||||||
|
.replace(/\//g, "_")
|
||||||
|
.replace(/=+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function generate() {
|
||||||
|
const type = getType();
|
||||||
|
const count = parseInt(countInput.value, 10);
|
||||||
|
const length = parseInt(lengthInput.value, 10);
|
||||||
|
|
||||||
|
lastTokens = Array.from({ length: count }, () => {
|
||||||
|
if (type === "uuid") return uuidV4();
|
||||||
|
if (type === "hex") return toHex(randomBytes(length));
|
||||||
|
return toBase64(randomBytes(length));
|
||||||
|
});
|
||||||
|
|
||||||
|
resultEl.innerHTML = "";
|
||||||
|
lastTokens.forEach((token) => {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className =
|
||||||
|
"flex items-center justify-between gap-3 bg-zinc-900 border border-zinc-700 rounded-lg px-3 py-2";
|
||||||
|
|
||||||
|
const code = document.createElement("code");
|
||||||
|
code.textContent = token;
|
||||||
|
code.className = "text-sm font-mono text-zinc-100 break-all";
|
||||||
|
|
||||||
|
row.appendChild(code);
|
||||||
|
resultEl.appendChild(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
copyBtn.classList.remove("invisible");
|
||||||
|
copyLabel.textContent = COPY_ALL_LABEL;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyAll() {
|
||||||
|
if (!lastTokens.length) return;
|
||||||
|
await navigator.clipboard.writeText(lastTokens.join("\n"));
|
||||||
|
|
||||||
|
clipboard.showCopied();
|
||||||
|
copyLabel.textContent = COPIED_LABEL;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
copyLabel.textContent = COPY_ALL_LABEL;
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.addEventListener("click", generate);
|
||||||
|
copyBtn.addEventListener("click", copyAll);
|
||||||
|
[countInput, lengthInput].forEach((el) => {
|
||||||
|
el.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Enter") generate();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
copyBtn.classList.remove('invisible');
|
|
||||||
copyLabel.textContent = COPY_ALL_LABEL;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copyAll() {
|
|
||||||
if (!lastTokens.length) return;
|
|
||||||
await navigator.clipboard.writeText(lastTokens.join('\n'));
|
|
||||||
|
|
||||||
copyIcon.classList.add('hidden');
|
|
||||||
checkIcon.classList.remove('hidden');
|
|
||||||
copyLabel.textContent = COPIED_LABEL;
|
|
||||||
|
|
||||||
if (copyRevertTimer) clearTimeout(copyRevertTimer);
|
|
||||||
copyRevertTimer = setTimeout(() => {
|
|
||||||
checkIcon.classList.add('hidden');
|
|
||||||
copyIcon.classList.remove('hidden');
|
|
||||||
copyLabel.textContent = COPY_ALL_LABEL;
|
|
||||||
}, 1500);
|
|
||||||
}
|
|
||||||
|
|
||||||
btn.addEventListener('click', generate);
|
|
||||||
copyBtn.addEventListener('click', copyAll);
|
|
||||||
[countInput, lengthInput].forEach((el) => {
|
|
||||||
el.addEventListener('keydown', (e) => { if (e.key === 'Enter') generate(); });
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,332 +1,436 @@
|
|||||||
---
|
---
|
||||||
import { useT } from '../../i18n/translations';
|
import { useT } from "../../i18n/translations";
|
||||||
const isRu = Astro.url.pathname.startsWith('/ru');
|
const isRu = Astro.url.pathname.startsWith("/ru");
|
||||||
const T = useT(isRu ? 'ru' : 'en');
|
const T = useT(isRu ? "ru" : "en");
|
||||||
---
|
---
|
||||||
|
|
||||||
<div id="wheel-spinner" class="mt-8">
|
<div id="wheel-spinner" class="mt-8">
|
||||||
|
<!-- Items input -->
|
||||||
<!-- Items input -->
|
<div class="flex flex-col gap-3">
|
||||||
<div class="flex flex-col gap-3">
|
<div>
|
||||||
<div>
|
<label
|
||||||
<label for="ws-items" class="block text-sm font-medium text-zinc-400 mb-1">
|
for="ws-items"
|
||||||
{T.items} <span class="text-zinc-600">({T.itemsOneLine224})</span>
|
class="block text-sm font-medium text-zinc-400 mb-1"
|
||||||
</label>
|
>
|
||||||
<textarea
|
{T.items}
|
||||||
id="ws-items"
|
<span class="text-zinc-600">({T.itemsOneLine224})</span>
|
||||||
rows="6"
|
</label>
|
||||||
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"
|
<textarea
|
||||||
>Alice
|
id="ws-items"
|
||||||
Bob
|
rows="6"
|
||||||
Carol
|
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"
|
||||||
Dave
|
>Alice Bob Carol Dave Eve Frank</textarea
|
||||||
Eve
|
>
|
||||||
Frank</textarea>
|
</div>
|
||||||
</div>
|
<p
|
||||||
<p id="ws-error" role="alert" aria-live="polite" class="text-sm text-red-500 hidden"></p>
|
id="ws-error"
|
||||||
</div>
|
role="alert"
|
||||||
|
aria-live="polite"
|
||||||
<!-- Wheel area -->
|
class="text-sm text-red-500 hidden"
|
||||||
<div class="flex flex-col items-center mt-8 gap-6">
|
>
|
||||||
<div class="relative w-full max-w-[480px]" id="ws-wheel-wrap">
|
</p>
|
||||||
<!-- Pointer: triangle pointing down at top-center of wheel -->
|
|
||||||
<div
|
|
||||||
id="ws-pointer"
|
|
||||||
class="absolute left-1/2 top-0 z-10 -translate-x-1/2 -translate-y-1/2"
|
|
||||||
style="width:0;height:0;border-left:14px solid transparent;border-right:14px solid transparent;border-top:24px solid #534AB7;filter:drop-shadow(0 2px 8px rgba(83,74,183,0.6));"
|
|
||||||
aria-hidden="true"
|
|
||||||
></div>
|
|
||||||
<canvas
|
|
||||||
id="ws-canvas"
|
|
||||||
class="block w-full"
|
|
||||||
aria-label="Wheel of fortune"
|
|
||||||
></canvas>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<!-- Wheel area -->
|
||||||
id="ws-spin-btn"
|
<div class="flex flex-col items-center mt-8 gap-6">
|
||||||
type="button"
|
<div class="relative w-full max-w-[480px]" id="ws-wheel-wrap">
|
||||||
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"
|
<!-- Pointer: triangle pointing down at top-center of wheel -->
|
||||||
>
|
<div
|
||||||
{T.spin}
|
id="ws-pointer"
|
||||||
</button>
|
class="absolute left-1/2 top-0 z-10 -translate-x-1/2 -translate-y-1/2"
|
||||||
</div>
|
style="width:0;height:0;border-left:14px solid transparent;border-right:14px solid transparent;border-top:24px solid #534AB7;filter:drop-shadow(0 2px 8px rgba(83,74,183,0.6));"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<canvas
|
||||||
|
id="ws-canvas"
|
||||||
|
class="block w-full"
|
||||||
|
aria-label="Wheel of fortune"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Result card (hidden until first spin) -->
|
<button
|
||||||
<div id="ws-result-card" class="hidden mt-10 flex flex-col items-center gap-3" aria-live="polite">
|
id="ws-spin-btn"
|
||||||
<p id="ws-winner-label" class="text-xs font-semibold uppercase tracking-widest text-zinc-500">{T.winner}</p>
|
type="button"
|
||||||
<span id="ws-result-text" class="text-3xl sm:text-4xl font-bold text-zinc-100 text-center break-words max-w-full"></span>
|
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"
|
||||||
<button
|
>
|
||||||
id="ws-copy-btn"
|
{T.spin}
|
||||||
type="button"
|
</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"
|
</div>
|
||||||
>
|
|
||||||
<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"/>
|
|
||||||
<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="ws-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>
|
|
||||||
|
|
||||||
|
<!-- 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
|
||||||
|
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">{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"
|
||||||
|
></rect>
|
||||||
|
<path
|
||||||
|
d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
id="ws-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"></path>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@keyframes ws-pop {
|
@keyframes ws-pop {
|
||||||
from { transform: scale(0.6); opacity: 0; }
|
from {
|
||||||
to { transform: scale(1); opacity: 1; }
|
transform: scale(0.6);
|
||||||
}
|
opacity: 0;
|
||||||
.ws-pop { animation: ws-pop 0.35s cubic-bezier(0.34, 1.56, 0.64, 1) both; }
|
}
|
||||||
|
to {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ws-pop {
|
||||||
|
animation: ws-pop 0.35s cubic-bezier(0.34, 1.56, 0.64, 1) both;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const isRu = document.documentElement.lang === 'ru';
|
import { CopyFeedback } from "@/lib/client/clipboard";
|
||||||
const COPY_LABEL = isRu ? 'Копировать' : 'Copy';
|
import { createErrorDisplay } from "@/lib/client/validation";
|
||||||
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 isRu = document.documentElement.lang === "ru";
|
||||||
const TAU = 2 * Math.PI;
|
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 textarea = document.getElementById('ws-items') as HTMLTextAreaElement;
|
const COLORS = [
|
||||||
const errorEl = document.getElementById('ws-error') as HTMLParagraphElement;
|
"#534AB7",
|
||||||
const canvas = document.getElementById('ws-canvas') as HTMLCanvasElement;
|
"#0F6E56",
|
||||||
const spinBtn = document.getElementById('ws-spin-btn') as HTMLButtonElement;
|
"#993C1D",
|
||||||
const resultCard = document.getElementById('ws-result-card') as HTMLDivElement;
|
"#185FA5",
|
||||||
const resultText = document.getElementById('ws-result-text') as HTMLSpanElement;
|
"#854F0B",
|
||||||
const copyBtn = document.getElementById('ws-copy-btn') as HTMLButtonElement;
|
"#993556",
|
||||||
const copyLabel = document.getElementById('ws-copy-label') as HTMLSpanElement;
|
"#3B6D11",
|
||||||
const copyIcon = document.getElementById('ws-copy-icon') as HTMLSpanElement;
|
"#A32D2D",
|
||||||
const checkIcon = document.getElementById('ws-check-icon') as HTMLSpanElement;
|
];
|
||||||
const wheelWrap = document.getElementById('ws-wheel-wrap') as HTMLDivElement;
|
const TAU = 2 * Math.PI;
|
||||||
|
|
||||||
const ctx = canvas.getContext('2d')!;
|
const textarea = document.getElementById("ws-items") as HTMLTextAreaElement;
|
||||||
let items: string[] = ['Alice', 'Bob', 'Carol', 'Dave', 'Eve', 'Frank'];
|
const errorEl = document.getElementById("ws-error") as HTMLParagraphElement;
|
||||||
let currentRotation = 0;
|
const canvas = document.getElementById("ws-canvas") as HTMLCanvasElement;
|
||||||
let isSpinning = false;
|
const spinBtn = document.getElementById("ws-spin-btn") as HTMLButtonElement;
|
||||||
let copyRevertTimer: ReturnType<typeof setTimeout> | null = null;
|
const resultCard = document.getElementById(
|
||||||
|
"ws-result-card",
|
||||||
|
) as HTMLDivElement;
|
||||||
|
const resultText = document.getElementById(
|
||||||
|
"ws-result-text",
|
||||||
|
) as HTMLSpanElement;
|
||||||
|
const copyBtn = document.getElementById("ws-copy-btn") as HTMLButtonElement;
|
||||||
|
const copyLabel = document.getElementById(
|
||||||
|
"ws-copy-label",
|
||||||
|
) as HTMLSpanElement;
|
||||||
|
const copyIcon = document.getElementById("ws-copy-icon") as HTMLSpanElement;
|
||||||
|
const checkIcon = document.getElementById(
|
||||||
|
"ws-check-icon",
|
||||||
|
) as HTMLSpanElement;
|
||||||
|
const wheelWrap = document.getElementById(
|
||||||
|
"ws-wheel-wrap",
|
||||||
|
) as HTMLDivElement;
|
||||||
|
|
||||||
// ── Canvas sizing ────────────────────────────────────────────────
|
const ctx = canvas.getContext("2d")!;
|
||||||
function resize() {
|
let items: string[] = ["Alice", "Bob", "Carol", "Dave", "Eve", "Frank"];
|
||||||
const size = Math.min(wheelWrap.offsetWidth, 480);
|
let currentRotation = 0;
|
||||||
canvas.width = size;
|
let isSpinning = false;
|
||||||
canvas.height = size;
|
|
||||||
draw(currentRotation);
|
|
||||||
}
|
|
||||||
new ResizeObserver(resize).observe(wheelWrap);
|
|
||||||
resize();
|
|
||||||
|
|
||||||
// ── Drawing ──────────────────────────────────────────────────────
|
const errors = createErrorDisplay(errorEl);
|
||||||
function clampText(text: string, maxWidth: number): string {
|
const clipboard = new CopyFeedback(copyIcon, checkIcon);
|
||||||
if (ctx.measureText(text).width <= maxWidth) return text;
|
|
||||||
let s = text;
|
|
||||||
while (s.length > 0 && ctx.measureText(s + '…').width > maxWidth) s = s.slice(0, -1);
|
|
||||||
return s + '…';
|
|
||||||
}
|
|
||||||
|
|
||||||
function draw(rotation: number) {
|
// ── Canvas sizing ────────────────────────────────────────────────
|
||||||
const size = canvas.width;
|
function resize() {
|
||||||
const r = size / 2 - 6;
|
const size = Math.min(wheelWrap.offsetWidth, 480);
|
||||||
const n = items.length;
|
canvas.width = size;
|
||||||
const seg = TAU / n;
|
canvas.height = size;
|
||||||
|
draw(currentRotation);
|
||||||
|
}
|
||||||
|
new ResizeObserver(resize).observe(wheelWrap);
|
||||||
|
resize();
|
||||||
|
|
||||||
ctx.clearRect(0, 0, size, size);
|
// ── Drawing ──────────────────────────────────────────────────────
|
||||||
ctx.save();
|
function clampText(text: string, maxWidth: number): string {
|
||||||
ctx.translate(size / 2, size / 2);
|
if (ctx.measureText(text).width <= maxWidth) return text;
|
||||||
|
let s = text;
|
||||||
for (let i = 0; i < n; i++) {
|
while (s.length > 0 && ctx.measureText(s + "…").width > maxWidth)
|
||||||
const a0 = -Math.PI / 2 + i * seg + rotation;
|
s = s.slice(0, -1);
|
||||||
const a1 = a0 + seg;
|
return s + "…";
|
||||||
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(0, 0);
|
|
||||||
ctx.arc(0, 0, r, a0, a1);
|
|
||||||
ctx.closePath();
|
|
||||||
ctx.fillStyle = COLORS[i % COLORS.length];
|
|
||||||
ctx.fill();
|
|
||||||
|
|
||||||
ctx.strokeStyle = 'rgba(255,255,255,0.13)';
|
|
||||||
ctx.lineWidth = 1.5;
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
const mid = a0 + seg / 2;
|
|
||||||
const fontSize = Math.max(9, Math.min(14, Math.floor(r * seg * 0.4)));
|
|
||||||
ctx.save();
|
|
||||||
ctx.rotate(mid);
|
|
||||||
ctx.textAlign = 'right';
|
|
||||||
ctx.fillStyle = 'rgba(255,255,255,0.92)';
|
|
||||||
ctx.font = `bold ${fontSize}px 'Space Grotesk', sans-serif`;
|
|
||||||
const label = clampText(items[i], r * 0.54);
|
|
||||||
ctx.fillText(label, r - 10, fontSize * 0.38);
|
|
||||||
ctx.restore();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.beginPath();
|
function draw(rotation: number) {
|
||||||
ctx.arc(0, 0, r, 0, TAU);
|
const size = canvas.width;
|
||||||
ctx.strokeStyle = 'rgba(255,255,255,0.2)';
|
const r = size / 2 - 6;
|
||||||
ctx.lineWidth = 3;
|
const n = items.length;
|
||||||
ctx.stroke();
|
const seg = TAU / n;
|
||||||
|
|
||||||
ctx.beginPath();
|
ctx.clearRect(0, 0, size, size);
|
||||||
ctx.arc(0, 0, 15, 0, TAU);
|
ctx.save();
|
||||||
ctx.fillStyle = '#18181b';
|
ctx.translate(size / 2, size / 2);
|
||||||
ctx.fill();
|
|
||||||
ctx.strokeStyle = 'rgba(255,255,255,0.22)';
|
|
||||||
ctx.lineWidth = 2;
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
ctx.restore();
|
for (let i = 0; i < n; i++) {
|
||||||
}
|
const a0 = -Math.PI / 2 + i * seg + rotation;
|
||||||
|
const a1 = a0 + seg;
|
||||||
|
|
||||||
// ── Items ────────────────────────────────────────────────────────
|
ctx.beginPath();
|
||||||
function parseItems(): string[] | null {
|
ctx.moveTo(0, 0);
|
||||||
const lines = textarea.value.split('\n').map(l => l.trim()).filter(Boolean);
|
ctx.arc(0, 0, r, a0, a1);
|
||||||
if (lines.length < 2) { showError(ERR_MIN2); return null; }
|
ctx.closePath();
|
||||||
if (lines.length > 24) { showError(ERR_MAX24); return null; }
|
ctx.fillStyle = COLORS[i % COLORS.length];
|
||||||
clearError();
|
ctx.fill();
|
||||||
return lines;
|
|
||||||
}
|
|
||||||
|
|
||||||
function showError(msg: string) { errorEl.textContent = msg; errorEl.classList.remove('hidden'); }
|
ctx.strokeStyle = "rgba(255,255,255,0.13)";
|
||||||
function clearError() { errorEl.textContent = ''; errorEl.classList.add('hidden'); }
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
function applyItems() {
|
const mid = a0 + seg / 2;
|
||||||
const parsed = parseItems();
|
const fontSize = Math.max(
|
||||||
if (parsed) { items = parsed; draw(currentRotation); }
|
9,
|
||||||
}
|
Math.min(14, Math.floor(r * seg * 0.4)),
|
||||||
|
);
|
||||||
|
ctx.save();
|
||||||
|
ctx.rotate(mid);
|
||||||
|
ctx.textAlign = "right";
|
||||||
|
ctx.fillStyle = "rgba(255,255,255,0.92)";
|
||||||
|
ctx.font = `bold ${fontSize}px 'Space Grotesk', sans-serif`;
|
||||||
|
const label = clampText(items[i], r * 0.54);
|
||||||
|
ctx.fillText(label, r - 10, fontSize * 0.38);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
ctx.beginPath();
|
||||||
textarea.addEventListener('input', () => {
|
ctx.arc(0, 0, r, 0, TAU);
|
||||||
if (debounceTimer) clearTimeout(debounceTimer);
|
ctx.strokeStyle = "rgba(255,255,255,0.2)";
|
||||||
debounceTimer = setTimeout(applyItems, 300);
|
ctx.lineWidth = 3;
|
||||||
});
|
ctx.stroke();
|
||||||
|
|
||||||
// ── Spin ─────────────────────────────────────────────────────────
|
ctx.beginPath();
|
||||||
spinBtn.addEventListener('click', () => {
|
ctx.arc(0, 0, 15, 0, TAU);
|
||||||
if (isSpinning) return;
|
ctx.fillStyle = "#18181b";
|
||||||
const parsed = parseItems();
|
ctx.fill();
|
||||||
if (!parsed) return;
|
ctx.strokeStyle = "rgba(255,255,255,0.22)";
|
||||||
items = parsed;
|
ctx.lineWidth = 2;
|
||||||
draw(currentRotation);
|
ctx.stroke();
|
||||||
|
|
||||||
const n = items.length;
|
ctx.restore();
|
||||||
const seg = TAU / n;
|
|
||||||
const winnerIdx = Math.floor(Math.random() * n);
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
const jitter = (Math.random() - 0.5) * seg * 0.55;
|
|
||||||
const fullTurns = 5 + Math.floor(Math.random() * 3);
|
|
||||||
const totalDelta = fullTurns * TAU + delta + jitter;
|
|
||||||
|
|
||||||
const startRot = currentRotation;
|
|
||||||
const duration = 4000 + Math.random() * 2000;
|
|
||||||
const t0 = performance.now();
|
|
||||||
|
|
||||||
isSpinning = true;
|
|
||||||
spinBtn.disabled = true;
|
|
||||||
resultCard.classList.add('hidden');
|
|
||||||
|
|
||||||
(function animate(now: number) {
|
|
||||||
const p = Math.min((now - t0) / duration, 1);
|
|
||||||
const eased = 1 - Math.pow(1 - p, 3);
|
|
||||||
currentRotation = startRot + totalDelta * eased;
|
|
||||||
draw(currentRotation);
|
|
||||||
|
|
||||||
if (p < 1) {
|
|
||||||
requestAnimationFrame(animate);
|
|
||||||
} else {
|
|
||||||
currentRotation = startRot + totalDelta;
|
|
||||||
isSpinning = false;
|
|
||||||
spinBtn.disabled = false;
|
|
||||||
showResult(items[winnerIdx]);
|
|
||||||
spawnConfetti();
|
|
||||||
}
|
|
||||||
})(t0);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Result ───────────────────────────────────────────────────────
|
|
||||||
function showResult(winner: string) {
|
|
||||||
resultText.textContent = winner;
|
|
||||||
resultCard.classList.remove('hidden');
|
|
||||||
resultText.classList.remove('ws-pop');
|
|
||||||
void resultText.offsetWidth;
|
|
||||||
resultText.classList.add('ws-pop');
|
|
||||||
copyLabel.textContent = COPY_LABEL;
|
|
||||||
copyIcon.classList.remove('hidden');
|
|
||||||
checkIcon.classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
copyBtn.addEventListener('click', async () => {
|
|
||||||
const value = resultText.textContent?.trim();
|
|
||||||
if (!value) return;
|
|
||||||
await navigator.clipboard.writeText(value);
|
|
||||||
copyIcon.classList.add('hidden');
|
|
||||||
checkIcon.classList.remove('hidden');
|
|
||||||
copyLabel.textContent = COPIED_LABEL;
|
|
||||||
if (copyRevertTimer) clearTimeout(copyRevertTimer);
|
|
||||||
copyRevertTimer = setTimeout(() => {
|
|
||||||
checkIcon.classList.add('hidden');
|
|
||||||
copyIcon.classList.remove('hidden');
|
|
||||||
copyLabel.textContent = COPY_LABEL;
|
|
||||||
}, 1500);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Confetti ─────────────────────────────────────────────────────
|
|
||||||
function spawnConfetti() {
|
|
||||||
const cr = canvas.getBoundingClientRect();
|
|
||||||
const cx = cr.left + cr.width / 2;
|
|
||||||
const cy = cr.top + cr.height / 2;
|
|
||||||
|
|
||||||
type Particle = { el: HTMLDivElement; x: number; y: number; vx: number; vy: number };
|
|
||||||
const particles: Particle[] = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < 60; i++) {
|
|
||||||
const el = document.createElement('div');
|
|
||||||
const w = 4 + Math.random() * 6;
|
|
||||||
const h = 4 + Math.random() * 6;
|
|
||||||
el.style.cssText = `position:fixed;width:${w}px;height:${h}px;` +
|
|
||||||
`background:${COLORS[i % COLORS.length]};border-radius:1px;` +
|
|
||||||
`pointer-events:none;left:${cx}px;top:${cy}px;will-change:transform,opacity;z-index:9999;`;
|
|
||||||
document.body.appendChild(el);
|
|
||||||
|
|
||||||
const angle = Math.random() * TAU;
|
|
||||||
const speed = 4 + Math.random() * 7;
|
|
||||||
particles.push({
|
|
||||||
el, x: cx, y: cy,
|
|
||||||
vx: Math.cos(angle) * speed,
|
|
||||||
vy: -(Math.abs(Math.sin(angle)) * speed + 2 + Math.random() * 4),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const dur = 2500;
|
// ── Items ────────────────────────────────────────────────────────
|
||||||
const t0 = performance.now();
|
function parseItems(): string[] | null {
|
||||||
const gravity = 0.2;
|
const lines = textarea.value
|
||||||
|
.split("\n")
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (lines.length < 2) {
|
||||||
|
errors.show(ERR_MIN2);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (lines.length > 24) {
|
||||||
|
errors.show(ERR_MAX24);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
errors.clear();
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
(function tick(now: number) {
|
function applyItems() {
|
||||||
const elapsed = now - t0;
|
const parsed = parseItems();
|
||||||
if (elapsed >= dur) { particles.forEach(p => p.el.remove()); return; }
|
if (parsed) {
|
||||||
const opacity = 1 - elapsed / dur;
|
items = parsed;
|
||||||
particles.forEach(p => {
|
draw(currentRotation);
|
||||||
p.vy += gravity;
|
}
|
||||||
p.vx *= 0.99;
|
}
|
||||||
p.x += p.vx;
|
|
||||||
p.y += p.vy;
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
p.el.style.transform = `translate(${p.x - cx}px,${p.y - cy}px) rotate(${p.x * 2}deg)`;
|
textarea.addEventListener("input", () => {
|
||||||
p.el.style.opacity = String(Math.max(0, opacity));
|
if (debounceTimer) clearTimeout(debounceTimer);
|
||||||
});
|
debounceTimer = setTimeout(applyItems, 300);
|
||||||
requestAnimationFrame(tick);
|
});
|
||||||
})(t0);
|
|
||||||
}
|
// ── Spin ─────────────────────────────────────────────────────────
|
||||||
|
spinBtn.addEventListener("click", () => {
|
||||||
|
if (isSpinning) return;
|
||||||
|
const parsed = parseItems();
|
||||||
|
if (!parsed) return;
|
||||||
|
items = parsed;
|
||||||
|
draw(currentRotation);
|
||||||
|
|
||||||
|
const n = items.length;
|
||||||
|
const seg = TAU / n;
|
||||||
|
const winnerIdx = Math.floor(Math.random() * n);
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
const jitter = (Math.random() - 0.5) * seg * 0.55;
|
||||||
|
const fullTurns = 5 + Math.floor(Math.random() * 3);
|
||||||
|
const totalDelta = fullTurns * TAU + delta + jitter;
|
||||||
|
|
||||||
|
const startRot = currentRotation;
|
||||||
|
const duration = 4000 + Math.random() * 2000;
|
||||||
|
const t0 = performance.now();
|
||||||
|
|
||||||
|
isSpinning = true;
|
||||||
|
spinBtn.disabled = true;
|
||||||
|
resultCard.classList.add("hidden");
|
||||||
|
|
||||||
|
(function animate(now: number) {
|
||||||
|
const p = Math.min((now - t0) / duration, 1);
|
||||||
|
const eased = 1 - Math.pow(1 - p, 3);
|
||||||
|
currentRotation = startRot + totalDelta * eased;
|
||||||
|
draw(currentRotation);
|
||||||
|
|
||||||
|
if (p < 1) {
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
} else {
|
||||||
|
currentRotation = startRot + totalDelta;
|
||||||
|
isSpinning = false;
|
||||||
|
spinBtn.disabled = false;
|
||||||
|
showResult(items[winnerIdx]);
|
||||||
|
spawnConfetti();
|
||||||
|
}
|
||||||
|
})(t0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Result ───────────────────────────────────────────────────────
|
||||||
|
function showResult(winner: string) {
|
||||||
|
resultText.textContent = winner;
|
||||||
|
resultCard.classList.remove("hidden");
|
||||||
|
resultText.classList.remove("ws-pop");
|
||||||
|
void resultText.offsetWidth;
|
||||||
|
resultText.classList.add("ws-pop");
|
||||||
|
copyLabel.textContent = COPY_LABEL;
|
||||||
|
copyIcon.classList.remove("hidden");
|
||||||
|
checkIcon.classList.add("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyResult() {
|
||||||
|
const value = resultText.textContent?.trim();
|
||||||
|
if (!value) return;
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
clipboard.showCopied();
|
||||||
|
copyLabel.textContent = COPIED_LABEL;
|
||||||
|
setTimeout(() => {
|
||||||
|
copyLabel.textContent = COPY_LABEL;
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
copyBtn.addEventListener("click", copyResult);
|
||||||
|
|
||||||
|
// ── Confetti ─────────────────────────────────────────────────────
|
||||||
|
function spawnConfetti() {
|
||||||
|
const cr = canvas.getBoundingClientRect();
|
||||||
|
const cx = cr.left + cr.width / 2;
|
||||||
|
const cy = cr.top + cr.height / 2;
|
||||||
|
|
||||||
|
type Particle = {
|
||||||
|
el: HTMLDivElement;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
vx: number;
|
||||||
|
vy: number;
|
||||||
|
};
|
||||||
|
const particles: Particle[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < 60; i++) {
|
||||||
|
const el = document.createElement("div");
|
||||||
|
const w = 4 + Math.random() * 6;
|
||||||
|
const h = 4 + Math.random() * 6;
|
||||||
|
el.style.cssText =
|
||||||
|
`position:fixed;width:${w}px;height:${h}px;` +
|
||||||
|
`background:${COLORS[i % COLORS.length]};border-radius:1px;` +
|
||||||
|
`pointer-events:none;left:${cx}px;top:${cy}px;will-change:transform,opacity;z-index:9999;`;
|
||||||
|
document.body.appendChild(el);
|
||||||
|
|
||||||
|
const angle = Math.random() * TAU;
|
||||||
|
const speed = 4 + Math.random() * 7;
|
||||||
|
particles.push({
|
||||||
|
el,
|
||||||
|
x: cx,
|
||||||
|
y: cy,
|
||||||
|
vx: Math.cos(angle) * speed,
|
||||||
|
vy: -(
|
||||||
|
Math.abs(Math.sin(angle)) * speed +
|
||||||
|
2 +
|
||||||
|
Math.random() * 4
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const dur = 2500;
|
||||||
|
const t0 = performance.now();
|
||||||
|
const gravity = 0.2;
|
||||||
|
|
||||||
|
(function tick(now: number) {
|
||||||
|
const elapsed = now - t0;
|
||||||
|
if (elapsed >= dur) {
|
||||||
|
particles.forEach((p) => p.el.remove());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const opacity = 1 - elapsed / dur;
|
||||||
|
particles.forEach((p) => {
|
||||||
|
p.vy += gravity;
|
||||||
|
p.vx *= 0.99;
|
||||||
|
p.x += p.vx;
|
||||||
|
p.y += p.vy;
|
||||||
|
p.el.style.transform = `translate(${p.x - cx}px,${p.y - cy}px) rotate(${p.x * 2}deg)`;
|
||||||
|
p.el.style.opacity = String(Math.max(0, opacity));
|
||||||
|
});
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
})(t0);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export function popElement(el: HTMLElement) {
|
||||||
|
el.style.transform = 'scale(1.18)';
|
||||||
|
el.style.opacity = '0.7';
|
||||||
|
requestAnimationFrame(() =>
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
el.style.transform = 'scale(1)';
|
||||||
|
el.style.opacity = '1';
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
export class CopyFeedback {
|
||||||
|
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private copyIcon: HTMLElement,
|
||||||
|
private checkIcon: HTMLElement,
|
||||||
|
private duration = 1500
|
||||||
|
) {}
|
||||||
|
|
||||||
|
showCopied() {
|
||||||
|
this.copyIcon.classList.add('hidden');
|
||||||
|
this.checkIcon.classList.remove('hidden');
|
||||||
|
if (this.timer) clearTimeout(this.timer);
|
||||||
|
this.timer = setTimeout(() => this.revert(), this.duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
revert() {
|
||||||
|
this.checkIcon.classList.add('hidden');
|
||||||
|
this.copyIcon.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
if (this.timer) clearTimeout(this.timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
export function createErrorDisplay(errorEl: HTMLElement) {
|
||||||
|
return {
|
||||||
|
show(msg: string) {
|
||||||
|
errorEl.textContent = msg;
|
||||||
|
errorEl.classList.remove('hidden');
|
||||||
|
},
|
||||||
|
clear() {
|
||||||
|
errorEl.textContent = '';
|
||||||
|
errorEl.classList.add('hidden');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isInteger(val: string): boolean {
|
||||||
|
return /^-?\d+$/.test(val.trim());
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user