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:
emil
2026-05-10 21:17:34 +03:00
parent bb62d7310e
commit 8421441664
55 changed files with 4625 additions and 2954 deletions
+6
View File
@@ -0,0 +1,6 @@
dist/
node_modules/
.astro/
package-lock.json
CLAUDE.md
.prettierignore
+11
View File
@@ -0,0 +1,11 @@
{
"plugins": ["prettier-plugin-astro"],
"overrides": [
{
"files": "*.astro",
"options": {
"parser": "astro"
}
}
]
}
+22
View File
@@ -0,0 +1,22 @@
import js from "@eslint/js";
import tseslint from "typescript-eslint";
import astro from "eslint-plugin-astro";
import globals from "globals";
export default [
{ ignores: ["dist/**", ".astro/**", "node_modules/**", "src/env.d.ts"] },
js.configs.recommended,
...tseslint.configs.recommended,
...astro.configs.recommended,
{
files: ["public/sw.js"],
languageOptions: {
globals: {
...globals.serviceworker,
},
},
rules: {
"@typescript-eslint/no-unused-vars": "off",
},
},
];
+1391
View File
File diff suppressed because it is too large Load Diff
+14 -1
View File
@@ -8,7 +8,11 @@
"preview": "astro preview", "preview": "astro preview",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:coverage": "vitest run --coverage" "test:coverage": "vitest run --coverage",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"format:check": "prettier --check ."
}, },
"dependencies": { "dependencies": {
"@astrojs/sitemap": "^3.2.1", "@astrojs/sitemap": "^3.2.1",
@@ -17,7 +21,16 @@
"tailwindcss": "^4.0.0" "tailwindcss": "^4.0.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1",
"@typescript-eslint/eslint-plugin": "^8.59.2",
"@typescript-eslint/parser": "^8.59.2",
"@vitest/coverage-v8": "^4.1.5", "@vitest/coverage-v8": "^4.1.5",
"eslint": "^10.3.0",
"eslint-plugin-astro": "^1.7.0",
"globals": "^17.6.0",
"prettier": "^3.8.3",
"prettier-plugin-astro": "^0.14.1",
"typescript-eslint": "^8.59.2",
"vitest": "^4.1.5" "vitest": "^4.1.5"
} }
} }
+12 -8
View File
@@ -1,16 +1,16 @@
const CACHE_NAME = 'randify-v1'; const CACHE_NAME = "randify-v1";
self.addEventListener('install', (event) => { self.addEventListener("install", (event) => {
self.skipWaiting(); self.skipWaiting();
}); });
self.addEventListener('activate', (event) => { self.addEventListener("activate", (event) => {
event.waitUntil(self.clients.claim()); event.waitUntil(self.clients.claim());
}); });
self.addEventListener('fetch', (event) => { self.addEventListener("fetch", (event) => {
const { request } = event; const { request } = event;
const isNavigate = request.mode === 'navigate'; const isNavigate = request.mode === "navigate";
const isSameOrigin = new URL(request.url).origin === self.location.origin; const isSameOrigin = new URL(request.url).origin === self.location.origin;
if (!isSameOrigin) { if (!isSameOrigin) {
@@ -29,7 +29,7 @@ self.addEventListener('fetch', (event) => {
} }
return response; return response;
}) })
.catch(() => caches.match(request)) .catch(() => caches.match(request)),
); );
} else { } else {
// Cache-first for static assets (JS, CSS, SVG, fonts, etc.) // Cache-first for static assets (JS, CSS, SVG, fonts, etc.)
@@ -39,14 +39,18 @@ self.addEventListener('fetch', (event) => {
return cached; return cached;
} }
return fetch(request).then((response) => { return fetch(request).then((response) => {
if (!response || response.status !== 200 || response.type !== 'basic') { if (
!response ||
response.status !== 200 ||
response.type !== "basic"
) {
return response; return response;
} }
const clone = response.clone(); const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone)); caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
return response; return response;
}); });
}) }),
); );
} }
}); });
+6 -4
View File
@@ -1,6 +1,8 @@
<html> <html>
<head> <head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</head> </head>
<body>Verification: dd9dad039a139021</body> <body>
Verification: dd9dad039a139021
</body>
</html> </html>
+60 -42
View File
@@ -1,33 +1,33 @@
--- ---
interface Props { interface Props {
size: 'leaderboard' | 'rectangle' | 'tile'; size: "leaderboard" | "rectangle" | "tile";
} }
const { size } = Astro.props; const { size } = Astro.props;
const ads = [ const ads = [
{ {
href: 'https://redirect.appmetrica.yandex.com/serve/29835370459904648?clid=15006577&appmetrica_js_redirect=0', href: "https://redirect.appmetrica.yandex.com/serve/29835370459904648?clid=15006577&appmetrica_js_redirect=0",
label: 'Яндекс с Алисой — скачать бесплатно', label: "Яндекс с Алисой — скачать бесплатно",
sublabel: 'Голосовой ИИ-помощник для Android и iOS', sublabel: "Голосовой ИИ-помощник для Android и iOS",
cta: 'Установить', cta: "Установить",
ctaMobile: 'Установить бесплатно', ctaMobile: "Установить бесплатно",
}, },
{ {
href: 'https://yandex.ru/project/browser/bonus/multioffer/affiliate_4prod?source=pWRP8eS1VsC2X59560&partner_string=P89XvN11U6RuE47077&cliddbro=15006579&clidmbro=15006578&cliddefault=15006570&clidpp=15006567', href: "https://yandex.ru/project/browser/bonus/multioffer/affiliate_4prod?source=pWRP8eS1VsC2X59560&partner_string=P89XvN11U6RuE47077&cliddbro=15006579&clidmbro=15006578&cliddefault=15006570&clidpp=15006567",
label: 'Яндекс Браузер — быстрый и безопасный', label: "Яндекс Браузер — быстрый и безопасный",
sublabel: 'Браузер с Алисой и защитой от угроз', sublabel: "Браузер с Алисой и защитой от угроз",
cta: 'Скачать', cta: "Скачать",
ctaMobile: 'Скачать бесплатно', ctaMobile: "Скачать бесплатно",
}, },
]; ];
const ad = ads[Math.floor(Math.random() * ads.length)]; const ad = ads[Math.floor(Math.random() * ads.length)];
const specs = { const specs = {
leaderboard: { class: 'w-full h-[90px]', horizontal: true }, leaderboard: { class: "w-full h-[90px]", horizontal: true },
rectangle: { class: 'w-full max-w-[300px] h-[250px]', horizontal: false }, rectangle: { class: "w-full max-w-[300px] h-[250px]", horizontal: false },
tile: { class: 'w-full h-[250px]', horizontal: false }, tile: { class: "w-full h-[250px]", horizontal: false },
}; };
const { class: sizeClass, horizontal } = specs[size]; const { class: sizeClass, horizontal } = specs[size];
@@ -38,37 +38,55 @@ const { class: sizeClass, horizontal } = specs[size];
href={ad.href} href={ad.href}
target="_blank" target="_blank"
rel="noopener sponsored" 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"> <svg
<circle cx="20" cy="20" r="20" fill="currentColor"/> class="shrink-0 text-[#FC3F1D]"
<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"/> 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> </svg>
{horizontal ? ( {
<div class="flex-1 min-w-0"> horizontal ? (
<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"> <div class="flex-1 min-w-0">
{ad.label} <div class="text-xs font-medium text-zinc-500 uppercase tracking-widest mb-0.5">
</div> Реклама
<div class="text-xs text-zinc-500 mt-0.5">{ad.sublabel}</div> </div>
</div> <div class="text-sm font-semibold text-zinc-100 group-hover:text-[#534AB7] transition-colors leading-tight">
<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.label}
{ad.cta} </div>
</span> <div class="text-xs text-zinc-500 mt-0.5">{ad.sublabel}</div>
) : (
<>
<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>
<div class="text-xs text-zinc-500 mt-1">{ad.sublabel}</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">
</div> {ad.cta}
<span class="text-xs font-semibold text-white bg-[#FC3F1D] px-4 py-2 rounded-lg group-hover:bg-[#e03518] transition-colors"> </span>
{ad.ctaMobile} </>
</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> </a>
</aside> </aside>
+73 -71
View File
@@ -7,85 +7,87 @@ const mailRuId = analytics.topMailRuId;
<!-- Yandex.Metrika counter --> <!-- Yandex.Metrika counter -->
<script type="text/javascript" define:vars={{ yandexId }}> <script type="text/javascript" define:vars={{ yandexId }}>
(function (m, e, t, r, i, k, a) { /* eslint-disable */
m[i] = (function (m, e, t, r, i, k, a) {
m[i] || m[i] =
function () { m[i] ||
(m[i].a = m[i].a || []).push(arguments); function () {
}; (m[i].a = m[i].a || []).push(arguments);
m[i].l = 1 * new Date(); };
for (var j = 0; j < document.scripts.length; j++) { m[i].l = 1 * new Date();
if (document.scripts[j].src === r) { for (let j = 0; j < document.scripts.length; j++) {
return; if (document.scripts[j].src === r) {
} return;
} }
((k = e.createElement(t)), }
(a = e.getElementsByTagName(t)[0]), ((k = e.createElement(t)),
(k.async = 1), (a = e.getElementsByTagName(t)[0]),
(k.src = r), (k.async = 1),
a.parentNode.insertBefore(k, a)); (k.src = r),
})( a.parentNode.insertBefore(k, a));
window, })(
document, window,
"script", document,
"https://mc.yandex.ru/metrika/tag.js?id=" + String(yandexId), "script",
"ym", "https://mc.yandex.ru/metrika/tag.js?id=" + String(yandexId),
); "ym",
ym(Number(yandexId), "init", { );
ssr: true, ym(Number(yandexId), "init", {
webvisor: true, ssr: true,
clickmap: true, webvisor: true,
ecommerce: "dataLayer", clickmap: true,
referrer: document.referrer, ecommerce: "dataLayer",
url: location.href, referrer: document.referrer,
accurateTrackBounce: true, url: location.href,
trackLinks: true, accurateTrackBounce: true,
}); trackLinks: true,
});
/* eslint-enable */
</script> </script>
<noscript <noscript
><div> ><div>
<img <img
src={`https://mc.yandex.ru/watch/${yandexId}`} src={`https://mc.yandex.ru/watch/${yandexId}`}
style="position:absolute; left:-9999px;" style="position:absolute; left:-9999px;"
alt="" alt=""
/> />
</div></noscript </div></noscript
> >
<!-- /Yandex.Metrika counter --> <!-- /Yandex.Metrika counter -->
<!-- Top.Mail.Ru counter --> <!-- Top.Mail.Ru counter -->
<script type="text/javascript" define:vars={{ mailRuId }}> <script type="text/javascript" define:vars={{ mailRuId }}>
var _tmr = window._tmr || (window._tmr = []); const _tmr = window._tmr || (window._tmr = []);
_tmr.push({ _tmr.push({
id: String(mailRuId), id: String(mailRuId),
type: "pageView", type: "pageView",
start: new Date().getTime(), start: new Date().getTime(),
}); });
(function (d, w, id) { (function (d, w, id) {
if (d.getElementById(id)) return; if (d.getElementById(id)) return;
var ts = d.createElement("script"); const ts = d.createElement("script");
ts.type = "text/javascript"; ts.type = "text/javascript";
ts.async = true; ts.async = true;
ts.id = id; ts.id = id;
ts.src = "https://top-fwz1.mail.ru/js/code.js"; ts.src = "https://top-fwz1.mail.ru/js/code.js";
var f = function () { const f = function () {
var s = d.getElementsByTagName("script")[0]; const s = d.getElementsByTagName("script")[0];
s.parentNode.insertBefore(ts, s); s.parentNode.insertBefore(ts, s);
}; };
if (w.opera == "[object Opera]") { if (w.opera == "[object Opera]") {
d.addEventListener("DOMContentLoaded", f, false); d.addEventListener("DOMContentLoaded", f, false);
} else { } else {
f(); f();
} }
})(document, window, "tmr-code"); })(document, window, "tmr-code");
</script> </script>
<noscript <noscript
><div> ><div>
<img <img
src={`https://top-fwz1.mail.ru/counter?id=${mailRuId};js=na`} src={`https://top-fwz1.mail.ru/counter?id=${mailRuId};js=na`}
style="position:absolute;left:-9999px;" style="position:absolute;left:-9999px;"
alt="Top.Mail.Ru" alt="Top.Mail.Ru"
/> />
</div></noscript </div></noscript
> >
<!-- /Top.Mail.Ru counter --> <!-- /Top.Mail.Ru counter -->
+52 -42
View File
@@ -1,7 +1,7 @@
--- ---
import type { Generator } from '../data/generators'; import type { Generator } from "../data/generators";
import { useT } from '../i18n/translations'; import { useT } from "../i18n/translations";
import type { Lang } from '../i18n/translations'; import type { Lang } from "../i18n/translations";
interface Props { interface Props {
generator: Generator; generator: Generator;
@@ -9,11 +9,11 @@ interface Props {
} }
const { generator, lang: langProp } = Astro.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 T = useT(lang);
const { slug, icon, status } = generator; const { slug, icon, status } = generator;
const isLive = status === 'live'; const isLive = status === "live";
const isRu = lang === 'ru'; const isRu = lang === "ru";
const title = isRu ? generator.ruTitle : generator.title; const title = isRu ? generator.ruTitle : generator.title;
const description = isRu ? generator.ruDescription : generator.description; const description = isRu ? generator.ruDescription : generator.description;
const href = isRu ? `/ru/generators/${slug}/` : `/generators/${slug}/`; 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>`, 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>`, 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>`, 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>`, "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>`, "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>`, "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>`, 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>`, 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>`, "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 isLive ? (
href={href} <a
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]" 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']} /> <div class="flex items-start justify-between gap-3">
<span class="text-xs font-medium text-[#534AB7] bg-[#534AB7]/10 px-2 py-0.5 rounded-full"> <div
{T.live} class="text-[#534AB7]"
</span> 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> </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>
)}
+27 -23
View File
@@ -1,39 +1,43 @@
--- ---
const currentPath = Astro.url.pathname; const currentPath = Astro.url.pathname;
const isRu = currentPath.startsWith('/ru'); const isRu = currentPath.startsWith("/ru");
const alternatePath = isRu const alternatePath = isRu
? (currentPath.replace(/^\/ru/, '') || '/') ? currentPath.replace(/^\/ru/, "") || "/"
: ('/ru' + currentPath); : "/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 <a
href={isRu ? alternatePath : '#'} href={isRu ? alternatePath : "#"}
id="lang-en" id="lang-en"
class={`px-2 py-1 rounded-md transition-colors ${isRu ? 'text-zinc-400 hover:text-zinc-200' : 'bg-[#534AB7] text-white'}`} 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'} aria-current={isRu ? undefined : "true"}
data-target-lang="en" data-target-lang="en"
data-alternate={isRu ? alternatePath : ''} data-alternate={isRu ? alternatePath : ""}>EN</a
>EN</a> >
<a <a
href={isRu ? '#' : alternatePath} href={isRu ? "#" : alternatePath}
id="lang-ru" id="lang-ru"
class={`px-2 py-1 rounded-md transition-colors ${isRu ? 'bg-[#534AB7] text-white' : 'text-zinc-400 hover:text-zinc-200'}`} 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} aria-current={isRu ? "true" : undefined}
data-target-lang="ru" data-target-lang="ru"
data-alternate={isRu ? '' : alternatePath} data-alternate={isRu ? "" : alternatePath}>RU</a
>RU</a> >
</div> </div>
<script> <script>
document.querySelectorAll<HTMLAnchorElement>('#lang-en, #lang-ru').forEach((el) => { document
el.addEventListener('click', (e) => { .querySelectorAll<HTMLAnchorElement>("#lang-en, #lang-ru")
const targetLang = el.dataset.targetLang!; .forEach((el) => {
const alternate = el.dataset.alternate!; el.addEventListener("click", (e) => {
if (!alternate || alternate === '#') return; const targetLang = el.dataset.targetLang!;
e.preventDefault(); const alternate = el.dataset.alternate!;
localStorage.setItem('lang-pref', targetLang); if (!alternate || alternate === "#") return;
location.href = alternate; e.preventDefault();
localStorage.setItem("lang-pref", targetLang);
location.href = alternate;
});
}); });
});
</script> </script>
+16 -6
View File
@@ -1,6 +1,6 @@
--- ---
import { useT } from '../i18n/translations'; import { useT } from "../i18n/translations";
import type { Lang } from '../i18n/translations'; import type { Lang } from "../i18n/translations";
interface Props { interface Props {
howTo: string[]; howTo: string[];
@@ -8,19 +8,29 @@ interface Props {
lang?: Lang; lang?: Lang;
} }
const { howTo, whenTo, lang = 'en' } = Astro.props; const { howTo, whenTo, lang = "en" } = Astro.props;
const T = useT(lang); 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> <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"> <ol class="space-y-1 list-decimal list-inside">
{howTo.map((step) => <li>{step}</li>)} {howTo.map((step) => <li>{step}</li>)}
</ol> </ol>
</div> </div>
<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"> <ul class="space-y-1 list-disc list-inside">
{whenTo.map((item) => <li>{item}</li>)} {whenTo.map((item) => <li>{item}</li>)}
</ul> </ul>
+227 -237
View File
@@ -5,268 +5,258 @@ const T = useT(isRu ? "ru" : "en");
--- ---
<div id="card-generator" class="mt-8"> <div id="card-generator" class="mt-8">
<div class="flex flex-col sm:flex-row gap-4 items-end"> <div class="flex flex-col sm:flex-row gap-4 items-end">
<div class="flex-1"> <div class="flex-1">
<label <label
for="crd-count" for="crd-count"
class="block text-sm font-medium text-zinc-400 mb-1" class="block text-sm font-medium text-zinc-400 mb-1"
>{T.cardsToDraw}</label >{T.cardsToDraw}</label
> >
<input <input
id="crd-count" id="crd-count"
type="number" type="number"
value="1" value="1"
min="1" min="1"
max="52" 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" 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} aria-label={T.cardsToDraw}
/> />
</div>
<label
class="flex items-center gap-2.5 cursor-pointer select-none group pb-2"
>
<input
id="crd-replace"
type="checkbox"
class="w-4 h-4 rounded accent-[#534AB7] cursor-pointer"
/>
<span
class="text-sm text-zinc-400 group-hover:text-zinc-200 transition-colors"
>{T.allowDuplicates}</span
>
</label>
</div> </div>
<label
<p class="flex items-center gap-2.5 cursor-pointer select-none group pb-2"
id="crd-error"
role="alert"
aria-live="polite"
class="mt-3 text-sm text-red-500 hidden"
> >
</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"> <p
<div id="crd-error"
id="crd-cards" role="alert"
class="flex flex-wrap justify-center gap-2" aria-live="polite"
aria-live="polite" class="mt-3 text-sm text-red-500 hidden"
aria-label="Drawn cards" >
> </p>
</div>
<button <div class="my-10 min-h-32 flex flex-col items-center justify-center gap-4">
id="crd-copy-btn" <div
type="button" id="crd-cards"
aria-label="Copy cards to clipboard" class="flex flex-wrap justify-center gap-2"
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" aria-live="polite"
> aria-label="Drawn cards"
<span id="crd-copy-label">{T.copy}</span> >
<span id="crd-copy-icon" aria-hidden="true">
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="14" height="14" x="8" y="8" rx="2" ry="2"
></rect>
<path
d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"
></path>
</svg>
</span>
<span
id="crd-check-icon"
class="hidden text-[#534AB7]"
aria-hidden="true"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20 6 9 17l-5-5"></path>
</svg>
</span>
</button>
</div> </div>
<div class="flex justify-center"> <button
<button id="crd-copy-btn"
id="crd-btn" type="button"
type="button" aria-label="Copy cards to clipboard"
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" 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} <rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect>
</button> <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"
</div> ></path>
</svg>
</span>
<span
id="crd-check-icon"
class="hidden text-[#534AB7]"
aria-hidden="true"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20 6 9 17l-5-5"></path>
</svg>
</span>
</button>
</div>
<div class="flex justify-center">
<button
id="crd-btn"
type="button"
class="w-full sm:w-auto px-8 py-3 bg-[#534AB7] text-white font-semibold rounded-lg hover:bg-[#4740a0] active:bg-[#3d3990] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#534AB7] focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors duration-100 cursor-pointer"
>
{T.draw}
</button>
</div>
</div> </div>
<script> <script>
import { CopyFeedback } from "@/lib/client/clipboard"; import { CopyFeedback } from "@/lib/client/clipboard";
import { createErrorDisplay } from "@/lib/client/validation"; import { createErrorDisplay } from "@/lib/client/validation";
const isRu = document.documentElement.lang === "ru"; const isRu = document.documentElement.lang === "ru";
const COPY_LABEL = isRu ? "Копировать" : "Copy"; const COPY_LABEL = isRu ? "Копировать" : "Copy";
const COPIED_LABEL = isRu ? "Скопировано" : "Copied"; const COPIED_LABEL = isRu ? "Скопировано" : "Copied";
const ERR_AT_LEAST = isRu const ERR_AT_LEAST = isRu
? "Вытащите хотя бы 1 карту." ? "Вытащите хотя бы 1 карту."
: "Draw at least 1 card."; : "Draw at least 1 card.";
const ERR_UNIQUE = isRu const ERR_UNIQUE = isRu
? "Нельзя вытащить более 52 уникальных карт." ? "Нельзя вытащить более 52 уникальных карт."
: "Cannot draw more than 52 unique cards."; : "Cannot draw more than 52 unique cards.";
const ERR_MAX52 = isRu const ERR_MAX52 = isRu
? "Максимум 52 карты одновременно." ? "Максимум 52 карты одновременно."
: "Maximum 52 cards at once."; : "Maximum 52 cards at once.";
const countInput = document.getElementById("crd-count") as HTMLInputElement; const countInput = document.getElementById("crd-count") as HTMLInputElement;
const replaceCb = document.getElementById( const replaceCb = document.getElementById("crd-replace") as HTMLInputElement;
"crd-replace", const btn = document.getElementById("crd-btn") as HTMLButtonElement;
) as HTMLInputElement; const copyBtn = document.getElementById("crd-copy-btn") as HTMLButtonElement;
const btn = document.getElementById("crd-btn") as HTMLButtonElement; const cardsEl = document.getElementById("crd-cards") as HTMLDivElement;
const copyBtn = document.getElementById( const errorEl = document.getElementById("crd-error") as HTMLParagraphElement;
"crd-copy-btn", const copyLabel = document.getElementById(
) as HTMLButtonElement; "crd-copy-label",
const cardsEl = document.getElementById("crd-cards") as HTMLDivElement; ) as HTMLSpanElement;
const errorEl = document.getElementById( const copyIcon = document.getElementById("crd-copy-icon") as HTMLSpanElement;
"crd-error", const checkIcon = document.getElementById(
) as HTMLParagraphElement; "crd-check-icon",
const copyLabel = document.getElementById( ) as HTMLSpanElement;
"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 = [ const RANKS = [
"A", "A",
"2", "2",
"3", "3",
"4", "4",
"5", "5",
"6", "6",
"7", "7",
"8", "8",
"9", "9",
"10", "10",
"J", "J",
"Q", "Q",
"K", "K",
]; ];
const SUITS = [ const SUITS = [
{ symbol: "♠", label: "Spades", red: false }, { symbol: "♠", label: "Spades", red: false },
{ symbol: "♥", label: "Hearts", red: true }, { symbol: "♥", label: "Hearts", red: true },
{ symbol: "♦", label: "Diamonds", red: true }, { symbol: "♦", label: "Diamonds", red: true },
{ symbol: "♣", label: "Clubs", red: false }, { 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 errors = createErrorDisplay(errorEl);
const clipboard = new CopyFeedback(copyIcon, checkIcon); const clipboard = new CopyFeedback(copyIcon, checkIcon);
function draw() { function draw() {
const count = parseInt(countInput.value, 10); const count = parseInt(countInput.value, 10);
const withReplacement = replaceCb.checked; const withReplacement = replaceCb.checked;
if (!Number.isInteger(count) || count < 1) { if (!Number.isInteger(count) || count < 1) {
errors.show(ERR_AT_LEAST); errors.show(ERR_AT_LEAST);
return; return;
} }
if (!withReplacement && count > 52) { if (!withReplacement && count > 52) {
errors.show(ERR_UNIQUE); errors.show(ERR_UNIQUE);
return; return;
} }
if (count > 52) { if (count > 52) {
errors.show(ERR_MAX52); errors.show(ERR_MAX52);
return; 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;
} }
function renderCards() { errors.clear();
cardsEl.innerHTML = "";
lastCards.forEach(({ rank, suit }) => {
const card = document.createElement("div");
card.setAttribute("aria-label", `${rank} of ${suit.label}`);
card.className = [
"flex flex-col items-center justify-between w-14 h-20 rounded-lg border px-1.5 py-1.5",
"bg-zinc-900 border-zinc-700 select-none",
suit.red ? "text-red-400" : "text-zinc-100",
].join(" ");
const top = document.createElement("div"); if (withReplacement) {
top.className = lastCards = Array.from(
"w-full text-left text-sm font-bold leading-none tabular-nums"; { length: count },
top.textContent = rank; () => DECK[Math.floor(Math.random() * DECK.length)],
);
const mid = document.createElement("div"); } else {
mid.className = "text-2xl leading-none"; const deck = [...DECK];
mid.textContent = suit.symbol; for (let i = 0; i < count; i++) {
const j = i + Math.floor(Math.random() * (deck.length - i));
const bot = document.createElement("div"); [deck[i], deck[j]] = [deck[j], deck[i]];
bot.className = }
"w-full text-right text-sm font-bold leading-none tabular-nums rotate-180"; lastCards = deck.slice(0, count);
bot.textContent = rank;
card.append(top, mid, bot);
cardsEl.appendChild(card);
});
} }
async function copyCards() { renderCards();
if (!lastCards.length) return; copyBtn.classList.remove("invisible");
const text = lastCards copyLabel.textContent = COPY_LABEL;
.map(({ rank, suit }) => `${rank}${suit.symbol}`) }
.join(", ");
await navigator.clipboard.writeText(text);
clipboard.showCopied(); function renderCards() {
copyLabel.textContent = COPIED_LABEL; 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(() => { const top = document.createElement("div");
copyLabel.textContent = COPY_LABEL; top.className =
}, 1500); "w-full text-left text-sm font-bold leading-none tabular-nums";
} top.textContent = rank;
btn.addEventListener("click", draw); const mid = document.createElement("div");
copyBtn.addEventListener("click", copyCards); mid.className = "text-2xl leading-none";
countInput.addEventListener("keydown", (e) => { mid.textContent = suit.symbol;
if (e.key === "Enter") draw();
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> </script>
+157 -172
View File
@@ -5,196 +5,181 @@ const T = useT(isRu ? "ru" : "en");
--- ---
<div id="coin-generator" class="mt-8"> <div id="coin-generator" class="mt-8">
<div> <div>
<div class="flex items-center justify-between mb-1"> <div class="flex items-center justify-between mb-1">
<label for="coin-count" class="text-sm font-medium text-zinc-400" <label for="coin-count" class="text-sm font-medium text-zinc-400"
>{T.numberOfCoins}</label >{T.numberOfCoins}</label
> >
<span <span
id="coin-count-val" id="coin-count-val"
class="text-sm font-semibold tabular-nums text-zinc-100">1</span class="text-sm font-semibold tabular-nums text-zinc-100">1</span
> >
</div> </div>
<input <input
id="coin-count" id="coin-count"
type="range" type="range"
min="1" min="1"
max="20" max="20"
value="1" value="1"
class="w-full accent-[#534AB7] cursor-pointer" class="w-full accent-[#534AB7] cursor-pointer"
aria-label={T.numberOfCoins} aria-label={T.numberOfCoins}
/> />
<div <div class="flex justify-between text-xs text-zinc-600 mt-1 select-none">
class="flex justify-between text-xs text-zinc-600 mt-1 select-none" <span>1</span><span>20</span>
> </div>
<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>
<div class="my-10 min-h-28 flex flex-col items-center justify-center gap-4"> <div id="coin-summary" class="hidden text-sm text-zinc-500 tabular-nums">
<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> </div>
<div class="flex justify-center"> <button
<button id="coin-copy-btn"
id="coin-btn" type="button"
type="button" aria-label="Copy results to clipboard"
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" 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} <rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect>
</button> <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"
</div> ></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> </div>
<script> <script>
import { CopyFeedback } from "@/lib/client/clipboard"; import { CopyFeedback } from "@/lib/client/clipboard";
const isRu = document.documentElement.lang === "ru"; const isRu = document.documentElement.lang === "ru";
const COPY_LABEL = isRu ? "Копировать" : "Copy"; const COPY_LABEL = isRu ? "Копировать" : "Copy";
const COPIED_LABEL = isRu ? "Скопировано" : "Copied"; const COPIED_LABEL = isRu ? "Скопировано" : "Copied";
const HEADS_LABEL = isRu ? "О" : "H"; const HEADS_LABEL = isRu ? "О" : "H";
const TAILS_LABEL = isRu ? "Р" : "T"; const TAILS_LABEL = isRu ? "Р" : "T";
const HEADS_WORD = isRu ? "орёл" : "Heads"; const HEADS_WORD = isRu ? "орёл" : "Heads";
const TAILS_WORD = isRu ? "решка" : "Tails"; const TAILS_WORD = isRu ? "решка" : "Tails";
const HEADS_SUMMARY = isRu ? "орёл" : "heads"; const HEADS_SUMMARY = isRu ? "орёл" : "heads";
const TAILS_SUMMARY = isRu ? "решка" : "tails"; const TAILS_SUMMARY = isRu ? "решка" : "tails";
const countInput = document.getElementById( const countInput = document.getElementById("coin-count") as HTMLInputElement;
"coin-count", const countVal = document.getElementById("coin-count-val") as HTMLSpanElement;
) as HTMLInputElement; const btn = document.getElementById("coin-btn") as HTMLButtonElement;
const countVal = document.getElementById( const copyBtn = document.getElementById("coin-copy-btn") as HTMLButtonElement;
"coin-count-val", const coinsEl = document.getElementById("coin-coins") as HTMLDivElement;
) as HTMLSpanElement; const summaryEl = document.getElementById("coin-summary") as HTMLDivElement;
const btn = document.getElementById("coin-btn") as HTMLButtonElement; const copyLabel = document.getElementById(
const copyBtn = document.getElementById( "coin-copy-label",
"coin-copy-btn", ) as HTMLSpanElement;
) as HTMLButtonElement; const copyIcon = document.getElementById("coin-copy-icon") as HTMLSpanElement;
const coinsEl = document.getElementById("coin-coins") as HTMLDivElement; const checkIcon = document.getElementById(
const summaryEl = document.getElementById("coin-summary") as HTMLDivElement; "coin-check-icon",
const copyLabel = document.getElementById( ) as HTMLSpanElement;
"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", () => { countInput.addEventListener("input", () => {
countVal.textContent = countInput.value; 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() { if (count > 1) {
const count = parseInt(countInput.value, 10); summaryEl.textContent = `${heads} ${HEADS_SUMMARY} · ${tails} ${TAILS_SUMMARY}`;
lastResults = Array.from({ length: count }, () => Math.random() < 0.5); summaryEl.classList.remove("hidden");
} else {
const heads = lastResults.filter(Boolean).length; summaryEl.classList.add("hidden");
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;
} }
async function copyResults() { copyBtn.classList.remove("invisible");
if (!lastResults.length) return; copyLabel.textContent = COPY_LABEL;
const text = lastResults }
.map((h) => (h ? HEADS_WORD : TAILS_WORD))
.join(", ");
await navigator.clipboard.writeText(text);
clipboard.showCopied(); async function copyResults() {
copyLabel.textContent = COPIED_LABEL; if (!lastResults.length) return;
const text = lastResults
.map((h) => (h ? HEADS_WORD : TAILS_WORD))
.join(", ");
await navigator.clipboard.writeText(text);
setTimeout(() => { clipboard.showCopied();
copyLabel.textContent = COPY_LABEL; copyLabel.textContent = COPIED_LABEL;
}, 1500);
}
btn.addEventListener("click", flip); setTimeout(() => {
copyBtn.addEventListener("click", copyResults); copyLabel.textContent = COPY_LABEL;
}, 1500);
}
btn.addEventListener("click", flip);
copyBtn.addEventListener("click", copyResults);
</script> </script>
+102 -58
View File
@@ -1,11 +1,10 @@
--- ---
import { useT } from '../../i18n/translations'; import { useT } from "../../i18n/translations";
const isRu = Astro.url.pathname.startsWith('/ru'); const isRu = Astro.url.pathname.startsWith("/ru");
const T = useT(isRu ? 'ru' : 'en'); const T = useT(isRu ? "ru" : "en");
--- ---
<div id="color-generator" class="mt-8"> <div id="color-generator" class="mt-8">
<!-- Swatch --> <!-- Swatch -->
<div class="flex justify-center mb-8"> <div class="flex justify-center mb-8">
<div <div
@@ -13,7 +12,8 @@ const T = useT(isRu ? 'ru' : 'en');
class="w-44 h-44 rounded-2xl border border-zinc-800" 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);" style="background-color: #1f1f22; transition: background-color 0.35s cubic-bezier(0.4,0,0.2,1);"
aria-hidden="true" aria-hidden="true"
></div> >
</div>
</div> </div>
<!-- Format rows --> <!-- Format rows -->
@@ -23,35 +23,60 @@ const T = useT(isRu ? 'ru' : 'en');
style="transition: opacity 0.25s ease;" style="transition: opacity 0.25s ease;"
aria-live="polite" aria-live="polite"
> >
{[ {
{ id: 'cg-hex', label: 'HEX' }, [
{ id: 'cg-rgb', label: 'RGB' }, { id: "cg-hex", label: "HEX" },
{ id: 'cg-hsl', label: 'HSL' }, { id: "cg-rgb", label: "RGB" },
].map(({ id, label }) => ( { id: "cg-hsl", label: "HSL" },
<div class="flex items-center gap-3 px-4 py-3 bg-zinc-900/40"> ].map(({ id, label }) => (
<span class="w-10 text-xs font-semibold uppercase tracking-widest text-zinc-500 shrink-0"> <div class="flex items-center gap-3 px-4 py-3 bg-zinc-900/40">
{label} <span class="w-10 text-xs font-semibold uppercase tracking-widest text-zinc-500 shrink-0">
</span> {label}
<span </span>
id={id} <span
class="flex-1 font-mono text-sm text-zinc-100 tabular-nums select-all" id={id}
></span> class="flex-1 font-mono text-sm text-zinc-100 tabular-nums select-all"
<button />
id={`${id}-copy`} <button
type="button" id={`${id}-copy`}
aria-label={`Copy ${label} value`} type="button"
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" 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"/> <svg
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/> id={`${id}-copy-icon`}
</svg> xmlns="http://www.w3.org/2000/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"> width="14"
<path d="M20 6 9 17l-5-5"/> height="14"
</svg> viewBox="0 0 24 24"
</button> fill="none"
</div> 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> </div>
<!-- Generate button --> <!-- Generate button -->
@@ -67,21 +92,24 @@ const T = useT(isRu ? 'ru' : 'en');
</div> </div>
<script> <script>
const swatch = document.getElementById('cg-swatch') as HTMLDivElement; const swatch = document.getElementById("cg-swatch") as HTMLDivElement;
const valuesEl = document.getElementById('cg-values') as HTMLDivElement; const valuesEl = document.getElementById("cg-values") as HTMLDivElement;
const hexEl = document.getElementById('cg-hex') as HTMLSpanElement; const hexEl = document.getElementById("cg-hex") as HTMLSpanElement;
const rgbEl = document.getElementById('cg-rgb') as HTMLSpanElement; const rgbEl = document.getElementById("cg-rgb") as HTMLSpanElement;
const hslEl = document.getElementById('cg-hsl') as HTMLSpanElement; const hslEl = document.getElementById("cg-hsl") as HTMLSpanElement;
const btn = document.getElementById('cg-btn') as HTMLButtonElement; const btn = document.getElementById("cg-btn") as HTMLButtonElement;
function rgbToHsl(r: number, g: number, b: number): [number, number, number] { function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
const rn = r / 255, gn = g / 255, bn = b / 255; const rn = r / 255,
const max = Math.max(rn, gn, bn), min = Math.min(rn, gn, bn); gn = g / 255,
bn = b / 255;
const max = Math.max(rn, gn, bn),
min = Math.min(rn, gn, bn);
const l = (max + min) / 2; const l = (max + min) / 2;
if (max === min) return [0, 0, Math.round(l * 100)]; if (max === min) return [0, 0, Math.round(l * 100)];
const d = max - min; const d = max - min;
const s = l > 0.5 ? d / (2 - max - min) : 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; if (max === rn) h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6;
else if (max === gn) h = ((bn - rn) / d + 2) / 6; else if (max === gn) h = ((bn - rn) / d + 2) / 6;
else h = ((rn - gn) / d + 4) / 6; else h = ((rn - gn) / d + 4) / 6;
@@ -89,11 +117,15 @@ const T = useT(isRu ? 'ru' : 'en');
} }
function toHex(n: number) { 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) { function makeCopyHandler(
const copyIcon = document.getElementById(copyIconId)!; el: HTMLSpanElement,
copyIconId: string,
checkIconId: string,
) {
const copyIcon = document.getElementById(copyIconId)!;
const checkIcon = document.getElementById(checkIconId)!; const checkIcon = document.getElementById(checkIconId)!;
let timer: ReturnType<typeof setTimeout> | null = null; let timer: ReturnType<typeof setTimeout> | null = null;
@@ -101,22 +133,34 @@ const T = useT(isRu ? 'ru' : 'en');
const value = el.textContent?.trim(); const value = el.textContent?.trim();
if (!value) return; if (!value) return;
await navigator.clipboard.writeText(value); await navigator.clipboard.writeText(value);
copyIcon.classList.add('hidden'); copyIcon.classList.add("hidden");
checkIcon.classList.remove('hidden'); checkIcon.classList.remove("hidden");
if (timer) clearTimeout(timer); if (timer) clearTimeout(timer);
timer = setTimeout(() => { timer = setTimeout(() => {
checkIcon.classList.add('hidden'); checkIcon.classList.add("hidden");
copyIcon.classList.remove('hidden'); copyIcon.classList.remove("hidden");
}, 1500); }, 1500);
}; };
} }
document.getElementById('cg-hex-copy')! document
.addEventListener('click', makeCopyHandler(hexEl, 'cg-hex-copy-icon', 'cg-hex-check-icon')); .getElementById("cg-hex-copy")!
document.getElementById('cg-rgb-copy')! .addEventListener(
.addEventListener('click', makeCopyHandler(rgbEl, 'cg-rgb-copy-icon', 'cg-rgb-check-icon')); "click",
document.getElementById('cg-hsl-copy')! makeCopyHandler(hexEl, "cg-hex-copy-icon", "cg-hex-check-icon"),
.addEventListener('click', makeCopyHandler(hslEl, 'cg-hsl-copy-icon', 'cg-hsl-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() { function generate() {
const r = Math.floor(Math.random() * 256); const r = Math.floor(Math.random() * 256);
@@ -130,8 +174,8 @@ const T = useT(isRu ? 'ru' : 'en');
hexEl.textContent = hex; hexEl.textContent = hex;
rgbEl.textContent = `rgb(${r}, ${g}, ${b})`; rgbEl.textContent = `rgb(${r}, ${g}, ${b})`;
hslEl.textContent = `hsl(${h}, ${s}%, ${l}%)`; hslEl.textContent = `hsl(${h}, ${s}%, ${l}%)`;
valuesEl.style.opacity = '1'; valuesEl.style.opacity = "1";
} }
btn.addEventListener('click', generate); btn.addEventListener("click", generate);
</script> </script>
File diff suppressed because it is too large Load Diff
+208 -219
View File
@@ -5,247 +5,236 @@ const T = useT(isRu ? "ru" : "en");
--- ---
<div id="list-generator" class="mt-8"> <div id="list-generator" class="mt-8">
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<div> <div>
<label <label
for="lst-items" for="lst-items"
class="block text-sm font-medium text-zinc-400 mb-1" class="block text-sm font-medium text-zinc-400 mb-1"
> >
{T.items} {T.items}
<span class="text-zinc-600">({T.itemsOneLine})</span> <span class="text-zinc-600">({T.itemsOneLine})</span>
</label> </label>
<textarea <textarea
id="lst-items" id="lst-items"
rows="6" rows="6"
placeholder="Alice placeholder="Alice
Bob Bob
Carol Carol
David" 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" 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> aria-label={T.items}></textarea>
</div>
<div class="flex flex-col sm:flex-row gap-4 items-end">
<div class="flex-1">
<label
for="lst-pick"
class="block text-sm font-medium text-zinc-400 mb-1"
>{T.pick}</label
>
<input
id="lst-pick"
type="number"
value="1"
min="1"
class="w-full bg-zinc-900 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 text-base focus:outline-none focus:border-[#534AB7] focus:ring-1 focus:ring-[#534AB7] transition-colors"
aria-label={T.pick}
/>
</div>
<label
class="flex items-center gap-2.5 cursor-pointer select-none group pb-2"
>
<input
id="lst-replace"
type="checkbox"
class="w-4 h-4 rounded accent-[#534AB7] cursor-pointer"
/>
<span
class="text-sm text-zinc-400 group-hover:text-zinc-200 transition-colors"
>{T.allowDuplicates}</span
>
</label>
</div>
</div> </div>
<p <div class="flex flex-col sm:flex-row gap-4 items-end">
id="lst-error" <div class="flex-1">
role="alert" <label
aria-live="polite" for="lst-pick"
class="mt-3 text-sm text-red-500 hidden" 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>
<div class="flex justify-center"> <button
<button id="lst-copy-btn"
id="lst-btn" type="button"
type="button" aria-label="Copy picked items to clipboard"
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" 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} <rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect>
</button> <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"
</div> ></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> </div>
<script> <script>
import { CopyFeedback } from "@/lib/client/clipboard"; import { CopyFeedback } from "@/lib/client/clipboard";
import { createErrorDisplay } from "@/lib/client/validation"; import { createErrorDisplay } from "@/lib/client/validation";
const isRu = document.documentElement.lang === "ru"; const isRu = document.documentElement.lang === "ru";
const COPY_LABEL = isRu ? "Копировать" : "Copy"; const COPY_LABEL = isRu ? "Копировать" : "Copy";
const COPIED_LABEL = isRu ? "Скопировано" : "Copied"; const COPIED_LABEL = isRu ? "Скопировано" : "Copied";
const ERR_ADD_ONE = isRu const ERR_ADD_ONE = isRu
? "Добавьте хотя бы один элемент в список." ? "Добавьте хотя бы один элемент в список."
: "Add at least one item to the list."; : "Add at least one item to the list.";
const ERR_PICK_ONE = isRu const ERR_PICK_ONE = isRu
? "Выберите хотя бы 1 элемент." ? "Выберите хотя бы 1 элемент."
: "Pick at least 1 item."; : "Pick at least 1 item.";
const itemsInput = document.getElementById( const itemsInput = document.getElementById(
"lst-items", "lst-items",
) as HTMLTextAreaElement; ) as HTMLTextAreaElement;
const pickInput = document.getElementById("lst-pick") as HTMLInputElement; const pickInput = document.getElementById("lst-pick") as HTMLInputElement;
const replaceCb = document.getElementById( const replaceCb = document.getElementById("lst-replace") as HTMLInputElement;
"lst-replace", const btn = document.getElementById("lst-btn") as HTMLButtonElement;
) as HTMLInputElement; const copyBtn = document.getElementById("lst-copy-btn") as HTMLButtonElement;
const btn = document.getElementById("lst-btn") as HTMLButtonElement; const resultEl = document.getElementById("lst-result") as HTMLDivElement;
const copyBtn = document.getElementById( const errorEl = document.getElementById("lst-error") as HTMLParagraphElement;
"lst-copy-btn", const copyLabel = document.getElementById(
) as HTMLButtonElement; "lst-copy-label",
const resultEl = document.getElementById("lst-result") as HTMLDivElement; ) as HTMLSpanElement;
const errorEl = document.getElementById( const copyIcon = document.getElementById("lst-copy-icon") as HTMLSpanElement;
"lst-error", const checkIcon = document.getElementById(
) as HTMLParagraphElement; "lst-check-icon",
const copyLabel = document.getElementById( ) as HTMLSpanElement;
"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 errors = createErrorDisplay(errorEl);
const clipboard = new CopyFeedback(copyIcon, checkIcon); const clipboard = new CopyFeedback(copyIcon, checkIcon);
function pick() { function pick() {
const items = itemsInput.value const items = itemsInput.value
.split("\n") .split("\n")
.map((s) => s.trim()) .map((s) => s.trim())
.filter(Boolean); .filter(Boolean);
const count = parseInt(pickInput.value, 10); const count = parseInt(pickInput.value, 10);
const withReplacement = replaceCb.checked; const withReplacement = replaceCb.checked;
if (items.length === 0) { if (items.length === 0) {
errors.show(ERR_ADD_ONE); errors.show(ERR_ADD_ONE);
return; return;
} }
if (!Number.isInteger(count) || count < 1) { if (!Number.isInteger(count) || count < 1) {
errors.show(ERR_PICK_ONE); errors.show(ERR_PICK_ONE);
return; return;
} }
if (!withReplacement && count > items.length) { if (!withReplacement && count > items.length) {
errors.show( errors.show(
isRu isRu
? `Нельзя выбрать ${count} уникальных элементов из списка ${items.length}.` ? `Нельзя выбрать ${count} уникальных элементов из списка ${items.length}.`
: `Cannot pick ${count} unique items from a list of ${items.length}.`, : `Cannot pick ${count} unique items from a list of ${items.length}.`,
); );
return; 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;
} }
async function copyPicked() { errors.clear();
if (!lastPicked.length) return;
await navigator.clipboard.writeText(lastPicked.join(", "));
clipboard.showCopied(); if (withReplacement) {
copyLabel.textContent = COPIED_LABEL; lastPicked = Array.from(
{ length: count },
setTimeout(() => { () => items[Math.floor(Math.random() * items.length)],
copyLabel.textContent = COPY_LABEL; );
}, 1500); } 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); resultEl.innerHTML = "";
copyBtn.addEventListener("click", copyPicked); lastPicked.forEach((item) => {
itemsInput.addEventListener("keydown", (e) => { const chip = document.createElement("span");
if (e.key === "Enter" && e.ctrlKey) pick(); 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> </script>
+186 -201
View File
@@ -5,220 +5,205 @@ const T = useT(isRu ? "ru" : "en");
--- ---
<div id="lottery-generator" class="mt-8"> <div id="lottery-generator" class="mt-8">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4"> <div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div> <div>
<label <label for="lg-from" class="block text-sm font-medium text-zinc-400 mb-1"
for="lg-from" >{T.from}</label
class="block text-sm font-medium text-zinc-400 mb-1" >
>{T.from}</label <input
> id="lg-from"
<input type="number"
id="lg-from" value="1"
type="number" 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"
value="1" />
class="w-full bg-zinc-900 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 text-base focus:outline-none focus:border-[#534AB7] focus:ring-1 focus:ring-[#534AB7] transition-colors"
/>
</div>
<div>
<label
for="lg-to"
class="block text-sm font-medium text-zinc-400 mb-1"
>{T.to}</label
>
<input
id="lg-to"
type="number"
value="49"
class="w-full bg-zinc-900 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 text-base focus:outline-none focus:border-[#534AB7] focus:ring-1 focus:ring-[#534AB7] transition-colors"
/>
</div>
<div>
<label
for="lg-pick"
class="block text-sm font-medium text-zinc-400 mb-1"
>{T.pick}</label
>
<input
id="lg-pick"
type="number"
value="6"
min="1"
class="w-full bg-zinc-900 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 text-base focus:outline-none focus:border-[#534AB7] focus:ring-1 focus:ring-[#534AB7] transition-colors"
/>
</div>
</div> </div>
<div>
<label for="lg-to" class="block text-sm font-medium text-zinc-400 mb-1"
>{T.to}</label
>
<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 <p
id="lg-error" id="lg-error"
role="alert" role="alert"
aria-live="polite" aria-live="polite"
class="mt-3 text-sm text-red-500 hidden" class="mt-3 text-sm text-red-500 hidden"
>
</p>
<div class="my-10 min-h-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> </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"> <div class="flex justify-center">
<button <button
id="lg-btn" id="lg-btn"
type="button" 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" 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} {T.draw}
</button> </button>
</div> </div>
</div> </div>
<script> <script>
import { CopyFeedback } from "@/lib/client/clipboard"; import { CopyFeedback } from "@/lib/client/clipboard";
import { createErrorDisplay, isInteger } from "@/lib/client/validation"; import { createErrorDisplay, isInteger } from "@/lib/client/validation";
const isRu = document.documentElement.lang === "ru"; const isRu = document.documentElement.lang === "ru";
const ERR_INTEGERS = isRu const ERR_INTEGERS = isRu
? "Все значения должны быть целыми числами." ? "Все значения должны быть целыми числами."
: "All values must be whole numbers."; : "All values must be whole numbers.";
const ERR_FROM_TO = isRu const ERR_FROM_TO = isRu
? "«От» должно быть меньше «До»." ? "«От» должно быть меньше «До»."
: '"From" must be less than "To".'; : '"From" must be less than "To".';
const ERR_PICK_MIN = isRu const ERR_PICK_MIN = isRu
? "«Выбрать» должно быть не менее 1." ? "«Выбрать» должно быть не менее 1."
: '"Pick" must be at least 1.'; : '"Pick" must be at least 1.';
const COPY_LABEL = isRu ? "Копировать" : "Copy"; const COPY_LABEL = isRu ? "Копировать" : "Copy";
const COPIED_LABEL = isRu ? "Скопировано" : "Copied"; const COPIED_LABEL = isRu ? "Скопировано" : "Copied";
const fromInput = document.getElementById("lg-from") as HTMLInputElement; const fromInput = document.getElementById("lg-from") as HTMLInputElement;
const toInput = document.getElementById("lg-to") as HTMLInputElement; const toInput = document.getElementById("lg-to") as HTMLInputElement;
const pickInput = document.getElementById("lg-pick") as HTMLInputElement; const pickInput = document.getElementById("lg-pick") as HTMLInputElement;
const btn = document.getElementById("lg-btn") as HTMLButtonElement; const btn = document.getElementById("lg-btn") as HTMLButtonElement;
const copyBtn = document.getElementById("lg-copy-btn") as HTMLButtonElement; const copyBtn = document.getElementById("lg-copy-btn") as HTMLButtonElement;
const resultEl = document.getElementById("lg-result") as HTMLDivElement; const resultEl = document.getElementById("lg-result") as HTMLDivElement;
const errorEl = document.getElementById("lg-error") as HTMLParagraphElement; const errorEl = document.getElementById("lg-error") as HTMLParagraphElement;
const copyLabel = document.getElementById( const copyLabel = document.getElementById("lg-copy-label") as HTMLSpanElement;
"lg-copy-label", const copyIcon = document.getElementById("lg-copy-icon") as HTMLSpanElement;
) as HTMLSpanElement; const checkIcon = document.getElementById("lg-check-icon") 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 errors = createErrorDisplay(errorEl);
const clipboard = new CopyFeedback(copyIcon, checkIcon); const clipboard = new CopyFeedback(copyIcon, checkIcon);
function draw() { function draw() {
if ( if (
!isInteger(fromInput.value) || !isInteger(fromInput.value) ||
!isInteger(toInput.value) || !isInteger(toInput.value) ||
!isInteger(pickInput.value) !isInteger(pickInput.value)
) { ) {
errors.show(ERR_INTEGERS); errors.show(ERR_INTEGERS);
return; return;
}
const min = parseInt(fromInput.value, 10);
const max = parseInt(toInput.value, 10);
const pick = parseInt(pickInput.value, 10);
const pool = max - min + 1;
if (min >= max) {
errors.show(ERR_FROM_TO);
return;
}
if (pick < 1) {
errors.show(ERR_PICK_MIN);
return;
}
if (pick > pool) {
errors.show(
isRu
? `Нельзя выбрать ${pick} уникальных чисел из диапазона ${pool}.`
: `Cannot pick ${pick} unique numbers from a range of ${pool}.`,
);
return;
}
errors.clear();
const nums = Array.from({ length: pool }, (_, i) => min + i);
for (let i = 0; i < pick; i++) {
const j = i + Math.floor(Math.random() * (pool - i));
[nums[i], nums[j]] = [nums[j], nums[i]];
}
const picked = nums.slice(0, pick).sort((a, b) => a - b);
resultEl.innerHTML = "";
picked.forEach((n) => {
const ball = document.createElement("span");
ball.textContent = String(n);
ball.className =
"inline-flex items-center justify-center min-w-[2.5rem] h-10 px-2 rounded-full bg-[#534AB7]/15 border border-[#534AB7]/40 text-zinc-100 font-bold tabular-nums text-sm";
resultEl.appendChild(ball);
});
copyBtn.classList.remove("invisible");
copyLabel.textContent = COPY_LABEL;
} }
const min = parseInt(fromInput.value, 10);
async function copyNumbers() { const max = parseInt(toInput.value, 10);
const balls = resultEl.querySelectorAll("span"); const pick = parseInt(pickInput.value, 10);
const value = Array.from(balls) const pool = max - min + 1;
.map((b) => b.textContent) if (min >= max) {
.join(", "); errors.show(ERR_FROM_TO);
if (!value) return; return;
await navigator.clipboard.writeText(value);
clipboard.showCopied();
copyLabel.textContent = COPIED_LABEL;
setTimeout(() => {
copyLabel.textContent = COPY_LABEL;
}, 1500);
} }
if (pick < 1) {
btn.addEventListener("click", draw); errors.show(ERR_PICK_MIN);
copyBtn.addEventListener("click", copyNumbers); return;
[fromInput, toInput, pickInput].forEach((input) => { }
input.addEventListener("keydown", (e) => { if (pick > pool) {
if (e.key === "Enter") draw(); 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> </script>
+151 -159
View File
@@ -5,178 +5,170 @@ const T = useT(isRu ? "ru" : "en");
--- ---
<div id="number-generator" class="mt-8" data-ru={isRu ? "1" : "0"}> <div id="number-generator" class="mt-8" data-ru={isRu ? "1" : "0"}>
<div class="flex flex-col sm:flex-row gap-4"> <div class="flex flex-col sm:flex-row gap-4">
<div class="flex-1"> <div class="flex-1">
<label <label for="ng-from" class="block text-sm font-medium text-zinc-400 mb-1"
for="ng-from" >{T.from}</label
class="block text-sm font-medium text-zinc-400 mb-1" >
>{T.from}</label <input
> id="ng-from"
<input type="number"
id="ng-from" value="1"
type="number" 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"
value="1" />
class="w-full bg-zinc-900 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 text-base focus:outline-none focus:border-[#534AB7] focus:ring-1 focus:ring-[#534AB7] transition-colors"
/>
</div>
<div class="flex-1">
<label
for="ng-to"
class="block text-sm font-medium text-zinc-400 mb-1"
>{T.to}</label
>
<input
id="ng-to"
type="number"
value="100"
class="w-full bg-zinc-900 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 text-base focus:outline-none focus:border-[#534AB7] focus:ring-1 focus:ring-[#534AB7] transition-colors"
/>
</div>
</div> </div>
<div class="flex-1">
<label for="ng-to" class="block text-sm font-medium text-zinc-400 mb-1"
>{T.to}</label
>
<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 <p
id="ng-error" id="ng-error"
role="alert" role="alert"
aria-live="polite" aria-live="polite"
class="mt-2 text-sm text-red-600 hidden" 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"> <div class="flex justify-center">
<button <button
id="ng-copy-btn" id="ng-btn"
type="button" 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]" class="w-full sm:w-auto px-8 py-3 bg-[#534AB7] text-white font-semibold rounded-lg hover:bg-[#4740a0] active:bg-[#3d3990] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#534AB7] focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors duration-100 cursor-pointer"
aria-live="polite" >
> {T.generate}
<span </button>
id="ng-result" </div>
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> </div>
<script> <script>
import { CopyFeedback } from "@/lib/client/clipboard"; import { CopyFeedback } from "@/lib/client/clipboard";
import { createErrorDisplay, isInteger } from "@/lib/client/validation"; import { createErrorDisplay, isInteger } from "@/lib/client/validation";
import { popElement } from "@/lib/client/animations"; import { popElement } from "@/lib/client/animations";
const isRu = document.documentElement.lang === "ru"; const isRu = document.documentElement.lang === "ru";
const ERR_INTEGERS = isRu const ERR_INTEGERS = isRu
? "Оба значения должны быть целыми числами." ? "Оба значения должны быть целыми числами."
: "Both values must be whole numbers."; : "Both values must be whole numbers.";
const ERR_FROM_TO = isRu const ERR_FROM_TO = isRu
? "«От» должно быть меньше «До»." ? "«От» должно быть меньше «До»."
: '"From" must be less than "To".'; : '"From" must be less than "To".';
const fromInput = document.getElementById("ng-from") as HTMLInputElement; const fromInput = document.getElementById("ng-from") as HTMLInputElement;
const toInput = document.getElementById("ng-to") as HTMLInputElement; const toInput = document.getElementById("ng-to") as HTMLInputElement;
const btn = document.getElementById("ng-btn") as HTMLButtonElement; const btn = document.getElementById("ng-btn") as HTMLButtonElement;
const copyBtn = document.getElementById("ng-copy-btn") as HTMLButtonElement; const copyBtn = document.getElementById("ng-copy-btn") as HTMLButtonElement;
const resultEl = document.getElementById("ng-result") as HTMLSpanElement; const resultEl = document.getElementById("ng-result") as HTMLSpanElement;
const errorEl = document.getElementById("ng-error") as HTMLParagraphElement; const errorEl = document.getElementById("ng-error") as HTMLParagraphElement;
const copyIcon = document.getElementById("ng-copy-icon") as HTMLSpanElement; const copyIcon = document.getElementById("ng-copy-icon") as HTMLSpanElement;
const checkIcon = document.getElementById( const checkIcon = document.getElementById("ng-check-icon") as HTMLSpanElement;
"ng-check-icon", const copiedLabel = document.getElementById(
) as HTMLSpanElement; "ng-copied-label",
const copiedLabel = document.getElementById( ) as HTMLSpanElement;
"ng-copied-label",
) as HTMLSpanElement;
const errors = createErrorDisplay(errorEl); const errors = createErrorDisplay(errorEl);
const clipboard = new CopyFeedback(copyIcon, checkIcon); const clipboard = new CopyFeedback(copyIcon, checkIcon);
function generate() { function generate() {
if (!isInteger(fromInput.value) || !isInteger(toInput.value)) { if (!isInteger(fromInput.value) || !isInteger(toInput.value)) {
errors.show(ERR_INTEGERS); errors.show(ERR_INTEGERS);
return; 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);
} }
const min = parseInt(fromInput.value, 10);
async function copyNumber() { const max = parseInt(toInput.value, 10);
const value = resultEl.textContent?.trim(); if (min >= max) {
if (!value) return; errors.show(ERR_FROM_TO);
await navigator.clipboard.writeText(value); return;
clipboard.showCopied();
copiedLabel.style.opacity = "1";
setTimeout(() => {
copiedLabel.style.opacity = "0";
}, 1500);
} }
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); async function copyNumber() {
copyBtn.addEventListener("click", copyNumber); const value = resultEl.textContent?.trim();
[fromInput, toInput].forEach((input) => { if (!value) return;
input.addEventListener("keydown", (e) => { await navigator.clipboard.writeText(value);
if (e.key === "Enter") generate(); 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> </script>
+187 -201
View File
@@ -5,223 +5,209 @@ const T = useT(isRu ? "ru" : "en");
--- ---
<div id="password-generator" class="mt-8"> <div id="password-generator" class="mt-8">
<div class="flex flex-col gap-5"> <div class="flex flex-col gap-5">
<div> <div>
<div class="flex items-center justify-between mb-1"> <div class="flex items-center justify-between mb-1">
<label for="pg-length" class="text-sm font-medium text-zinc-400" <label for="pg-length" class="text-sm font-medium text-zinc-400"
>{T.length}</label >{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]"
> >
<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 <span
id="pg-copied-label" id="pg-length-val"
class="text-xs font-medium text-[#534AB7] opacity-0 transition-opacity duration-200" class="text-sm font-semibold tabular-nums text-zinc-100">16</span
aria-live="polite"
aria-atomic="true">{T.copied}</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>
<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"> <p
<button id="pg-error"
id="pg-btn" role="alert"
type="button" aria-live="polite"
class="w-full sm:w-auto px-8 py-3 bg-[#534AB7] text-white font-semibold rounded-lg hover:bg-[#4740a0] active:bg-[#3d3990] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#534AB7] focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors duration-100 cursor-pointer" 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} </span>
</button> <span
</div> 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> </div>
<script> <script>
import { CopyFeedback } from "@/lib/client/clipboard"; import { CopyFeedback } from "@/lib/client/clipboard";
import { createErrorDisplay } from "@/lib/client/validation"; import { createErrorDisplay } from "@/lib/client/validation";
const isRu = document.documentElement.lang === "ru"; const isRu = document.documentElement.lang === "ru";
const ERR_SELECT_TYPE = isRu const ERR_SELECT_TYPE = isRu
? "Выберите хотя бы один тип символов." ? "Выберите хотя бы один тип символов."
: "Select at least one character type."; : "Select at least one character type.";
const lengthInput = document.getElementById( const lengthInput = document.getElementById("pg-length") as HTMLInputElement;
"pg-length", const lengthVal = document.getElementById("pg-length-val") as HTMLSpanElement;
) as HTMLInputElement; const upperCb = document.getElementById("pg-upper") as HTMLInputElement;
const lengthVal = document.getElementById( const lowerCb = document.getElementById("pg-lower") as HTMLInputElement;
"pg-length-val", const digitsCb = document.getElementById("pg-digits") as HTMLInputElement;
) as HTMLSpanElement; const symbolsCb = document.getElementById("pg-symbols") as HTMLInputElement;
const upperCb = document.getElementById("pg-upper") as HTMLInputElement; const btn = document.getElementById("pg-btn") as HTMLButtonElement;
const lowerCb = document.getElementById("pg-lower") as HTMLInputElement; const copyBtn = document.getElementById("pg-copy-btn") as HTMLButtonElement;
const digitsCb = document.getElementById("pg-digits") as HTMLInputElement; const resultEl = document.getElementById("pg-result") as HTMLSpanElement;
const symbolsCb = document.getElementById("pg-symbols") as HTMLInputElement; const errorEl = document.getElementById("pg-error") as HTMLParagraphElement;
const btn = document.getElementById("pg-btn") as HTMLButtonElement; const copyIcon = document.getElementById("pg-copy-icon") as HTMLSpanElement;
const copyBtn = document.getElementById("pg-copy-btn") as HTMLButtonElement; const checkIcon = document.getElementById("pg-check-icon") as HTMLSpanElement;
const resultEl = document.getElementById("pg-result") as HTMLSpanElement; const copiedLabel = document.getElementById(
const errorEl = document.getElementById("pg-error") as HTMLParagraphElement; "pg-copied-label",
const copyIcon = document.getElementById("pg-copy-icon") as HTMLSpanElement; ) as HTMLSpanElement;
const checkIcon = document.getElementById(
"pg-check-icon",
) as HTMLSpanElement;
const copiedLabel = document.getElementById(
"pg-copied-label",
) as HTMLSpanElement;
const UPPER = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; const UPPER = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const LOWER = "abcdefghijklmnopqrstuvwxyz"; const LOWER = "abcdefghijklmnopqrstuvwxyz";
const DIGITS = "0123456789"; const DIGITS = "0123456789";
const SYMBOLS = "!@#$%^&*()-_=+[]{}|;:,.<>?"; const SYMBOLS = "!@#$%^&*()-_=+[]{}|;:,.<>?";
lengthInput.addEventListener("input", () => { lengthInput.addEventListener("input", () => {
lengthVal.textContent = lengthInput.value; lengthVal.textContent = lengthInput.value;
}); });
const errors = createErrorDisplay(errorEl); const errors = createErrorDisplay(errorEl);
const clipboard = new CopyFeedback(copyIcon, checkIcon); const clipboard = new CopyFeedback(copyIcon, checkIcon);
function generate() { function generate() {
const length = parseInt(lengthInput.value, 10); const length = parseInt(lengthInput.value, 10);
const pools: string[] = []; const pools: string[] = [];
if (upperCb.checked) pools.push(UPPER); if (upperCb.checked) pools.push(UPPER);
if (lowerCb.checked) pools.push(LOWER); if (lowerCb.checked) pools.push(LOWER);
if (digitsCb.checked) pools.push(DIGITS); if (digitsCb.checked) pools.push(DIGITS);
if (symbolsCb.checked) pools.push(SYMBOLS); if (symbolsCb.checked) pools.push(SYMBOLS);
if (pools.length === 0) { if (pools.length === 0) {
errors.show(ERR_SELECT_TYPE); errors.show(ERR_SELECT_TYPE);
return; 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");
} }
errors.clear();
async function copyPassword() { const required = pools.map((p) => p[Math.floor(Math.random() * p.length)]);
const value = resultEl.textContent?.trim(); const combined = pools.join("");
if (!value) return; const rest = Array.from(
await navigator.clipboard.writeText(value); { length: length - required.length },
clipboard.showCopied(); () => combined[Math.floor(Math.random() * combined.length)],
copiedLabel.style.opacity = "1"; );
setTimeout(() => { const chars = [...required, ...rest];
copiedLabel.style.opacity = "0"; for (let i = chars.length - 1; i > 0; i--) {
}, 1500); 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); async function copyPassword() {
copyBtn.addEventListener("click", 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> </script>
+219 -236
View File
@@ -5,259 +5,242 @@ const T = useT(isRu ? "ru" : "en");
--- ---
<div id="uuid-generator" class="mt-8"> <div id="uuid-generator" class="mt-8">
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<div> <div>
<label class="block text-sm font-medium text-zinc-400 mb-2" <label class="block text-sm font-medium text-zinc-400 mb-2"
>{T.type}</label >{T.type}</label
> >
<div <div class="flex flex-wrap gap-2" role="radiogroup" aria-label={T.type}>
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 }) => (
{ value: "uuid", label: "UUID v4" }, <label class="cursor-pointer">
{ value: "hex", label: "Hex" }, <input
{ value: "base64", label: "Base64" }, type="radio"
].map(({ value, label }) => ( name="ug-type"
<label class="cursor-pointer"> value={value}
<input class="sr-only peer"
type="radio" checked={value === "uuid"}
name="ug-type" />
value={value} <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">
class="sr-only peer" {label}
checked={value === "uuid"} </span>
/> </label>
<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> </div>
</label>
))
}
</div>
</div>
<div class="flex flex-col sm:flex-row gap-4">
<div id="ug-length-wrap" class="flex-1 hidden">
<label
for="ug-length"
class="block text-sm font-medium text-zinc-400 mb-1"
>{T.lengthBytes}</label
>
<input
id="ug-length"
type="number"
value="16"
min="4"
max="64"
class="w-full bg-zinc-900 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 text-base focus:outline-none focus:border-[#534AB7] focus:ring-1 focus:ring-[#534AB7] transition-colors"
aria-label={T.lengthBytes}
/>
</div>
<div class="flex-1">
<label
for="ug-count"
class="block text-sm font-medium text-zinc-400 mb-1"
>{T.count}</label
>
<input
id="ug-count"
type="number"
value="1"
min="1"
max="20"
class="w-full bg-zinc-900 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 text-base focus:outline-none focus:border-[#534AB7] focus:ring-1 focus:ring-[#534AB7] transition-colors"
aria-label={T.count}
/>
</div>
</div>
</div> </div>
<div <div class="flex flex-col sm:flex-row gap-4">
class="my-8 min-h-16 flex flex-col gap-2" <div id="ug-length-wrap" class="flex-1 hidden">
aria-live="polite" <label
aria-label="Generated tokens" 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> <span id="ug-copy-label">{T.copyAll}</span>
<span id="ug-copy-icon" aria-hidden="true">
<button <svg
id="ug-copy-btn" xmlns="http://www.w3.org/2000/svg"
type="button" width="14"
aria-label="Copy all tokens to clipboard" height="14"
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" 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> <rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect>
<span id="ug-copy-icon" aria-hidden="true"> <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"
<svg ></path>
xmlns="http://www.w3.org/2000/svg" </svg>
width="14" </span>
height="14" <span id="ug-check-icon" class="hidden text-[#534AB7]" aria-hidden="true">
viewBox="0 0 24 24" <svg
fill="none" xmlns="http://www.w3.org/2000/svg"
stroke="currentColor" width="14"
stroke-width="2" height="14"
stroke-linecap="round" viewBox="0 0 24 24"
stroke-linejoin="round" fill="none"
> stroke="currentColor"
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" stroke-width="2.5"
></rect> stroke-linecap="round"
<path stroke-linejoin="round"
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"
> >
{T.generate} <path d="M20 6 9 17l-5-5"></path>
</button> </svg>
</div> </span>
</button>
</div>
<div class="flex justify-center">
<button
id="ug-btn"
type="button"
class="w-full sm:w-auto px-8 py-3 bg-[#534AB7] text-white font-semibold rounded-lg hover:bg-[#4740a0] active:bg-[#3d3990] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#534AB7] focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors duration-100 cursor-pointer"
>
{T.generate}
</button>
</div>
</div> </div>
<script> <script>
import { CopyFeedback } from "@/lib/client/clipboard"; import { CopyFeedback } from "@/lib/client/clipboard";
const isRu = document.documentElement.lang === "ru"; const isRu = document.documentElement.lang === "ru";
const COPY_ALL_LABEL = isRu ? "Копировать всё" : "Copy all"; const COPY_ALL_LABEL = isRu ? "Копировать всё" : "Copy all";
const COPIED_LABEL = isRu ? "Скопировано" : "Copied"; const COPIED_LABEL = isRu ? "Скопировано" : "Copied";
const lengthWrap = document.getElementById( const lengthWrap = document.getElementById(
"ug-length-wrap", "ug-length-wrap",
) as HTMLDivElement; ) as HTMLDivElement;
const lengthInput = document.getElementById( const lengthInput = document.getElementById("ug-length") as HTMLInputElement;
"ug-length", const countInput = document.getElementById("ug-count") as HTMLInputElement;
) as HTMLInputElement; const btn = document.getElementById("ug-btn") as HTMLButtonElement;
const countInput = document.getElementById("ug-count") as HTMLInputElement; const copyBtn = document.getElementById("ug-copy-btn") as HTMLButtonElement;
const btn = document.getElementById("ug-btn") as HTMLButtonElement; const resultEl = document.getElementById("ug-result") as HTMLDivElement;
const copyBtn = document.getElementById("ug-copy-btn") as HTMLButtonElement; const copyLabel = document.getElementById("ug-copy-label") as HTMLSpanElement;
const resultEl = document.getElementById("ug-result") as HTMLDivElement; const copyIcon = document.getElementById("ug-copy-icon") as HTMLSpanElement;
const copyLabel = document.getElementById( const checkIcon = document.getElementById("ug-check-icon") as HTMLSpanElement;
"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 { function getType(): string {
return ( return (
( (
document.querySelector( document.querySelector(
'input[name="ug-type"]:checked', 'input[name="ug-type"]:checked',
) as HTMLInputElement ) as HTMLInputElement
)?.value ?? "uuid" )?.value ?? "uuid"
); );
} }
document.querySelectorAll('input[name="ug-type"]').forEach((radio) => { document.querySelectorAll('input[name="ug-type"]').forEach((radio) => {
radio.addEventListener("change", () => { radio.addEventListener("change", () => {
const type = getType(); const type = getType();
lengthWrap.classList.toggle("hidden", type === "uuid"); 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 { resultEl.innerHTML = "";
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { lastTokens.forEach((token) => {
const r = crypto.getRandomValues(new Uint8Array(1))[0] & 0xf; const row = document.createElement("div");
return (c === "x" ? r : (r & 0x3) | 0x8).toString(16); 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 code = document.createElement("code");
const buf = new Uint8Array(n); code.textContent = token;
crypto.getRandomValues(buf); code.className = "text-sm font-mono text-zinc-100 break-all";
return buf;
}
function toHex(bytes: Uint8Array): string { row.appendChild(code);
return Array.from(bytes) resultEl.appendChild(row);
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
function toBase64(bytes: Uint8Array): string {
return btoa(String.fromCharCode(...bytes))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
function generate() {
const type = getType();
const count = parseInt(countInput.value, 10);
const length = parseInt(lengthInput.value, 10);
lastTokens = Array.from({ length: count }, () => {
if (type === "uuid") return uuidV4();
if (type === "hex") return toHex(randomBytes(length));
return toBase64(randomBytes(length));
});
resultEl.innerHTML = "";
lastTokens.forEach((token) => {
const row = document.createElement("div");
row.className =
"flex items-center justify-between gap-3 bg-zinc-900 border border-zinc-700 rounded-lg px-3 py-2";
const code = document.createElement("code");
code.textContent = token;
code.className = "text-sm font-mono text-zinc-100 break-all";
row.appendChild(code);
resultEl.appendChild(row);
});
copyBtn.classList.remove("invisible");
copyLabel.textContent = COPY_ALL_LABEL;
}
async function copyAll() {
if (!lastTokens.length) return;
await navigator.clipboard.writeText(lastTokens.join("\n"));
clipboard.showCopied();
copyLabel.textContent = COPIED_LABEL;
setTimeout(() => {
copyLabel.textContent = COPY_ALL_LABEL;
}, 1500);
}
btn.addEventListener("click", generate);
copyBtn.addEventListener("click", copyAll);
[countInput, lengthInput].forEach((el) => {
el.addEventListener("keydown", (e) => {
if (e.key === "Enter") generate();
});
}); });
copyBtn.classList.remove("invisible");
copyLabel.textContent = COPY_ALL_LABEL;
}
async function copyAll() {
if (!lastTokens.length) return;
await navigator.clipboard.writeText(lastTokens.join("\n"));
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> </script>
+376 -397
View File
@@ -5,432 +5,411 @@ const T = useT(isRu ? "ru" : "en");
--- ---
<div id="wheel-spinner" class="mt-8"> <div id="wheel-spinner" class="mt-8">
<!-- Items input --> <!-- Items input -->
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<div> <div>
<label <label
for="ws-items" for="ws-items"
class="block text-sm font-medium text-zinc-400 mb-1" class="block text-sm font-medium text-zinc-400 mb-1"
> >
{T.items} {T.items}
<span class="text-zinc-600">({T.itemsOneLine224})</span> <span class="text-zinc-600">({T.itemsOneLine224})</span>
</label> </label>
<textarea <textarea
id="ws-items" id="ws-items"
rows="6" 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" 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 >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>
</div> </div>
<p
<!-- Wheel area --> id="ws-error"
<div class="flex flex-col items-center mt-8 gap-6"> role="alert"
<div class="relative w-full max-w-[480px]" id="ws-wheel-wrap"> aria-live="polite"
<!-- Pointer: triangle pointing down at top-center of wheel --> class="text-sm text-red-500 hidden"
<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 </p>
id="ws-winner-label" </div>
class="text-xs font-semibold uppercase tracking-widest text-zinc-500"
> <!-- Wheel area -->
{T.winner} <div class="flex flex-col items-center mt-8 gap-6">
</p> <div class="relative w-full max-w-[480px]" id="ws-wheel-wrap">
<span <!-- Pointer: triangle pointing down at top-center of wheel -->
id="ws-result-text" <div
class="text-3xl sm:text-4xl font-bold text-zinc-100 text-center break-words max-w-full" id="ws-pointer"
></span> class="absolute left-1/2 top-0 z-10 -translate-x-1/2 -translate-y-1/2"
<button 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));"
id="ws-copy-btn" aria-hidden="true"
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" </div>
> <canvas id="ws-canvas" class="block w-full" aria-label="Wheel of fortune"
<span id="ws-copy-label">{T.copy}</span> ></canvas>
<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>
<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> </div>
<style> <style>
@keyframes ws-pop { @keyframes ws-pop {
from { from {
transform: scale(0.6); transform: scale(0.6);
opacity: 0; opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
} }
.ws-pop { to {
animation: ws-pop 0.35s cubic-bezier(0.34, 1.56, 0.64, 1) both; transform: scale(1);
opacity: 1;
} }
}
.ws-pop {
animation: ws-pop 0.35s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
</style> </style>
<script> <script>
import { CopyFeedback } from "@/lib/client/clipboard"; import { CopyFeedback } from "@/lib/client/clipboard";
import { createErrorDisplay } from "@/lib/client/validation"; import { createErrorDisplay } from "@/lib/client/validation";
const isRu = document.documentElement.lang === "ru"; const isRu = document.documentElement.lang === "ru";
const COPY_LABEL = isRu ? "Копировать" : "Copy"; const COPY_LABEL = isRu ? "Копировать" : "Copy";
const COPIED_LABEL = isRu ? "Скопировано" : "Copied"; const COPIED_LABEL = isRu ? "Скопировано" : "Copied";
const ERR_MIN2 = isRu const ERR_MIN2 = isRu
? "Добавьте хотя бы 2 элемента." ? "Добавьте хотя бы 2 элемента."
: "Add at least 2 items."; : "Add at least 2 items.";
const ERR_MAX24 = isRu ? "Максимум 24 элемента." : "Maximum 24 items."; const ERR_MAX24 = isRu ? "Максимум 24 элемента." : "Maximum 24 items.";
const COLORS = [ const COLORS = [
"#534AB7", "#534AB7",
"#0F6E56", "#0F6E56",
"#993C1D", "#993C1D",
"#185FA5", "#185FA5",
"#854F0B", "#854F0B",
"#993556", "#993556",
"#3B6D11", "#3B6D11",
"#A32D2D", "#A32D2D",
]; ];
const TAU = 2 * Math.PI; const TAU = 2 * Math.PI;
const textarea = document.getElementById("ws-items") as HTMLTextAreaElement; const textarea = document.getElementById("ws-items") as HTMLTextAreaElement;
const errorEl = document.getElementById("ws-error") as HTMLParagraphElement; const errorEl = document.getElementById("ws-error") as HTMLParagraphElement;
const canvas = document.getElementById("ws-canvas") as HTMLCanvasElement; const canvas = document.getElementById("ws-canvas") as HTMLCanvasElement;
const spinBtn = document.getElementById("ws-spin-btn") as HTMLButtonElement; const spinBtn = document.getElementById("ws-spin-btn") as HTMLButtonElement;
const resultCard = document.getElementById( const resultCard = document.getElementById(
"ws-result-card", "ws-result-card",
) as HTMLDivElement; ) as HTMLDivElement;
const resultText = document.getElementById( const resultText = document.getElementById(
"ws-result-text", "ws-result-text",
) as HTMLSpanElement; ) as HTMLSpanElement;
const copyBtn = document.getElementById("ws-copy-btn") as HTMLButtonElement; const copyBtn = document.getElementById("ws-copy-btn") as HTMLButtonElement;
const copyLabel = document.getElementById( const copyLabel = document.getElementById("ws-copy-label") as HTMLSpanElement;
"ws-copy-label", const copyIcon = document.getElementById("ws-copy-icon") as HTMLSpanElement;
) as HTMLSpanElement; const checkIcon = document.getElementById("ws-check-icon") as HTMLSpanElement;
const copyIcon = document.getElementById("ws-copy-icon") as HTMLSpanElement; const wheelWrap = document.getElementById("ws-wheel-wrap") as HTMLDivElement;
const checkIcon = document.getElementById(
"ws-check-icon",
) as HTMLSpanElement;
const wheelWrap = document.getElementById(
"ws-wheel-wrap",
) as HTMLDivElement;
const ctx = canvas.getContext("2d")!; const ctx = canvas.getContext("2d")!;
let items: string[] = ["Alice", "Bob", "Carol", "Dave", "Eve", "Frank"]; let items: string[] = ["Alice", "Bob", "Carol", "Dave", "Eve", "Frank"];
let currentRotation = 0; let currentRotation = 0;
let isSpinning = false; let isSpinning = false;
const errors = createErrorDisplay(errorEl); const errors = createErrorDisplay(errorEl);
const clipboard = new CopyFeedback(copyIcon, checkIcon); const clipboard = new CopyFeedback(copyIcon, checkIcon);
// ── Canvas sizing ──────────────────────────────────────────────── // ── Canvas sizing ────────────────────────────────────────────────
function resize() { function resize() {
const size = Math.min(wheelWrap.offsetWidth, 480); const size = Math.min(wheelWrap.offsetWidth, 480);
canvas.width = size; canvas.width = size;
canvas.height = size; canvas.height = size;
draw(currentRotation); draw(currentRotation);
} }
new ResizeObserver(resize).observe(wheelWrap); new ResizeObserver(resize).observe(wheelWrap);
resize(); resize();
// ── Drawing ────────────────────────────────────────────────────── // ── Drawing ──────────────────────────────────────────────────────
function clampText(text: string, maxWidth: number): string { function clampText(text: string, maxWidth: number): string {
if (ctx.measureText(text).width <= maxWidth) return text; if (ctx.measureText(text).width <= maxWidth) return text;
let s = text; let s = text;
while (s.length > 0 && ctx.measureText(s + "…").width > maxWidth) while (s.length > 0 && ctx.measureText(s + "…").width > maxWidth)
s = s.slice(0, -1); s = s.slice(0, -1);
return s + "…"; 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) { ctx.beginPath();
const size = canvas.width; ctx.arc(0, 0, r, 0, TAU);
const r = size / 2 - 6; ctx.strokeStyle = "rgba(255,255,255,0.2)";
const n = items.length; ctx.lineWidth = 3;
const seg = TAU / n; ctx.stroke();
ctx.clearRect(0, 0, size, size); ctx.beginPath();
ctx.save(); ctx.arc(0, 0, 15, 0, TAU);
ctx.translate(size / 2, size / 2); 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++) { ctx.restore();
const a0 = -Math.PI / 2 + i * seg + rotation; }
const a1 = a0 + seg;
ctx.beginPath(); // ── Items ────────────────────────────────────────────────────────
ctx.moveTo(0, 0); function parseItems(): string[] | null {
ctx.arc(0, 0, r, a0, a1); const lines = textarea.value
ctx.closePath(); .split("\n")
ctx.fillStyle = COLORS[i % COLORS.length]; .map((l) => l.trim())
ctx.fill(); .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)"; function applyItems() {
ctx.lineWidth = 1.5; const parsed = parseItems();
ctx.stroke(); if (parsed) {
items = parsed;
draw(currentRotation);
}
}
const mid = a0 + seg / 2; let debounceTimer: ReturnType<typeof setTimeout> | null = null;
const fontSize = Math.max( textarea.addEventListener("input", () => {
9, if (debounceTimer) clearTimeout(debounceTimer);
Math.min(14, Math.floor(r * seg * 0.4)), debounceTimer = setTimeout(applyItems, 300);
); });
ctx.save();
ctx.rotate(mid);
ctx.textAlign = "right";
ctx.fillStyle = "rgba(255,255,255,0.92)";
ctx.font = `bold ${fontSize}px 'Space Grotesk', sans-serif`;
const label = clampText(items[i], r * 0.54);
ctx.fillText(label, r - 10, fontSize * 0.38);
ctx.restore();
}
ctx.beginPath(); // ── Spin ─────────────────────────────────────────────────────────
ctx.arc(0, 0, r, 0, TAU); spinBtn.addEventListener("click", () => {
ctx.strokeStyle = "rgba(255,255,255,0.2)"; if (isSpinning) return;
ctx.lineWidth = 3; const parsed = parseItems();
ctx.stroke(); if (!parsed) return;
items = parsed;
draw(currentRotation);
ctx.beginPath(); const n = items.length;
ctx.arc(0, 0, 15, 0, TAU); const seg = TAU / n;
ctx.fillStyle = "#18181b"; const winnerIdx = Math.floor(Math.random() * n);
ctx.fill();
ctx.strokeStyle = "rgba(255,255,255,0.22)";
ctx.lineWidth = 2;
ctx.stroke();
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 ──────────────────────────────────────────────────────── const dur = 2500;
function parseItems(): string[] | null { const t0 = performance.now();
const lines = textarea.value const gravity = 0.2;
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
if (lines.length < 2) {
errors.show(ERR_MIN2);
return null;
}
if (lines.length > 24) {
errors.show(ERR_MAX24);
return null;
}
errors.clear();
return lines;
}
function applyItems() { (function tick(now: number) {
const parsed = parseItems(); const elapsed = now - t0;
if (parsed) { if (elapsed >= dur) {
items = parsed; particles.forEach((p) => p.el.remove());
draw(currentRotation); return;
} }
} const opacity = 1 - elapsed / dur;
particles.forEach((p) => {
let debounceTimer: ReturnType<typeof setTimeout> | null = null; p.vy += gravity;
textarea.addEventListener("input", () => { p.vx *= 0.99;
if (debounceTimer) clearTimeout(debounceTimer); p.x += p.vx;
debounceTimer = setTimeout(applyItems, 300); 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));
// ── Spin ───────────────────────────────────────────────────────── });
spinBtn.addEventListener("click", () => { requestAnimationFrame(tick);
if (isSpinning) return; })(t0);
const parsed = parseItems(); }
if (!parsed) return;
items = parsed;
draw(currentRotation);
const n = items.length;
const seg = TAU / n;
const winnerIdx = Math.floor(Math.random() * n);
const desiredPhase = (((-(winnerIdx + 0.5) * seg) % TAU) + TAU) % TAU;
const currentPhase = ((currentRotation % TAU) + TAU) % TAU;
let delta = (desiredPhase - currentPhase + TAU) % TAU;
if (delta < 0.5) delta += TAU;
const jitter = (Math.random() - 0.5) * seg * 0.55;
const fullTurns = 5 + Math.floor(Math.random() * 3);
const totalDelta = fullTurns * TAU + delta + jitter;
const startRot = currentRotation;
const duration = 4000 + Math.random() * 2000;
const t0 = performance.now();
isSpinning = true;
spinBtn.disabled = true;
resultCard.classList.add("hidden");
(function animate(now: number) {
const p = Math.min((now - t0) / duration, 1);
const eased = 1 - Math.pow(1 - p, 3);
currentRotation = startRot + totalDelta * eased;
draw(currentRotation);
if (p < 1) {
requestAnimationFrame(animate);
} else {
currentRotation = startRot + totalDelta;
isSpinning = false;
spinBtn.disabled = false;
showResult(items[winnerIdx]);
spawnConfetti();
}
})(t0);
});
// ── Result ───────────────────────────────────────────────────────
function showResult(winner: string) {
resultText.textContent = winner;
resultCard.classList.remove("hidden");
resultText.classList.remove("ws-pop");
void resultText.offsetWidth;
resultText.classList.add("ws-pop");
copyLabel.textContent = COPY_LABEL;
copyIcon.classList.remove("hidden");
checkIcon.classList.add("hidden");
}
async function copyResult() {
const value = resultText.textContent?.trim();
if (!value) return;
await navigator.clipboard.writeText(value);
clipboard.showCopied();
copyLabel.textContent = COPIED_LABEL;
setTimeout(() => {
copyLabel.textContent = COPY_LABEL;
}, 1500);
}
copyBtn.addEventListener("click", copyResult);
// ── Confetti ─────────────────────────────────────────────────────
function spawnConfetti() {
const cr = canvas.getBoundingClientRect();
const cx = cr.left + cr.width / 2;
const cy = cr.top + cr.height / 2;
type Particle = {
el: HTMLDivElement;
x: number;
y: number;
vx: number;
vy: number;
};
const particles: Particle[] = [];
for (let i = 0; i < 60; i++) {
const el = document.createElement("div");
const w = 4 + Math.random() * 6;
const h = 4 + Math.random() * 6;
el.style.cssText =
`position:fixed;width:${w}px;height:${h}px;` +
`background:${COLORS[i % COLORS.length]};border-radius:1px;` +
`pointer-events:none;left:${cx}px;top:${cy}px;will-change:transform,opacity;z-index:9999;`;
document.body.appendChild(el);
const angle = Math.random() * TAU;
const speed = 4 + Math.random() * 7;
particles.push({
el,
x: cx,
y: cy,
vx: Math.cos(angle) * speed,
vy: -(
Math.abs(Math.sin(angle)) * speed +
2 +
Math.random() * 4
),
});
}
const dur = 2500;
const t0 = performance.now();
const gravity = 0.2;
(function tick(now: number) {
const elapsed = now - t0;
if (elapsed >= dur) {
particles.forEach((p) => p.el.remove());
return;
}
const opacity = 1 - elapsed / dur;
particles.forEach((p) => {
p.vy += gravity;
p.vx *= 0.99;
p.x += p.vx;
p.y += p.vy;
p.el.style.transform = `translate(${p.x - cx}px,${p.y - cy}px) rotate(${p.x * 2}deg)`;
p.el.style.opacity = String(Math.max(0, opacity));
});
requestAnimationFrame(tick);
})(t0);
}
</script> </script>
+3 -3
View File
@@ -1,6 +1,6 @@
export const siteUrl = 'https://randify.pro'; export const siteUrl = "https://randify.pro";
export const analytics = { export const analytics = {
yandexMetrikaId: '109130319', yandexMetrikaId: "109130319",
topMailRuId: '3765043', topMailRuId: "3765043",
} as const; } as const;
+1 -1
View File
@@ -1 +1 @@
/// <reference path="../.astro/types.d.ts" /> /// <reference path="../.astro/types.d.ts" />
+119 -115
View File
@@ -1,133 +1,137 @@
export type Lang = 'en' | 'ru'; export type Lang = "en" | "ru";
export const translations = { export const translations = {
en: { en: {
allGenerators: 'All generators', allGenerators: "All generators",
live: 'Live', live: "Live",
comingSoon: 'Coming soon', comingSoon: "Coming soon",
copy: 'Copy', copy: "Copy",
copied: 'Copied', copied: "Copied",
copyAll: 'Copy all', copyAll: "Copy all",
howToUse: 'How to use', howToUse: "How to use",
whenToUse: 'When to use', whenToUse: "When to use",
generate: 'Generate', generate: "Generate",
draw: 'Draw', draw: "Draw",
roll: 'Roll', roll: "Roll",
flip: 'Flip', flip: "Flip",
pick: 'Pick', pick: "Pick",
spin: 'Spin', spin: "Spin",
from: 'From', from: "From",
to: 'To', to: "To",
length: 'Length', length: "Length",
count: 'Count', count: "Count",
winner: 'Winner', winner: "Winner",
total: 'Total', total: "Total",
sides: 'Sides', sides: "Sides",
numberOfDice: 'Number of dice', numberOfDice: "Number of dice",
numberOfCoins: 'Number of coins', numberOfCoins: "Number of coins",
cardsToDraw: 'Cards to draw', cardsToDraw: "Cards to draw",
allowDuplicates: 'Allow duplicates', allowDuplicates: "Allow duplicates",
uppercase: 'Uppercase (A–Z)', uppercase: "Uppercase (A–Z)",
lowercase: 'Lowercase (a–z)', lowercase: "Lowercase (a–z)",
digits: 'Digits (0–9)', digits: "Digits (0–9)",
symbols: 'Symbols (!@#…)', symbols: "Symbols (!@#…)",
type: 'Type', type: "Type",
lengthBytes: 'Length (bytes)', lengthBytes: "Length (bytes)",
itemsOneLine: 'one per line', itemsOneLine: "one per line",
itemsOneLine224: 'one per line, 2–24', itemsOneLine224: "one per line, 2–24",
items: 'Items', items: "Items",
headsLabel: 'H', headsLabel: "H",
tailsLabel: 'T', tailsLabel: "T",
headsWord: 'heads', headsWord: "heads",
tailsWord: 'tails', tailsWord: "tails",
errBothIntegers: 'Both values must be whole numbers.', errBothIntegers: "Both values must be whole numbers.",
errFromLessThanTo: '"From" must be less than "To".', errFromLessThanTo: '"From" must be less than "To".',
errSelectOneType: 'Select at least one character type.', errSelectOneType: "Select at least one character type.",
errAllIntegers: 'All values must be whole numbers.', errAllIntegers: "All values must be whole numbers.",
errPickAtLeastOne: '"Pick" must be at least 1.', errPickAtLeastOne: '"Pick" must be at least 1.',
errDiceAtLeast: 'Number of dice must be at least 1.', errDiceAtLeast: "Number of dice must be at least 1.",
errDiceMax: 'Maximum 20 dice at once.', errDiceMax: "Maximum 20 dice at once.",
errDrawAtLeast: 'Draw at least 1 card.', errDrawAtLeast: "Draw at least 1 card.",
errMaxUniqueCards: 'Cannot draw more than 52 unique cards.', errMaxUniqueCards: "Cannot draw more than 52 unique cards.",
errMax52Cards: 'Maximum 52 cards at once.', errMax52Cards: "Maximum 52 cards at once.",
errAddAtLeast2: 'Add at least 2 items.', errAddAtLeast2: "Add at least 2 items.",
errMax24: 'Maximum 24 items.', errMax24: "Maximum 24 items.",
errAddAtLeast1: 'Add at least one item to the list.', errAddAtLeast1: "Add at least one item to the list.",
errPickAtLeast1: 'Pick at least 1 item.', errPickAtLeast1: "Pick at least 1 item.",
homeTitle: 'Random generators for every occasion', homeTitle: "Random generators for every occasion",
homeSubtitle: 'Simple tools for raffles, games, and everything that needs randomness.', homeSubtitle:
defaultTitle: 'Randify — Random Value Generators', "Simple tools for raffles, games, and everything that needs randomness.",
defaultDesc: 'Simple tools for raffles, games, and everything that needs randomness.', defaultTitle: "Randify — Random Value Generators",
brandLabel: 'randify', defaultDesc:
backToAll: 'All generators', "Simple tools for raffles, games, and everything that needs randomness.",
brandLabel: "randify",
backToAll: "All generators",
}, },
ru: { ru: {
allGenerators: 'Все генераторы', allGenerators: "Все генераторы",
live: 'Активен', live: "Активен",
comingSoon: 'Скоро', comingSoon: "Скоро",
copy: 'Копировать', copy: "Копировать",
copied: 'Скопировано', copied: "Скопировано",
copyAll: 'Копировать всё', copyAll: "Копировать всё",
howToUse: 'Как пользоваться', howToUse: "Как пользоваться",
whenToUse: 'Когда использовать', whenToUse: "Когда использовать",
generate: 'Сгенерировать', generate: "Сгенерировать",
draw: 'Тянуть', draw: "Тянуть",
roll: 'Бросить', roll: "Бросить",
flip: 'Подбросить', flip: "Подбросить",
pick: 'Выбрать', pick: "Выбрать",
spin: 'Крутить', spin: "Крутить",
from: 'От', from: "От",
to: 'До', to: "До",
length: 'Длина', length: "Длина",
count: 'Количество', count: "Количество",
winner: 'Победитель', winner: "Победитель",
total: 'Итого', total: "Итого",
sides: 'Грани', sides: "Грани",
numberOfDice: 'Количество кубиков', numberOfDice: "Количество кубиков",
numberOfCoins: 'Количество монет', numberOfCoins: "Количество монет",
cardsToDraw: 'Карт вытащить', cardsToDraw: "Карт вытащить",
allowDuplicates: 'Разрешить повторения', allowDuplicates: "Разрешить повторения",
uppercase: 'Заглавные (A–Z)', uppercase: "Заглавные (A–Z)",
lowercase: 'Строчные (a–z)', lowercase: "Строчные (a–z)",
digits: 'Цифры (0–9)', digits: "Цифры (0–9)",
symbols: 'Символы (!@#…)', symbols: "Символы (!@#…)",
type: 'Тип', type: "Тип",
lengthBytes: 'Длина (байты)', lengthBytes: "Длина (байты)",
itemsOneLine: 'по одному на строку', itemsOneLine: "по одному на строку",
itemsOneLine224: 'по одному на строку, 2–24', itemsOneLine224: "по одному на строку, 2–24",
items: 'Элементы', items: "Элементы",
headsLabel: 'О', headsLabel: "О",
tailsLabel: 'Р', tailsLabel: "Р",
headsWord: 'орёл', headsWord: "орёл",
tailsWord: 'решка', tailsWord: "решка",
errBothIntegers: 'Оба значения должны быть целыми числами.', errBothIntegers: "Оба значения должны быть целыми числами.",
errFromLessThanTo: '«От» должно быть меньше «До».', errFromLessThanTo: "«От» должно быть меньше «До».",
errSelectOneType: 'Выберите хотя бы один тип символов.', errSelectOneType: "Выберите хотя бы один тип символов.",
errAllIntegers: 'Все значения должны быть целыми числами.', errAllIntegers: "Все значения должны быть целыми числами.",
errPickAtLeastOne: '«Выбрать» должно быть не менее 1.', errPickAtLeastOne: "«Выбрать» должно быть не менее 1.",
errDiceAtLeast: 'Количество кубиков должно быть не менее 1.', errDiceAtLeast: "Количество кубиков должно быть не менее 1.",
errDiceMax: 'Максимум 20 кубиков одновременно.', errDiceMax: "Максимум 20 кубиков одновременно.",
errDrawAtLeast: 'Вытащите хотя бы 1 карту.', errDrawAtLeast: "Вытащите хотя бы 1 карту.",
errMaxUniqueCards: 'Нельзя вытащить более 52 уникальных карт.', errMaxUniqueCards: "Нельзя вытащить более 52 уникальных карт.",
errMax52Cards: 'Максимум 52 карты одновременно.', errMax52Cards: "Максимум 52 карты одновременно.",
errAddAtLeast2: 'Добавьте хотя бы 2 элемента.', errAddAtLeast2: "Добавьте хотя бы 2 элемента.",
errMax24: 'Максимум 24 элемента.', errMax24: "Максимум 24 элемента.",
errAddAtLeast1: 'Добавьте хотя бы один элемент в список.', errAddAtLeast1: "Добавьте хотя бы один элемент в список.",
errPickAtLeast1: 'Выберите хотя бы 1 элемент.', errPickAtLeast1: "Выберите хотя бы 1 элемент.",
homeTitle: 'Генераторы случайных значений на любой случай', homeTitle: "Генераторы случайных значений на любой случай",
homeSubtitle: 'Простые инструменты для розыгрышей, игр и всего, что требует случайности.', homeSubtitle:
defaultTitle: 'Randify — Генераторы случайных значений', "Простые инструменты для розыгрышей, игр и всего, что требует случайности.",
defaultDesc: 'Простые инструменты для розыгрышей, игр и всего, что требует случайности.', defaultTitle: "Randify — Генераторы случайных значений",
brandLabel: 'randify', defaultDesc:
backToAll: 'Все генераторы', "Простые инструменты для розыгрышей, игр и всего, что требует случайности.",
brandLabel: "randify",
backToAll: "Все генераторы",
}, },
} as const; } as const;
+62 -65
View File
@@ -6,8 +6,8 @@ import { useT } from "../i18n/translations";
import type { Lang } from "../i18n/translations"; import type { Lang } from "../i18n/translations";
interface Props { interface Props {
title?: string; title?: string;
description?: string; description?: string;
} }
const lang = (Astro.currentLocale as Lang) || "en"; const lang = (Astro.currentLocale as Lang) || "en";
@@ -22,80 +22,77 @@ const ruUrl = `https://randify.pro${getRelativeLocaleUrl("ru", pathWithoutLocale
<!doctype html> <!doctype html>
<html lang={lang}> <html lang={lang}>
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content={description} /> <meta name="description" content={description} />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link <link
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300..700&display=swap" href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300..700&display=swap"
rel="stylesheet" rel="stylesheet"
/> />
<link rel="alternate" hreflang="en" href={enUrl} /> <link rel="alternate" hreflang="en" href={enUrl} />
<link rel="alternate" hreflang="ru" href={ruUrl} /> <link rel="alternate" hreflang="ru" href={ruUrl} />
<meta name="verification" content="er9ndnv9ih7agmh8" /> <meta name="verification" content="er9ndnv9ih7agmh8" />
<meta name="theme-color" content="#534AB7" /> <meta name="theme-color" content="#534AB7" />
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<title>{title}</title> <title>{title}</title>
<Analytics /> <Analytics />
<script> <script>
if ("serviceWorker" in navigator) { if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/sw.js"); navigator.serviceWorker.register("/sw.js");
} }
</script> </script>
</head> </head>
<body class="bg-zinc-950 text-zinc-100 min-h-screen font-sans antialiased"> <body class="bg-zinc-950 text-zinc-100 min-h-screen font-sans antialiased">
<LanguageSwitcher /> <LanguageSwitcher />
<slot /> <slot />
</body> </body>
</html> </html>
<!-- Auto-detect language on first visit --> <!-- Auto-detect language on first visit -->
<script> <script>
if (!localStorage.getItem("lang-pref")) { if (!localStorage.getItem("lang-pref")) {
const browserLang = (navigator.language || "").toLowerCase(); const browserLang = (navigator.language || "").toLowerCase();
if ( if (browserLang.startsWith("ru") && !location.pathname.startsWith("/ru")) {
browserLang.startsWith("ru") && location.replace("/ru" + location.pathname);
!location.pathname.startsWith("/ru")
) {
location.replace("/ru" + location.pathname);
}
} }
}
</script> </script>
<style is:global> <style is:global>
@import "tailwindcss"; @import "tailwindcss";
@theme { @theme {
--font-sans: "Space Grotesk", sans-serif; --font-sans: "Space Grotesk", sans-serif;
} }
:root { :root {
--accent: #534ab7; --accent: #534ab7;
} }
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
body { body {
background-image: background-image:
radial-gradient( radial-gradient(
ellipse 90% 45% at 50% -5%, ellipse 90% 45% at 50% -5%,
rgba(83, 74, 183, 0.2) 0%, rgba(83, 74, 183, 0.2) 0%,
transparent 65% transparent 65%
), ),
radial-gradient( radial-gradient(
ellipse 90% 45% at 50% 105%, ellipse 90% 45% at 50% 105%,
rgba(83, 74, 183, 0.13) 0%, rgba(83, 74, 183, 0.13) 0%,
transparent 65% transparent 65%
); );
} }
input[type="number"] { input[type="number"] {
color-scheme: dark; color-scheme: dark;
} }
</style> </style>
+18 -14
View File
@@ -1,19 +1,19 @@
--- ---
import BaseLayout from './BaseLayout.astro'; import BaseLayout from "./BaseLayout.astro";
import AdBanner from '../components/AdBanner.astro'; import AdBanner from "../components/AdBanner.astro";
import SeoBlock from '../components/SeoBlock.astro'; import SeoBlock from "../components/SeoBlock.astro";
import { useT } from '../i18n/translations'; import { useT } from "../i18n/translations";
import type { Lang } from '../i18n/translations'; import type { Lang } from "../i18n/translations";
import type { Generator } from '../data/generators'; import type { Generator } from "../data/generators";
interface Props { interface Props {
generator: Generator; generator: Generator;
} }
const { generator } = Astro.props; const { generator } = Astro.props;
const lang = (Astro.currentLocale as Lang) || 'en'; const lang = (Astro.currentLocale as Lang) || "en";
const T = useT(lang); const T = useT(lang);
const isRu = lang === 'ru'; const isRu = lang === "ru";
--- ---
<BaseLayout <BaseLayout
@@ -23,7 +23,7 @@ const isRu = lang === 'ru';
<div class="max-w-2xl mx-auto px-4 py-12 sm:py-20"> <div class="max-w-2xl mx-auto px-4 py-12 sm:py-20">
<nav aria-label="Breadcrumb" class="mb-10"> <nav aria-label="Breadcrumb" class="mb-10">
<a <a
href={isRu ? '/ru/' : '/'} href={isRu ? "/ru/" : "/"}
class="inline-flex items-center gap-1.5 text-sm text-zinc-500 hover:text-zinc-200 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[#534AB7] rounded" class="inline-flex items-center gap-1.5 text-sm text-zinc-500 hover:text-zinc-200 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[#534AB7] rounded"
aria-label={T.backToAll} aria-label={T.backToAll}
> >
@@ -39,7 +39,7 @@ const isRu = lang === 'ru';
stroke-linejoin="round" stroke-linejoin="round"
aria-hidden="true" aria-hidden="true"
> >
<path d="m15 18-6-6 6-6"/> <path d="m15 18-6-6 6-6"></path>
</svg> </svg>
{T.backToAll} {T.backToAll}
</a> </a>
@@ -49,14 +49,18 @@ const isRu = lang === 'ru';
<div class="flex items-center gap-2 mb-4"> <div class="flex items-center gap-2 mb-4">
<span <span
class="inline-block w-2.5 h-2.5 rounded-full bg-[#534AB7]" class="inline-block w-2.5 h-2.5 rounded-full bg-[#534AB7]"
aria-hidden="true" aria-hidden="true"></span>
></span> <span
<span class="text-sm font-medium text-zinc-400 uppercase tracking-widest">randify</span> class="text-sm font-medium text-zinc-400 uppercase tracking-widest"
>randify</span
>
</div> </div>
<h1 class="text-3xl sm:text-4xl font-bold text-zinc-100"> <h1 class="text-3xl sm:text-4xl font-bold text-zinc-100">
{isRu ? generator.ruPageTitle : generator.pageTitle} {isRu ? generator.ruPageTitle : generator.pageTitle}
</h1> </h1>
<p class="mt-2 text-base text-zinc-400">{isRu ? generator.ruDescription : generator.description}</p> <p class="mt-2 text-base text-zinc-400">
{isRu ? generator.ruDescription : generator.description}
</p>
</header> </header>
<main> <main>
+5 -5
View File
@@ -1,10 +1,10 @@
export function popElement(el: HTMLElement) { export function popElement(el: HTMLElement) {
el.style.transform = 'scale(1.18)'; el.style.transform = "scale(1.18)";
el.style.opacity = '0.7'; el.style.opacity = "0.7";
requestAnimationFrame(() => requestAnimationFrame(() =>
requestAnimationFrame(() => { requestAnimationFrame(() => {
el.style.transform = 'scale(1)'; el.style.transform = "scale(1)";
el.style.opacity = '1'; el.style.opacity = "1";
}) }),
); );
} }
+5 -5
View File
@@ -4,19 +4,19 @@ export class CopyFeedback {
constructor( constructor(
private copyIcon: HTMLElement, private copyIcon: HTMLElement,
private checkIcon: HTMLElement, private checkIcon: HTMLElement,
private duration = 1500 private duration = 1500,
) {} ) {}
showCopied() { showCopied() {
this.copyIcon.classList.add('hidden'); this.copyIcon.classList.add("hidden");
this.checkIcon.classList.remove('hidden'); this.checkIcon.classList.remove("hidden");
if (this.timer) clearTimeout(this.timer); if (this.timer) clearTimeout(this.timer);
this.timer = setTimeout(() => this.revert(), this.duration); this.timer = setTimeout(() => this.revert(), this.duration);
} }
revert() { revert() {
this.checkIcon.classList.add('hidden'); this.checkIcon.classList.add("hidden");
this.copyIcon.classList.remove('hidden'); this.copyIcon.classList.remove("hidden");
} }
cleanup() { cleanup() {
+3 -3
View File
@@ -2,11 +2,11 @@ export function createErrorDisplay(errorEl: HTMLElement) {
return { return {
show(msg: string) { show(msg: string) {
errorEl.textContent = msg; errorEl.textContent = msg;
errorEl.classList.remove('hidden'); errorEl.classList.remove("hidden");
}, },
clear() { clear() {
errorEl.textContent = ''; errorEl.textContent = "";
errorEl.classList.add('hidden'); errorEl.classList.add("hidden");
}, },
}; };
} }
+1 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { describe, it, expect, vi, afterEach } from "vitest";
import { import {
parseDiceNotation, parseDiceNotation,
rollDice, rollDice,
+7 -3
View File
@@ -89,7 +89,11 @@ export function parseDiceNotation(raw: string): Parsed | null {
} }
// Exploding: ! or !>N or !p or !p>N // Exploding: ! or !>N or !p or !p>N
let explode: Explode = { active: false, threshold: null, penetrating: false }; const explode: Explode = {
active: false,
threshold: null,
penetrating: false,
};
const expMatch = remaining.match(/^(!p|!)(>?)(\d*)/); const expMatch = remaining.match(/^(!p|!)(>?)(\d*)/);
if (expMatch) { if (expMatch) {
explode.active = true; explode.active = true;
@@ -104,7 +108,7 @@ export function parseDiceNotation(raw: string): Parsed | null {
} }
// Reroll: rN, roN, r<N, ro<N // Reroll: rN, roN, r<N, ro<N
let reroll: Reroll = { const reroll: Reroll = {
active: false, active: false,
values: new Set(), values: new Set(),
once: false, once: false,
@@ -158,7 +162,7 @@ function rollDie(sides: number, explode: Explode, reroll: Reroll): DieResult {
let value = Math.floor(Math.random() * sides) + 1; let value = Math.floor(Math.random() * sides) + 1;
const original = value; const original = value;
let rerolledFrom: number | null = null; let rerolledFrom: number | null = null;
let explosions: number[] = []; const explosions: number[] = [];
let exploded = false; let exploded = false;
// Handle reroll // Handle reroll
+1 -1
View File
@@ -7,5 +7,5 @@ const generator = generators.find((g) => g.slug === "cards")!;
--- ---
<GeneratorLayout generator={generator}> <GeneratorLayout generator={generator}>
<CardGenerator /> <CardGenerator />
</GeneratorLayout> </GeneratorLayout>
+1 -1
View File
@@ -7,5 +7,5 @@ const generator = generators.find((g) => g.slug === "coin")!;
--- ---
<GeneratorLayout generator={generator}> <GeneratorLayout generator={generator}>
<CoinGenerator /> <CoinGenerator />
</GeneratorLayout> </GeneratorLayout>
+1 -1
View File
@@ -7,5 +7,5 @@ const generator = generators.find((g) => g.slug === "colors")!;
--- ---
<GeneratorLayout generator={generator}> <GeneratorLayout generator={generator}>
<ColorGenerator /> <ColorGenerator />
</GeneratorLayout> </GeneratorLayout>
+1 -1
View File
@@ -7,5 +7,5 @@ const generator = generators.find((g) => g.slug === "dice")!;
--- ---
<GeneratorLayout generator={generator}> <GeneratorLayout generator={generator}>
<DiceGenerator /> <DiceGenerator />
</GeneratorLayout> </GeneratorLayout>
+1 -1
View File
@@ -7,5 +7,5 @@ const generator = generators.find((g) => g.slug === "list")!;
--- ---
<GeneratorLayout generator={generator}> <GeneratorLayout generator={generator}>
<ListGenerator /> <ListGenerator />
</GeneratorLayout> </GeneratorLayout>
+1 -1
View File
@@ -7,5 +7,5 @@ const generator = generators.find((g) => g.slug === "lottery")!;
--- ---
<GeneratorLayout generator={generator}> <GeneratorLayout generator={generator}>
<LotteryGenerator /> <LotteryGenerator />
</GeneratorLayout> </GeneratorLayout>
+1 -1
View File
@@ -7,5 +7,5 @@ const generator = generators.find((g) => g.slug === "numbers")!;
--- ---
<GeneratorLayout generator={generator}> <GeneratorLayout generator={generator}>
<NumberGenerator /> <NumberGenerator />
</GeneratorLayout> </GeneratorLayout>
+1 -1
View File
@@ -7,5 +7,5 @@ const generator = generators.find((g) => g.slug === "password")!;
--- ---
<GeneratorLayout generator={generator}> <GeneratorLayout generator={generator}>
<PasswordGenerator /> <PasswordGenerator />
</GeneratorLayout> </GeneratorLayout>
+1 -1
View File
@@ -7,5 +7,5 @@ const generator = generators.find((g) => g.slug === "uuid")!;
--- ---
<GeneratorLayout generator={generator}> <GeneratorLayout generator={generator}>
<UuidGenerator /> <UuidGenerator />
</GeneratorLayout> </GeneratorLayout>
+1 -1
View File
@@ -7,5 +7,5 @@ const generator = generators.find((g) => g.slug === "wheel")!;
--- ---
<GeneratorLayout generator={generator}> <GeneratorLayout generator={generator}>
<WheelSpinner /> <WheelSpinner />
</GeneratorLayout> </GeneratorLayout>
+34 -36
View File
@@ -11,43 +11,41 @@ const T = useT(lang);
--- ---
<BaseLayout> <BaseLayout>
<div class="max-w-4xl mx-auto px-4 py-12 sm:py-20"> <div class="max-w-4xl mx-auto px-4 py-12 sm:py-20">
<header class="mb-12"> <header class="mb-12">
<div class="flex items-center gap-2 mb-6"> <div class="flex items-center gap-2 mb-6">
<span <span
class="inline-block w-3 h-3 rounded-full bg-[#534AB7]" class="inline-block w-3 h-3 rounded-full bg-[#534AB7]"
aria-hidden="true"></span> aria-hidden="true"></span>
<span class="text-xl font-bold tracking-tight text-zinc-100" <span class="text-xl font-bold tracking-tight text-zinc-100"
>{T.brandLabel}</span >{T.brandLabel}</span
> >
</div> </div>
<h1 <h1 class="text-3xl sm:text-4xl font-bold text-zinc-100 leading-tight">
class="text-3xl sm:text-4xl font-bold text-zinc-100 leading-tight" {T.homeTitle}
> </h1>
{T.homeTitle} <p class="mt-3 text-base text-zinc-400 max-w-md">
</h1> {T.homeSubtitle}
<p class="mt-3 text-base text-zinc-400 max-w-md"> </p>
{T.homeSubtitle} </header>
</p>
</header>
<AdBanner size="leaderboard" /> <AdBanner size="leaderboard" />
<main class="mt-8"> <main class="mt-8">
<div <div
class="grid gap-3" class="grid gap-3"
style="grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));" style="grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));"
role="list" role="list"
aria-label="Generator catalog" aria-label="Generator catalog"
> >
{ {
generators.map((generator) => ( generators.map((generator) => (
<div role="listitem"> <div role="listitem">
<GeneratorCard generator={generator} /> <GeneratorCard generator={generator} />
</div>
))
}
</div> </div>
</main> ))
</div> }
</div>
</main>
</div>
</BaseLayout> </BaseLayout>
+1 -1
View File
@@ -7,5 +7,5 @@ const generator = generators.find((g) => g.slug === "cards")!;
--- ---
<GeneratorLayout generator={generator}> <GeneratorLayout generator={generator}>
<CardGenerator /> <CardGenerator />
</GeneratorLayout> </GeneratorLayout>
+1 -1
View File
@@ -7,5 +7,5 @@ const generator = generators.find((g) => g.slug === "coin")!;
--- ---
<GeneratorLayout generator={generator}> <GeneratorLayout generator={generator}>
<CoinGenerator /> <CoinGenerator />
</GeneratorLayout> </GeneratorLayout>
+1 -1
View File
@@ -7,5 +7,5 @@ const generator = generators.find((g) => g.slug === "colors")!;
--- ---
<GeneratorLayout generator={generator}> <GeneratorLayout generator={generator}>
<ColorGenerator /> <ColorGenerator />
</GeneratorLayout> </GeneratorLayout>
+1 -1
View File
@@ -7,5 +7,5 @@ const generator = generators.find((g) => g.slug === "dice")!;
--- ---
<GeneratorLayout generator={generator}> <GeneratorLayout generator={generator}>
<DiceGenerator /> <DiceGenerator />
</GeneratorLayout> </GeneratorLayout>
+1 -1
View File
@@ -7,5 +7,5 @@ const generator = generators.find((g) => g.slug === "list")!;
--- ---
<GeneratorLayout generator={generator}> <GeneratorLayout generator={generator}>
<ListGenerator /> <ListGenerator />
</GeneratorLayout> </GeneratorLayout>
+1 -1
View File
@@ -7,5 +7,5 @@ const generator = generators.find((g) => g.slug === "lottery")!;
--- ---
<GeneratorLayout generator={generator}> <GeneratorLayout generator={generator}>
<LotteryGenerator /> <LotteryGenerator />
</GeneratorLayout> </GeneratorLayout>
+1 -1
View File
@@ -7,5 +7,5 @@ const generator = generators.find((g) => g.slug === "numbers")!;
--- ---
<GeneratorLayout generator={generator}> <GeneratorLayout generator={generator}>
<NumberGenerator /> <NumberGenerator />
</GeneratorLayout> </GeneratorLayout>
+1 -1
View File
@@ -7,5 +7,5 @@ const generator = generators.find((g) => g.slug === "password")!;
--- ---
<GeneratorLayout generator={generator}> <GeneratorLayout generator={generator}>
<PasswordGenerator /> <PasswordGenerator />
</GeneratorLayout> </GeneratorLayout>
+1 -1
View File
@@ -7,5 +7,5 @@ const generator = generators.find((g) => g.slug === "uuid")!;
--- ---
<GeneratorLayout generator={generator}> <GeneratorLayout generator={generator}>
<UuidGenerator /> <UuidGenerator />
</GeneratorLayout> </GeneratorLayout>
+1 -1
View File
@@ -7,5 +7,5 @@ const generator = generators.find((g) => g.slug === "wheel")!;
--- ---
<GeneratorLayout generator={generator}> <GeneratorLayout generator={generator}>
<WheelSpinner /> <WheelSpinner />
</GeneratorLayout> </GeneratorLayout>
+34 -36
View File
@@ -11,43 +11,41 @@ const T = useT(lang);
--- ---
<BaseLayout> <BaseLayout>
<div class="max-w-4xl mx-auto px-4 py-12 sm:py-20"> <div class="max-w-4xl mx-auto px-4 py-12 sm:py-20">
<header class="mb-12"> <header class="mb-12">
<div class="flex items-center gap-2 mb-6"> <div class="flex items-center gap-2 mb-6">
<span <span
class="inline-block w-3 h-3 rounded-full bg-[#534AB7]" class="inline-block w-3 h-3 rounded-full bg-[#534AB7]"
aria-hidden="true"></span> aria-hidden="true"></span>
<span class="text-xl font-bold tracking-tight text-zinc-100" <span class="text-xl font-bold tracking-tight text-zinc-100"
>{T.brandLabel}</span >{T.brandLabel}</span
> >
</div> </div>
<h1 <h1 class="text-3xl sm:text-4xl font-bold text-zinc-100 leading-tight">
class="text-3xl sm:text-4xl font-bold text-zinc-100 leading-tight" {T.homeTitle}
> </h1>
{T.homeTitle} <p class="mt-3 text-base text-zinc-400 max-w-md">
</h1> {T.homeSubtitle}
<p class="mt-3 text-base text-zinc-400 max-w-md"> </p>
{T.homeSubtitle} </header>
</p>
</header>
<AdBanner size="leaderboard" /> <AdBanner size="leaderboard" />
<main class="mt-8"> <main class="mt-8">
<div <div
class="grid gap-3" class="grid gap-3"
style="grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));" style="grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));"
role="list" role="list"
aria-label="Generator catalog" aria-label="Generator catalog"
> >
{ {
generators.map((generator) => ( generators.map((generator) => (
<div role="listitem"> <div role="listitem">
<GeneratorCard generator={generator} /> <GeneratorCard generator={generator} />
</div>
))
}
</div> </div>
</main> ))
</div> }
</div>
</main>
</div>
</BaseLayout> </BaseLayout>
+3 -3
View File
@@ -1,9 +1,9 @@
import { defineConfig } from 'vitest/config'; import { defineConfig } from "vitest/config";
export default defineConfig({ export default defineConfig({
test: { test: {
globals: true, globals: true,
environment: 'node', environment: "node",
include: ['src/**/*.test.ts'], include: ["src/**/*.test.ts"],
}, },
}); });