chore: add ESLint + Prettier, fix all lint errors
- Install eslint, typescript-eslint, eslint-plugin-astro, prettier, prettier-plugin-astro, globals - Add flat eslint.config.mjs with recommended rules for JS, TS, Astro - Add .prettierrc with astro plugin - Add lint, lint:fix, format, format:check scripts - Fix prefer-const, no-unused-vars, no-useless-assignment across codebase - Fix AdBanner.astro JSX fragment for Prettier compatibility - Add eslint-disable around Yandex/Top.Mail.Ru inline scripts - Auto-format entire codebase with Prettier
This commit is contained in:
@@ -1,33 +1,33 @@
|
||||
---
|
||||
interface Props {
|
||||
size: 'leaderboard' | 'rectangle' | 'tile';
|
||||
size: "leaderboard" | "rectangle" | "tile";
|
||||
}
|
||||
|
||||
const { size } = Astro.props;
|
||||
|
||||
const ads = [
|
||||
{
|
||||
href: 'https://redirect.appmetrica.yandex.com/serve/29835370459904648?clid=15006577&appmetrica_js_redirect=0',
|
||||
label: 'Яндекс с Алисой — скачать бесплатно',
|
||||
sublabel: 'Голосовой ИИ-помощник для Android и iOS',
|
||||
cta: 'Установить',
|
||||
ctaMobile: 'Установить бесплатно',
|
||||
href: "https://redirect.appmetrica.yandex.com/serve/29835370459904648?clid=15006577&appmetrica_js_redirect=0",
|
||||
label: "Яндекс с Алисой — скачать бесплатно",
|
||||
sublabel: "Голосовой ИИ-помощник для Android и iOS",
|
||||
cta: "Установить",
|
||||
ctaMobile: "Установить бесплатно",
|
||||
},
|
||||
{
|
||||
href: 'https://yandex.ru/project/browser/bonus/multioffer/affiliate_4prod?source=pWRP8eS1VsC2X59560&partner_string=P89XvN11U6RuE47077&cliddbro=15006579&clidmbro=15006578&cliddefault=15006570&clidpp=15006567',
|
||||
label: 'Яндекс Браузер — быстрый и безопасный',
|
||||
sublabel: 'Браузер с Алисой и защитой от угроз',
|
||||
cta: 'Скачать',
|
||||
ctaMobile: 'Скачать бесплатно',
|
||||
href: "https://yandex.ru/project/browser/bonus/multioffer/affiliate_4prod?source=pWRP8eS1VsC2X59560&partner_string=P89XvN11U6RuE47077&cliddbro=15006579&clidmbro=15006578&cliddefault=15006570&clidpp=15006567",
|
||||
label: "Яндекс Браузер — быстрый и безопасный",
|
||||
sublabel: "Браузер с Алисой и защитой от угроз",
|
||||
cta: "Скачать",
|
||||
ctaMobile: "Скачать бесплатно",
|
||||
},
|
||||
];
|
||||
|
||||
const ad = ads[Math.floor(Math.random() * ads.length)];
|
||||
|
||||
const specs = {
|
||||
leaderboard: { class: 'w-full h-[90px]', horizontal: true },
|
||||
rectangle: { class: 'w-full max-w-[300px] h-[250px]', horizontal: false },
|
||||
tile: { class: 'w-full h-[250px]', horizontal: false },
|
||||
leaderboard: { class: "w-full h-[90px]", horizontal: true },
|
||||
rectangle: { class: "w-full max-w-[300px] h-[250px]", horizontal: false },
|
||||
tile: { class: "w-full h-[250px]", horizontal: false },
|
||||
};
|
||||
|
||||
const { class: sizeClass, horizontal } = specs[size];
|
||||
@@ -38,37 +38,55 @@ const { class: sizeClass, horizontal } = specs[size];
|
||||
href={ad.href}
|
||||
target="_blank"
|
||||
rel="noopener sponsored"
|
||||
class={`${sizeClass} flex ${horizontal ? 'flex-row items-center gap-4 px-6' : 'flex-col items-center justify-center gap-3 px-4'} rounded-xl border border-zinc-800 bg-zinc-900/50 hover:border-[#534AB7]/50 hover:bg-zinc-900 transition-colors duration-200 group`}
|
||||
class={`${sizeClass} flex ${horizontal ? "flex-row items-center gap-4 px-6" : "flex-col items-center justify-center gap-3 px-4"} rounded-xl border border-zinc-800 bg-zinc-900/50 hover:border-[#534AB7]/50 hover:bg-zinc-900 transition-colors duration-200 group`}
|
||||
>
|
||||
<svg class="shrink-0 text-[#FC3F1D]" width={horizontal ? '32' : '40'} height={horizontal ? '32' : '40'} viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<circle cx="20" cy="20" r="20" fill="currentColor"/>
|
||||
<path d="M22.44 10H19.1c-3.78 0-6.1 2.02-6.1 5.32 0 2.66 1.26 4.24 3.64 5.8L13.5 30h3.38l3.4-8.38-.96-.58c-1.84-1.12-2.72-2.08-2.72-3.92 0-1.94 1.3-3.08 3.44-3.08h1.4V30h3V10z" fill="white"/>
|
||||
<svg
|
||||
class="shrink-0 text-[#FC3F1D]"
|
||||
width={horizontal ? "32" : "40"}
|
||||
height={horizontal ? "32" : "40"}
|
||||
viewBox="0 0 40 40"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="20" cy="20" r="20" fill="currentColor"></circle>
|
||||
<path
|
||||
d="M22.44 10H19.1c-3.78 0-6.1 2.02-6.1 5.32 0 2.66 1.26 4.24 3.64 5.8L13.5 30h3.38l3.4-8.38-.96-.58c-1.84-1.12-2.72-2.08-2.72-3.92 0-1.94 1.3-3.08 3.44-3.08h1.4V30h3V10z"
|
||||
fill="white"></path>
|
||||
</svg>
|
||||
|
||||
{horizontal ? (
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-xs font-medium text-zinc-500 uppercase tracking-widest mb-0.5">Реклама</div>
|
||||
<div class="text-sm font-semibold text-zinc-100 group-hover:text-[#534AB7] transition-colors leading-tight">
|
||||
{ad.label}
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500 mt-0.5">{ad.sublabel}</div>
|
||||
</div>
|
||||
<span class="shrink-0 text-xs font-semibold text-white bg-[#FC3F1D] px-3 py-1.5 rounded-lg group-hover:bg-[#e03518] transition-colors whitespace-nowrap">
|
||||
{ad.cta}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<div class="text-center">
|
||||
<div class="text-xs font-medium text-zinc-500 uppercase tracking-widest mb-2">Реклама</div>
|
||||
<div class="text-base font-semibold text-zinc-100 group-hover:text-[#534AB7] transition-colors leading-snug">
|
||||
{ad.label}
|
||||
{
|
||||
horizontal ? (
|
||||
<>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-xs font-medium text-zinc-500 uppercase tracking-widest mb-0.5">
|
||||
Реклама
|
||||
</div>
|
||||
<div class="text-sm font-semibold text-zinc-100 group-hover:text-[#534AB7] transition-colors leading-tight">
|
||||
{ad.label}
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500 mt-0.5">{ad.sublabel}</div>
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500 mt-1">{ad.sublabel}</div>
|
||||
</div>
|
||||
<span class="text-xs font-semibold text-white bg-[#FC3F1D] px-4 py-2 rounded-lg group-hover:bg-[#e03518] transition-colors">
|
||||
{ad.ctaMobile}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span class="shrink-0 text-xs font-semibold text-white bg-[#FC3F1D] px-3 py-1.5 rounded-lg group-hover:bg-[#e03518] transition-colors whitespace-nowrap">
|
||||
{ad.cta}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div class="text-center">
|
||||
<div class="text-xs font-medium text-zinc-500 uppercase tracking-widest mb-2">
|
||||
Реклама
|
||||
</div>
|
||||
<div class="text-base font-semibold text-zinc-100 group-hover:text-[#534AB7] transition-colors leading-snug">
|
||||
{ad.label}
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500 mt-1">{ad.sublabel}</div>
|
||||
</div>
|
||||
<span class="text-xs font-semibold text-white bg-[#FC3F1D] px-4 py-2 rounded-lg group-hover:bg-[#e03518] transition-colors">
|
||||
{ad.ctaMobile}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</a>
|
||||
</aside>
|
||||
|
||||
@@ -7,85 +7,87 @@ const mailRuId = analytics.topMailRuId;
|
||||
|
||||
<!-- Yandex.Metrika counter -->
|
||||
<script type="text/javascript" define:vars={{ yandexId }}>
|
||||
(function (m, e, t, r, i, k, a) {
|
||||
m[i] =
|
||||
m[i] ||
|
||||
function () {
|
||||
(m[i].a = m[i].a || []).push(arguments);
|
||||
};
|
||||
m[i].l = 1 * new Date();
|
||||
for (var j = 0; j < document.scripts.length; j++) {
|
||||
if (document.scripts[j].src === r) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
((k = e.createElement(t)),
|
||||
(a = e.getElementsByTagName(t)[0]),
|
||||
(k.async = 1),
|
||||
(k.src = r),
|
||||
a.parentNode.insertBefore(k, a));
|
||||
})(
|
||||
window,
|
||||
document,
|
||||
"script",
|
||||
"https://mc.yandex.ru/metrika/tag.js?id=" + String(yandexId),
|
||||
"ym",
|
||||
);
|
||||
ym(Number(yandexId), "init", {
|
||||
ssr: true,
|
||||
webvisor: true,
|
||||
clickmap: true,
|
||||
ecommerce: "dataLayer",
|
||||
referrer: document.referrer,
|
||||
url: location.href,
|
||||
accurateTrackBounce: true,
|
||||
trackLinks: true,
|
||||
});
|
||||
/* eslint-disable */
|
||||
(function (m, e, t, r, i, k, a) {
|
||||
m[i] =
|
||||
m[i] ||
|
||||
function () {
|
||||
(m[i].a = m[i].a || []).push(arguments);
|
||||
};
|
||||
m[i].l = 1 * new Date();
|
||||
for (let j = 0; j < document.scripts.length; j++) {
|
||||
if (document.scripts[j].src === r) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
((k = e.createElement(t)),
|
||||
(a = e.getElementsByTagName(t)[0]),
|
||||
(k.async = 1),
|
||||
(k.src = r),
|
||||
a.parentNode.insertBefore(k, a));
|
||||
})(
|
||||
window,
|
||||
document,
|
||||
"script",
|
||||
"https://mc.yandex.ru/metrika/tag.js?id=" + String(yandexId),
|
||||
"ym",
|
||||
);
|
||||
ym(Number(yandexId), "init", {
|
||||
ssr: true,
|
||||
webvisor: true,
|
||||
clickmap: true,
|
||||
ecommerce: "dataLayer",
|
||||
referrer: document.referrer,
|
||||
url: location.href,
|
||||
accurateTrackBounce: true,
|
||||
trackLinks: true,
|
||||
});
|
||||
/* eslint-enable */
|
||||
</script>
|
||||
<noscript
|
||||
><div>
|
||||
<img
|
||||
src={`https://mc.yandex.ru/watch/${yandexId}`}
|
||||
style="position:absolute; left:-9999px;"
|
||||
alt=""
|
||||
/>
|
||||
</div></noscript
|
||||
><div>
|
||||
<img
|
||||
src={`https://mc.yandex.ru/watch/${yandexId}`}
|
||||
style="position:absolute; left:-9999px;"
|
||||
alt=""
|
||||
/>
|
||||
</div></noscript
|
||||
>
|
||||
<!-- /Yandex.Metrika counter -->
|
||||
|
||||
<!-- Top.Mail.Ru counter -->
|
||||
<script type="text/javascript" define:vars={{ mailRuId }}>
|
||||
var _tmr = window._tmr || (window._tmr = []);
|
||||
_tmr.push({
|
||||
id: String(mailRuId),
|
||||
type: "pageView",
|
||||
start: new Date().getTime(),
|
||||
});
|
||||
(function (d, w, id) {
|
||||
if (d.getElementById(id)) return;
|
||||
var ts = d.createElement("script");
|
||||
ts.type = "text/javascript";
|
||||
ts.async = true;
|
||||
ts.id = id;
|
||||
ts.src = "https://top-fwz1.mail.ru/js/code.js";
|
||||
var f = function () {
|
||||
var s = d.getElementsByTagName("script")[0];
|
||||
s.parentNode.insertBefore(ts, s);
|
||||
};
|
||||
if (w.opera == "[object Opera]") {
|
||||
d.addEventListener("DOMContentLoaded", f, false);
|
||||
} else {
|
||||
f();
|
||||
}
|
||||
})(document, window, "tmr-code");
|
||||
const _tmr = window._tmr || (window._tmr = []);
|
||||
_tmr.push({
|
||||
id: String(mailRuId),
|
||||
type: "pageView",
|
||||
start: new Date().getTime(),
|
||||
});
|
||||
(function (d, w, id) {
|
||||
if (d.getElementById(id)) return;
|
||||
const ts = d.createElement("script");
|
||||
ts.type = "text/javascript";
|
||||
ts.async = true;
|
||||
ts.id = id;
|
||||
ts.src = "https://top-fwz1.mail.ru/js/code.js";
|
||||
const f = function () {
|
||||
const s = d.getElementsByTagName("script")[0];
|
||||
s.parentNode.insertBefore(ts, s);
|
||||
};
|
||||
if (w.opera == "[object Opera]") {
|
||||
d.addEventListener("DOMContentLoaded", f, false);
|
||||
} else {
|
||||
f();
|
||||
}
|
||||
})(document, window, "tmr-code");
|
||||
</script>
|
||||
<noscript
|
||||
><div>
|
||||
<img
|
||||
src={`https://top-fwz1.mail.ru/counter?id=${mailRuId};js=na`}
|
||||
style="position:absolute;left:-9999px;"
|
||||
alt="Top.Mail.Ru"
|
||||
/>
|
||||
</div></noscript
|
||||
><div>
|
||||
<img
|
||||
src={`https://top-fwz1.mail.ru/counter?id=${mailRuId};js=na`}
|
||||
style="position:absolute;left:-9999px;"
|
||||
alt="Top.Mail.Ru"
|
||||
/>
|
||||
</div></noscript
|
||||
>
|
||||
<!-- /Top.Mail.Ru counter -->
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
import type { Generator } from '../data/generators';
|
||||
import { useT } from '../i18n/translations';
|
||||
import type { Lang } from '../i18n/translations';
|
||||
import type { Generator } from "../data/generators";
|
||||
import { useT } from "../i18n/translations";
|
||||
import type { Lang } from "../i18n/translations";
|
||||
|
||||
interface Props {
|
||||
generator: Generator;
|
||||
@@ -9,11 +9,11 @@ interface Props {
|
||||
}
|
||||
|
||||
const { generator, lang: langProp } = Astro.props;
|
||||
const lang = (langProp || (Astro.currentLocale as Lang) || 'en');
|
||||
const lang = langProp || (Astro.currentLocale as Lang) || "en";
|
||||
const T = useT(lang);
|
||||
const { slug, icon, status } = generator;
|
||||
const isLive = status === 'live';
|
||||
const isRu = lang === 'ru';
|
||||
const isLive = status === "live";
|
||||
const isRu = lang === "ru";
|
||||
const title = isRu ? generator.ruTitle : generator.title;
|
||||
const description = isRu ? generator.ruDescription : generator.description;
|
||||
const href = isRu ? `/ru/generators/${slug}/` : `/generators/${slug}/`;
|
||||
@@ -23,43 +23,53 @@ const icons: Record<string, string> = {
|
||||
palette: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="13.5" cy="6.5" r=".5" fill="currentColor"/><circle cx="17.5" cy="10.5" r=".5" fill="currentColor"/><circle cx="8.5" cy="7.5" r=".5" fill="currentColor"/><circle cx="6.5" cy="12.5" r=".5" fill="currentColor"/><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z"/></svg>`,
|
||||
lock: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>`,
|
||||
ticket: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 9a3 3 0 0 1 0 6v2a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-2a3 3 0 0 1 0-6V7a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2Z"/><path d="M13 5v2"/><path d="M13 17v2"/><path d="M13 11v2"/></svg>`,
|
||||
'dice-6': `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><path d="M16 8h.01"/><path d="M16 12h.01"/><path d="M16 16h.01"/><path d="M8 8h.01"/><path d="M8 12h.01"/><path d="M8 16h.01"/></svg>`,
|
||||
'square-stack': `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 10c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h4c1.1 0 2 .9 2 2"/><path d="M10 16c-1.1 0-2-.9-2-2v-4c0-1.1.9-2 2-2h4c1.1 0 2 .9 2 2"/><rect width="8" height="8" x="14" y="14" rx="2"/></svg>`,
|
||||
'circle-dollar-sign': `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8"/><path d="M12 18V6"/></svg>`,
|
||||
'list': `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" x2="21" y1="6" y2="6"/><line x1="8" x2="21" y1="12" y2="12"/><line x1="8" x2="21" y1="18" y2="18"/><line x1="3" x2="3.01" y1="6" y2="6"/><line x1="3" x2="3.01" y1="12" y2="12"/><line x1="3" x2="3.01" y1="18" y2="18"/></svg>`,
|
||||
'fingerprint': `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 10a2 2 0 0 0-2 2c0 1.02-.1 2.51-.26 4"/><path d="M14 13.12c0 2.38 0 6.38-1 8.88"/><path d="M17.29 21.02c.12-.6.43-2.3.5-3.02"/><path d="M2 12a10 10 0 0 1 18-6"/><path d="M2 17c1 .5 2.25 1 4 1 1.5 0 3-.5 4-1"/><path d="M20 12c0 2-.5 4.5-2 6"/><path d="M7 13.02c0 2.38 0 5.5 1 7.48"/><path d="M8.5 8.5A5 5 0 0 1 17 12"/></svg>`,
|
||||
'pie-chart': `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.21 15.89A10 10 0 1 1 8 2.83"/><path d="M22 12A10 10 0 0 0 12 2v10z"/></svg>`,
|
||||
"dice-6": `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><path d="M16 8h.01"/><path d="M16 12h.01"/><path d="M16 16h.01"/><path d="M8 8h.01"/><path d="M8 12h.01"/><path d="M8 16h.01"/></svg>`,
|
||||
"square-stack": `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 10c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h4c1.1 0 2 .9 2 2"/><path d="M10 16c-1.1 0-2-.9-2-2v-4c0-1.1.9-2 2-2h4c1.1 0 2 .9 2 2"/><rect width="8" height="8" x="14" y="14" rx="2"/></svg>`,
|
||||
"circle-dollar-sign": `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8"/><path d="M12 18V6"/></svg>`,
|
||||
list: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" x2="21" y1="6" y2="6"/><line x1="8" x2="21" y1="12" y2="12"/><line x1="8" x2="21" y1="18" y2="18"/><line x1="3" x2="3.01" y1="6" y2="6"/><line x1="3" x2="3.01" y1="12" y2="12"/><line x1="3" x2="3.01" y1="18" y2="18"/></svg>`,
|
||||
fingerprint: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 10a2 2 0 0 0-2 2c0 1.02-.1 2.51-.26 4"/><path d="M14 13.12c0 2.38 0 6.38-1 8.88"/><path d="M17.29 21.02c.12-.6.43-2.3.5-3.02"/><path d="M2 12a10 10 0 0 1 18-6"/><path d="M2 17c1 .5 2.25 1 4 1 1.5 0 3-.5 4-1"/><path d="M20 12c0 2-.5 4.5-2 6"/><path d="M7 13.02c0 2.38 0 5.5 1 7.48"/><path d="M8.5 8.5A5 5 0 0 1 17 12"/></svg>`,
|
||||
"pie-chart": `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.21 15.89A10 10 0 1 1 8 2.83"/><path d="M22 12A10 10 0 0 0 12 2v10z"/></svg>`,
|
||||
};
|
||||
---
|
||||
|
||||
{isLive ? (
|
||||
<a
|
||||
href={href}
|
||||
class="group block border border-zinc-800 rounded-xl p-5 hover:border-[#534AB7] transition-colors duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#534AB7]"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="text-[#534AB7]" aria-hidden="true" set:html={icons[icon] ?? icons['hash']} />
|
||||
<span class="text-xs font-medium text-[#534AB7] bg-[#534AB7]/10 px-2 py-0.5 rounded-full">
|
||||
{T.live}
|
||||
</span>
|
||||
{
|
||||
isLive ? (
|
||||
<a
|
||||
href={href}
|
||||
class="group block border border-zinc-800 rounded-xl p-5 hover:border-[#534AB7] transition-colors duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#534AB7]"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div
|
||||
class="text-[#534AB7]"
|
||||
aria-hidden="true"
|
||||
set:html={icons[icon] ?? icons["hash"]}
|
||||
/>
|
||||
<span class="text-xs font-medium text-[#534AB7] bg-[#534AB7]/10 px-2 py-0.5 rounded-full">
|
||||
{T.live}
|
||||
</span>
|
||||
</div>
|
||||
<h2 class="mt-3 text-base font-semibold text-zinc-100 group-hover:text-[#534AB7] transition-colors duration-150">
|
||||
{title}
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-zinc-400 leading-relaxed">{description}</p>
|
||||
</a>
|
||||
) : (
|
||||
<div
|
||||
class="block border border-zinc-800/50 rounded-xl p-5 opacity-40 cursor-default"
|
||||
aria-label={`${title} — ${T.comingSoon}`}
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div
|
||||
class="text-zinc-500"
|
||||
aria-hidden="true"
|
||||
set:html={icons[icon] ?? icons["hash"]}
|
||||
/>
|
||||
<span class="text-xs font-medium text-zinc-500 bg-zinc-800 px-2 py-0.5 rounded-full">
|
||||
{T.comingSoon}
|
||||
</span>
|
||||
</div>
|
||||
<h2 class="mt-3 text-base font-semibold text-zinc-400">{title}</h2>
|
||||
<p class="mt-1 text-sm text-zinc-500 leading-relaxed">{description}</p>
|
||||
</div>
|
||||
<h2 class="mt-3 text-base font-semibold text-zinc-100 group-hover:text-[#534AB7] transition-colors duration-150">
|
||||
{title}
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-zinc-400 leading-relaxed">{description}</p>
|
||||
</a>
|
||||
) : (
|
||||
<div
|
||||
class="block border border-zinc-800/50 rounded-xl p-5 opacity-40 cursor-default"
|
||||
aria-label={`${title} — ${T.comingSoon}`}
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="text-zinc-500" aria-hidden="true" set:html={icons[icon] ?? icons['hash']} />
|
||||
<span class="text-xs font-medium text-zinc-500 bg-zinc-800 px-2 py-0.5 rounded-full">
|
||||
{T.comingSoon}
|
||||
</span>
|
||||
</div>
|
||||
<h2 class="mt-3 text-base font-semibold text-zinc-400">{title}</h2>
|
||||
<p class="mt-1 text-sm text-zinc-500 leading-relaxed">{description}</p>
|
||||
</div>
|
||||
)}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,39 +1,43 @@
|
||||
---
|
||||
const currentPath = Astro.url.pathname;
|
||||
const isRu = currentPath.startsWith('/ru');
|
||||
const isRu = currentPath.startsWith("/ru");
|
||||
const alternatePath = isRu
|
||||
? (currentPath.replace(/^\/ru/, '') || '/')
|
||||
: ('/ru' + currentPath);
|
||||
? currentPath.replace(/^\/ru/, "") || "/"
|
||||
: "/ru" + currentPath;
|
||||
---
|
||||
|
||||
<div class="fixed top-3 right-3 z-50 flex items-center gap-1 bg-zinc-900/80 border border-zinc-800 rounded-lg px-1 py-1 backdrop-blur-sm text-xs font-semibold">
|
||||
<div
|
||||
class="fixed top-3 right-3 z-50 flex items-center gap-1 bg-zinc-900/80 border border-zinc-800 rounded-lg px-1 py-1 backdrop-blur-sm text-xs font-semibold"
|
||||
>
|
||||
<a
|
||||
href={isRu ? alternatePath : '#'}
|
||||
href={isRu ? alternatePath : "#"}
|
||||
id="lang-en"
|
||||
class={`px-2 py-1 rounded-md transition-colors ${isRu ? 'text-zinc-400 hover:text-zinc-200' : 'bg-[#534AB7] text-white'}`}
|
||||
aria-current={isRu ? undefined : 'true'}
|
||||
class={`px-2 py-1 rounded-md transition-colors ${isRu ? "text-zinc-400 hover:text-zinc-200" : "bg-[#534AB7] text-white"}`}
|
||||
aria-current={isRu ? undefined : "true"}
|
||||
data-target-lang="en"
|
||||
data-alternate={isRu ? alternatePath : ''}
|
||||
>EN</a>
|
||||
data-alternate={isRu ? alternatePath : ""}>EN</a
|
||||
>
|
||||
<a
|
||||
href={isRu ? '#' : alternatePath}
|
||||
href={isRu ? "#" : alternatePath}
|
||||
id="lang-ru"
|
||||
class={`px-2 py-1 rounded-md transition-colors ${isRu ? 'bg-[#534AB7] text-white' : 'text-zinc-400 hover:text-zinc-200'}`}
|
||||
aria-current={isRu ? 'true' : undefined}
|
||||
class={`px-2 py-1 rounded-md transition-colors ${isRu ? "bg-[#534AB7] text-white" : "text-zinc-400 hover:text-zinc-200"}`}
|
||||
aria-current={isRu ? "true" : undefined}
|
||||
data-target-lang="ru"
|
||||
data-alternate={isRu ? '' : alternatePath}
|
||||
>RU</a>
|
||||
data-alternate={isRu ? "" : alternatePath}>RU</a
|
||||
>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.querySelectorAll<HTMLAnchorElement>('#lang-en, #lang-ru').forEach((el) => {
|
||||
el.addEventListener('click', (e) => {
|
||||
const targetLang = el.dataset.targetLang!;
|
||||
const alternate = el.dataset.alternate!;
|
||||
if (!alternate || alternate === '#') return;
|
||||
e.preventDefault();
|
||||
localStorage.setItem('lang-pref', targetLang);
|
||||
location.href = alternate;
|
||||
document
|
||||
.querySelectorAll<HTMLAnchorElement>("#lang-en, #lang-ru")
|
||||
.forEach((el) => {
|
||||
el.addEventListener("click", (e) => {
|
||||
const targetLang = el.dataset.targetLang!;
|
||||
const alternate = el.dataset.alternate!;
|
||||
if (!alternate || alternate === "#") return;
|
||||
e.preventDefault();
|
||||
localStorage.setItem("lang-pref", targetLang);
|
||||
location.href = alternate;
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
import { useT } from '../i18n/translations';
|
||||
import type { Lang } from '../i18n/translations';
|
||||
import { useT } from "../i18n/translations";
|
||||
import type { Lang } from "../i18n/translations";
|
||||
|
||||
interface Props {
|
||||
howTo: string[];
|
||||
@@ -8,19 +8,29 @@ interface Props {
|
||||
lang?: Lang;
|
||||
}
|
||||
|
||||
const { howTo, whenTo, lang = 'en' } = Astro.props;
|
||||
const { howTo, whenTo, lang = "en" } = Astro.props;
|
||||
const T = useT(lang);
|
||||
---
|
||||
|
||||
<section class="mt-12 border-t border-zinc-800/60 pt-8 text-sm text-zinc-500 space-y-4">
|
||||
<section
|
||||
class="mt-12 border-t border-zinc-800/60 pt-8 text-sm text-zinc-500 space-y-4"
|
||||
>
|
||||
<div>
|
||||
<h2 class="text-xs font-semibold uppercase tracking-widest text-zinc-600 mb-2">{T.howToUse}</h2>
|
||||
<h2
|
||||
class="text-xs font-semibold uppercase tracking-widest text-zinc-600 mb-2"
|
||||
>
|
||||
{T.howToUse}
|
||||
</h2>
|
||||
<ol class="space-y-1 list-decimal list-inside">
|
||||
{howTo.map((step) => <li>{step}</li>)}
|
||||
</ol>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-xs font-semibold uppercase tracking-widest text-zinc-600 mb-2">{T.whenToUse}</h2>
|
||||
<h2
|
||||
class="text-xs font-semibold uppercase tracking-widest text-zinc-600 mb-2"
|
||||
>
|
||||
{T.whenToUse}
|
||||
</h2>
|
||||
<ul class="space-y-1 list-disc list-inside">
|
||||
{whenTo.map((item) => <li>{item}</li>)}
|
||||
</ul>
|
||||
|
||||
@@ -5,268 +5,258 @@ const T = useT(isRu ? "ru" : "en");
|
||||
---
|
||||
|
||||
<div id="card-generator" class="mt-8">
|
||||
<div class="flex flex-col sm:flex-row gap-4 items-end">
|
||||
<div class="flex-1">
|
||||
<label
|
||||
for="crd-count"
|
||||
class="block text-sm font-medium text-zinc-400 mb-1"
|
||||
>{T.cardsToDraw}</label
|
||||
>
|
||||
<input
|
||||
id="crd-count"
|
||||
type="number"
|
||||
value="1"
|
||||
min="1"
|
||||
max="52"
|
||||
class="w-full bg-zinc-900 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 text-base focus:outline-none focus:border-[#534AB7] focus:ring-1 focus:ring-[#534AB7] transition-colors"
|
||||
aria-label={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 class="flex flex-col sm:flex-row gap-4 items-end">
|
||||
<div class="flex-1">
|
||||
<label
|
||||
for="crd-count"
|
||||
class="block text-sm font-medium text-zinc-400 mb-1"
|
||||
>{T.cardsToDraw}</label
|
||||
>
|
||||
<input
|
||||
id="crd-count"
|
||||
type="number"
|
||||
value="1"
|
||||
min="1"
|
||||
max="52"
|
||||
class="w-full bg-zinc-900 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 text-base focus:outline-none focus:border-[#534AB7] focus:ring-1 focus:ring-[#534AB7] transition-colors"
|
||||
aria-label={T.cardsToDraw}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p
|
||||
id="crd-error"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
class="mt-3 text-sm text-red-500 hidden"
|
||||
<label
|
||||
class="flex items-center gap-2.5 cursor-pointer select-none group pb-2"
|
||||
>
|
||||
</p>
|
||||
<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 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>
|
||||
<p
|
||||
id="crd-error"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
class="mt-3 text-sm text-red-500 hidden"
|
||||
>
|
||||
</p>
|
||||
|
||||
<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 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>
|
||||
|
||||
<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"
|
||||
<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"
|
||||
>
|
||||
{T.draw}
|
||||
</button>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<script>
|
||||
import { CopyFeedback } from "@/lib/client/clipboard";
|
||||
import { createErrorDisplay } from "@/lib/client/validation";
|
||||
import { CopyFeedback } from "@/lib/client/clipboard";
|
||||
import { createErrorDisplay } from "@/lib/client/validation";
|
||||
|
||||
const isRu = document.documentElement.lang === "ru";
|
||||
const COPY_LABEL = isRu ? "Копировать" : "Copy";
|
||||
const COPIED_LABEL = isRu ? "Скопировано" : "Copied";
|
||||
const ERR_AT_LEAST = isRu
|
||||
? "Вытащите хотя бы 1 карту."
|
||||
: "Draw at least 1 card.";
|
||||
const ERR_UNIQUE = isRu
|
||||
? "Нельзя вытащить более 52 уникальных карт."
|
||||
: "Cannot draw more than 52 unique cards.";
|
||||
const ERR_MAX52 = isRu
|
||||
? "Максимум 52 карты одновременно."
|
||||
: "Maximum 52 cards at once.";
|
||||
const isRu = document.documentElement.lang === "ru";
|
||||
const COPY_LABEL = isRu ? "Копировать" : "Copy";
|
||||
const COPIED_LABEL = isRu ? "Скопировано" : "Copied";
|
||||
const ERR_AT_LEAST = isRu
|
||||
? "Вытащите хотя бы 1 карту."
|
||||
: "Draw at least 1 card.";
|
||||
const ERR_UNIQUE = isRu
|
||||
? "Нельзя вытащить более 52 уникальных карт."
|
||||
: "Cannot draw more than 52 unique cards.";
|
||||
const ERR_MAX52 = isRu
|
||||
? "Максимум 52 карты одновременно."
|
||||
: "Maximum 52 cards at once.";
|
||||
|
||||
const countInput = document.getElementById("crd-count") as HTMLInputElement;
|
||||
const replaceCb = document.getElementById(
|
||||
"crd-replace",
|
||||
) as HTMLInputElement;
|
||||
const btn = document.getElementById("crd-btn") as HTMLButtonElement;
|
||||
const copyBtn = document.getElementById(
|
||||
"crd-copy-btn",
|
||||
) as HTMLButtonElement;
|
||||
const cardsEl = document.getElementById("crd-cards") as HTMLDivElement;
|
||||
const errorEl = document.getElementById(
|
||||
"crd-error",
|
||||
) as HTMLParagraphElement;
|
||||
const copyLabel = document.getElementById(
|
||||
"crd-copy-label",
|
||||
) as HTMLSpanElement;
|
||||
const copyIcon = document.getElementById(
|
||||
"crd-copy-icon",
|
||||
) as HTMLSpanElement;
|
||||
const checkIcon = document.getElementById(
|
||||
"crd-check-icon",
|
||||
) as HTMLSpanElement;
|
||||
const countInput = document.getElementById("crd-count") as HTMLInputElement;
|
||||
const replaceCb = document.getElementById("crd-replace") as HTMLInputElement;
|
||||
const btn = document.getElementById("crd-btn") as HTMLButtonElement;
|
||||
const copyBtn = document.getElementById("crd-copy-btn") as HTMLButtonElement;
|
||||
const cardsEl = document.getElementById("crd-cards") as HTMLDivElement;
|
||||
const errorEl = document.getElementById("crd-error") as HTMLParagraphElement;
|
||||
const copyLabel = document.getElementById(
|
||||
"crd-copy-label",
|
||||
) as HTMLSpanElement;
|
||||
const copyIcon = document.getElementById("crd-copy-icon") as HTMLSpanElement;
|
||||
const checkIcon = document.getElementById(
|
||||
"crd-check-icon",
|
||||
) as HTMLSpanElement;
|
||||
|
||||
const RANKS = [
|
||||
"A",
|
||||
"2",
|
||||
"3",
|
||||
"4",
|
||||
"5",
|
||||
"6",
|
||||
"7",
|
||||
"8",
|
||||
"9",
|
||||
"10",
|
||||
"J",
|
||||
"Q",
|
||||
"K",
|
||||
];
|
||||
const SUITS = [
|
||||
{ symbol: "♠", label: "Spades", red: false },
|
||||
{ symbol: "♥", label: "Hearts", red: true },
|
||||
{ symbol: "♦", label: "Diamonds", red: true },
|
||||
{ symbol: "♣", label: "Clubs", red: false },
|
||||
];
|
||||
const RANKS = [
|
||||
"A",
|
||||
"2",
|
||||
"3",
|
||||
"4",
|
||||
"5",
|
||||
"6",
|
||||
"7",
|
||||
"8",
|
||||
"9",
|
||||
"10",
|
||||
"J",
|
||||
"Q",
|
||||
"K",
|
||||
];
|
||||
const SUITS = [
|
||||
{ symbol: "♠", label: "Spades", red: false },
|
||||
{ symbol: "♥", label: "Hearts", red: true },
|
||||
{ symbol: "♦", label: "Diamonds", red: true },
|
||||
{ symbol: "♣", label: "Clubs", red: false },
|
||||
];
|
||||
|
||||
const DECK = SUITS.flatMap((suit) => RANKS.map((rank) => ({ rank, suit })));
|
||||
const DECK = SUITS.flatMap((suit) => RANKS.map((rank) => ({ rank, suit })));
|
||||
|
||||
let lastCards: { rank: string; suit: (typeof SUITS)[number] }[] = [];
|
||||
let lastCards: { rank: string; suit: (typeof SUITS)[number] }[] = [];
|
||||
|
||||
const errors = createErrorDisplay(errorEl);
|
||||
const clipboard = new CopyFeedback(copyIcon, checkIcon);
|
||||
const errors = createErrorDisplay(errorEl);
|
||||
const clipboard = new CopyFeedback(copyIcon, checkIcon);
|
||||
|
||||
function draw() {
|
||||
const count = parseInt(countInput.value, 10);
|
||||
const withReplacement = replaceCb.checked;
|
||||
function draw() {
|
||||
const count = parseInt(countInput.value, 10);
|
||||
const withReplacement = replaceCb.checked;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
errors.clear();
|
||||
|
||||
if (withReplacement) {
|
||||
lastCards = Array.from(
|
||||
{ length: count },
|
||||
() => DECK[Math.floor(Math.random() * DECK.length)],
|
||||
);
|
||||
} else {
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
function renderCards() {
|
||||
cardsEl.innerHTML = "";
|
||||
lastCards.forEach(({ rank, suit }) => {
|
||||
const card = document.createElement("div");
|
||||
card.setAttribute("aria-label", `${rank} of ${suit.label}`);
|
||||
card.className = [
|
||||
"flex flex-col items-center justify-between w-14 h-20 rounded-lg border px-1.5 py-1.5",
|
||||
"bg-zinc-900 border-zinc-700 select-none",
|
||||
suit.red ? "text-red-400" : "text-zinc-100",
|
||||
].join(" ");
|
||||
errors.clear();
|
||||
|
||||
const top = document.createElement("div");
|
||||
top.className =
|
||||
"w-full text-left text-sm font-bold leading-none tabular-nums";
|
||||
top.textContent = rank;
|
||||
|
||||
const mid = document.createElement("div");
|
||||
mid.className = "text-2xl leading-none";
|
||||
mid.textContent = suit.symbol;
|
||||
|
||||
const bot = document.createElement("div");
|
||||
bot.className =
|
||||
"w-full text-right text-sm font-bold leading-none tabular-nums rotate-180";
|
||||
bot.textContent = rank;
|
||||
|
||||
card.append(top, mid, bot);
|
||||
cardsEl.appendChild(card);
|
||||
});
|
||||
if (withReplacement) {
|
||||
lastCards = Array.from(
|
||||
{ length: count },
|
||||
() => DECK[Math.floor(Math.random() * DECK.length)],
|
||||
);
|
||||
} else {
|
||||
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);
|
||||
}
|
||||
|
||||
async function copyCards() {
|
||||
if (!lastCards.length) return;
|
||||
const text = lastCards
|
||||
.map(({ rank, suit }) => `${rank}${suit.symbol}`)
|
||||
.join(", ");
|
||||
await navigator.clipboard.writeText(text);
|
||||
renderCards();
|
||||
copyBtn.classList.remove("invisible");
|
||||
copyLabel.textContent = COPY_LABEL;
|
||||
}
|
||||
|
||||
clipboard.showCopied();
|
||||
copyLabel.textContent = COPIED_LABEL;
|
||||
function renderCards() {
|
||||
cardsEl.innerHTML = "";
|
||||
lastCards.forEach(({ rank, suit }) => {
|
||||
const card = document.createElement("div");
|
||||
card.setAttribute("aria-label", `${rank} of ${suit.label}`);
|
||||
card.className = [
|
||||
"flex flex-col items-center justify-between w-14 h-20 rounded-lg border px-1.5 py-1.5",
|
||||
"bg-zinc-900 border-zinc-700 select-none",
|
||||
suit.red ? "text-red-400" : "text-zinc-100",
|
||||
].join(" ");
|
||||
|
||||
setTimeout(() => {
|
||||
copyLabel.textContent = COPY_LABEL;
|
||||
}, 1500);
|
||||
}
|
||||
const top = document.createElement("div");
|
||||
top.className =
|
||||
"w-full text-left text-sm font-bold leading-none tabular-nums";
|
||||
top.textContent = rank;
|
||||
|
||||
btn.addEventListener("click", draw);
|
||||
copyBtn.addEventListener("click", copyCards);
|
||||
countInput.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") draw();
|
||||
const mid = document.createElement("div");
|
||||
mid.className = "text-2xl leading-none";
|
||||
mid.textContent = suit.symbol;
|
||||
|
||||
const bot = document.createElement("div");
|
||||
bot.className =
|
||||
"w-full text-right text-sm font-bold leading-none tabular-nums rotate-180";
|
||||
bot.textContent = rank;
|
||||
|
||||
card.append(top, mid, bot);
|
||||
cardsEl.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
async function copyCards() {
|
||||
if (!lastCards.length) return;
|
||||
const text = lastCards
|
||||
.map(({ rank, suit }) => `${rank}${suit.symbol}`)
|
||||
.join(", ");
|
||||
await navigator.clipboard.writeText(text);
|
||||
|
||||
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();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -5,196 +5,181 @@ const T = useT(isRu ? "ru" : "en");
|
||||
---
|
||||
|
||||
<div id="coin-generator" class="mt-8">
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<label for="coin-count" class="text-sm font-medium text-zinc-400"
|
||||
>{T.numberOfCoins}</label
|
||||
>
|
||||
<span
|
||||
id="coin-count-val"
|
||||
class="text-sm font-semibold tabular-nums text-zinc-100">1</span
|
||||
>
|
||||
</div>
|
||||
<input
|
||||
id="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 class="flex items-center justify-between mb-1">
|
||||
<label for="coin-count" class="text-sm font-medium text-zinc-400"
|
||||
>{T.numberOfCoins}</label
|
||||
>
|
||||
<span
|
||||
id="coin-count-val"
|
||||
class="text-sm font-semibold tabular-nums text-zinc-100">1</span
|
||||
>
|
||||
</div>
|
||||
<input
|
||||
id="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 class="my-10 min-h-28 flex flex-col items-center justify-center gap-4">
|
||||
<div
|
||||
id="coin-coins"
|
||||
class="flex flex-wrap justify-center gap-2"
|
||||
aria-live="polite"
|
||||
aria-label="Coin results"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="my-10 min-h-28 flex flex-col items-center justify-center gap-4">
|
||||
<div
|
||||
id="coin-coins"
|
||||
class="flex flex-wrap justify-center gap-2"
|
||||
aria-live="polite"
|
||||
aria-label="Coin results"
|
||||
>
|
||||
</div>
|
||||
|
||||
<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 id="coin-summary" class="hidden text-sm text-zinc-500 tabular-nums">
|
||||
</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"
|
||||
<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"
|
||||
>
|
||||
{T.flip}
|
||||
</button>
|
||||
</div>
|
||||
<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 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>
|
||||
|
||||
<script>
|
||||
import { CopyFeedback } from "@/lib/client/clipboard";
|
||||
import { CopyFeedback } from "@/lib/client/clipboard";
|
||||
|
||||
const isRu = document.documentElement.lang === "ru";
|
||||
const COPY_LABEL = isRu ? "Копировать" : "Copy";
|
||||
const COPIED_LABEL = isRu ? "Скопировано" : "Copied";
|
||||
const HEADS_LABEL = isRu ? "О" : "H";
|
||||
const TAILS_LABEL = isRu ? "Р" : "T";
|
||||
const HEADS_WORD = isRu ? "орёл" : "Heads";
|
||||
const TAILS_WORD = isRu ? "решка" : "Tails";
|
||||
const HEADS_SUMMARY = isRu ? "орёл" : "heads";
|
||||
const TAILS_SUMMARY = isRu ? "решка" : "tails";
|
||||
const isRu = document.documentElement.lang === "ru";
|
||||
const COPY_LABEL = isRu ? "Копировать" : "Copy";
|
||||
const COPIED_LABEL = isRu ? "Скопировано" : "Copied";
|
||||
const HEADS_LABEL = isRu ? "О" : "H";
|
||||
const TAILS_LABEL = isRu ? "Р" : "T";
|
||||
const HEADS_WORD = isRu ? "орёл" : "Heads";
|
||||
const TAILS_WORD = isRu ? "решка" : "Tails";
|
||||
const HEADS_SUMMARY = isRu ? "орёл" : "heads";
|
||||
const TAILS_SUMMARY = isRu ? "решка" : "tails";
|
||||
|
||||
const countInput = document.getElementById(
|
||||
"coin-count",
|
||||
) as HTMLInputElement;
|
||||
const countVal = document.getElementById(
|
||||
"coin-count-val",
|
||||
) as HTMLSpanElement;
|
||||
const btn = document.getElementById("coin-btn") as HTMLButtonElement;
|
||||
const copyBtn = document.getElementById(
|
||||
"coin-copy-btn",
|
||||
) as HTMLButtonElement;
|
||||
const coinsEl = document.getElementById("coin-coins") as HTMLDivElement;
|
||||
const summaryEl = document.getElementById("coin-summary") as HTMLDivElement;
|
||||
const copyLabel = document.getElementById(
|
||||
"coin-copy-label",
|
||||
) as HTMLSpanElement;
|
||||
const copyIcon = document.getElementById(
|
||||
"coin-copy-icon",
|
||||
) as HTMLSpanElement;
|
||||
const checkIcon = document.getElementById(
|
||||
"coin-check-icon",
|
||||
) as HTMLSpanElement;
|
||||
const countInput = document.getElementById("coin-count") as HTMLInputElement;
|
||||
const countVal = document.getElementById("coin-count-val") as HTMLSpanElement;
|
||||
const btn = document.getElementById("coin-btn") as HTMLButtonElement;
|
||||
const copyBtn = document.getElementById("coin-copy-btn") as HTMLButtonElement;
|
||||
const coinsEl = document.getElementById("coin-coins") as HTMLDivElement;
|
||||
const summaryEl = document.getElementById("coin-summary") as HTMLDivElement;
|
||||
const copyLabel = document.getElementById(
|
||||
"coin-copy-label",
|
||||
) as HTMLSpanElement;
|
||||
const copyIcon = document.getElementById("coin-copy-icon") as HTMLSpanElement;
|
||||
const checkIcon = document.getElementById(
|
||||
"coin-check-icon",
|
||||
) as HTMLSpanElement;
|
||||
|
||||
let lastResults: boolean[] = [];
|
||||
let lastResults: boolean[] = [];
|
||||
|
||||
const clipboard = new CopyFeedback(copyIcon, checkIcon);
|
||||
const clipboard = new CopyFeedback(copyIcon, checkIcon);
|
||||
|
||||
countInput.addEventListener("input", () => {
|
||||
countVal.textContent = countInput.value;
|
||||
countInput.addEventListener("input", () => {
|
||||
countVal.textContent = countInput.value;
|
||||
});
|
||||
|
||||
function flip() {
|
||||
const count = parseInt(countInput.value, 10);
|
||||
lastResults = Array.from({ length: count }, () => Math.random() < 0.5);
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
function flip() {
|
||||
const count = parseInt(countInput.value, 10);
|
||||
lastResults = Array.from({ length: count }, () => Math.random() < 0.5);
|
||||
|
||||
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;
|
||||
if (count > 1) {
|
||||
summaryEl.textContent = `${heads} ${HEADS_SUMMARY} · ${tails} ${TAILS_SUMMARY}`;
|
||||
summaryEl.classList.remove("hidden");
|
||||
} else {
|
||||
summaryEl.classList.add("hidden");
|
||||
}
|
||||
|
||||
async function copyResults() {
|
||||
if (!lastResults.length) return;
|
||||
const text = lastResults
|
||||
.map((h) => (h ? HEADS_WORD : TAILS_WORD))
|
||||
.join(", ");
|
||||
await navigator.clipboard.writeText(text);
|
||||
copyBtn.classList.remove("invisible");
|
||||
copyLabel.textContent = COPY_LABEL;
|
||||
}
|
||||
|
||||
clipboard.showCopied();
|
||||
copyLabel.textContent = COPIED_LABEL;
|
||||
async function copyResults() {
|
||||
if (!lastResults.length) return;
|
||||
const text = lastResults
|
||||
.map((h) => (h ? HEADS_WORD : TAILS_WORD))
|
||||
.join(", ");
|
||||
await navigator.clipboard.writeText(text);
|
||||
|
||||
setTimeout(() => {
|
||||
copyLabel.textContent = COPY_LABEL;
|
||||
}, 1500);
|
||||
}
|
||||
clipboard.showCopied();
|
||||
copyLabel.textContent = COPIED_LABEL;
|
||||
|
||||
btn.addEventListener("click", flip);
|
||||
copyBtn.addEventListener("click", copyResults);
|
||||
setTimeout(() => {
|
||||
copyLabel.textContent = COPY_LABEL;
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
btn.addEventListener("click", flip);
|
||||
copyBtn.addEventListener("click", copyResults);
|
||||
</script>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
---
|
||||
import { useT } from '../../i18n/translations';
|
||||
const isRu = Astro.url.pathname.startsWith('/ru');
|
||||
const T = useT(isRu ? 'ru' : 'en');
|
||||
import { useT } from "../../i18n/translations";
|
||||
const isRu = Astro.url.pathname.startsWith("/ru");
|
||||
const T = useT(isRu ? "ru" : "en");
|
||||
---
|
||||
|
||||
<div id="color-generator" class="mt-8">
|
||||
|
||||
<!-- Swatch -->
|
||||
<div class="flex justify-center mb-8">
|
||||
<div
|
||||
@@ -13,7 +12,8 @@ const T = useT(isRu ? 'ru' : 'en');
|
||||
class="w-44 h-44 rounded-2xl border border-zinc-800"
|
||||
style="background-color: #1f1f22; transition: background-color 0.35s cubic-bezier(0.4,0,0.2,1);"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Format rows -->
|
||||
@@ -23,35 +23,60 @@ const T = useT(isRu ? 'ru' : 'en');
|
||||
style="transition: opacity 0.25s ease;"
|
||||
aria-live="polite"
|
||||
>
|
||||
{[
|
||||
{ id: 'cg-hex', label: 'HEX' },
|
||||
{ id: 'cg-rgb', label: 'RGB' },
|
||||
{ id: 'cg-hsl', label: 'HSL' },
|
||||
].map(({ id, label }) => (
|
||||
<div class="flex items-center gap-3 px-4 py-3 bg-zinc-900/40">
|
||||
<span class="w-10 text-xs font-semibold uppercase tracking-widest text-zinc-500 shrink-0">
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
id={id}
|
||||
class="flex-1 font-mono text-sm text-zinc-100 tabular-nums select-all"
|
||||
></span>
|
||||
<button
|
||||
id={`${id}-copy`}
|
||||
type="button"
|
||||
aria-label={`Copy ${label} value`}
|
||||
class="group shrink-0 p-1 rounded text-zinc-600 hover:text-zinc-300 focus:outline-none focus-visible:ring-1 focus-visible:ring-[#534AB7] transition-colors cursor-pointer"
|
||||
>
|
||||
<svg id={`${id}-copy-icon`} 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>
|
||||
<svg id={`${id}-check-icon`} class="hidden text-[#534AB7]" 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>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{
|
||||
[
|
||||
{ id: "cg-hex", label: "HEX" },
|
||||
{ id: "cg-rgb", label: "RGB" },
|
||||
{ id: "cg-hsl", label: "HSL" },
|
||||
].map(({ id, label }) => (
|
||||
<div class="flex items-center gap-3 px-4 py-3 bg-zinc-900/40">
|
||||
<span class="w-10 text-xs font-semibold uppercase tracking-widest text-zinc-500 shrink-0">
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
id={id}
|
||||
class="flex-1 font-mono text-sm text-zinc-100 tabular-nums select-all"
|
||||
/>
|
||||
<button
|
||||
id={`${id}-copy`}
|
||||
type="button"
|
||||
aria-label={`Copy ${label} value`}
|
||||
class="group shrink-0 p-1 rounded text-zinc-600 hover:text-zinc-300 focus:outline-none focus-visible:ring-1 focus-visible:ring-[#534AB7] transition-colors cursor-pointer"
|
||||
>
|
||||
<svg
|
||||
id={`${id}-copy-icon`}
|
||||
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>
|
||||
<svg
|
||||
id={`${id}-check-icon`}
|
||||
class="hidden text-[#534AB7]"
|
||||
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>
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Generate button -->
|
||||
@@ -67,21 +92,24 @@ const T = useT(isRu ? 'ru' : 'en');
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const swatch = document.getElementById('cg-swatch') as HTMLDivElement;
|
||||
const valuesEl = document.getElementById('cg-values') as HTMLDivElement;
|
||||
const hexEl = document.getElementById('cg-hex') as HTMLSpanElement;
|
||||
const rgbEl = document.getElementById('cg-rgb') as HTMLSpanElement;
|
||||
const hslEl = document.getElementById('cg-hsl') as HTMLSpanElement;
|
||||
const btn = document.getElementById('cg-btn') as HTMLButtonElement;
|
||||
const swatch = document.getElementById("cg-swatch") as HTMLDivElement;
|
||||
const valuesEl = document.getElementById("cg-values") as HTMLDivElement;
|
||||
const hexEl = document.getElementById("cg-hex") as HTMLSpanElement;
|
||||
const rgbEl = document.getElementById("cg-rgb") as HTMLSpanElement;
|
||||
const hslEl = document.getElementById("cg-hsl") as HTMLSpanElement;
|
||||
const btn = document.getElementById("cg-btn") as HTMLButtonElement;
|
||||
|
||||
function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
|
||||
const rn = r / 255, gn = g / 255, bn = b / 255;
|
||||
const max = Math.max(rn, gn, bn), min = Math.min(rn, gn, bn);
|
||||
const rn = r / 255,
|
||||
gn = g / 255,
|
||||
bn = b / 255;
|
||||
const max = Math.max(rn, gn, bn),
|
||||
min = Math.min(rn, gn, bn);
|
||||
const l = (max + min) / 2;
|
||||
if (max === min) return [0, 0, Math.round(l * 100)];
|
||||
const d = max - min;
|
||||
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
let h = 0;
|
||||
let h;
|
||||
if (max === rn) h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6;
|
||||
else if (max === gn) h = ((bn - rn) / d + 2) / 6;
|
||||
else h = ((rn - gn) / d + 4) / 6;
|
||||
@@ -89,11 +117,15 @@ const T = useT(isRu ? 'ru' : 'en');
|
||||
}
|
||||
|
||||
function toHex(n: number) {
|
||||
return n.toString(16).padStart(2, '0');
|
||||
return n.toString(16).padStart(2, "0");
|
||||
}
|
||||
|
||||
function makeCopyHandler(el: HTMLSpanElement, copyIconId: string, checkIconId: string) {
|
||||
const copyIcon = document.getElementById(copyIconId)!;
|
||||
function makeCopyHandler(
|
||||
el: HTMLSpanElement,
|
||||
copyIconId: string,
|
||||
checkIconId: string,
|
||||
) {
|
||||
const copyIcon = document.getElementById(copyIconId)!;
|
||||
const checkIcon = document.getElementById(checkIconId)!;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
@@ -101,22 +133,34 @@ const T = useT(isRu ? 'ru' : 'en');
|
||||
const value = el.textContent?.trim();
|
||||
if (!value) return;
|
||||
await navigator.clipboard.writeText(value);
|
||||
copyIcon.classList.add('hidden');
|
||||
checkIcon.classList.remove('hidden');
|
||||
copyIcon.classList.add("hidden");
|
||||
checkIcon.classList.remove("hidden");
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
checkIcon.classList.add('hidden');
|
||||
copyIcon.classList.remove('hidden');
|
||||
checkIcon.classList.add("hidden");
|
||||
copyIcon.classList.remove("hidden");
|
||||
}, 1500);
|
||||
};
|
||||
}
|
||||
|
||||
document.getElementById('cg-hex-copy')!
|
||||
.addEventListener('click', makeCopyHandler(hexEl, 'cg-hex-copy-icon', 'cg-hex-check-icon'));
|
||||
document.getElementById('cg-rgb-copy')!
|
||||
.addEventListener('click', makeCopyHandler(rgbEl, 'cg-rgb-copy-icon', 'cg-rgb-check-icon'));
|
||||
document.getElementById('cg-hsl-copy')!
|
||||
.addEventListener('click', makeCopyHandler(hslEl, 'cg-hsl-copy-icon', 'cg-hsl-check-icon'));
|
||||
document
|
||||
.getElementById("cg-hex-copy")!
|
||||
.addEventListener(
|
||||
"click",
|
||||
makeCopyHandler(hexEl, "cg-hex-copy-icon", "cg-hex-check-icon"),
|
||||
);
|
||||
document
|
||||
.getElementById("cg-rgb-copy")!
|
||||
.addEventListener(
|
||||
"click",
|
||||
makeCopyHandler(rgbEl, "cg-rgb-copy-icon", "cg-rgb-check-icon"),
|
||||
);
|
||||
document
|
||||
.getElementById("cg-hsl-copy")!
|
||||
.addEventListener(
|
||||
"click",
|
||||
makeCopyHandler(hslEl, "cg-hsl-copy-icon", "cg-hsl-check-icon"),
|
||||
);
|
||||
|
||||
function generate() {
|
||||
const r = Math.floor(Math.random() * 256);
|
||||
@@ -130,8 +174,8 @@ const T = useT(isRu ? 'ru' : 'en');
|
||||
hexEl.textContent = hex;
|
||||
rgbEl.textContent = `rgb(${r}, ${g}, ${b})`;
|
||||
hslEl.textContent = `hsl(${h}, ${s}%, ${l}%)`;
|
||||
valuesEl.style.opacity = '1';
|
||||
valuesEl.style.opacity = "1";
|
||||
}
|
||||
|
||||
btn.addEventListener('click', generate);
|
||||
btn.addEventListener("click", generate);
|
||||
</script>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,247 +5,236 @@ const T = useT(isRu ? "ru" : "en");
|
||||
---
|
||||
|
||||
<div id="list-generator" class="mt-8">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<label
|
||||
for="lst-items"
|
||||
class="block text-sm font-medium text-zinc-400 mb-1"
|
||||
>
|
||||
{T.items}
|
||||
<span class="text-zinc-600">({T.itemsOneLine})</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="lst-items"
|
||||
rows="6"
|
||||
placeholder="Alice
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<label
|
||||
for="lst-items"
|
||||
class="block text-sm font-medium text-zinc-400 mb-1"
|
||||
>
|
||||
{T.items}
|
||||
<span class="text-zinc-600">({T.itemsOneLine})</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="lst-items"
|
||||
rows="6"
|
||||
placeholder="Alice
|
||||
Bob
|
||||
Carol
|
||||
David"
|
||||
class="w-full bg-zinc-900 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 text-sm font-mono focus:outline-none focus:border-[#534AB7] focus:ring-1 focus:ring-[#534AB7] transition-colors resize-y"
|
||||
aria-label={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>
|
||||
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>
|
||||
|
||||
<p
|
||||
id="lst-error"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
class="mt-3 text-sm text-red-500 hidden"
|
||||
<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>
|
||||
|
||||
<p
|
||||
id="lst-error"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
class="mt-3 text-sm text-red-500 hidden"
|
||||
>
|
||||
</p>
|
||||
|
||||
<div class="my-8 min-h-16 flex flex-col items-center justify-center gap-3">
|
||||
<div
|
||||
id="lst-result"
|
||||
class="flex flex-wrap justify-center gap-2"
|
||||
aria-live="polite"
|
||||
aria-label="Picked items"
|
||||
>
|
||||
</p>
|
||||
|
||||
<div class="my-8 min-h-16 flex flex-col items-center justify-center gap-3">
|
||||
<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"
|
||||
></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 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"
|
||||
<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"
|
||||
>
|
||||
{T.pick}
|
||||
</button>
|
||||
</div>
|
||||
<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 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>
|
||||
|
||||
<script>
|
||||
import { CopyFeedback } from "@/lib/client/clipboard";
|
||||
import { createErrorDisplay } from "@/lib/client/validation";
|
||||
import { CopyFeedback } from "@/lib/client/clipboard";
|
||||
import { createErrorDisplay } from "@/lib/client/validation";
|
||||
|
||||
const isRu = document.documentElement.lang === "ru";
|
||||
const COPY_LABEL = isRu ? "Копировать" : "Copy";
|
||||
const COPIED_LABEL = isRu ? "Скопировано" : "Copied";
|
||||
const ERR_ADD_ONE = isRu
|
||||
? "Добавьте хотя бы один элемент в список."
|
||||
: "Add at least one item to the list.";
|
||||
const ERR_PICK_ONE = isRu
|
||||
? "Выберите хотя бы 1 элемент."
|
||||
: "Pick at least 1 item.";
|
||||
const isRu = document.documentElement.lang === "ru";
|
||||
const COPY_LABEL = isRu ? "Копировать" : "Copy";
|
||||
const COPIED_LABEL = isRu ? "Скопировано" : "Copied";
|
||||
const ERR_ADD_ONE = isRu
|
||||
? "Добавьте хотя бы один элемент в список."
|
||||
: "Add at least one item to the list.";
|
||||
const ERR_PICK_ONE = isRu
|
||||
? "Выберите хотя бы 1 элемент."
|
||||
: "Pick at least 1 item.";
|
||||
|
||||
const itemsInput = document.getElementById(
|
||||
"lst-items",
|
||||
) as HTMLTextAreaElement;
|
||||
const pickInput = document.getElementById("lst-pick") as HTMLInputElement;
|
||||
const replaceCb = document.getElementById(
|
||||
"lst-replace",
|
||||
) as HTMLInputElement;
|
||||
const btn = document.getElementById("lst-btn") as HTMLButtonElement;
|
||||
const copyBtn = document.getElementById(
|
||||
"lst-copy-btn",
|
||||
) as HTMLButtonElement;
|
||||
const resultEl = document.getElementById("lst-result") as HTMLDivElement;
|
||||
const errorEl = document.getElementById(
|
||||
"lst-error",
|
||||
) as HTMLParagraphElement;
|
||||
const copyLabel = document.getElementById(
|
||||
"lst-copy-label",
|
||||
) as HTMLSpanElement;
|
||||
const copyIcon = document.getElementById(
|
||||
"lst-copy-icon",
|
||||
) as HTMLSpanElement;
|
||||
const checkIcon = document.getElementById(
|
||||
"lst-check-icon",
|
||||
) as HTMLSpanElement;
|
||||
const itemsInput = document.getElementById(
|
||||
"lst-items",
|
||||
) as HTMLTextAreaElement;
|
||||
const pickInput = document.getElementById("lst-pick") as HTMLInputElement;
|
||||
const replaceCb = document.getElementById("lst-replace") as HTMLInputElement;
|
||||
const btn = document.getElementById("lst-btn") as HTMLButtonElement;
|
||||
const copyBtn = document.getElementById("lst-copy-btn") as HTMLButtonElement;
|
||||
const resultEl = document.getElementById("lst-result") as HTMLDivElement;
|
||||
const errorEl = document.getElementById("lst-error") as HTMLParagraphElement;
|
||||
const copyLabel = document.getElementById(
|
||||
"lst-copy-label",
|
||||
) as HTMLSpanElement;
|
||||
const copyIcon = document.getElementById("lst-copy-icon") as HTMLSpanElement;
|
||||
const checkIcon = document.getElementById(
|
||||
"lst-check-icon",
|
||||
) as HTMLSpanElement;
|
||||
|
||||
let lastPicked: string[] = [];
|
||||
let lastPicked: string[] = [];
|
||||
|
||||
const errors = createErrorDisplay(errorEl);
|
||||
const clipboard = new CopyFeedback(copyIcon, checkIcon);
|
||||
const errors = createErrorDisplay(errorEl);
|
||||
const clipboard = new CopyFeedback(copyIcon, checkIcon);
|
||||
|
||||
function pick() {
|
||||
const items = itemsInput.value
|
||||
.split("\n")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const count = parseInt(pickInput.value, 10);
|
||||
const withReplacement = replaceCb.checked;
|
||||
function pick() {
|
||||
const items = itemsInput.value
|
||||
.split("\n")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const count = parseInt(pickInput.value, 10);
|
||||
const withReplacement = replaceCb.checked;
|
||||
|
||||
if (items.length === 0) {
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
async function copyPicked() {
|
||||
if (!lastPicked.length) return;
|
||||
await navigator.clipboard.writeText(lastPicked.join(", "));
|
||||
errors.clear();
|
||||
|
||||
clipboard.showCopied();
|
||||
copyLabel.textContent = COPIED_LABEL;
|
||||
|
||||
setTimeout(() => {
|
||||
copyLabel.textContent = COPY_LABEL;
|
||||
}, 1500);
|
||||
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);
|
||||
}
|
||||
|
||||
btn.addEventListener("click", pick);
|
||||
copyBtn.addEventListener("click", copyPicked);
|
||||
itemsInput.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" && e.ctrlKey) pick();
|
||||
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;
|
||||
}
|
||||
|
||||
async function copyPicked() {
|
||||
if (!lastPicked.length) return;
|
||||
await navigator.clipboard.writeText(lastPicked.join(", "));
|
||||
|
||||
clipboard.showCopied();
|
||||
copyLabel.textContent = COPIED_LABEL;
|
||||
|
||||
setTimeout(() => {
|
||||
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>
|
||||
|
||||
@@ -5,220 +5,205 @@ const T = useT(isRu ? "ru" : "en");
|
||||
---
|
||||
|
||||
<div id="lottery-generator" class="mt-8">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label
|
||||
for="lg-from"
|
||||
class="block text-sm font-medium text-zinc-400 mb-1"
|
||||
>{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 class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label for="lg-from" class="block text-sm font-medium text-zinc-400 mb-1"
|
||||
>{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>
|
||||
|
||||
<p
|
||||
id="lg-error"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
class="mt-3 text-sm text-red-500 hidden"
|
||||
<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"
|
||||
>
|
||||
</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>
|
||||
<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 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 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>
|
||||
|
||||
<script>
|
||||
import { CopyFeedback } from "@/lib/client/clipboard";
|
||||
import { createErrorDisplay, isInteger } from "@/lib/client/validation";
|
||||
import { CopyFeedback } from "@/lib/client/clipboard";
|
||||
import { createErrorDisplay, isInteger } from "@/lib/client/validation";
|
||||
|
||||
const isRu = document.documentElement.lang === "ru";
|
||||
const ERR_INTEGERS = isRu
|
||||
? "Все значения должны быть целыми числами."
|
||||
: "All values must be whole numbers.";
|
||||
const ERR_FROM_TO = isRu
|
||||
? "«От» должно быть меньше «До»."
|
||||
: '"From" must be less than "To".';
|
||||
const ERR_PICK_MIN = isRu
|
||||
? "«Выбрать» должно быть не менее 1."
|
||||
: '"Pick" must be at least 1.';
|
||||
const COPY_LABEL = isRu ? "Копировать" : "Copy";
|
||||
const COPIED_LABEL = isRu ? "Скопировано" : "Copied";
|
||||
const isRu = document.documentElement.lang === "ru";
|
||||
const ERR_INTEGERS = isRu
|
||||
? "Все значения должны быть целыми числами."
|
||||
: "All values must be whole numbers.";
|
||||
const ERR_FROM_TO = isRu
|
||||
? "«От» должно быть меньше «До»."
|
||||
: '"From" must be less than "To".';
|
||||
const ERR_PICK_MIN = isRu
|
||||
? "«Выбрать» должно быть не менее 1."
|
||||
: '"Pick" must be at least 1.';
|
||||
const COPY_LABEL = isRu ? "Копировать" : "Copy";
|
||||
const COPIED_LABEL = isRu ? "Скопировано" : "Copied";
|
||||
|
||||
const fromInput = document.getElementById("lg-from") as HTMLInputElement;
|
||||
const toInput = document.getElementById("lg-to") as HTMLInputElement;
|
||||
const pickInput = document.getElementById("lg-pick") as HTMLInputElement;
|
||||
const btn = document.getElementById("lg-btn") as HTMLButtonElement;
|
||||
const copyBtn = document.getElementById("lg-copy-btn") as HTMLButtonElement;
|
||||
const resultEl = document.getElementById("lg-result") as HTMLDivElement;
|
||||
const errorEl = document.getElementById("lg-error") as HTMLParagraphElement;
|
||||
const copyLabel = document.getElementById(
|
||||
"lg-copy-label",
|
||||
) as HTMLSpanElement;
|
||||
const copyIcon = document.getElementById("lg-copy-icon") as HTMLSpanElement;
|
||||
const checkIcon = document.getElementById(
|
||||
"lg-check-icon",
|
||||
) as HTMLSpanElement;
|
||||
const fromInput = document.getElementById("lg-from") as HTMLInputElement;
|
||||
const toInput = document.getElementById("lg-to") as HTMLInputElement;
|
||||
const pickInput = document.getElementById("lg-pick") as HTMLInputElement;
|
||||
const btn = document.getElementById("lg-btn") as HTMLButtonElement;
|
||||
const copyBtn = document.getElementById("lg-copy-btn") as HTMLButtonElement;
|
||||
const resultEl = document.getElementById("lg-result") as HTMLDivElement;
|
||||
const errorEl = document.getElementById("lg-error") as HTMLParagraphElement;
|
||||
const copyLabel = document.getElementById("lg-copy-label") as HTMLSpanElement;
|
||||
const copyIcon = document.getElementById("lg-copy-icon") as HTMLSpanElement;
|
||||
const checkIcon = document.getElementById("lg-check-icon") as HTMLSpanElement;
|
||||
|
||||
const errors = createErrorDisplay(errorEl);
|
||||
const clipboard = new CopyFeedback(copyIcon, checkIcon);
|
||||
const errors = createErrorDisplay(errorEl);
|
||||
const clipboard = new CopyFeedback(copyIcon, checkIcon);
|
||||
|
||||
function draw() {
|
||||
if (
|
||||
!isInteger(fromInput.value) ||
|
||||
!isInteger(toInput.value) ||
|
||||
!isInteger(pickInput.value)
|
||||
) {
|
||||
errors.show(ERR_INTEGERS);
|
||||
return;
|
||||
}
|
||||
const min = parseInt(fromInput.value, 10);
|
||||
const max = parseInt(toInput.value, 10);
|
||||
const pick = parseInt(pickInput.value, 10);
|
||||
const pool = max - min + 1;
|
||||
if (min >= max) {
|
||||
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;
|
||||
function draw() {
|
||||
if (
|
||||
!isInteger(fromInput.value) ||
|
||||
!isInteger(toInput.value) ||
|
||||
!isInteger(pickInput.value)
|
||||
) {
|
||||
errors.show(ERR_INTEGERS);
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
clipboard.showCopied();
|
||||
copyLabel.textContent = COPIED_LABEL;
|
||||
setTimeout(() => {
|
||||
copyLabel.textContent = COPY_LABEL;
|
||||
}, 1500);
|
||||
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;
|
||||
}
|
||||
|
||||
btn.addEventListener("click", draw);
|
||||
copyBtn.addEventListener("click", copyNumbers);
|
||||
[fromInput, toInput, pickInput].forEach((input) => {
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") draw();
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
clipboard.showCopied();
|
||||
copyLabel.textContent = COPIED_LABEL;
|
||||
setTimeout(() => {
|
||||
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>
|
||||
|
||||
@@ -5,178 +5,170 @@ const T = useT(isRu ? "ru" : "en");
|
||||
---
|
||||
|
||||
<div id="number-generator" class="mt-8" data-ru={isRu ? "1" : "0"}>
|
||||
<div class="flex flex-col sm:flex-row gap-4">
|
||||
<div class="flex-1">
|
||||
<label
|
||||
for="ng-from"
|
||||
class="block text-sm font-medium text-zinc-400 mb-1"
|
||||
>{T.from}</label
|
||||
>
|
||||
<input
|
||||
id="ng-from"
|
||||
type="number"
|
||||
value="1"
|
||||
class="w-full bg-zinc-900 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 text-base focus:outline-none focus:border-[#534AB7] focus:ring-1 focus:ring-[#534AB7] transition-colors"
|
||||
/>
|
||||
</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 class="flex flex-col sm:flex-row gap-4">
|
||||
<div class="flex-1">
|
||||
<label for="ng-from" class="block text-sm font-medium text-zinc-400 mb-1"
|
||||
>{T.from}</label
|
||||
>
|
||||
<input
|
||||
id="ng-from"
|
||||
type="number"
|
||||
value="1"
|
||||
class="w-full bg-zinc-900 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 text-base focus:outline-none focus:border-[#534AB7] focus:ring-1 focus:ring-[#534AB7] transition-colors"
|
||||
/>
|
||||
</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>
|
||||
|
||||
<p
|
||||
id="ng-error"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
class="mt-2 text-sm text-red-600 hidden"
|
||||
<p
|
||||
id="ng-error"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
class="mt-2 text-sm text-red-600 hidden"
|
||||
>
|
||||
</p>
|
||||
|
||||
<div class="my-10 min-h-36 flex flex-col items-center justify-center gap-3">
|
||||
<button
|
||||
id="ng-copy-btn"
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
</p>
|
||||
<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 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 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 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>
|
||||
|
||||
<script>
|
||||
import { CopyFeedback } from "@/lib/client/clipboard";
|
||||
import { createErrorDisplay, isInteger } from "@/lib/client/validation";
|
||||
import { popElement } from "@/lib/client/animations";
|
||||
import { CopyFeedback } from "@/lib/client/clipboard";
|
||||
import { createErrorDisplay, isInteger } from "@/lib/client/validation";
|
||||
import { popElement } from "@/lib/client/animations";
|
||||
|
||||
const isRu = document.documentElement.lang === "ru";
|
||||
const ERR_INTEGERS = isRu
|
||||
? "Оба значения должны быть целыми числами."
|
||||
: "Both values must be whole numbers.";
|
||||
const ERR_FROM_TO = isRu
|
||||
? "«От» должно быть меньше «До»."
|
||||
: '"From" must be less than "To".';
|
||||
const isRu = document.documentElement.lang === "ru";
|
||||
const ERR_INTEGERS = isRu
|
||||
? "Оба значения должны быть целыми числами."
|
||||
: "Both values must be whole numbers.";
|
||||
const ERR_FROM_TO = isRu
|
||||
? "«От» должно быть меньше «До»."
|
||||
: '"From" must be less than "To".';
|
||||
|
||||
const fromInput = document.getElementById("ng-from") as HTMLInputElement;
|
||||
const toInput = document.getElementById("ng-to") as HTMLInputElement;
|
||||
const btn = document.getElementById("ng-btn") as HTMLButtonElement;
|
||||
const copyBtn = document.getElementById("ng-copy-btn") as HTMLButtonElement;
|
||||
const resultEl = document.getElementById("ng-result") as HTMLSpanElement;
|
||||
const errorEl = document.getElementById("ng-error") as HTMLParagraphElement;
|
||||
const copyIcon = document.getElementById("ng-copy-icon") as HTMLSpanElement;
|
||||
const checkIcon = document.getElementById(
|
||||
"ng-check-icon",
|
||||
) as HTMLSpanElement;
|
||||
const copiedLabel = document.getElementById(
|
||||
"ng-copied-label",
|
||||
) as HTMLSpanElement;
|
||||
const fromInput = document.getElementById("ng-from") as HTMLInputElement;
|
||||
const toInput = document.getElementById("ng-to") as HTMLInputElement;
|
||||
const btn = document.getElementById("ng-btn") as HTMLButtonElement;
|
||||
const copyBtn = document.getElementById("ng-copy-btn") as HTMLButtonElement;
|
||||
const resultEl = document.getElementById("ng-result") as HTMLSpanElement;
|
||||
const errorEl = document.getElementById("ng-error") as HTMLParagraphElement;
|
||||
const copyIcon = document.getElementById("ng-copy-icon") as HTMLSpanElement;
|
||||
const checkIcon = document.getElementById("ng-check-icon") as HTMLSpanElement;
|
||||
const copiedLabel = document.getElementById(
|
||||
"ng-copied-label",
|
||||
) as HTMLSpanElement;
|
||||
|
||||
const errors = createErrorDisplay(errorEl);
|
||||
const clipboard = new CopyFeedback(copyIcon, checkIcon);
|
||||
const errors = createErrorDisplay(errorEl);
|
||||
const clipboard = new CopyFeedback(copyIcon, checkIcon);
|
||||
|
||||
function generate() {
|
||||
if (!isInteger(fromInput.value) || !isInteger(toInput.value)) {
|
||||
errors.show(ERR_INTEGERS);
|
||||
return;
|
||||
}
|
||||
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() {
|
||||
if (!isInteger(fromInput.value) || !isInteger(toInput.value)) {
|
||||
errors.show(ERR_INTEGERS);
|
||||
return;
|
||||
}
|
||||
|
||||
async function copyNumber() {
|
||||
const value = resultEl.textContent?.trim();
|
||||
if (!value) return;
|
||||
await navigator.clipboard.writeText(value);
|
||||
clipboard.showCopied();
|
||||
copiedLabel.style.opacity = "1";
|
||||
setTimeout(() => {
|
||||
copiedLabel.style.opacity = "0";
|
||||
}, 1500);
|
||||
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);
|
||||
}
|
||||
|
||||
btn.addEventListener("click", generate);
|
||||
copyBtn.addEventListener("click", copyNumber);
|
||||
[fromInput, toInput].forEach((input) => {
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") generate();
|
||||
});
|
||||
async function copyNumber() {
|
||||
const value = resultEl.textContent?.trim();
|
||||
if (!value) return;
|
||||
await navigator.clipboard.writeText(value);
|
||||
clipboard.showCopied();
|
||||
copiedLabel.style.opacity = "1";
|
||||
setTimeout(() => {
|
||||
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>
|
||||
|
||||
@@ -5,223 +5,209 @@ const T = useT(isRu ? "ru" : "en");
|
||||
---
|
||||
|
||||
<div id="password-generator" class="mt-8">
|
||||
<div class="flex flex-col gap-5">
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<label for="pg-length" class="text-sm font-medium text-zinc-400"
|
||||
>{T.length}</label
|
||||
>
|
||||
<span
|
||||
id="pg-length-val"
|
||||
class="text-sm font-semibold tabular-nums text-zinc-100"
|
||||
>16</span
|
||||
>
|
||||
</div>
|
||||
<input
|
||||
id="pg-length"
|
||||
type="range"
|
||||
min="4"
|
||||
max="64"
|
||||
value="16"
|
||||
class="w-full accent-[#534AB7] cursor-pointer"
|
||||
/>
|
||||
<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>
|
||||
|
||||
<p
|
||||
id="pg-error"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
class="mt-4 text-sm text-red-500 hidden"
|
||||
>
|
||||
</p>
|
||||
|
||||
<div class="my-10 min-h-24 flex flex-col items-center justify-center gap-3">
|
||||
<button
|
||||
id="pg-copy-btn"
|
||||
type="button"
|
||||
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]"
|
||||
<div class="flex flex-col gap-5">
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<label for="pg-length" class="text-sm font-medium text-zinc-400"
|
||||
>{T.length}</label
|
||||
>
|
||||
<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
|
||||
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 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"
|
||||
<p
|
||||
id="pg-error"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
class="mt-4 text-sm text-red-500 hidden"
|
||||
>
|
||||
</p>
|
||||
|
||||
<div class="my-10 min-h-24 flex flex-col items-center justify-center gap-3">
|
||||
<button
|
||||
id="pg-copy-btn"
|
||||
type="button"
|
||||
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
|
||||
>
|
||||
{T.generate}
|
||||
</button>
|
||||
</div>
|
||||
</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 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>
|
||||
|
||||
<script>
|
||||
import { CopyFeedback } from "@/lib/client/clipboard";
|
||||
import { createErrorDisplay } from "@/lib/client/validation";
|
||||
import { CopyFeedback } from "@/lib/client/clipboard";
|
||||
import { createErrorDisplay } from "@/lib/client/validation";
|
||||
|
||||
const isRu = document.documentElement.lang === "ru";
|
||||
const ERR_SELECT_TYPE = isRu
|
||||
? "Выберите хотя бы один тип символов."
|
||||
: "Select at least one character type.";
|
||||
const isRu = document.documentElement.lang === "ru";
|
||||
const ERR_SELECT_TYPE = isRu
|
||||
? "Выберите хотя бы один тип символов."
|
||||
: "Select at least one character type.";
|
||||
|
||||
const lengthInput = document.getElementById(
|
||||
"pg-length",
|
||||
) as HTMLInputElement;
|
||||
const lengthVal = document.getElementById(
|
||||
"pg-length-val",
|
||||
) as HTMLSpanElement;
|
||||
const upperCb = document.getElementById("pg-upper") as HTMLInputElement;
|
||||
const lowerCb = document.getElementById("pg-lower") as HTMLInputElement;
|
||||
const digitsCb = document.getElementById("pg-digits") as HTMLInputElement;
|
||||
const symbolsCb = document.getElementById("pg-symbols") as HTMLInputElement;
|
||||
const btn = document.getElementById("pg-btn") as HTMLButtonElement;
|
||||
const copyBtn = document.getElementById("pg-copy-btn") as HTMLButtonElement;
|
||||
const resultEl = document.getElementById("pg-result") as HTMLSpanElement;
|
||||
const errorEl = document.getElementById("pg-error") as HTMLParagraphElement;
|
||||
const copyIcon = document.getElementById("pg-copy-icon") as HTMLSpanElement;
|
||||
const checkIcon = document.getElementById(
|
||||
"pg-check-icon",
|
||||
) as HTMLSpanElement;
|
||||
const copiedLabel = document.getElementById(
|
||||
"pg-copied-label",
|
||||
) as HTMLSpanElement;
|
||||
const lengthInput = document.getElementById("pg-length") as HTMLInputElement;
|
||||
const lengthVal = document.getElementById("pg-length-val") as HTMLSpanElement;
|
||||
const upperCb = document.getElementById("pg-upper") as HTMLInputElement;
|
||||
const lowerCb = document.getElementById("pg-lower") as HTMLInputElement;
|
||||
const digitsCb = document.getElementById("pg-digits") as HTMLInputElement;
|
||||
const symbolsCb = document.getElementById("pg-symbols") as HTMLInputElement;
|
||||
const btn = document.getElementById("pg-btn") as HTMLButtonElement;
|
||||
const copyBtn = document.getElementById("pg-copy-btn") as HTMLButtonElement;
|
||||
const resultEl = document.getElementById("pg-result") as HTMLSpanElement;
|
||||
const errorEl = document.getElementById("pg-error") as HTMLParagraphElement;
|
||||
const copyIcon = document.getElementById("pg-copy-icon") as HTMLSpanElement;
|
||||
const checkIcon = document.getElementById("pg-check-icon") as HTMLSpanElement;
|
||||
const copiedLabel = document.getElementById(
|
||||
"pg-copied-label",
|
||||
) as HTMLSpanElement;
|
||||
|
||||
const UPPER = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
const LOWER = "abcdefghijklmnopqrstuvwxyz";
|
||||
const DIGITS = "0123456789";
|
||||
const SYMBOLS = "!@#$%^&*()-_=+[]{}|;:,.<>?";
|
||||
const UPPER = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
const LOWER = "abcdefghijklmnopqrstuvwxyz";
|
||||
const DIGITS = "0123456789";
|
||||
const SYMBOLS = "!@#$%^&*()-_=+[]{}|;:,.<>?";
|
||||
|
||||
lengthInput.addEventListener("input", () => {
|
||||
lengthVal.textContent = lengthInput.value;
|
||||
});
|
||||
lengthInput.addEventListener("input", () => {
|
||||
lengthVal.textContent = lengthInput.value;
|
||||
});
|
||||
|
||||
const errors = createErrorDisplay(errorEl);
|
||||
const clipboard = new CopyFeedback(copyIcon, checkIcon);
|
||||
const errors = createErrorDisplay(errorEl);
|
||||
const clipboard = new CopyFeedback(copyIcon, checkIcon);
|
||||
|
||||
function generate() {
|
||||
const length = parseInt(lengthInput.value, 10);
|
||||
const pools: string[] = [];
|
||||
if (upperCb.checked) pools.push(UPPER);
|
||||
if (lowerCb.checked) pools.push(LOWER);
|
||||
if (digitsCb.checked) pools.push(DIGITS);
|
||||
if (symbolsCb.checked) pools.push(SYMBOLS);
|
||||
if (pools.length === 0) {
|
||||
errors.show(ERR_SELECT_TYPE);
|
||||
return;
|
||||
}
|
||||
errors.clear();
|
||||
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");
|
||||
function generate() {
|
||||
const length = parseInt(lengthInput.value, 10);
|
||||
const pools: string[] = [];
|
||||
if (upperCb.checked) pools.push(UPPER);
|
||||
if (lowerCb.checked) pools.push(LOWER);
|
||||
if (digitsCb.checked) pools.push(DIGITS);
|
||||
if (symbolsCb.checked) pools.push(SYMBOLS);
|
||||
if (pools.length === 0) {
|
||||
errors.show(ERR_SELECT_TYPE);
|
||||
return;
|
||||
}
|
||||
|
||||
async function copyPassword() {
|
||||
const value = resultEl.textContent?.trim();
|
||||
if (!value) return;
|
||||
await navigator.clipboard.writeText(value);
|
||||
clipboard.showCopied();
|
||||
copiedLabel.style.opacity = "1";
|
||||
setTimeout(() => {
|
||||
copiedLabel.style.opacity = "0";
|
||||
}, 1500);
|
||||
errors.clear();
|
||||
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");
|
||||
}
|
||||
|
||||
btn.addEventListener("click", generate);
|
||||
copyBtn.addEventListener("click", copyPassword);
|
||||
async function copyPassword() {
|
||||
const value = resultEl.textContent?.trim();
|
||||
if (!value) return;
|
||||
await navigator.clipboard.writeText(value);
|
||||
clipboard.showCopied();
|
||||
copiedLabel.style.opacity = "1";
|
||||
setTimeout(() => {
|
||||
copiedLabel.style.opacity = "0";
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
btn.addEventListener("click", generate);
|
||||
copyBtn.addEventListener("click", copyPassword);
|
||||
</script>
|
||||
|
||||
@@ -5,259 +5,242 @@ const T = useT(isRu ? "ru" : "en");
|
||||
---
|
||||
|
||||
<div id="uuid-generator" class="mt-8">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-zinc-400 mb-2"
|
||||
>{T.type}</label
|
||||
>
|
||||
<div
|
||||
class="flex flex-wrap gap-2"
|
||||
role="radiogroup"
|
||||
aria-label={T.type}
|
||||
>
|
||||
{
|
||||
[
|
||||
{ value: "uuid", label: "UUID v4" },
|
||||
{ value: "hex", label: "Hex" },
|
||||
{ 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 class="flex flex-col gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-zinc-400 mb-2"
|
||||
>{T.type}</label
|
||||
>
|
||||
<div class="flex flex-wrap gap-2" role="radiogroup" aria-label={T.type}>
|
||||
{
|
||||
[
|
||||
{ value: "uuid", label: "UUID v4" },
|
||||
{ value: "hex", label: "Hex" },
|
||||
{ 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="my-8 min-h-16 flex flex-col gap-2"
|
||||
aria-live="polite"
|
||||
aria-label="Generated tokens"
|
||||
<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"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<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
|
||||
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"></path>
|
||||
</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"
|
||||
<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="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"
|
||||
>
|
||||
{T.generate}
|
||||
</button>
|
||||
</div>
|
||||
<path d="M20 6 9 17l-5-5"></path>
|
||||
</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>
|
||||
|
||||
<script>
|
||||
import { CopyFeedback } from "@/lib/client/clipboard";
|
||||
import { CopyFeedback } from "@/lib/client/clipboard";
|
||||
|
||||
const isRu = document.documentElement.lang === "ru";
|
||||
const COPY_ALL_LABEL = isRu ? "Копировать всё" : "Copy all";
|
||||
const COPIED_LABEL = isRu ? "Скопировано" : "Copied";
|
||||
const isRu = document.documentElement.lang === "ru";
|
||||
const COPY_ALL_LABEL = isRu ? "Копировать всё" : "Copy all";
|
||||
const COPIED_LABEL = isRu ? "Скопировано" : "Copied";
|
||||
|
||||
const lengthWrap = document.getElementById(
|
||||
"ug-length-wrap",
|
||||
) as HTMLDivElement;
|
||||
const lengthInput = document.getElementById(
|
||||
"ug-length",
|
||||
) as HTMLInputElement;
|
||||
const countInput = document.getElementById("ug-count") as HTMLInputElement;
|
||||
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;
|
||||
const lengthWrap = document.getElementById(
|
||||
"ug-length-wrap",
|
||||
) as HTMLDivElement;
|
||||
const lengthInput = document.getElementById("ug-length") as HTMLInputElement;
|
||||
const countInput = document.getElementById("ug-count") as HTMLInputElement;
|
||||
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[] = [];
|
||||
let lastTokens: string[] = [];
|
||||
|
||||
const clipboard = new CopyFeedback(copyIcon, checkIcon);
|
||||
const clipboard = new CopyFeedback(copyIcon, checkIcon);
|
||||
|
||||
function getType(): string {
|
||||
return (
|
||||
(
|
||||
document.querySelector(
|
||||
'input[name="ug-type"]:checked',
|
||||
) as HTMLInputElement
|
||||
)?.value ?? "uuid"
|
||||
);
|
||||
}
|
||||
function getType(): string {
|
||||
return (
|
||||
(
|
||||
document.querySelector(
|
||||
'input[name="ug-type"]:checked',
|
||||
) as HTMLInputElement
|
||||
)?.value ?? "uuid"
|
||||
);
|
||||
}
|
||||
|
||||
document.querySelectorAll('input[name="ug-type"]').forEach((radio) => {
|
||||
radio.addEventListener("change", () => {
|
||||
const type = getType();
|
||||
lengthWrap.classList.toggle("hidden", type === "uuid");
|
||||
});
|
||||
document.querySelectorAll('input[name="ug-type"]').forEach((radio) => {
|
||||
radio.addEventListener("change", () => {
|
||||
const type = getType();
|
||||
lengthWrap.classList.toggle("hidden", type === "uuid");
|
||||
});
|
||||
});
|
||||
|
||||
function uuidV4(): string {
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
||||
const r = crypto.getRandomValues(new Uint8Array(1))[0] & 0xf;
|
||||
return (c === "x" ? r : (r & 0x3) | 0x8).toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
function randomBytes(n: number): Uint8Array {
|
||||
const buf = new Uint8Array(n);
|
||||
crypto.getRandomValues(buf);
|
||||
return buf;
|
||||
}
|
||||
|
||||
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));
|
||||
});
|
||||
|
||||
function uuidV4(): string {
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
||||
const r = crypto.getRandomValues(new Uint8Array(1))[0] & 0xf;
|
||||
return (c === "x" ? r : (r & 0x3) | 0x8).toString(16);
|
||||
});
|
||||
}
|
||||
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";
|
||||
|
||||
function randomBytes(n: number): Uint8Array {
|
||||
const buf = new Uint8Array(n);
|
||||
crypto.getRandomValues(buf);
|
||||
return buf;
|
||||
}
|
||||
const code = document.createElement("code");
|
||||
code.textContent = token;
|
||||
code.className = "text-sm font-mono text-zinc-100 break-all";
|
||||
|
||||
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 = "";
|
||||
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();
|
||||
});
|
||||
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();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -5,432 +5,411 @@ const T = useT(isRu ? "ru" : "en");
|
||||
---
|
||||
|
||||
<div id="wheel-spinner" class="mt-8">
|
||||
<!-- Items input -->
|
||||
<div class="flex flex-col gap-3">
|
||||
<div>
|
||||
<label
|
||||
for="ws-items"
|
||||
class="block text-sm font-medium text-zinc-400 mb-1"
|
||||
>
|
||||
{T.items}
|
||||
<span class="text-zinc-600">({T.itemsOneLine224})</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="ws-items"
|
||||
rows="6"
|
||||
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"
|
||||
>Alice Bob Carol Dave Eve Frank</textarea
|
||||
>
|
||||
</div>
|
||||
<p
|
||||
id="ws-error"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
class="text-sm text-red-500 hidden"
|
||||
>
|
||||
</p>
|
||||
<!-- Items input -->
|
||||
<div class="flex flex-col gap-3">
|
||||
<div>
|
||||
<label
|
||||
for="ws-items"
|
||||
class="block text-sm font-medium text-zinc-400 mb-1"
|
||||
>
|
||||
{T.items}
|
||||
<span class="text-zinc-600">({T.itemsOneLine224})</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="ws-items"
|
||||
rows="6"
|
||||
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"
|
||||
>Alice Bob Carol Dave Eve Frank</textarea
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Wheel area -->
|
||||
<div class="flex flex-col items-center mt-8 gap-6">
|
||||
<div class="relative w-full max-w-[480px]" id="ws-wheel-wrap">
|
||||
<!-- 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>
|
||||
|
||||
<button
|
||||
id="ws-spin-btn"
|
||||
type="button"
|
||||
class="w-full sm:w-auto px-10 py-3 bg-[#534AB7] text-white font-semibold rounded-lg hover:bg-[#4740a0] active:bg-[#3d3990] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#534AB7] focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors duration-100 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{T.spin}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Result card (hidden until first spin) -->
|
||||
<div
|
||||
id="ws-result-card"
|
||||
class="hidden mt-10 flex flex-col items-center gap-3"
|
||||
aria-live="polite"
|
||||
<p
|
||||
id="ws-error"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
class="text-sm text-red-500 hidden"
|
||||
>
|
||||
<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>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Wheel area -->
|
||||
<div class="flex flex-col items-center mt-8 gap-6">
|
||||
<div class="relative w-full max-w-[480px]" id="ws-wheel-wrap">
|
||||
<!-- 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>
|
||||
|
||||
<button
|
||||
id="ws-spin-btn"
|
||||
type="button"
|
||||
class="w-full sm:w-auto px-10 py-3 bg-[#534AB7] text-white font-semibold rounded-lg hover:bg-[#4740a0] active:bg-[#3d3990] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#534AB7] focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors duration-100 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{T.spin}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Result card (hidden until first spin) -->
|
||||
<div
|
||||
id="ws-result-card"
|
||||
class="hidden mt-10 flex flex-col items-center gap-3"
|
||||
aria-live="polite"
|
||||
>
|
||||
<p
|
||||
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>
|
||||
|
||||
<style>
|
||||
@keyframes ws-pop {
|
||||
from {
|
||||
transform: scale(0.6);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
@keyframes ws-pop {
|
||||
from {
|
||||
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>
|
||||
|
||||
<script>
|
||||
import { CopyFeedback } from "@/lib/client/clipboard";
|
||||
import { createErrorDisplay } from "@/lib/client/validation";
|
||||
import { CopyFeedback } from "@/lib/client/clipboard";
|
||||
import { createErrorDisplay } from "@/lib/client/validation";
|
||||
|
||||
const isRu = document.documentElement.lang === "ru";
|
||||
const COPY_LABEL = isRu ? "Копировать" : "Copy";
|
||||
const COPIED_LABEL = isRu ? "Скопировано" : "Copied";
|
||||
const ERR_MIN2 = isRu
|
||||
? "Добавьте хотя бы 2 элемента."
|
||||
: "Add at least 2 items.";
|
||||
const ERR_MAX24 = isRu ? "Максимум 24 элемента." : "Maximum 24 items.";
|
||||
const isRu = document.documentElement.lang === "ru";
|
||||
const COPY_LABEL = isRu ? "Копировать" : "Copy";
|
||||
const COPIED_LABEL = isRu ? "Скопировано" : "Copied";
|
||||
const ERR_MIN2 = isRu
|
||||
? "Добавьте хотя бы 2 элемента."
|
||||
: "Add at least 2 items.";
|
||||
const ERR_MAX24 = isRu ? "Максимум 24 элемента." : "Maximum 24 items.";
|
||||
|
||||
const COLORS = [
|
||||
"#534AB7",
|
||||
"#0F6E56",
|
||||
"#993C1D",
|
||||
"#185FA5",
|
||||
"#854F0B",
|
||||
"#993556",
|
||||
"#3B6D11",
|
||||
"#A32D2D",
|
||||
];
|
||||
const TAU = 2 * Math.PI;
|
||||
const COLORS = [
|
||||
"#534AB7",
|
||||
"#0F6E56",
|
||||
"#993C1D",
|
||||
"#185FA5",
|
||||
"#854F0B",
|
||||
"#993556",
|
||||
"#3B6D11",
|
||||
"#A32D2D",
|
||||
];
|
||||
const TAU = 2 * Math.PI;
|
||||
|
||||
const textarea = document.getElementById("ws-items") as HTMLTextAreaElement;
|
||||
const errorEl = document.getElementById("ws-error") as HTMLParagraphElement;
|
||||
const canvas = document.getElementById("ws-canvas") as HTMLCanvasElement;
|
||||
const spinBtn = document.getElementById("ws-spin-btn") as HTMLButtonElement;
|
||||
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;
|
||||
const textarea = document.getElementById("ws-items") as HTMLTextAreaElement;
|
||||
const errorEl = document.getElementById("ws-error") as HTMLParagraphElement;
|
||||
const canvas = document.getElementById("ws-canvas") as HTMLCanvasElement;
|
||||
const spinBtn = document.getElementById("ws-spin-btn") as HTMLButtonElement;
|
||||
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;
|
||||
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
let items: string[] = ["Alice", "Bob", "Carol", "Dave", "Eve", "Frank"];
|
||||
let currentRotation = 0;
|
||||
let isSpinning = false;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
let items: string[] = ["Alice", "Bob", "Carol", "Dave", "Eve", "Frank"];
|
||||
let currentRotation = 0;
|
||||
let isSpinning = false;
|
||||
|
||||
const errors = createErrorDisplay(errorEl);
|
||||
const clipboard = new CopyFeedback(copyIcon, checkIcon);
|
||||
const errors = createErrorDisplay(errorEl);
|
||||
const clipboard = new CopyFeedback(copyIcon, checkIcon);
|
||||
|
||||
// ── Canvas sizing ────────────────────────────────────────────────
|
||||
function resize() {
|
||||
const size = Math.min(wheelWrap.offsetWidth, 480);
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
draw(currentRotation);
|
||||
}
|
||||
new ResizeObserver(resize).observe(wheelWrap);
|
||||
resize();
|
||||
// ── Canvas sizing ────────────────────────────────────────────────
|
||||
function resize() {
|
||||
const size = Math.min(wheelWrap.offsetWidth, 480);
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
draw(currentRotation);
|
||||
}
|
||||
new ResizeObserver(resize).observe(wheelWrap);
|
||||
resize();
|
||||
|
||||
// ── Drawing ──────────────────────────────────────────────────────
|
||||
function clampText(text: string, maxWidth: number): string {
|
||||
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 + "…";
|
||||
// ── Drawing ──────────────────────────────────────────────────────
|
||||
function clampText(text: string, maxWidth: number): string {
|
||||
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) {
|
||||
const size = canvas.width;
|
||||
const r = size / 2 - 6;
|
||||
const n = items.length;
|
||||
const seg = TAU / n;
|
||||
|
||||
ctx.clearRect(0, 0, size, size);
|
||||
ctx.save();
|
||||
ctx.translate(size / 2, size / 2);
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a0 = -Math.PI / 2 + i * seg + rotation;
|
||||
const a1 = a0 + seg;
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
function draw(rotation: number) {
|
||||
const size = canvas.width;
|
||||
const r = size / 2 - 6;
|
||||
const n = items.length;
|
||||
const seg = TAU / n;
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, r, 0, TAU);
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.2)";
|
||||
ctx.lineWidth = 3;
|
||||
ctx.stroke();
|
||||
|
||||
ctx.clearRect(0, 0, size, size);
|
||||
ctx.save();
|
||||
ctx.translate(size / 2, size / 2);
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, 15, 0, TAU);
|
||||
ctx.fillStyle = "#18181b";
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.22)";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a0 = -Math.PI / 2 + i * seg + rotation;
|
||||
const a1 = a0 + seg;
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, 0);
|
||||
ctx.arc(0, 0, r, a0, a1);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = COLORS[i % COLORS.length];
|
||||
ctx.fill();
|
||||
// ── Items ────────────────────────────────────────────────────────
|
||||
function parseItems(): string[] | null {
|
||||
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;
|
||||
}
|
||||
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.13)";
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
function applyItems() {
|
||||
const parsed = parseItems();
|
||||
if (parsed) {
|
||||
items = parsed;
|
||||
draw(currentRotation);
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
textarea.addEventListener("input", () => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(applyItems, 300);
|
||||
});
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, r, 0, TAU);
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.2)";
|
||||
ctx.lineWidth = 3;
|
||||
ctx.stroke();
|
||||
// ── Spin ─────────────────────────────────────────────────────────
|
||||
spinBtn.addEventListener("click", () => {
|
||||
if (isSpinning) return;
|
||||
const parsed = parseItems();
|
||||
if (!parsed) return;
|
||||
items = parsed;
|
||||
draw(currentRotation);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, 15, 0, TAU);
|
||||
ctx.fillStyle = "#18181b";
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.22)";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
const n = items.length;
|
||||
const seg = TAU / n;
|
||||
const winnerIdx = Math.floor(Math.random() * n);
|
||||
|
||||
ctx.restore();
|
||||
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),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Items ────────────────────────────────────────────────────────
|
||||
function parseItems(): string[] | null {
|
||||
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;
|
||||
}
|
||||
const dur = 2500;
|
||||
const t0 = performance.now();
|
||||
const gravity = 0.2;
|
||||
|
||||
function applyItems() {
|
||||
const parsed = parseItems();
|
||||
if (parsed) {
|
||||
items = parsed;
|
||||
draw(currentRotation);
|
||||
}
|
||||
}
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
textarea.addEventListener("input", () => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(applyItems, 300);
|
||||
});
|
||||
|
||||
// ── 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);
|
||||
}
|
||||
(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>
|
||||
|
||||
Reference in New Issue
Block a user