feat: add favorites panel to meal generator, remove redundant Another one button

- Add clickable favorites counter with expandable panel
- Render saved recipes list with remove button
- Remove duplicate 'Another one' button (Generate already covers it)
- Sync favorite button state when removing from panel
This commit is contained in:
emil
2026-05-13 00:25:09 +03:00
commit 8f11c5af43
161 changed files with 14023 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
.git
.env
*.log
+33
View File
@@ -0,0 +1,33 @@
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run build
- name: Setup SSH key
run: |
mkdir -p ~/.ssh
printf '%s\n' "${{ secrets.DEPLOY_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -H ${{ secrets.DEPLOY_HOST }} >> ~/.ssh/known_hosts
- name: Deploy via rsync
run: |
rsync -avz --delete \
-e "ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no" \
dist/ \
${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:~/www/randify.pro/
+10
View File
@@ -0,0 +1,10 @@
node_modules/
dist/
.env
*.log
.astro/
.kimi/
node_modules_old/
node_modules/
package-lock.json
plan.md
+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"
}
}
]
}
+75
View File
@@ -0,0 +1,75 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this is
Randify is a bilingual (EN/RU) static site (Astro 4 + Tailwind CSS 4) hosting random-value generators. All 10 generators are live. Deployed to randify.pro on reg.ru via GitHub Actions + rsync on push to `main`.
**i18n is handled via Astro's built-in i18n routing.** English pages live at `src/pages/`, Russian pages at `src/pages/ru/`. Both share identical templates because `BaseLayout`, `GeneratorLayout`, and components derive the active locale from `Astro.currentLocale`.
## Commands
```bash
npm run dev # Dev server (localhost:4321)
npm run build # Static build → dist/
npm run preview # Serve the built dist/ locally
```
**Docker (production):** multi-stage build with Node 20, served via `nginx:alpine` with pretty URLs.
```bash
docker build -t randify .
docker run -p 8080:80 randify
```
No test runner or linter is configured.
## Adding a new generator
Every new generator must be created in **both English and Russian simultaneously**.
1. **`src/content/generators/<slug>.json`** — create a JSON file with all fields: `slug`, `title`, `description`, `icon`, `status`, `seoTitle`, `seoDescription`, `ruTitle`, `ruDescription`, `ruSeoTitle`, `ruSeoDescription`, `pageTitle`, `ruPageTitle`, `howTo`, `whenTo`, `ruHowTo`, `ruWhenTo`. The schema is defined in `src/lib/generator-schema.ts` and validated automatically at build time via Zod (`.strict()` — unknown fields will fail the build).
2. **`src/components/generators/<Name>Generator.astro`** — the interactive component. Language detection pattern:
- Frontmatter: `const isRu = Astro.url.pathname.startsWith('/ru'); const T = useT(isRu ? 'ru' : 'en');`
- Client script: `const isRu = document.documentElement.lang === 'ru';`
- Use `T.*` keys for all user-visible strings in the template; use `isRu` ternaries in the `<script>` block.
3. **`src/pages/generators/<slug>.astro`** — create the page using `GeneratorLayout`:
```astro
---
import GeneratorLayout from '@/layouts/GeneratorLayout.astro';
import <Name>Generator from '@/components/generators/<Name>Generator.astro';
import { generators } from '@/data/generators';
const generator = generators.find((g) => g.slug === '<slug>')!;
---
<GeneratorLayout generator={generator}>
<<Name>Generator />
</GeneratorLayout>
```
4. **`src/pages/ru/generators/<slug>.astro`** — **copy the English file exactly**. Because both files use `@/` path aliases and `GeneratorLayout` derives the locale from `Astro.currentLocale`, the file content is identical for both languages.
**Critical:** Never use a shared dynamic `[slug].astro` for Russian pages. Astro bundles scripts from all imported components — using a single file that imports all 10 generators causes every Russian page to run all 10 scripts, breaking them. Each page must be its own file importing only its generator.
## i18n system
- **`src/i18n/translations.ts`** — central `en`/`ru` translation objects. All UI strings (labels, buttons, errors, copy/copied feedback) live here. `useT(lang)` returns the typed translation object.
- **`src/components/LanguageSwitcher.astro`** — fixed top-right EN/RU toggle; persists choice in `localStorage('lang-pref')`.
- **`src/layouts/BaseLayout.astro`** — accepts `lang` prop (`'en' | 'ru'`), sets `<html lang>`, injects `hreflang` alternates, and includes auto-redirect script (first visit, Russian browser → `/ru/`).
- English pages: `/generators/<slug>/` — Russian pages: `/ru/generators/<slug>/`
- Path aliases `@/*` resolve to `src/*` and are used in all page files so EN and RU templates can be identical.
- `GeneratorLayout.astro` wraps every generator page: breadcrumb, header, AdBanner, SeoBlock, and slot for the interactive component.
## Key conventions
- Accent color: `#534AB7` (CSS var `--accent` in BaseLayout).
- All SVG icons are inlined strings in the `icons` map inside `GeneratorCard.astro`; add new ones there.
- `GeneratorCard.astro` auto-detects locale from `Astro.currentLocale` when no `lang` prop is passed.
- `SeoBlock.astro` accepts `lang` prop for translated "How to use" / "When to use" headers.
- `AdBanner.astro` randomises between two Yandex referral links; three size variants: `leaderboard`, `rectangle`, `tile`.
- Yandex Metrika counter (ID `109130319`) and Top.Mail.Ru counter (ID `3765043`) live in `src/components/Analytics.astro`. IDs are configured in `src/data/config.ts` and injected via `define:vars`.
- No client-side router — each generator is a separate static HTML page.
+14
View File
@@ -0,0 +1,14 @@
# Stage 1 — build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2 — serve
FROM nginx:alpine AS runner
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig } from "astro/config";
import tailwindcss from "@tailwindcss/vite";
import sitemap from "@astrojs/sitemap";
export default defineConfig({
site: "https://randify.pro",
integrations: [sitemap()],
vite: {
plugins: [tailwindcss()],
},
i18n: {
defaultLocale: "en",
locales: ["en", "ru"],
routing: {
prefixDefaultLocale: false,
},
},
});
+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",
},
},
];
+18
View File
@@ -0,0 +1,18 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
absolute_redirect off;
location / {
try_files $uri $uri/index.html $uri.html =404;
}
error_page 404 /404.html;
gzip on;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;
}
+36
View File
@@ -0,0 +1,36 @@
{
"name": "randify",
"type": "module",
"version": "0.0.1",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"dependencies": {
"@astrojs/sitemap": "^3.2.1",
"@tailwindcss/vite": "^4.0.0",
"astro": "^4.16.0",
"tailwindcss": "^4.0.0"
},
"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",
"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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="8" fill="#534AB7"/>
<text x="16" y="22" font-size="16" font-family="monospace" font-weight="bold" text-anchor="middle" fill="white">#</text>
</svg>

After

Width:  |  Height:  |  Size: 246 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+24
View File
@@ -0,0 +1,24 @@
{
"name": "Randify — Random Value Generators",
"short_name": "Randify",
"description": "Simple tools for raffles, games, and everything that needs randomness.",
"start_url": "/",
"display": "standalone",
"background_color": "#09090b",
"theme_color": "#534AB7",
"orientation": "portrait-primary",
"scope": "/",
"lang": "en",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
+4
View File
@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://randify.pro/sitemap-index.xml
+56
View File
@@ -0,0 +1,56 @@
const CACHE_NAME = "randify-v1";
self.addEventListener("install", (event) => {
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(self.clients.claim());
});
self.addEventListener("fetch", (event) => {
const { request } = event;
const isNavigate = request.mode === "navigate";
const isSameOrigin = new URL(request.url).origin === self.location.origin;
if (!isSameOrigin) {
return;
}
if (isNavigate) {
// Network-first for HTML pages: fresh content when online,
// fallback to cache when offline.
event.respondWith(
fetch(request)
.then((response) => {
if (response && response.status === 200) {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
}
return response;
})
.catch(() => caches.match(request)),
);
} else {
// Cache-first for static assets (JS, CSS, SVG, fonts, etc.)
event.respondWith(
caches.match(request).then((cached) => {
if (cached) {
return cached;
}
return fetch(request).then((response) => {
if (
!response ||
response.status !== 200 ||
response.type !== "basic"
) {
return response;
}
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
return response;
});
}),
);
}
});
+8
View File
@@ -0,0 +1,8 @@
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</head>
<body>
Verification: dd9dad039a139021
</body>
</html>
+92
View File
@@ -0,0 +1,92 @@
---
interface Props {
size: "leaderboard" | "rectangle" | "tile";
}
const { size } = Astro.props;
const ads = [
{
href: "https://redirect.appmetrica.yandex.com/serve/29835370459904648?clid=15006577&appmetrica_js_redirect=0",
label: "Яндекс с Алисой — скачать бесплатно",
sublabel: "Голосовой ИИ-помощник для Android и iOS",
cta: "Установить",
ctaMobile: "Установить бесплатно",
},
{
href: "https://yandex.ru/project/browser/bonus/multioffer/affiliate_4prod?source=pWRP8eS1VsC2X59560&partner_string=P89XvN11U6RuE47077&cliddbro=15006579&clidmbro=15006578&cliddefault=15006570&clidpp=15006567",
label: "Яндекс Браузер — быстрый и безопасный",
sublabel: "Браузер с Алисой и защитой от угроз",
cta: "Скачать",
ctaMobile: "Скачать бесплатно",
},
];
const ad = ads[Math.floor(Math.random() * ads.length)];
const specs = {
leaderboard: { class: "w-full h-[90px]", horizontal: true },
rectangle: { class: "w-full max-w-[300px] h-[250px]", horizontal: false },
tile: { class: "w-full h-[250px]", horizontal: false },
};
const { class: sizeClass, horizontal } = specs[size];
---
<aside aria-label="Реклама">
<a
href={ad.href}
target="_blank"
rel="noopener sponsored"
class={`${sizeClass} flex ${horizontal ? "flex-row items-center gap-4 px-6" : "flex-col items-center justify-center gap-3 px-4"} rounded-xl border border-zinc-800 bg-zinc-900/50 hover:border-accent/50 hover:bg-zinc-900 transition-colors duration-200 group`}
>
<svg
class="shrink-0 text-yandex"
width={horizontal ? "32" : "40"}
height={horizontal ? "32" : "40"}
viewBox="0 0 40 40"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<circle cx="20" cy="20" r="20" fill="currentColor"></circle>
<path
d="M22.44 10H19.1c-3.78 0-6.1 2.02-6.1 5.32 0 2.66 1.26 4.24 3.64 5.8L13.5 30h3.38l3.4-8.38-.96-.58c-1.84-1.12-2.72-2.08-2.72-3.92 0-1.94 1.3-3.08 3.44-3.08h1.4V30h3V10z"
fill="white"></path>
</svg>
{
horizontal ? (
<>
<div class="flex-1 min-w-0">
<div class="text-xs font-medium text-zinc-500 uppercase tracking-widest mb-0.5">
Реклама
</div>
<div class="text-sm font-semibold text-zinc-100 group-hover:text-accent transition-colors leading-tight">
{ad.label}
</div>
<div class="text-xs text-zinc-500 mt-0.5">{ad.sublabel}</div>
</div>
<span class="shrink-0 text-xs font-semibold text-white bg-yandex px-3 py-1.5 rounded-lg group-hover:bg-yandex-hover transition-colors whitespace-nowrap">
{ad.cta}
</span>
</>
) : (
<>
<div class="text-center">
<div class="text-xs font-medium text-zinc-500 uppercase tracking-widest mb-2">
Реклама
</div>
<div class="text-base font-semibold text-zinc-100 group-hover:text-accent 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-yandex px-4 py-2 rounded-lg group-hover:bg-yandex-hover transition-colors">
{ad.ctaMobile}
</span>
</>
)
}
</a>
</aside>
+93
View File
@@ -0,0 +1,93 @@
---
import { analytics } from "@/data/config";
const yandexId = analytics.yandexMetrikaId;
const mailRuId = analytics.topMailRuId;
---
<!-- Yandex.Metrika counter -->
<script type="text/javascript" define:vars={{ yandexId }}>
/* eslint-disable */
(function (m, e, t, r, i, k, a) {
m[i] =
m[i] ||
function () {
(m[i].a = m[i].a || []).push(arguments);
};
m[i].l = 1 * new Date();
for (let j = 0; j < document.scripts.length; j++) {
if (document.scripts[j].src === r) {
return;
}
}
((k = e.createElement(t)),
(a = e.getElementsByTagName(t)[0]),
(k.async = 1),
(k.src = r),
a.parentNode.insertBefore(k, a));
})(
window,
document,
"script",
"https://mc.yandex.ru/metrika/tag.js?id=" + String(yandexId),
"ym",
);
ym(Number(yandexId), "init", {
ssr: true,
webvisor: true,
clickmap: true,
ecommerce: "dataLayer",
referrer: document.referrer,
url: location.href,
accurateTrackBounce: true,
trackLinks: true,
});
/* eslint-enable */
</script>
<noscript
><div>
<img
src={`https://mc.yandex.ru/watch/${yandexId}`}
style="position:absolute; left:-9999px;"
alt=""
/>
</div></noscript
>
<!-- /Yandex.Metrika counter -->
<!-- Top.Mail.Ru counter -->
<script type="text/javascript" define:vars={{ mailRuId }}>
const _tmr = window._tmr || (window._tmr = []);
_tmr.push({
id: String(mailRuId),
type: "pageView",
start: new Date().getTime(),
});
(function (d, w, id) {
if (d.getElementById(id)) return;
const ts = d.createElement("script");
ts.type = "text/javascript";
ts.async = true;
ts.id = id;
ts.src = "https://top-fwz1.mail.ru/js/code.js";
const f = function () {
const s = d.getElementsByTagName("script")[0];
s.parentNode.insertBefore(ts, s);
};
if (w.opera == "[object Opera]") {
d.addEventListener("DOMContentLoaded", f, false);
} else {
f();
}
})(document, window, "tmr-code");
</script>
<noscript
><div>
<img
src={`https://top-fwz1.mail.ru/counter?id=${mailRuId};js=na`}
style="position:absolute;left:-9999px;"
alt="Top.Mail.Ru"
/>
</div></noscript
>
<!-- /Top.Mail.Ru counter -->
+33
View File
@@ -0,0 +1,33 @@
---
import { useT } from "../i18n/translations";
import type { Lang } from "../i18n/translations";
interface Props {
questions: Array<{ q: string; a: string }>;
lang?: Lang;
}
const { questions, lang = "en" } = Astro.props;
const T = useT(lang);
---
<section class="mt-4">
<div class="rounded-xl bg-zinc-900/50 border border-zinc-800/60 p-5">
<h2
class="flex items-center gap-2 text-xs font-semibold uppercase tracking-widest text-zinc-400 mb-3"
>
<span class="w-1.5 h-1.5 rounded-full bg-accent" aria-hidden="true"></span>
{T.faq}
</h2>
<div class="space-y-3">
{
questions.map(({ q, a }) => (
<div>
<p class="font-medium text-zinc-300 text-sm">{q}</p>
<p class="text-zinc-400 text-sm mt-0.5">{a}</p>
</div>
))
}
</div>
</div>
</section>
+88
View File
@@ -0,0 +1,88 @@
---
import type { Generator } from "../data/generators";
import { useT } from "../i18n/translations";
import type { Lang } from "../i18n/translations";
interface Props {
generator: Generator;
lang?: Lang;
}
const { generator, lang: langProp } = Astro.props;
const lang = langProp || (Astro.currentLocale as Lang) || "en";
const T = useT(lang);
const { slug, icon, status } = generator;
const isLive = status === "live";
const isRu = lang === "ru";
const title = isRu ? generator.ruTitle : generator.title;
const description = isRu ? generator.ruDescription : generator.description;
const href = isRu ? `/ru/generators/${slug}/` : `/generators/${slug}/`;
const icons: Record<string, string> = {
hash: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" x2="20" y1="9" y2="9"/><line x1="4" x2="20" y1="15" y2="15"/><line x1="10" x2="8" y1="3" y2="21"/><line x1="16" x2="14" y1="3" y2="21"/></svg>`,
palette: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="13.5" cy="6.5" r=".5" fill="currentColor"/><circle cx="17.5" cy="10.5" r=".5" fill="currentColor"/><circle cx="8.5" cy="7.5" r=".5" fill="currentColor"/><circle cx="6.5" cy="12.5" r=".5" fill="currentColor"/><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z"/></svg>`,
lock: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>`,
ticket: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 9a3 3 0 0 1 0 6v2a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-2a3 3 0 0 1 0-6V7a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2Z"/><path d="M13 5v2"/><path d="M13 17v2"/><path d="M13 11v2"/></svg>`,
"dice-6": `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><path d="M16 8h.01"/><path d="M16 12h.01"/><path d="M16 16h.01"/><path d="M8 8h.01"/><path d="M8 12h.01"/><path d="M8 16h.01"/></svg>`,
"square-stack": `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 10c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h4c1.1 0 2 .9 2 2"/><path d="M10 16c-1.1 0-2-.9-2-2v-4c0-1.1.9-2 2-2h4c1.1 0 2 .9 2 2"/><rect width="8" height="8" x="14" y="14" rx="2"/></svg>`,
"circle-dollar-sign": `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8"/><path d="M12 18V6"/></svg>`,
list: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" x2="21" y1="6" y2="6"/><line x1="8" x2="21" y1="12" y2="12"/><line x1="8" x2="21" y1="18" y2="18"/><line x1="3" x2="3.01" y1="6" y2="6"/><line x1="3" x2="3.01" y1="12" y2="12"/><line x1="3" x2="3.01" y1="18" y2="18"/></svg>`,
fingerprint: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 10a2 2 0 0 0-2 2c0 1.02-.1 2.51-.26 4"/><path d="M14 13.12c0 2.38 0 6.38-1 8.88"/><path d="M17.29 21.02c.12-.6.43-2.3.5-3.02"/><path d="M2 12a10 10 0 0 1 18-6"/><path d="M2 17c1 .5 2.25 1 4 1 1.5 0 3-.5 4-1"/><path d="M20 12c0 2-.5 4.5-2 6"/><path d="M7 13.02c0 2.38 0 5.5 1 7.48"/><path d="M8.5 8.5A5 5 0 0 1 17 12"/></svg>`,
"pie-chart": `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.21 15.89A10 10 0 1 1 8 2.83"/><path d="M22 12A10 10 0 0 0 12 2v10z"/></svg>`,
"help-circle": `<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="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/></svg>`,
user: `<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="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>`,
users: `<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="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>`,
type: `<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"><polyline points="4 7 4 4 20 4 20 7"/><line x1="9" x2="15" y1="20" y2="20"/><line x1="12" x2="12" y1="4" y2="20"/></svg>`,
calendar: `<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="4" rx="2" ry="2"/><line x1="16" x2="16" y1="2" y2="6"/><line x1="8" x2="8" y1="2" y2="6"/><line x1="3" x2="21" y1="10" y2="10"/></svg>`,
hand: `<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="M18 11V6a2 2 0 0 0-2-2v0a2 2 0 0 0-2 2v0"/><path d="M14 10V4a2 2 0 0 0-2-2v0a2 2 0 0 0-2 2v2"/><path d="M10 10.5V6a2 2 0 0 0-2-2v0a2 2 0 0 0-2 2v8"/><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/></svg>`,
smile: `<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="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" x2="9.01" y1="9" y2="9"/><line x1="15" x2="15.01" y1="9" y2="9"/></svg>`,
paintbrush: `<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="m14.622 17.897-10.68-2.913"/><path d="M18.376 2.622a1 1 0 1 1 3.002 3.002L17.36 9.643a.5.5 0 0 0 0 .707l.944.944a2.41 2.41 0 0 1 0 3.408l-.944.944a.5.5 0 0 1-.707 0L8.354 7.348a.5.5 0 0 1 0-.707l.944-.944a2.41 2.41 0 0 1 3.408 0l.944.944a.5.5 0 0 0 .707 0z"/><path d="M9 8c-1.804 2.71-3.97 3.46-6.583 3.948a.507.507 0 0 0-.302.819l7.32 8.883a1 1 0 0 0 1.185.204C12.735 20.405 16 16.792 16 15"/></svg>`,
shuffle: `<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 18h1.4c1.3 0 2.5-.6 3.3-1.7l14.4-14.4"/><path d="M16 2h6v6"/><path d="M22 18l-5.8-5.8"/><path d="M22 18h-1.4c-1.3 0-2.5.6-3.3 1.7l-4 5"/><path d="M8 22h-6v-6"/><path d="m2 6 5.8 5.8"/></svg>`,
sparkles: `<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 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/><path d="M5 3v4"/><path d="M9 5H5"/><path d="M19 10v4"/><path d="M19 17h4"/><path d="M15 19h4"/></svg>`,
font: `<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 7V4h16v3"/><path d="M9 20h6"/><path d="M12 4v16"/></svg>`,
clock: `<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"/><polyline points="12 6 12 12 16 14"/></svg>`,
utensils: `<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="M3 2v7c0 1.1.9 2 2 2h4a2 2 0 0 0 2-2V2"/><path d="M7 2v20"/><path d="M21 15V2v0a5 5 0 0 0-5 5v6c0 1.1.9 2 2 2h3Zm0 0v7"/></svg>`,
};
---
{
isLive ? (
<a
href={href}
class="group block h-full border border-zinc-800 rounded-xl p-5 shadow-lg shadow-black/20 hover:border-accent hover:shadow-xl hover:shadow-black/30 hover:-translate-y-0.5 transition-colors transition-shadow transition-transform duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950"
>
<div class="flex items-start justify-between gap-3">
<div
class="inline-flex w-8 h-8 rounded-lg bg-accent/10 items-center justify-center text-accent shrink-0"
aria-hidden="true"
set:html={icons[icon] ?? icons["hash"]}
/>
<span class="text-xs font-medium text-accent bg-accent/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-accent transition-colors duration-150">
{title}
</h2>
<p class="mt-1 text-sm text-zinc-400 leading-relaxed">{description}</p>
</a>
) : (
<div
class="block h-full border border-zinc-800/50 rounded-xl p-5 shadow-lg shadow-black/10 opacity-40 cursor-default"
aria-label={`${title} — ${T.comingSoon}`}
>
<div class="flex items-start justify-between gap-3">
<div
class="inline-flex w-8 h-8 rounded-lg bg-zinc-800 items-center justify-center text-zinc-500 shrink-0"
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>
)
}
+57
View File
@@ -0,0 +1,57 @@
---
const currentPath = Astro.url.pathname;
const isRu = currentPath.startsWith("/ru");
const alternatePath = isRu
? currentPath.replace(/^\/ru/, "") || "/"
: "/ru" + currentPath;
---
<div
class="fixed top-3 right-3 z-50 flex items-center gap-1 bg-zinc-900/80 border border-zinc-800 rounded-lg px-1 py-1 backdrop-blur-sm text-xs font-semibold"
>
<a
href={isRu ? alternatePath : "#"}
id="lang-en"
class={`px-2 py-1 rounded-md transition-colors ${isRu ? "text-zinc-400 hover:text-zinc-200" : "bg-accent text-white"}`}
aria-current={isRu ? undefined : "true"}
data-target-lang="en"
data-alternate={isRu ? alternatePath : ""}>EN</a
>
<a
href={isRu ? "#" : alternatePath}
id="lang-ru"
class={`px-2 py-1 rounded-md transition-colors ${isRu ? "bg-accent text-white" : "text-zinc-400 hover:text-zinc-200"}`}
aria-current={isRu ? "true" : undefined}
data-target-lang="ru"
data-alternate={isRu ? "" : alternatePath}>RU</a
>
<span class="w-px h-3 bg-zinc-700 mx-0.5" aria-hidden="true"></span>
<a
href={isRu ? "/ru/about/" : "/about/"}
class="px-2 py-1 rounded-md transition-colors text-zinc-400 hover:text-zinc-200"
>
{isRu ? "О проекте" : "About"}
</a>
<span class="w-px h-3 bg-zinc-700 mx-0.5" aria-hidden="true"></span>
<a
href={isRu ? "/ru/privacy/" : "/privacy/"}
class="px-2 py-1 rounded-md transition-colors text-zinc-400 hover:text-zinc-200"
>
{isRu ? "Конфиденциальность" : "Privacy"}
</a>
</div>
<script>
document
.querySelectorAll<HTMLAnchorElement>("#lang-en, #lang-ru")
.forEach((el) => {
el.addEventListener("click", (e) => {
const targetLang = el.dataset.targetLang!;
const alternate = el.dataset.alternate!;
if (!alternate || alternate === "#") return;
e.preventDefault();
localStorage.setItem("lang-pref", targetLang);
location.href = alternate;
});
});
</script>
+40
View File
@@ -0,0 +1,40 @@
---
import { useT } from "../i18n/translations";
import type { Lang } from "../i18n/translations";
interface Props {
howTo: string[];
whenTo: string[];
lang?: Lang;
}
const { howTo, whenTo, lang = "en" } = Astro.props;
const T = useT(lang);
---
<section
class="mt-12 border-t border-zinc-800/60 pt-8 text-sm text-zinc-500 flex flex-col gap-4"
>
<div class="rounded-xl bg-zinc-900/50 border border-zinc-800/60 p-5">
<h2
class="flex items-center gap-2 text-xs font-semibold uppercase tracking-widest text-zinc-400 mb-2"
>
<span class="w-1.5 h-1.5 rounded-full bg-accent" aria-hidden="true"></span>
{T.howToUse}
</h2>
<ol class="space-y-1 list-decimal list-inside">
{howTo.map((step) => <li>{step}</li>)}
</ol>
</div>
<div class="rounded-xl bg-zinc-900/50 border border-zinc-800/60 p-5">
<h2
class="flex items-center gap-2 text-xs font-semibold uppercase tracking-widest text-zinc-400 mb-2"
>
<span class="w-1.5 h-1.5 rounded-full bg-accent" aria-hidden="true"></span>
{T.whenToUse}
</h2>
<ul class="space-y-1 list-disc list-inside">
{whenTo.map((item) => <li>{item}</li>)}
</ul>
</div>
</section>
+97
View File
@@ -0,0 +1,97 @@
<canvas
id="starfield"
class="fixed inset-0 w-full h-full pointer-events-none"
style="z-index: 0;"
aria-hidden="true"
></canvas>
<script>
(function () {
const canvas = document.getElementById("starfield") as HTMLCanvasElement;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
let width = 0;
let height = 0;
let stars: { x: number; y: number; size: number; speed: number; opacity: number; phase: number }[] = [];
let animationId: number;
const STAR_COUNT = 180;
const TWINKLE_SPEED = 0.0008;
function resize() {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
width = window.innerWidth;
height = window.innerHeight;
canvas.width = width * dpr;
canvas.height = height * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function initStars() {
stars = [];
for (let i = 0; i < STAR_COUNT; i++) {
stars.push({
x: Math.random() * width,
y: Math.random() * height,
size: Math.random() < 0.95 ? 1 : Math.random() < 0.7 ? 1.5 : 2,
speed: 0.02 + Math.random() * 0.04,
opacity: 0.15 + Math.random() * 0.55,
phase: Math.random() * Math.PI * 2,
});
}
}
function draw(time: number) {
ctx.clearRect(0, 0, width, height);
for (const star of stars) {
const twinkle =
0.6 + 0.4 * Math.sin(time * TWINKLE_SPEED + star.phase);
const alpha = star.opacity * twinkle;
ctx.beginPath();
ctx.arc(star.x, star.y, star.size, 0, Math.PI * 2);
ctx.fillStyle = `rgba(210, 210, 220, ${alpha})`;
ctx.fill();
star.y -= star.speed;
if (star.y < -2) {
star.y = height + 2;
star.x = Math.random() * width;
}
}
animationId = requestAnimationFrame(draw);
}
function start() {
resize();
initStars();
animationId = requestAnimationFrame(draw);
}
function stop() {
cancelAnimationFrame(animationId);
}
start();
window.addEventListener("resize", () => {
stop();
resize();
initStars();
start();
});
document.addEventListener("visibilitychange", () => {
if (document.hidden) {
stop();
} else {
start();
}
});
})();
</script>
+17
View File
@@ -0,0 +1,17 @@
<!-- Yandex.RTB R-A-19259130-1 -->
<div
id="yandex_rtb_R-A-19259130-1"
class="min-h-[90px] w-full flex items-center justify-center rounded-lg bg-zinc-900/30"
>
<span class="text-xs text-zinc-600 uppercase tracking-widest select-none"
>Advertisement</span
>
</div>
<script>
window.yaContextCb.push(() => {
Ya.Context.AdvManager.render({
blockId: "R-A-19259130-1",
renderTo: "yandex_rtb_R-A-19259130-1",
});
});
</script>
@@ -0,0 +1,264 @@
---
import { useT } from "../../i18n/translations";
const isRu = Astro.url.pathname.startsWith("/ru");
const T = useT(isRu ? "ru" : "en");
---
<div id="card-generator" class="mt-8">
<div class="rounded-xl bg-zinc-900/50 border border-zinc-800/60 p-5">
<div class="flex flex-col sm:flex-row gap-4 items-end">
<div class="flex-1">
<label
for="crd-count"
class="block text-sm font-medium text-zinc-400 mb-1"
>{T.cardsToDraw}</label
>
<input
id="crd-count"
type="number"
value="1"
min="1"
max="52"
class="w-full bg-zinc-900 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 text-base focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent transition-colors"
aria-label={T.cardsToDraw}
/>
</div>
<label
class="flex items-center gap-2.5 cursor-pointer select-none group pb-2"
>
<input
id="crd-replace"
type="checkbox"
class="w-4 h-4 rounded accent-accent cursor-pointer"
/>
<span
class="text-sm text-zinc-400 group-hover:text-zinc-200 transition-colors"
>{T.allowDuplicates}</span
>
</label>
</div>
</div>
<p
id="crd-error"
role="alert"
aria-live="polite"
class="mt-3 text-sm text-red-500 hidden"
>
</p>
<div class="my-6 rounded-2xl bg-zinc-900/80 border border-zinc-800/80 p-8 h-52 overflow-hidden flex flex-col items-center justify-center gap-4">
<div
id="crd-cards"
class="flex flex-wrap justify-center gap-2"
aria-live="polite"
aria-label="Drawn cards"
>
</div>
<button
id="crd-copy-btn"
type="button"
aria-label="Copy cards to clipboard"
class="invisible inline-flex items-center gap-1.5 text-xs font-medium text-zinc-500 hover:text-zinc-200 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 rounded"
>
<span id="crd-copy-label">{T.copy}</span>
<span id="crd-copy-icon" aria-hidden="true">
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect>
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"
></path>
</svg>
</span>
<span
id="crd-check-icon"
class="hidden text-accent"
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-accent text-white font-semibold rounded-xl shadow-lg shadow-accent/25 hover:bg-accent-hover active:bg-accent-active focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors duration-100 cursor-pointer"
>
{T.draw}
</button>
</div>
</div>
<script>
import { CopyFeedback } from "@/lib/client/clipboard";
import { createErrorDisplay } from "@/lib/client/validation";
const isRu = document.documentElement.lang === "ru";
const COPY_LABEL = isRu ? "Копировать" : "Copy";
const COPIED_LABEL = isRu ? "Скопировано" : "Copied";
const ERR_AT_LEAST = isRu
? "Вытащите хотя бы 1 карту."
: "Draw at least 1 card.";
const ERR_UNIQUE = isRu
? "Нельзя вытащить более 52 уникальных карт."
: "Cannot draw more than 52 unique cards.";
const ERR_MAX52 = isRu
? "Максимум 52 карты одновременно."
: "Maximum 52 cards at once.";
const countInput = document.getElementById("crd-count") as HTMLInputElement;
const replaceCb = document.getElementById("crd-replace") as HTMLInputElement;
const btn = document.getElementById("crd-btn") as HTMLButtonElement;
const copyBtn = document.getElementById("crd-copy-btn") as HTMLButtonElement;
const cardsEl = document.getElementById("crd-cards") as HTMLDivElement;
const errorEl = document.getElementById("crd-error") as HTMLParagraphElement;
const copyLabel = document.getElementById(
"crd-copy-label",
) as HTMLSpanElement;
const copyIcon = document.getElementById("crd-copy-icon") as HTMLSpanElement;
const checkIcon = document.getElementById(
"crd-check-icon",
) as HTMLSpanElement;
const RANKS = [
"A",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
"10",
"J",
"Q",
"K",
];
const SUITS = [
{ symbol: "♠", label: "Spades", red: false },
{ symbol: "♥", label: "Hearts", red: true },
{ symbol: "♦", label: "Diamonds", red: true },
{ symbol: "♣", label: "Clubs", red: false },
];
const DECK = SUITS.flatMap((suit) => RANKS.map((rank) => ({ rank, suit })));
let lastCards: { rank: string; suit: (typeof SUITS)[number] }[] = [];
const errors = createErrorDisplay(errorEl);
const clipboard = new CopyFeedback(copyIcon, checkIcon);
function draw() {
const count = parseInt(countInput.value, 10);
const withReplacement = replaceCb.checked;
if (!Number.isInteger(count) || count < 1) {
errors.show(ERR_AT_LEAST);
return;
}
if (!withReplacement && count > 52) {
errors.show(ERR_UNIQUE);
return;
}
if (count > 52) {
errors.show(ERR_MAX52);
return;
}
errors.clear();
if (withReplacement) {
lastCards = Array.from(
{ length: count },
() => DECK[Math.floor(Math.random() * DECK.length)],
);
} else {
const deck = [...DECK];
for (let i = 0; i < count; i++) {
const j = i + Math.floor(Math.random() * (deck.length - i));
[deck[i], deck[j]] = [deck[j], deck[i]];
}
lastCards = deck.slice(0, count);
}
renderCards();
copyBtn.classList.remove("invisible");
copyLabel.textContent = COPY_LABEL;
}
function renderCards() {
cardsEl.innerHTML = "";
lastCards.forEach(({ rank, suit }) => {
const card = document.createElement("div");
card.setAttribute("aria-label", `${rank} of ${suit.label}`);
card.className = [
"flex flex-col items-center justify-between w-14 h-20 rounded-lg border px-1.5 py-1.5",
"bg-zinc-900 border-zinc-700 select-none",
suit.red ? "text-red-400" : "text-zinc-100",
].join(" ");
const top = document.createElement("div");
top.className =
"w-full text-left text-sm font-bold leading-none tabular-nums";
top.textContent = rank;
const mid = document.createElement("div");
mid.className = "text-2xl leading-none";
mid.textContent = suit.symbol;
const bot = document.createElement("div");
bot.className =
"w-full text-right text-sm font-bold leading-none tabular-nums rotate-180";
bot.textContent = rank;
card.append(top, mid, bot);
cardsEl.appendChild(card);
});
}
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>
@@ -0,0 +1,185 @@
---
import { useT } from "../../i18n/translations";
const isRu = Astro.url.pathname.startsWith("/ru");
const T = useT(isRu ? "ru" : "en");
---
<div id="coin-generator" class="mt-8">
<div class="rounded-xl bg-zinc-900/50 border border-zinc-800/60 p-5">
<div class="flex items-center justify-between mb-1">
<label for="coin-count" class="text-sm font-medium text-zinc-400"
>{T.numberOfCoins}</label
>
<span
id="coin-count-val"
class="text-sm font-semibold tabular-nums text-zinc-100">1</span
>
</div>
<input
id="coin-count"
type="range"
min="1"
max="20"
value="1"
class="w-full accent-accent cursor-pointer"
aria-label={T.numberOfCoins}
/>
<div class="flex justify-between text-xs text-zinc-600 mt-1 select-none">
<span>1</span><span>20</span>
</div>
</div>
<div class="my-6 rounded-2xl bg-zinc-900/80 border border-zinc-800/80 p-8 h-52 overflow-hidden flex flex-col items-center justify-center gap-4">
<div
id="coin-coins"
class="flex flex-wrap justify-center gap-2"
aria-live="polite"
aria-label="Coin results"
>
</div>
<div id="coin-summary" class="hidden text-sm text-zinc-500 tabular-nums">
</div>
<button
id="coin-copy-btn"
type="button"
aria-label="Copy results to clipboard"
class="invisible inline-flex items-center gap-1.5 text-xs font-medium text-zinc-500 hover:text-zinc-200 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 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-accent"
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-accent text-white font-semibold rounded-xl shadow-lg shadow-accent/25 hover:bg-accent-hover active:bg-accent-active focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors duration-100 cursor-pointer"
>
{T.flip}
</button>
</div>
</div>
<script>
import { CopyFeedback } from "@/lib/client/clipboard";
const isRu = document.documentElement.lang === "ru";
const COPY_LABEL = isRu ? "Копировать" : "Copy";
const COPIED_LABEL = isRu ? "Скопировано" : "Copied";
const HEADS_LABEL = isRu ? "О" : "H";
const TAILS_LABEL = isRu ? "Р" : "T";
const HEADS_WORD = isRu ? "орёл" : "Heads";
const TAILS_WORD = isRu ? "решка" : "Tails";
const HEADS_SUMMARY = isRu ? "орёл" : "heads";
const TAILS_SUMMARY = isRu ? "решка" : "tails";
const countInput = document.getElementById("coin-count") as HTMLInputElement;
const countVal = document.getElementById("coin-count-val") as HTMLSpanElement;
const btn = document.getElementById("coin-btn") as HTMLButtonElement;
const copyBtn = document.getElementById("coin-copy-btn") as HTMLButtonElement;
const coinsEl = document.getElementById("coin-coins") as HTMLDivElement;
const summaryEl = document.getElementById("coin-summary") as HTMLDivElement;
const copyLabel = document.getElementById(
"coin-copy-label",
) as HTMLSpanElement;
const copyIcon = document.getElementById("coin-copy-icon") as HTMLSpanElement;
const checkIcon = document.getElementById(
"coin-check-icon",
) as HTMLSpanElement;
let lastResults: boolean[] = [];
const clipboard = new CopyFeedback(copyIcon, checkIcon);
countInput.addEventListener("input", () => {
countVal.textContent = countInput.value;
});
function flip() {
const count = parseInt(countInput.value, 10);
lastResults = Array.from({ length: count }, () => Math.random() < 0.5);
const heads = lastResults.filter(Boolean).length;
const tails = count - heads;
coinsEl.innerHTML = "";
lastResults.forEach((isHeads) => {
const coin = document.createElement("div");
coin.setAttribute("aria-label", isHeads ? HEADS_WORD : TAILS_WORD);
coin.className = [
"flex flex-col items-center justify-center w-14 h-14 rounded-full border-2 font-bold text-xs select-none",
isHeads
? "border-accent bg-accent/10 text-accent"
: "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() {
if (!lastResults.length) return;
const text = lastResults
.map((h) => (h ? HEADS_WORD : TAILS_WORD))
.join(", ");
await navigator.clipboard.writeText(text);
clipboard.showCopied();
copyLabel.textContent = COPIED_LABEL;
setTimeout(() => {
copyLabel.textContent = COPY_LABEL;
}, 1500);
}
btn.addEventListener("click", flip);
copyBtn.addEventListener("click", copyResults);
</script>
@@ -0,0 +1,184 @@
---
import { useT } from "../../i18n/translations";
const isRu = Astro.url.pathname.startsWith("/ru");
const T = useT(isRu ? "ru" : "en");
---
<div id="color-generator" class="mt-8">
<div class="my-6 rounded-2xl bg-zinc-900/80 border border-zinc-800/80 p-8">
<!-- Swatch -->
<div class="flex justify-center mb-8">
<div
id="cg-swatch"
class="w-44 h-44 rounded-2xl border border-zinc-800"
style="background-color: #1f1f22; transition: background-color 0.35s cubic-bezier(0.4,0,0.2,1);"
aria-hidden="true"
>
</div>
</div>
<!-- Format rows -->
<div
id="cg-values"
class="mb-8 divide-y divide-zinc-800 border border-zinc-800 rounded-xl overflow-hidden opacity-0"
style="transition: opacity 0.25s ease;"
aria-live="polite"
>
{
[
{ id: "cg-hex", label: "HEX" },
{ id: "cg-rgb", label: "RGB" },
{ id: "cg-hsl", label: "HSL" },
].map(({ id, label }) => (
<div class="flex items-center gap-3 px-4 py-3 bg-zinc-900/40">
<span class="w-10 text-xs font-semibold uppercase tracking-widest text-zinc-500 shrink-0">
{label}
</span>
<span
id={id}
class="flex-1 font-mono text-sm text-zinc-100 tabular-nums select-all"
/>
<button
id={`${id}-copy`}
type="button"
aria-label={`Copy ${label} value`}
class="group shrink-0 p-1 rounded text-zinc-600 hover:text-zinc-300 focus:outline-none focus-visible:ring-1 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors cursor-pointer"
>
<svg
id={`${id}-copy-icon`}
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
</svg>
<svg
id={`${id}-check-icon`}
class="hidden text-accent"
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 -->
<div class="flex justify-center">
<button
id="cg-btn"
type="button"
class="w-full sm:w-auto px-8 py-3 bg-accent text-white font-semibold rounded-xl shadow-lg shadow-accent/25 hover:bg-accent-hover active:bg-accent-active focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors duration-100 cursor-pointer"
>
{T.generate}
</button>
</div>
</div>
<script>
const swatch = document.getElementById("cg-swatch") as HTMLDivElement;
const valuesEl = document.getElementById("cg-values") as HTMLDivElement;
const hexEl = document.getElementById("cg-hex") as HTMLSpanElement;
const rgbEl = document.getElementById("cg-rgb") as HTMLSpanElement;
const hslEl = document.getElementById("cg-hsl") as HTMLSpanElement;
const btn = document.getElementById("cg-btn") as HTMLButtonElement;
function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
const rn = r / 255,
gn = g / 255,
bn = b / 255;
const max = Math.max(rn, gn, bn),
min = Math.min(rn, gn, bn);
const l = (max + min) / 2;
if (max === min) return [0, 0, Math.round(l * 100)];
const d = max - min;
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
let h;
if (max === rn) h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6;
else if (max === gn) h = ((bn - rn) / d + 2) / 6;
else h = ((rn - gn) / d + 4) / 6;
return [Math.round(h * 360), Math.round(s * 100), Math.round(l * 100)];
}
function toHex(n: number) {
return n.toString(16).padStart(2, "0");
}
function makeCopyHandler(
el: HTMLSpanElement,
copyIconId: string,
checkIconId: string,
) {
const copyIcon = document.getElementById(copyIconId)!;
const checkIcon = document.getElementById(checkIconId)!;
let timer: ReturnType<typeof setTimeout> | null = null;
return async () => {
const value = el.textContent?.trim();
if (!value) return;
await navigator.clipboard.writeText(value);
copyIcon.classList.add("hidden");
checkIcon.classList.remove("hidden");
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
checkIcon.classList.add("hidden");
copyIcon.classList.remove("hidden");
}, 1500);
};
}
document
.getElementById("cg-hex-copy")!
.addEventListener(
"click",
makeCopyHandler(hexEl, "cg-hex-copy-icon", "cg-hex-check-icon"),
);
document
.getElementById("cg-rgb-copy")!
.addEventListener(
"click",
makeCopyHandler(rgbEl, "cg-rgb-copy-icon", "cg-rgb-check-icon"),
);
document
.getElementById("cg-hsl-copy")!
.addEventListener(
"click",
makeCopyHandler(hslEl, "cg-hsl-copy-icon", "cg-hsl-check-icon"),
);
function generate() {
const r = Math.floor(Math.random() * 256);
const g = Math.floor(Math.random() * 256);
const b = Math.floor(Math.random() * 256);
const [h, s, l] = rgbToHsl(r, g, b);
const hex = `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase();
swatch.style.backgroundColor = hex;
hexEl.textContent = hex;
rgbEl.textContent = `rgb(${r}, ${g}, ${b})`;
hslEl.textContent = `hsl(${h}, ${s}%, ${l}%)`;
valuesEl.style.opacity = "1";
}
btn.addEventListener("click", generate);
</script>
@@ -0,0 +1,249 @@
---
import { useT } from "../../i18n/translations";
const isRu = Astro.url.pathname.startsWith("/ru");
const T = useT(isRu ? "ru" : "en");
const regions = isRu
? [
{ value: "all", label: "Все" },
{ value: "europe", label: "Европа" },
{ value: "asia", label: "Азия" },
{ value: "americas", label: "Америка" },
{ value: "africa", label: "Африка" },
{ value: "oceania", label: "Океания" },
]
: [
{ value: "all", label: "All" },
{ value: "europe", label: "Europe" },
{ value: "asia", label: "Asia" },
{ value: "americas", label: "Americas" },
{ value: "africa", label: "Africa" },
{ value: "oceania", label: "Oceania" },
];
---
<div id="country-generator" class="mt-8">
<!-- Filter -->
<div class="mb-6 flex flex-wrap items-center gap-3">
<label for="cg-region" class="text-sm font-medium text-zinc-300">
{isRu ? "Регион" : "Region"}
</label>
<select
id="cg-region"
class="px-3 py-2 rounded-lg bg-zinc-900 border border-zinc-800 text-sm text-zinc-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 cursor-pointer"
>
{regions.map((r) => <option value={r.value}>{r.label}</option>)}
</select>
</div>
<!-- Result card -->
<div
id="cg-result"
class="rounded-2xl bg-zinc-900/80 border border-zinc-800/80 p-8 opacity-0"
style="transition: opacity 0.25s ease;"
aria-live="polite"
>
<div class="flex flex-col items-center text-center">
<div
id="cg-flag"
class="text-7xl leading-none mb-4"
aria-hidden="true"
/>
<h3
id="cg-name"
class="text-2xl font-bold text-zinc-100 mb-1"
/>
<div class="flex flex-wrap items-center justify-center gap-2 text-sm text-zinc-400 mt-2">
<span id="cg-capital-label" class="text-zinc-500">
{isRu ? "Столица:" : "Capital:"}
</span>
<span id="cg-capital" class="text-zinc-200 font-medium" />
<span class="text-zinc-600">•</span>
<span id="cg-region-label" class="text-zinc-500">
{isRu ? "Регион:" : "Region:"}
</span>
<span id="cg-region-display" class="text-zinc-200 font-medium" />
<span class="text-zinc-600">•</span>
<span id="cg-population-label" class="text-zinc-500">
{isRu ? "Население:" : "Population:"}
</span>
<span id="cg-population" class="text-zinc-200 font-medium" />
</div>
<!-- Copy button -->
<button
id="cg-copy"
type="button"
class="mt-6 inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-zinc-800 text-zinc-300 hover:text-zinc-100 hover:bg-zinc-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors cursor-pointer"
>
<svg
id="cg-copy-icon"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
</svg>
<svg
id="cg-check-icon"
class="hidden text-accent"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20 6 9 17l-5-5" />
</svg>
<span id="cg-copy-text">{T.copy}</span>
</button>
</div>
</div>
<!-- Generate button -->
<div class="flex justify-center mt-8">
<button
id="cg-btn"
type="button"
class="w-full sm:w-auto px-8 py-3 bg-accent text-white font-semibold rounded-xl shadow-lg shadow-accent/25 hover:bg-accent-hover active:bg-accent-active focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors duration-100 cursor-pointer"
>
{T.generate}
</button>
</div>
</div>
<script>
interface Country {
name: string;
ruName: string;
capital: string;
ruCapital: string;
region: string;
ruRegion: string;
population: number;
flagEmoji: string;
}
const countries: Country[] = [
{ name: "France", ruName: "Франция", capital: "Paris", ruCapital: "Париж", region: "europe", ruRegion: "Европа", population: 68000000, flagEmoji: "🇫🇷" },
{ name: "Germany", ruName: "Германия", capital: "Berlin", ruCapital: "Берлин", region: "europe", ruRegion: "Европа", population: 83000000, flagEmoji: "🇩🇪" },
{ name: "Italy", ruName: "Италия", capital: "Rome", ruCapital: "Рим", region: "europe", ruRegion: "Европа", population: 59000000, flagEmoji: "🇮🇹" },
{ name: "Spain", ruName: "Испания", capital: "Madrid", ruCapital: "Мадрид", region: "europe", ruRegion: "Европа", population: 47000000, flagEmoji: "🇪🇸" },
{ name: "United Kingdom", ruName: "Великобритания", capital: "London", ruCapital: "Лондон", region: "europe", ruRegion: "Европа", population: 67000000, flagEmoji: "🇬🇧" },
{ name: "Japan", ruName: "Япония", capital: "Tokyo", ruCapital: "Токио", region: "asia", ruRegion: "Азия", population: 125000000, flagEmoji: "🇯🇵" },
{ name: "China", ruName: "Китай", capital: "Beijing", ruCapital: "Пекин", region: "asia", ruRegion: "Азия", population: 1410000000, flagEmoji: "🇨🇳" },
{ name: "India", ruName: "Индия", capital: "New Delhi", ruCapital: "Нью-Дели", region: "asia", ruRegion: "Азия", population: 1380000000, flagEmoji: "🇮🇳" },
{ name: "South Korea", ruName: "Южная Корея", capital: "Seoul", ruCapital: "Сеул", region: "asia", ruRegion: "Азия", population: 52000000, flagEmoji: "🇰🇷" },
{ name: "Thailand", ruName: "Таиланд", capital: "Bangkok", ruCapital: "Бангкок", region: "asia", ruRegion: "Азия", population: 70000000, flagEmoji: "🇹🇭" },
{ name: "United States", ruName: "США", capital: "Washington, D.C.", ruCapital: "Вашингтон", region: "americas", ruRegion: "Америка", population: 331000000, flagEmoji: "🇺🇸" },
{ name: "Brazil", ruName: "Бразилия", capital: "Brasília", ruCapital: "Бразилиа", region: "americas", ruRegion: "Америка", population: 213000000, flagEmoji: "🇧🇷" },
{ name: "Canada", ruName: "Канада", capital: "Ottawa", ruCapital: "Оттава", region: "americas", ruRegion: "Америка", population: 38000000, flagEmoji: "🇨🇦" },
{ name: "Mexico", ruName: "Мексика", capital: "Mexico City", ruCapital: "Мехико", region: "americas", ruRegion: "Америка", population: 126000000, flagEmoji: "🇲🇽" },
{ name: "Argentina", ruName: "Аргентина", capital: "Buenos Aires", ruCapital: "Буэнос-Айрес", region: "americas", ruRegion: "Америка", population: 45000000, flagEmoji: "🇦🇷" },
{ name: "Egypt", ruName: "Египет", capital: "Cairo", ruCapital: "Каир", region: "africa", ruRegion: "Африка", population: 102000000, flagEmoji: "🇪🇬" },
{ name: "South Africa", ruName: "ЮАР", capital: "Pretoria", ruCapital: "Претория", region: "africa", ruRegion: "Африка", population: 59000000, flagEmoji: "🇿🇦" },
{ name: "Nigeria", ruName: "Нигерия", capital: "Abuja", ruCapital: "Абуджа", region: "africa", ruRegion: "Африка", population: 206000000, flagEmoji: "🇳🇬" },
{ name: "Kenya", ruName: "Кения", capital: "Nairobi", ruCapital: "Найроби", region: "africa", ruRegion: "Африка", population: 54000000, flagEmoji: "🇰🇪" },
{ name: "Morocco", ruName: "Марокко", capital: "Rabat", ruCapital: "Рабат", region: "africa", ruRegion: "Африка", population: 37000000, flagEmoji: "🇲🇦" },
{ name: "Australia", ruName: "Австралия", capital: "Canberra", ruCapital: "Канберра", region: "oceania", ruRegion: "Океания", population: 26000000, flagEmoji: "🇦🇺" },
{ name: "New Zealand", ruName: "Новая Зеландия", capital: "Wellington", ruCapital: "Веллингтон", region: "oceania", ruRegion: "Океания", population: 5000000, flagEmoji: "🇳🇿" },
{ name: "Russia", ruName: "Россия", capital: "Moscow", ruCapital: "Москва", region: "europe", ruRegion: "Европа", population: 146000000, flagEmoji: "🇷🇺" },
{ name: "Turkey", ruName: "Турция", capital: "Ankara", ruCapital: "Анкара", region: "asia", ruRegion: "Азия", population: 84000000, flagEmoji: "🇹🇷" },
{ name: "Saudi Arabia", ruName: "Саудовская Аравия", capital: "Riyadh", ruCapital: "Эр-Рияд", region: "asia", ruRegion: "Азия", population: 35000000, flagEmoji: "🇸🇦" },
{ name: "Indonesia", ruName: "Индонезия", capital: "Jakarta", ruCapital: "Джакарта", region: "asia", ruRegion: "Азия", population: 274000000, flagEmoji: "🇮🇩" },
{ name: "Vietnam", ruName: "Вьетнам", capital: "Hanoi", ruCapital: "Ханой", region: "asia", ruRegion: "Азия", population: 97000000, flagEmoji: "🇻🇳" },
{ name: "Colombia", ruName: "Колумбия", capital: "Bogotá", ruCapital: "Богота", region: "americas", ruRegion: "Америка", population: 50000000, flagEmoji: "🇨🇴" },
{ name: "Peru", ruName: "Перу", capital: "Lima", ruCapital: "Лима", region: "americas", ruRegion: "Америка", population: 33000000, flagEmoji: "🇵🇪" },
{ name: "Chile", ruName: "Чили", capital: "Santiago", ruCapital: "Сантьяго", region: "americas", ruRegion: "Америка", population: 19000000, flagEmoji: "🇨🇱" },
{ name: "Ethiopia", ruName: "Эфиопия", capital: "Addis Ababa", ruCapital: "Аддис-Абеба", region: "africa", ruRegion: "Африка", population: 115000000, flagEmoji: "🇪🇹" },
{ name: "Ghana", ruName: "Гана", capital: "Accra", ruCapital: "Аккра", region: "africa", ruRegion: "Африка", population: 31000000, flagEmoji: "🇬🇭" },
{ name: "Tanzania", ruName: "Танзания", capital: "Dodoma", ruCapital: "Додома", region: "africa", ruRegion: "Африка", population: 61000000, flagEmoji: "🇹🇿" },
{ name: "Sweden", ruName: "Швеция", capital: "Stockholm", ruCapital: "Стокгольм", region: "europe", ruRegion: "Европа", population: 10000000, flagEmoji: "🇸🇪" },
{ name: "Norway", ruName: "Норвегия", capital: "Oslo", ruCapital: "Осло", region: "europe", ruRegion: "Европа", population: 5400000, flagEmoji: "🇳🇴" },
{ name: "Netherlands", ruName: "Нидерланды", capital: "Amsterdam", ruCapital: "Амстердам", region: "europe", ruRegion: "Европа", population: 17400000, flagEmoji: "🇳🇱" },
{ name: "Poland", ruName: "Польша", capital: "Warsaw", ruCapital: "Варшава", region: "europe", ruRegion: "Европа", population: 38000000, flagEmoji: "🇵🇱" },
{ name: "Greece", ruName: "Греция", capital: "Athens", ruCapital: "Афины", region: "europe", ruRegion: "Европа", population: 10700000, flagEmoji: "🇬🇷" },
{ name: "Portugal", ruName: "Португалия", capital: "Lisbon", ruCapital: "Лиссабон", region: "europe", ruRegion: "Европа", population: 10300000, flagEmoji: "🇵🇹" },
];
const regionSelect = document.getElementById("cg-region") as HTMLSelectElement;
const resultEl = document.getElementById("cg-result") as HTMLDivElement;
const flagEl = document.getElementById("cg-flag") as HTMLDivElement;
const nameEl = document.getElementById("cg-name") as HTMLHeadingElement;
const capitalEl = document.getElementById("cg-capital") as HTMLSpanElement;
const regionEl = document.getElementById("cg-region-display") as HTMLSpanElement;
const populationEl = document.getElementById("cg-population") as HTMLSpanElement;
const btn = document.getElementById("cg-btn") as HTMLButtonElement;
const copyBtn = document.getElementById("cg-copy") as HTMLButtonElement;
const copyIcon = document.getElementById("cg-copy-icon") as SVGElement;
const checkIcon = document.getElementById("cg-check-icon") as SVGElement;
const copyText = document.getElementById("cg-copy-text") as HTMLSpanElement;
const isRu = document.documentElement.lang === "ru";
function formatPopulation(n: number): string {
if (n >= 1_000_000_000) return (n / 1_000_000_000).toFixed(1).replace(/\.0$/, "") + "B";
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1).replace(/\.0$/, "") + "M";
if (n >= 1_000) return (n / 1_000).toFixed(1).replace(/\.0$/, "") + "K";
return n.toString();
}
function getFilteredCountries(): Country[] {
const region = regionSelect.value;
if (region === "all") return countries;
return countries.filter((c) => c.region === region);
}
function generate() {
const list = getFilteredCountries();
if (list.length === 0) return;
const country = list[Math.floor(Math.random() * list.length)];
flagEl.textContent = country.flagEmoji;
nameEl.textContent = isRu ? country.ruName : country.name;
capitalEl.textContent = isRu ? country.ruCapital : country.capital;
regionEl.textContent = isRu ? country.ruRegion : country.region.charAt(0).toUpperCase() + country.region.slice(1);
populationEl.textContent = formatPopulation(country.population);
resultEl.style.opacity = "1";
}
let copyTimer: ReturnType<typeof setTimeout> | null = null;
copyBtn.addEventListener("click", async () => {
const flag = flagEl.textContent?.trim() || "";
const name = nameEl.textContent?.trim() || "";
const capital = capitalEl.textContent?.trim() || "";
const region = regionEl.textContent?.trim() || "";
const population = populationEl.textContent?.trim() || "";
if (!name) return;
const text = `${flag} ${name} — ${capital} — ${region} — ${population} ${isRu ? "чел." : "people"}`;
await navigator.clipboard.writeText(text);
copyIcon.classList.add("hidden");
checkIcon.classList.remove("hidden");
copyText.textContent = isRu ? "Скопировано" : "Copied";
if (copyTimer) clearTimeout(copyTimer);
copyTimer = setTimeout(() => {
checkIcon.classList.add("hidden");
copyIcon.classList.remove("hidden");
copyText.textContent = isRu ? "Копировать" : "Copy";
}, 1500);
});
btn.addEventListener("click", generate);
</script>
@@ -0,0 +1,193 @@
---
import { useT } from "../../i18n/translations";
const isRu = Astro.url.pathname.startsWith("/ru");
const T = useT(isRu ? "ru" : "en");
---
<div id="date-generator" class="mt-8" data-ru={isRu ? "1" : "0"}>
<div class="rounded-xl bg-zinc-900/50 border border-zinc-800/60 p-5">
<div class="flex flex-col sm:flex-row gap-4">
<div class="flex-1">
<label for="dg-from" class="block text-sm font-medium text-zinc-400 mb-1"
>{T.from}</label
>
<input
id="dg-from"
type="date"
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-accent focus:ring-1 focus:ring-accent transition-colors"
/>
</div>
<div class="flex-1">
<label for="dg-to" class="block text-sm font-medium text-zinc-400 mb-1"
>{T.to}</label
>
<input
id="dg-to"
type="date"
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-accent focus:ring-1 focus:ring-accent transition-colors"
/>
</div>
</div>
</div>
<p
id="dg-error"
role="alert"
aria-live="polite"
class="mt-2 text-sm text-red-600 hidden"
>
</p>
<div class="my-6 rounded-2xl bg-zinc-900/80 border border-zinc-800/80 p-8 h-52 overflow-hidden">
<div class="h-full flex flex-col items-center justify-center gap-3">
<button
id="dg-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-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950"
aria-live="polite"
>
<span
id="dg-result"
class="text-3xl sm:text-4xl font-semibold text-zinc-100 select-none group-hover:text-zinc-300 text-center"
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="dg-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="dg-check-icon"
class="absolute -right-7 top-1/2 -translate-y-1/2 hidden text-accent"
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="dg-copied-label"
class="text-xs font-medium text-accent opacity-0 transition-opacity duration-200"
aria-live="polite"
aria-atomic="true">{T.copied}</span
>
</div>
</div>
<div class="flex justify-center">
<button
id="dg-btn"
type="button"
class="w-full sm:w-auto px-8 py-3 bg-accent text-white font-semibold rounded-xl hover:bg-accent-hover active:bg-accent-active focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors duration-100 cursor-pointer shadow-lg shadow-accent/25"
>
{T.generate}
</button>
</div>
</div>
<script>
import { CopyFeedback } from "@/lib/client/clipboard";
import { createErrorDisplay } from "@/lib/client/validation";
import { popElement } from "@/lib/client/animations";
const isRu = document.documentElement.lang === "ru";
const ERR_BOTH_DATES = isRu
? "Укажите обе даты."
: "Please enter both dates.";
const ERR_FROM_BEFORE_TO = isRu
? "«От» должно быть раньше «До»."
: '"From" must be before "To".';
const fromInput = document.getElementById("dg-from") as HTMLInputElement;
const toInput = document.getElementById("dg-to") as HTMLInputElement;
const btn = document.getElementById("dg-btn") as HTMLButtonElement;
const copyBtn = document.getElementById("dg-copy-btn") as HTMLButtonElement;
const resultEl = document.getElementById("dg-result") as HTMLSpanElement;
const errorEl = document.getElementById("dg-error") as HTMLParagraphElement;
const copyIcon = document.getElementById("dg-copy-icon") as HTMLSpanElement;
const checkIcon = document.getElementById("dg-check-icon") as HTMLSpanElement;
const copiedLabel = document.getElementById(
"dg-copied-label",
) as HTMLSpanElement;
const errors = createErrorDisplay(errorEl);
const clipboard = new CopyFeedback(copyIcon, checkIcon);
function generate() {
const fromVal = fromInput.value;
const toVal = toInput.value;
if (!fromVal || !toVal) {
errors.show(ERR_BOTH_DATES);
return;
}
const fromDate = new Date(fromVal + "T00:00:00");
const toDate = new Date(toVal + "T00:00:00");
if (fromDate.getTime() >= toDate.getTime()) {
errors.show(ERR_FROM_BEFORE_TO);
return;
}
errors.clear();
const fromMs = fromDate.getTime();
const toMs = toDate.getTime();
const randomMs = fromMs + Math.random() * (toMs - fromMs);
const resultDate = new Date(randomMs);
const formatted = resultDate.toLocaleDateString(isRu ? "ru-RU" : "en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
resultEl.textContent = formatted;
copyBtn.classList.remove("invisible");
popElement(resultEl);
}
async function copyDate() {
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", copyDate);
[fromInput, toInput].forEach((input) => {
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") generate();
});
});
</script>
@@ -0,0 +1,958 @@
---
import { useT } from "../../i18n/translations";
const isRu = Astro.url.pathname.startsWith("/ru");
const T = useT(isRu ? "ru" : "en");
const notationPlaceholder = isRu
? "напр. 2d6+3, 1d20, 4d6dl1"
: "e.g. 2d6+3, 1d20, 4d6dl1";
const modeNormal = isRu ? "Обычный" : "Normal";
const modeAdvantage = isRu ? "Преимущество" : "Advantage";
const modeDisadvantage = isRu ? "Помеха" : "Disadvantage";
const advOnly = isRu
? "Преимущество работает только с одним кубиком"
: "Advantage only works with a single die";
const historyTitle = isRu ? "История бросков" : "Roll history";
const clearHistory = isRu ? "Очистить историю" : "Clear history";
const presetsLabel = isRu ? "Пресеты" : "Presets";
const presets = [
{ label: isRu ? "Атака" : "Attack", notation: "1d20" },
{ label: isRu ? "Урон" : "Damage", notation: "1d8+3" },
{ label: isRu ? "Огненный шар" : "Fireball", notation: "8d6" },
{ label: isRu ? "Скрытая атака" : "Sneak Attack", notation: "3d6" },
{ label: isRu ? "Характеристика" : "Stat Roll", notation: "4d6dl1" },
{ label: isRu ? "Яростный удар" : "Savage Strike", notation: "2d6!" },
];
---
<div id="dice-generator" class="mt-8">
<div class="rounded-xl bg-zinc-900/50 border border-zinc-800/60 p-5">
<!-- Presets toggle -->
<div class="mb-2">
<button
id="dg-presets-toggle"
type="button"
class="inline-flex items-center gap-1 text-sm font-medium text-zinc-500 hover:text-zinc-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 rounded transition-colors cursor-pointer"
>
<span>{presetsLabel}</span>
<svg
id="dg-presets-arrow"
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"
class="transition-transform duration-150"
><path d="m6 9 6 6 6-6"></path></svg
>
</button>
<div id="dg-presets-panel" class="hidden mt-2">
<div
class="flex flex-wrap gap-2"
role="group"
aria-label={isRu ? "Быстрые пресеты" : "Quick presets"}
>
{
presets.map((p) => (
<button
type="button"
class="dg-preset px-3 py-1.5 rounded-lg border border-zinc-700 text-sm font-medium text-zinc-400 hover:border-accent hover:text-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors cursor-pointer"
data-preset={p.notation}
>
{p.label}
</button>
))
}
</div>
</div>
</div>
<!-- Notation input -->
<div class="mb-4">
<input
id="dg-notation"
type="text"
placeholder={notationPlaceholder}
autocomplete="off"
spellcheck="false"
class="w-full bg-zinc-900 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 text-base font-mono focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent transition-colors"
/>
<p id="dg-notation-err" class="mt-1 text-xs text-red-500 hidden"></p>
</div>
<!-- Count + Sides -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label for="dg-count" class="block text-sm font-medium text-zinc-400 mb-1"
>{T.numberOfDice}</label
>
<input
id="dg-count"
type="number"
value="2"
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-accent focus:ring-1 focus:ring-accent transition-colors"
aria-label={T.numberOfDice}
/>
</div>
<div>
<label class="block text-sm font-medium text-zinc-400 mb-1"
>{T.sides}</label
>
<div class="flex flex-wrap gap-2" role="radiogroup" aria-label={T.sides}>
{
["4", "6", "8", "10", "12", "20", "100"].map((s) => (
<label class="cursor-pointer">
<input
type="radio"
name="dg-sides"
value={s}
class="sr-only peer"
checked={s === "6"}
/>
<span class="inline-flex items-center justify-center w-10 h-10 rounded-lg border border-zinc-700 text-sm font-semibold text-zinc-400 peer-checked:border-accent peer-checked:text-accent peer-checked:bg-accent/10 hover:border-zinc-500 transition-colors select-none">
d{s}
</span>
</label>
))
}
</div>
</div>
</div>
<!-- Advantage / Disadvantage toggle -->
<div class="mt-4">
<label class="block text-sm font-medium text-zinc-400 mb-1.5"
>{isRu ? "Режим" : "Mode"}</label
>
<div
id="dg-mode-wrap"
class="inline-flex rounded-lg border border-zinc-700 overflow-hidden transition-opacity"
role="radiogroup"
aria-label={isRu ? "Режим" : "Mode"}
>
{
[
{ value: "normal", label: modeNormal },
{ value: "advantage", label: modeAdvantage },
{ value: "disadvantage", label: modeDisadvantage },
].map(({ value, label }, i) => (
<label class="cursor-pointer">
<input
type="radio"
name="dg-mode"
value={value}
class="sr-only peer"
checked={value === "normal"}
/>
<span
class={`inline-flex items-center justify-center px-3 sm:px-4 h-9 text-sm font-medium text-zinc-400 peer-checked:text-accent peer-checked:bg-accent/10 hover:text-zinc-200 transition-colors select-none whitespace-nowrap${i < 2 ? " border-r border-zinc-700" : ""}`}
>
{label}
</span>
</label>
))
}
</div>
<p id="dg-adv-hint" class="mt-1 text-xs text-zinc-600 hidden">{advOnly}</p>
</div>
<!-- Roll error -->
<p
id="dg-error"
role="alert"
aria-live="polite"
class="mt-3 text-sm text-red-500 hidden"
>
</p>
</div>
<!-- Results -->
<div class="my-6 h-52 overflow-hidden flex flex-col items-center justify-center gap-3 rounded-2xl bg-zinc-900/80 border border-zinc-800/80 p-8">
<div
id="dg-dice"
class="flex flex-wrap justify-center gap-2"
aria-live="polite"
aria-label="Dice results"
>
</div>
<div
id="dg-adv-info"
class="hidden text-sm font-mono text-zinc-400 text-center px-2"
>
</div>
<div
id="dg-modifier-line"
class="hidden text-sm text-zinc-500 tabular-nums"
>
</div>
<div id="dg-total-wrap" class="hidden flex items-center gap-2">
<span class="text-sm text-zinc-500">{T.total}</span>
<span id="dg-total" class="text-2xl font-bold tabular-nums text-zinc-100"
></span>
</div>
<button
id="dg-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-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 rounded"
>
<span id="dg-copy-label">{T.copy}</span>
<span id="dg-copy-icon" aria-hidden="true">
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="14" height="14" x="8" y="8" rx="2" ry="2"></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="dg-check-icon" class="hidden text-accent" 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>
<!-- Roll button -->
<div class="flex justify-center">
<button
id="dg-btn"
type="button"
class="w-full sm:w-auto px-8 py-3 bg-accent text-white font-semibold rounded-xl shadow-lg shadow-accent/25 hover:bg-accent-hover active:bg-accent-active focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors duration-100 cursor-pointer"
>
{T.roll}
</button>
</div>
<!-- Roll history -->
<div id="dg-history-panel" class="hidden mt-10 pt-6 border-t border-zinc-800">
<div class="flex items-center justify-between mb-3">
<h3 class="text-xs font-semibold uppercase tracking-widest text-zinc-600">
{historyTitle}
</h3>
<button
id="dg-clear-btn"
type="button"
class="text-xs text-zinc-600 hover:text-zinc-300 transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 rounded"
>
{clearHistory}
</button>
</div>
<div id="dg-history-list" class="space-y-1"></div>
</div>
</div>
<!-- Script section appended to DiceGenerator.astro -->
<script type="module">
// ═══ Inline Dice Engine ═══════════════════════════════════════════════════════
const EXPLODE_CAP = 100;
const REROLL_CAP = 1000;
function rollDie(sides, explode, reroll) {
let value = Math.floor(Math.random() * sides) + 1;
const original = value;
let rerolledFrom = null;
const explosions = [];
let exploded = false;
if (reroll.active && reroll.values.has(value)) {
rerolledFrom = value;
let rc = 0;
while (reroll.values.has(value) && rc < REROLL_CAP) {
value = Math.floor(Math.random() * sides) + 1;
rc++;
if (reroll.once) break;
}
}
const threshold = explode.threshold ?? sides;
if (explode.active && value >= threshold) {
exploded = true;
let chain = 0;
while (chain < EXPLODE_CAP) {
let next = Math.floor(Math.random() * sides) + 1;
if (explode.penetrating) next = Math.max(1, next - 1);
explosions.push(next);
if (next < threshold) break;
chain++;
}
}
return {
value,
original,
exploded,
explosions,
rerolledFrom,
dropped: false,
};
}
function parseDiceNotation(raw) {
const s = raw.replace(/\s+/g, "").toLowerCase();
if (!s) return null;
const main = s.match(/^(\d*)d(\d+)(.*)$/);
if (!main) return null;
const count = main[1] === "" ? 1 : parseInt(main[1], 10);
const sides = parseInt(main[2], 10);
let remaining = main[3];
if (count < 1 || count > 20 || sides < 1 || sides > 9999) return null;
let keepDrop = null;
const kd = remaining.match(/^(kh|kl|dh|dl)(\d+)/);
if (kd) {
const kc = parseInt(kd[2], 10);
if (kc < 1 || kc >= count) return null;
keepDrop = { type: kd[1], count: kc };
remaining = remaining.slice(kd[0].length);
}
const explode = { active: false, threshold: null, penetrating: false };
const em = remaining.match(/^(!p|!)(>?)(\d*)/);
if (em) {
explode.active = true;
explode.penetrating = em[1] === "!p";
if (em[2] === ">" && em[3]) {
explode.threshold = parseInt(em[3], 10);
if (explode.threshold < 2 || explode.threshold > sides) return null;
}
remaining = remaining.slice(em[0].length);
}
const reroll = {
active: false,
values: new Set(),
once: false,
operator: "eq",
};
const rm = remaining.match(/^(ro|r)([<>]?)(\d+)/);
if (rm) {
reroll.active = true;
reroll.once = rm[1] === "ro";
const op = rm[2],
val = parseInt(rm[3], 10);
if (op === "<") {
reroll.operator = "lt";
for (let i = 1; i < val && i < sides; i++) reroll.values.add(i);
} else if (op === ">") {
reroll.operator = "gt";
for (let i = val + 1; i <= sides; i++) reroll.values.add(i);
} else {
reroll.operator = "eq";
reroll.values.add(val);
}
remaining = remaining.slice(rm[0].length);
}
let modifier = 0;
const mm = remaining.match(/^([+-]\d+)$/);
if (mm) {
modifier = parseInt(mm[1], 10);
if (Math.abs(modifier) > 999) return null;
remaining = remaining.slice(mm[0].length);
}
if (remaining.length > 0) return null;
return {
count,
sides,
modifier,
keepDrop,
explode,
reroll,
advantage: false,
disadvantage: false,
};
}
function rollDice(parsed) {
const dice = [];
for (let i = 0; i < parsed.count; i++)
dice.push(rollDie(parsed.sides, parsed.explode, parsed.reroll));
let kept = [...dice];
let dropped = [];
if (parsed.keepDrop) {
const indexed = dice.map((d, i) => ({ die: d, idx: i }));
if (parsed.keepDrop.type === "kh")
indexed.sort((a, b) => b.die.value - a.die.value);
else if (parsed.keepDrop.type === "kl")
indexed.sort((a, b) => a.die.value - b.die.value);
else if (parsed.keepDrop.type === "dh")
indexed.sort((a, b) => b.die.value - a.die.value);
else if (parsed.keepDrop.type === "dl")
indexed.sort((a, b) => a.die.value - b.die.value);
const keepN = parsed.keepDrop.type.startsWith("k")
? parsed.keepDrop.count
: parsed.count - parsed.keepDrop.count;
const keepSet = new Set(indexed.slice(0, keepN).map((x) => x.idx));
kept = dice.filter((_, i) => keepSet.has(i));
dropped = dice.filter((_, i) => !keepSet.has(i));
}
dropped.forEach((d) => (d.dropped = true));
let total = kept.reduce((s, d) => s + d.value, 0);
total += kept.reduce(
(s, d) => s + d.explosions.reduce((ss, v) => ss + v, 0),
0,
);
total += parsed.modifier;
return {
dice,
kept,
dropped,
modifier: parsed.modifier,
total,
notation: "",
advantageRolls: null,
};
}
function rollAdvantage(sides, modifier, advantage) {
const r1 = Math.floor(Math.random() * sides) + 1;
const r2 = Math.floor(Math.random() * sides) + 1;
const kv = advantage ? Math.max(r1, r2) : Math.min(r1, r2);
const die = {
value: kv,
original: kv,
exploded: false,
explosions: [],
rerolledFrom: null,
dropped: false,
};
return {
dice: [die],
kept: [die],
dropped: [],
modifier,
total: kv + modifier,
notation: "",
advantageRolls: [r1, r2],
};
}
function buildNotation(count, sides, modifier) {
let s = `${count}d${sides}`;
if (modifier > 0) s += `+${modifier}`;
else if (modifier < 0) s += modifier;
return s;
}
// ═══ DOM + Logic ══════════════════════════════════════════════════════════════
const isRu = document.documentElement.lang === "ru";
const COPY_LABEL = isRu ? "Копировать" : "Copy";
const COPIED_LABEL = isRu ? "Скопировано" : "Copied";
const ERR_COUNT = isRu
? "Количество кубиков должно быть не менее 1."
: "Number of dice must be at least 1.";
const ERR_MAX = isRu
? "Максимум 20 кубиков одновременно."
: "Maximum 20 dice at once.";
const ERR_NOTATION = isRu
? "Неверный формат — попробуйте 2d6+3"
: "Invalid notation — try 2d6+3";
// refs
const notationInput = document.getElementById("dg-notation");
const notationErr = document.getElementById("dg-notation-err");
const countInput = document.getElementById("dg-count");
const btn = document.getElementById("dg-btn");
const copyBtn = document.getElementById("dg-copy-btn");
const diceEl = document.getElementById("dg-dice");
const advInfoEl = document.getElementById("dg-adv-info");
const modifierLine = document.getElementById("dg-modifier-line");
const totalWrap = document.getElementById("dg-total-wrap");
const totalEl = document.getElementById("dg-total");
const errorEl = document.getElementById("dg-error");
const copyLabel = document.getElementById("dg-copy-label");
const copyIcon = document.getElementById("dg-copy-icon");
const checkIcon = document.getElementById("dg-check-icon");
const modeWrap = document.getElementById("dg-mode-wrap");
const advHint = document.getElementById("dg-adv-hint");
const historyPanel = document.getElementById("dg-history-panel");
const historyList = document.getElementById("dg-history-list");
const clearBtn = document.getElementById("dg-clear-btn");
const presetsToggle = document.getElementById("dg-presets-toggle");
const presetsPanel = document.getElementById("dg-presets-panel");
const presetsArrow = document.getElementById("dg-presets-arrow");
let suppressSync = false;
let lastCopyText = "";
let copyRevertTimer = null;
const HISTORY_KEY = "dg-history2";
function getSelectedSides() {
const r = document.querySelector('input[name="dg-sides"]:checked');
return r ? parseInt(r.value, 10) : 6;
}
function setSelectedSides(sides) {
document.querySelectorAll('input[name="dg-sides"]').forEach((r) => {
r.checked = parseInt(r.value, 10) === sides;
});
}
function getMode() {
const r = document.querySelector('input[name="dg-mode"]:checked');
return r?.value ?? "normal";
}
function setMode(v) {
document.querySelectorAll('input[name="dg-mode"]').forEach((r) => {
if (r.value === v) r.checked = true;
});
}
function showError(msg) {
errorEl.textContent = msg;
errorEl.classList.remove("hidden");
}
function clearError() {
errorEl.textContent = "";
errorEl.classList.add("hidden");
}
function showNotationErr(msg) {
notationErr.textContent = msg;
notationErr.classList.remove("hidden");
}
function clearNotationErr() {
notationErr.textContent = "";
notationErr.classList.add("hidden");
}
function updateModeToggle() {
const parsed = parseDiceNotation(notationInput.value.trim());
const count = parsed ? parsed.count : parseInt(countInput.value, 10);
const hasKeepDrop = parsed?.keepDrop;
const disabled = (!isNaN(count) && count > 1) || hasKeepDrop;
modeWrap.classList.toggle("opacity-40", disabled);
modeWrap.classList.toggle("pointer-events-none", disabled);
advHint.classList.toggle("hidden", !disabled);
}
function makeDieChip(v, sides, extra = "") {
const el = document.createElement("span");
const text = String(v);
const color =
v === sides
? "border-accent text-accent bg-accent/10"
: v === 1
? "border-red-700/50 text-red-400 bg-red-900/10"
: "border-zinc-700 text-zinc-100 bg-zinc-900";
el.className =
`inline-flex items-center justify-center min-w-[2.75rem] h-11 px-2 rounded-lg border text-sm font-bold tabular-nums ${color} ${extra}`.trim();
el.textContent = text;
return el;
}
function dieHtml(v, sides) {
const cls =
v === sides
? "text-accent font-bold"
: v === 1
? "text-red-400 font-bold"
: "text-zinc-300";
return `<span class="${cls}">${v}</span>`;
}
function loadHistory() {
try {
return JSON.parse(sessionStorage.getItem(HISTORY_KEY) ?? "[]");
} catch {
return [];
}
}
function saveHistory(h) {
sessionStorage.setItem(HISTORY_KEY, JSON.stringify(h.slice(0, 10)));
}
function renderHistory() {
const h = loadHistory();
if (h.length === 0) {
historyPanel.classList.add("hidden");
return;
}
historyPanel.classList.remove("hidden");
historyList.innerHTML = h
.map((entry) => {
let html = `<div class="flex items-center gap-1.5 text-xs font-mono py-1 border-b border-zinc-800/40 last:border-b-0">`;
html += `<span class="text-zinc-500 shrink-0 mr-1">${entry.label}</span>`;
html += `<span class="text-zinc-400">${entry.text}</span>`;
html += `</div>`;
return html;
})
.join("");
}
function addToHistory(label, text) {
const h = loadHistory();
h.unshift({ label, text, ts: Date.now() });
saveHistory(h);
renderHistory();
}
function formatDieResult(d, sides) {
let text = String(d.value);
if (d.dropped) {
return { text, cls: "line-through opacity-40 text-zinc-500" };
}
if (d.rerolledFrom !== null) {
text = `${d.rerolledFrom}→${d.value}`;
}
if (d.exploded) {
text += d.explosions.map((v) => `→${v}`).join("");
}
let color =
d.value === sides
? "text-accent"
: d.value === 1
? "text-red-400"
: "text-zinc-100";
if (d.rerolledFrom !== null) color = "text-zinc-300";
return { text, cls: color };
}
function formatHistoryDice(d, sides) {
if (d.dropped) return `<s class="text-zinc-500">${d.value}</s>`;
let out = dieHtml(d.value, sides);
if (d.rerolledFrom !== null) {
out = `~~${d.rerolledFrom}~~→${out}`;
}
if (d.exploded) {
out += d.explosions.map((v) => `→${dieHtml(v, sides)}`).join("");
}
return out;
}
function copyResults() {
if (!lastCopyText) return;
navigator.clipboard.writeText(lastCopyText);
copyIcon.classList.add("hidden");
checkIcon.classList.remove("hidden");
copyLabel.textContent = COPIED_LABEL;
if (copyRevertTimer) clearTimeout(copyRevertTimer);
copyRevertTimer = setTimeout(() => {
checkIcon.classList.add("hidden");
copyIcon.classList.remove("hidden");
copyLabel.textContent = COPY_LABEL;
}, 1500);
}
// ═══ Roll logic ═══════════════════════════════════════════════════════════════
function roll() {
clearError();
const raw = notationInput.value.trim();
let parsed = null;
let notation;
let result;
let isAdv = false,
isDis = false;
if (raw) {
parsed = parseDiceNotation(raw);
if (!parsed) {
showNotationErr(ERR_NOTATION);
return;
}
clearNotationErr();
} else {
const c = parseInt(countInput.value, 10);
const s = getSelectedSides();
if (!Number.isInteger(c) || c < 1) {
showError(ERR_COUNT);
return;
}
if (c > 20) {
showError(ERR_MAX);
return;
}
parsed = {
count: c,
sides: s,
modifier: 0,
keepDrop: null,
explode: { active: false },
reroll: { active: false },
advantage: false,
disadvantage: false,
};
}
// Determine mode from notation or toggle
const mode = getMode();
if (mode === "advantage" || mode === "disadvantage") {
if (parsed.count === 1 && !parsed.keepDrop) {
isAdv = mode === "advantage";
isDis = mode === "disadvantage";
}
}
// Build notation string
notation =
raw || buildNotation(parsed.count, parsed.sides, parsed.modifier);
// Append mode symbols for history if from toggle
if (isAdv && !notation.includes("kh"))
notation = "2d" + parsed.sides + "kh1";
if (isDis && !notation.includes("kl"))
notation = "2d" + parsed.sides + "kl1";
// Reset display
advInfoEl.classList.add("hidden");
advInfoEl.innerHTML = "";
modifierLine.classList.add("hidden");
totalWrap.classList.add("hidden");
diceEl.innerHTML = "";
if (isAdv || isDis) {
result = rollAdvantage(parsed.sides, parsed.modifier, isAdv);
const [r1, r2] = result.advantageRolls;
const winner = isAdv ? Math.max(r1, r2) : Math.min(r1, r2);
const prefix = isAdv ? "↑" : "↓";
const modStr =
parsed.modifier !== 0
? ` ${parsed.modifier > 0 ? "+" : ""}${parsed.modifier} = ${result.total}`
: "";
let w1 = false;
[r1, r2].forEach((v) => {
const isW = v === winner && !w1;
if (isW) w1 = true;
diceEl.appendChild(
makeDieChip(v, parsed.sides, isW ? "" : "opacity-40"),
);
});
advInfoEl.textContent = `${prefix} ${winner}${modStr} (${r1}, ${r2})`;
advInfoEl.classList.remove("hidden");
if (parsed.modifier !== 0) {
totalEl.textContent = String(result.total);
totalWrap.classList.remove("hidden");
}
lastCopyText = `${prefix} ${winner}${modStr} (${r1}, ${r2})`;
addToHistory(notation, `${prefix} ${winner}${modStr} (${r1}, ${r2})`);
} else {
result = rollDice(parsed);
// Render dice chips
result.dice.forEach((d) => {
const fmt = formatDieResult(d, parsed.sides);
const chip = makeDieChip(d.value, parsed.sides, fmt.cls);
if (d.exploded && d.explosions.length > 0) {
chip.textContent = fmt.text;
}
diceEl.appendChild(chip);
});
if (parsed.modifier !== 0) {
modifierLine.textContent =
parsed.modifier > 0 ? `+${parsed.modifier}` : String(parsed.modifier);
modifierLine.classList.remove("hidden");
}
totalEl.textContent = String(result.total);
totalWrap.classList.remove("hidden");
// Build history entry
const diceStr =
"[" +
result.dice.map((d) => formatHistoryDice(d, parsed.sides)).join(", ") +
"]";
const modStr =
parsed.modifier > 0
? ` +${parsed.modifier}`
: parsed.modifier < 0
? ` ${parsed.modifier}`
: "";
const text = `${diceStr}${modStr} = ${result.total}`;
lastCopyText = text;
addToHistory(notation, text);
}
copyBtn.classList.remove("invisible");
copyLabel.textContent = COPY_LABEL;
}
// ═══ Presets toggle ═══════════════════════════════════════════════════════════
function togglePresets() {
const closed = presetsPanel.classList.contains("hidden");
if (closed) {
presetsPanel.classList.remove("hidden");
presetsArrow.style.transform = "rotate(180deg)";
sessionStorage.setItem("dg-presets-open", "1");
} else {
presetsPanel.classList.add("hidden");
presetsArrow.style.transform = "rotate(0deg)";
sessionStorage.setItem("dg-presets-open", "0");
}
}
function initPresets() {
if (sessionStorage.getItem("dg-presets-open") === "1") {
presetsPanel.classList.remove("hidden");
presetsArrow.style.transform = "rotate(180deg)";
}
}
// ═══ Event listeners ══════════════════════════════════════════════════════════
// Notation input → controls sync
notationInput.addEventListener("input", () => {
const raw = notationInput.value.trim();
if (!raw) {
clearNotationErr();
return;
}
const parsed = parseDiceNotation(raw);
if (!parsed) {
showNotationErr(ERR_NOTATION);
return;
}
clearNotationErr();
suppressSync = true;
countInput.value = String(parsed.count);
setSelectedSides(parsed.sides);
suppressSync = false;
updateModeToggle();
});
notationInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
roll();
}
});
// Controls → notation sync
countInput.addEventListener("input", () => {
if (suppressSync) return;
const count = parseInt(countInput.value, 10);
const sides = getSelectedSides();
const parsed = parseDiceNotation(notationInput.value.trim());
const modifier = parsed?.modifier ?? 0;
if (!isNaN(count) && count > 0) {
notationInput.value = buildNotation(count, sides, modifier);
clearNotationErr();
}
updateModeToggle();
});
countInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") roll();
});
document.querySelectorAll('input[name="dg-sides"]').forEach((radio) => {
radio.addEventListener("change", () => {
if (suppressSync) return;
const count = parseInt(countInput.value, 10) || 1;
const sides = getSelectedSides();
const parsed = parseDiceNotation(notationInput.value.trim());
const modifier = parsed?.modifier ?? 0;
notationInput.value = buildNotation(count, sides, modifier);
clearNotationErr();
updateModeToggle();
});
});
// Mode toggle → notation sync
document.querySelectorAll('input[name="dg-mode"]').forEach((radio) => {
radio.addEventListener("change", () => {
const raw = notationInput.value.trim();
const mode = getMode();
if (!raw) return;
const parsed = parseDiceNotation(raw);
if (!parsed) return;
// If notation has keep/drop, clear mode
if (parsed.keepDrop) {
setMode("normal");
return;
}
// If mode is adv/dis and count is 1, update notation to kh1/kl1
if (mode === "advantage" && parsed.count === 1) {
notationInput.value =
buildNotation(2, parsed.sides, parsed.modifier) + "kh1";
} else if (mode === "disadvantage" && parsed.count === 1) {
notationInput.value =
buildNotation(2, parsed.sides, parsed.modifier) + "kl1";
} else if (mode === "normal") {
notationInput.value = buildNotation(
parsed.count,
parsed.sides,
parsed.modifier,
);
}
updateModeToggle();
});
});
// Presets
document.querySelectorAll(".dg-preset").forEach((el) => {
el.addEventListener("click", () => {
const notation = el.getAttribute("data-preset");
notationInput.value = notation;
clearNotationErr();
const parsed = parseDiceNotation(notation);
if (parsed) {
suppressSync = true;
countInput.value = String(parsed.count);
setSelectedSides(parsed.sides);
suppressSync = false;
}
updateModeToggle();
roll();
});
});
// Presets toggle
presetsToggle?.addEventListener("click", togglePresets);
// Buttons
btn.addEventListener("click", roll);
copyBtn.addEventListener("click", copyResults);
clearBtn.addEventListener("click", () => {
sessionStorage.removeItem(HISTORY_KEY);
renderHistory();
});
// ═══ Init ═════════════════════════════════════════════════════════════════════
initPresets();
notationInput.value = buildNotation(
parseInt(countInput.value, 10),
getSelectedSides(),
0,
);
updateModeToggle();
renderHistory();
</script>
@@ -0,0 +1,675 @@
---
import { useT } from "../../i18n/translations";
const isRu = Astro.url.pathname.startsWith("/ru");
const T = useT(isRu ? "ru" : "en");
const categoryLabel = isRu ? "Категория" : "Category";
const countLabel = isRu ? "Количество" : "Count";
const allLabel = isRu ? "Все" : "All";
const smileysLabel = isRu ? "Смайлики" : "Smileys";
const gesturesLabel = isRu ? "Жесты и тело" : "Gestures";
const animalsLabel = isRu ? "Животные" : "Animals";
const foodLabel = isRu ? "Еда и напитки" : "Food";
const travelLabel = isRu ? "Путешествия" : "Travel";
const activitiesLabel = isRu ? "Активности" : "Activities";
const objectsLabel = isRu ? "Предметы" : "Objects";
const symbolsLabel = isRu ? "Символы" : "Symbols";
const clickToCopyLabel = isRu ? "Нажмите, чтобы скопировать" : "Click to copy";
---
<div id="emoji-generator" class="mt-8">
<!-- Controls -->
<div class="rounded-xl bg-zinc-900/50 border border-zinc-800/60 p-5">
<div class="flex flex-col sm:flex-row gap-4">
<div class="flex-1">
<label for="eg-category" class="block text-sm font-medium text-zinc-400 mb-1">{categoryLabel}</label>
<select
id="eg-category"
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-accent focus:ring-1 focus:ring-accent transition-colors appearance-none cursor-pointer"
aria-label={categoryLabel}
>
<option value="all">{allLabel}</option>
<option value="smileys">{smileysLabel}</option>
<option value="gestures">{gesturesLabel}</option>
<option value="animals">{animalsLabel}</option>
<option value="food">{foodLabel}</option>
<option value="travel">{travelLabel}</option>
<option value="activities">{activitiesLabel}</option>
<option value="objects">{objectsLabel}</option>
<option value="symbols">{symbolsLabel}</option>
</select>
</div>
<div class="sm:w-32">
<label for="eg-count" class="block text-sm font-medium text-zinc-400 mb-1">{countLabel}</label>
<input
id="eg-count"
type="number"
value="5"
min="1"
max="50"
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-accent focus:ring-1 focus:ring-accent transition-colors"
aria-label={countLabel}
/>
</div>
</div>
</div>
<!-- Error -->
<p
id="eg-error"
role="alert"
aria-live="polite"
class="mt-2 text-sm text-red-600 hidden"
></p>
<!-- Result area -->
<div
id="eg-result-area"
class="my-6 rounded-2xl bg-zinc-900/80 border border-zinc-800/80 p-6 sm:p-8 min-h-[10rem] overflow-hidden"
aria-live="polite"
>
<div
id="eg-result"
class="flex flex-wrap justify-center gap-3 sm:gap-4"
role="list"
aria-label={isRu ? "Сгенерированные эмодзи" : "Generated emojis"}
>
</div>
<div class="flex justify-center mt-6">
<button
id="eg-copy-all-btn"
type="button"
aria-label={isRu ? "Копировать все эмодзи" : "Copy all emojis"}
class="invisible inline-flex items-center gap-2 px-5 py-2.5 bg-accent text-white font-semibold rounded-xl hover:bg-accent-hover active:bg-accent-active focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors duration-100 cursor-pointer shadow-lg shadow-accent/25 text-sm"
>
<span id="eg-copy-all-label">{T.copyAll}</span>
<span id="eg-copy-all-icon" aria-hidden="true">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
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="eg-copy-all-check"
class="hidden text-white"
aria-hidden="true"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
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>
<!-- Generate button -->
<div class="flex justify-center">
<button
id="eg-btn"
type="button"
class="w-full sm:w-auto px-8 py-3 bg-accent text-white font-semibold rounded-xl hover:bg-accent-hover active:bg-accent-active focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors duration-100 cursor-pointer shadow-lg shadow-accent/25"
>
{T.generate}
</button>
</div>
</div>
<script>
import { createErrorDisplay } from "@/lib/client/validation";
import { CopyFeedback } from "@/lib/client/clipboard";
import { popElement } from "@/lib/client/animations";
const isRu = document.documentElement.lang === "ru";
const COPIED_LABEL = isRu ? "Скопировано!" : "Copied!";
const COPY_ALL_LABEL = isRu ? "Копировать всё" : "Copy all";
const ERR_COUNT_RANGE = isRu ? "Укажите количество от 1 до 50." : "Enter a count between 1 and 50.";
const TOOLTIP_COPIED = isRu ? "Скопировано!" : "Copied!";
const EMOJIS: Record<string, string[]> = {
smileys: ["😀","😃","😄","😁","😆","😅","😂","🤣","😊","😇","🙂","🙃","😉","😌","😍","🥰","😘","😗","😙","😚","😋","😛","😝","😜","🤪","🤨","🧐","🤓","😎","🥸","🤩","🥳","😏","😒","😞","😔","😟","😕","🙁","☹️","😣","😖","😫","😩","🥺","😢","😭","😤","😠","😡","🤬","🤯","😳","🥵","🥶","😱","😨","😰","😥","😓","🤗","🤔","🤭","🤫","🤥","😶","😐","😑","😬","🙄","😯","😦","😧","😮","😲","🥱","😴","🤤","😪","😵","🤐","🥴","🤢","🤮","🤧","😷","🤒","🤕","🤑","🤠","😈","👿","👹","👺","🤡","💩","👻","💀","☠️","👽","👾","🤖","🎃","😺","😸","😹","😻","😼","😽","🙀","😿","😾"],
gestures: ["👋","🤚","🖐","✋","🖖","👌","🤌","🤏","✌️","🤞","🫰","🤟","🤘","🤙","🫵","🫱","🫲","🫳","🫴","👈","👉","👆","🖕","👇","☝️","👍","👎","✊","👊","🤛","🤜","👏","🙌","🫶","👐","🤲","🤝","🙏","✍️","💅","🤳","💪","🦾","🦿","🦵","🦶","👂","🦻","👃","🧠","🫀","🫁","🦷","🦴","👀","👁","👅","👄","🫦","💋","🩸"],
animals: ["🐶","🐱","🐭","🐹","🐰","🦊","🐻","🐼","🐻‍❄️","🐨","🐯","🦁","🐮","🐷","🐽","🐸","🐵","🙈","🙉","🙊","🐒","🐔","🐧","🐦","🐤","🐣","🐥","🦆","🦅","🦉","🦇","🐺","🐗","🐴","🦄","🐝","🪱","🐛","🦋","🐌","🐞","🐜","🦗","🪳","🕷","🕸","🦂","🐢","🐍","🦎","🦖","🦕","🐙","🦑","🦐","🦞","🦀","🐡","🐠","🐟","🐬","🐳","🐋","🦈","🐊","🐅","🐆","🦓","🫏","🦍","🦧","🦣","🐘","🦛","🦏","🐪","🐫","🦒","🦘","🦬","🐃","🐂","🐄","🐎","🐖","🐏","🐑","🦙","🐐","🦌","🐕","🐩","🦮","🐈","🐈‍⬛","🪶","🪽","🐓","🦃","🕊","🐇","🐁","🐀","🐿","🦫","🦔","🐾","🐉","🐲"],
food: ["🍏","🍎","🍐","🍊","🍋","🍌","🍉","🍇","🍓","🫐","🍈","🍒","🍑","🥭","🍍","🥥","🥝","🍅","🍆","🥑","🥦","🥬","🥒","🌶","🫑","🌽","🥕","🫒","🧄","🧅","🥔","🍠","🫘","🥐","🥯","🍞","🥖","🥨","🧀","🥚","🍳","🧈","🥞","🧇","🥓","🥩","🍗","🍖","🦴","🌭","🍔","🍟","🍕","🫓","🥪","🥙","🧆","🌮","🌯","🫔","🥗","🥘","🫕","🥫","🍝","🍜","🍲","🍛","🍣","🍱","🥟","🦪","🍤","🍙","🍚","🍘","🍥","🥠","🥮","🍢","🍡","🍧","🍨","🍦","🥧","🧁","🍰","🎂","🍮","🍭","🍬","🍫","🍿","🍩","🍪","🌰","🥜","🍯","🥛","🍼","🫖","☕️","🍵","🧃","🥤","🧋","🫙","🍶","🍺","🍻","🥂","🍷","🫗","🥃","🍸","🍹","🧉","🍾","🧊","🥄","🍴","🍽","🥣","🥡","🥢","🧂"],
travel: ["🚗","🚕","🚙","🚌","🚎","🏎","🚓","🚑","🚒","🚐","🛻","🚚","🚛","🚜","🦯","🦽","🦼","🩼","🛴","🚲","🛵","🏍","🛺","🚨","🚔","🚍","🚘","🚖","🚡","🚠","🚟","🚃","🚋","🚞","🚝","🚄","🚅","🚈","🚂","🚆","🚇","🚊","🚉","✈️","🛫","🛬","🛩","💺","🛰","🚀","🛸","🚁","🛶","⛵️","🚤","🛥","🛳","⛴","🚢","⚓️","⛽️","🚧","🚦","🚥","🚏","🗺","🗿","🗽","🗼","🏰","🏯","🏟","🎡","🎢","🎠","⛲️","⛱","🏖","🏝","🏜","🌋","⛰","🏔","🗻","🏕","⛺️","🛖","🏠","🏡","🏘","🏚","🏗","🏭","🏢","🏬","🏣","🏤","🏥","🏦","🏨","🏪","🏫","🏩","💒","🏛","⛪️","🕌","🕍","🛕","🕋","⛩","🛤","🛣","🗾","🎑","🏞","🌅","🌄","🌠","🎇","🎆","🌇","🌆","🏙","🌃","🌌","🌉","🌁"],
activities: ["⚽️","🏀","🏈","⚾️","🥎","🎾","🏐","🏉","🥏","🎱","🪀","🏓","🏸","🏒","🏑","🥍","🏏","🪃","🥅","⛳️","🪁","🏹","🎣","🤿","🥊","🥋","🎽","🛹","🛼","🛷","⛸","🥌","🎿","⛷","🏂","🪂","🏋️","🤼","🤸","⛹️","🤺","🤾","🏌️","🏇","🧘","🏄","🏊","🤽","🚣","🧗","🚵","🚴","🏆","🥇","🥈","🥉","🏅","🎖","🏵","🎗","🎫","🎟","🎪","🤹","🎭","🩰","🎨","🎬","🎤","🎧","🎼","🎹","🥁","🪘","🪇","🎷","🎺","🪗","🎸","🪕","🎻","🎲","♟","🎯","🎳","🎮","🎰","🧩"],
objects: ["⌚️","📱","📲","💻","⌨️","🖥","🖨","🖱","🖲","🕹","🗜","💽","💾","💿","📀","📼","📷","📸","📹","🎥","📽","🎞","📞","☎️","📟","📠","📺","📻","🎙","🎚","🎛","🧭","⏱","⏲","⏰","🕰","⌛️","⏳","📡","🔋","🪫","🔌","💡","🔦","🕯","🪔","🧯","🛢","💸","💵","💴","💶","💷","🪙","💰","💳","🪪","💎","⚖️","🦯","🪜","🧰","🪛","🔧","🔨","⚒","🛠","⛏","🪚","🔩","⚙️","🪤","🧱","⛓","🧲","🔫","💣","🔪","🗡","⚔️","🛡","🚬","⚰️","🪦","⚱️","🏺","🔮","📿","🧿","💈","⚗️","🔭","🔬","🕳","🩹","🩺","🩻","🩼","💊","💉","🩸","🧬","🦠","🧫","🧪","🌡","🧹","🪠","🧺","🧻","🚽","🚰","🚿","🛁","🛀","🧼","🪥","🪒","🧽","🪣","🧴","🛎","🔑","🗝","🚪","🪑","🛋","🛏","🛌","🧸","🪆","🖼","🪞","🪟","🛍","🛒","🎁","🎈","🎏","🎀","🪄","🪅","🎊","🎉","🎎","🏮","🎐","🧧","✉️","📩","📨","📧","💌","📥","📤","📦","🏷","🪧","📪","📫","📬","📭","📮","📯","📜","📃","📄","📑","🧾","📊","📈","📉","🗒","🗓","📆","📅","🗑","🪪","📇","🗃","🗳","🗄","📋","📁","📂","🗂","🗞","📰","📓","📔","📒","📕","📗","📘","📙","📚","📖","🔖","🧷","🔗","📎","🖇","📐","📏","🧮","📌","📍","✂️","🖊","🖋","✒️","🖌","🖍","📝","✏️","🔍","🔎","🔏","🔐","🔒","🔓"],
symbols: ["❤️","🧡","💛","💚","💙","💜","🖤","🤍","🤎","❤️‍🔥","❤️‍🩹","💔","❣️","💕","💞","💓","💗","💖","💘","💝","💟","☮️","✝️","☪️","🕉","☸️","✡️","🔯","🕎","☯️","☦️","🛐","⛎","♈️","♉️","♊️","♋️","♌️","♍️","♎️","♏️","♐️","♑️","♒️","♓️","🆔","⚛️","🉑","☢️","☣️","📴","📳","🈶","🈚️","🈸","🈺","🈷️","✴️","🆚","💮","🉐","㊙️","㊗️","🈴","🈵","🈹","🈲","🅰️","🅱️","🆎","🆑","🅾️","🆘","❌","⭕️","🛑","⛔️","📛","🚫","💯","💢","♨️","🚷","🚯","🚳","🚱","🔞","📵","🚭","❗️","❕","❓","❔","‼️","⁉️","🔅","🔆","〽️","⚠️","🚸","🔱","⚜️","🔰","♻️","✅","🈯️","💹","❇️","✳️","❎","🌐","💠","Ⓜ️","🌀","💤","🏧","🚾","♿️","🅿️","🈳","🈂️","🛂","🛃","🛄","🛅","🛗","⚧️","🚹","🚺","🚼","🚻","🚮","🎦","📶","🈁","🔣","ℹ️","🔤","🔡","🔠","🆖","🆗","🆙","🆒","🆕","🆓","0️⃣","1️⃣","2️⃣","3️⃣","4️⃣","5️⃣","6️⃣","7️⃣","8️⃣","9️⃣","🔟","🔢","#️⃣","*️⃣","⏏️","▶️","⏸","⏯","⏹","⏺","⏭","⏮","⏩","⏪","⏫","⏬","◀️","🔼","🔽","➡️","⬅️","⬆️","⬇️","↗️","↘️","↙️","↖️","↕️","↔️","↪️","↩️","⤴️","⤵️","🔀","🔁","🔂","🔄","🔃","🎵","🎶","➕","➖","➗","✖️","💲","💱","™️","©️","®️","👁‍🗨","🔚","🔙","🔛","🔝","🔜","〰️","➰","➿","✔️","☑️","🔘","🔴","🟠","🟡","🟢","🔵","🟣","⚫️","⚪️","🟤","🔺","🔻","🔸","🔹","🔶","🔷","🔳","🔲","▪️","▫️","◾️","◽️","◼️","◻️","🟥","🟧","🟨","🟩","🟦","🟪","⬛️","⬜️","🟫","🔈","🔇","🔉","🔊","🔔","🔕","📣","📢","💬","💭","🗯","♠️","♣️","♥️","♦️","🃏","🎴","🀄️","🕐","🕑","🕒","🕓","🕔","🕕","🕖","🕗","🕘","🕙","🕚","🕛","🕜","🕝","🕞","🕟","🕠","🕡","🕢","🕣","🕤","🕥","🕦","🕧"]
};
// Minimal emoji name map for tooltips (fallback for known emojis)
const EMOJI_NAMES_EN: Record<string, string> = {
"😀": "Grinning Face", "😃": "Smiling Face", "😄": "Smiling Face with Smiling Eyes", "😁": "Beaming Face", "😆": "Grinning Squinting Face",
"😅": "Grinning Face with Sweat", "😂": "Face with Tears of Joy", "🤣": "Rolling on the Floor Laughing", "😊": "Smiling Face with Smiling Eyes",
"😇": "Smiling Face with Halo", "🙂": "Slightly Smiling Face", "🙃": "Upside-Down Face", "😉": "Winking Face", "😌": "Relieved Face",
"😍": "Smiling Face with Heart-Eyes", "🥰": "Smiling Face with Hearts", "😘": "Face Blowing a Kiss", "😗": "Kissing Face",
"😙": "Kissing Face with Smiling Eyes", "😚": "Kissing Face with Closed Eyes", "😋": "Face Savoring Food", "😛": "Face with Tongue",
"😝": "Squinting Face with Tongue", "😜": "Winking Face with Tongue", "🤪": "Zany Face", "🤨": "Face with Raised Eyebrow",
"🧐": "Face with Monocle", "🤓": "Nerd Face", "😎": "Smiling Face with Sunglasses", "🥸": "Disguised Face", "🤩": "Star-Struck",
"🥳": "Partying Face", "😏": "Smirking Face", "😒": "Unamused Face", "😞": "Disappointed Face", "😔": "Pensive Face",
"😟": "Worried Face", "😕": "Confused Face", "🙁": "Slightly Frowning Face", "☹️": "Frowning Face", "😣": "Persevering Face",
"😖": "Confounded Face", "😫": "Tired Face", "😩": "Weary Face", "🥺": "Pleading Face", "😢": "Crying Face",
"😭": "Loudly Crying Face", "😤": "Face with Steam From Nose", "😠": "Angry Face", "😡": "Enraged Face", "🤬": "Face with Symbols on Mouth",
"🤯": "Exploding Head", "😳": "Flushed Face", "🥵": "Hot Face", "🥶": "Cold Face", "😱": "Face Screaming in Fear",
"😨": "Fearful Face", "😰": "Anxious Face", "😥": "Sad but Relieved Face", "😓": "Face with Cold Sweat", "🤗": "Hugging Face",
"🤔": "Thinking Face", "🤭": "Face with Hand Over Mouth", "🤫": "Shushing Face", "🤥": "Lying Face", "😶": "Face Without Mouth",
"😐": "Neutral Face", "😑": "Expressionless Face", "😬": "Grimacing Face", "🙄": "Face with Rolling Eyes",
"😯": "Hushed Face", "😦": "Frowning Face with Open Mouth", "😧": "Anguished Face", "😮": "Face with Open Mouth",
"😲": "Astonished Face", "🥱": "Yawning Face", "😴": "Sleeping Face", "🤤": "Drooling Face", "😪": "Sleepy Face",
"😵": "Face with Crossed-Out Eyes", "🤐": "Zipper-Mouth Face", "🥴": "Woozy Face", "🤢": "Nauseated Face",
"🤮": "Face Vomiting", "🤧": "Sneezing Face", "😷": "Face with Medical Mask", "🤒": "Face with Thermometer",
"🤕": "Face with Head-Bandage", "🤑": "Money-Mouth Face", "🤠": "Cowboy Hat Face", "😈": "Smiling Face with Horns",
"👿": "Angry Face with Horns", "👹": "Ogre", "👺": "Goblin", "🤡": "Clown Face", "💩": "Pile of Poo",
"👻": "Ghost", "💀": "Skull", "☠️": "Skull and Crossbones", "👽": "Alien", "👾": "Alien Monster", "🤖": "Robot",
"🎃": "Jack-O-Lantern", "😺": "Grinning Cat", "😸": "Grinning Cat with Smiling Eyes", "😹": "Cat with Tears of Joy",
"😻": "Smiling Cat with Heart-Eyes", "😼": "Cat with Wry Smile", "😽": "Kissing Cat", "🙀": "Weary Cat", "😿": "Crying Cat",
"😾": "Pouting Cat",
"👋": "Waving Hand", "🤚": "Raised Back of Hand", "🖐": "Hand with Fingers Splayed", "✋": "Raised Hand",
"🖖": "Vulcan Salute", "👌": "OK Hand", "🤌": "Pinched Fingers", "🤏": "Pinching Hand", "✌️": "Victory Hand",
"🤞": "Crossed Fingers", "🫰": "Hand with Index Finger and Thumb Crossed", "🤟": "Love-You Gesture", "🤘": "Sign of the Horns",
"🤙": "Call Me Hand", "🫵": "Index Pointing at the Viewer", "👈": "Backhand Index Pointing Left",
"👉": "Backhand Index Pointing Right", "👆": "Backhand Index Pointing Up", "🖕": "Middle Finger",
"👇": "Backhand Index Pointing Down", "☝️": "Index Pointing Up", "👍": "Thumbs Up", "👎": "Thumbs Down",
"✊": "Raised Fist", "👊": "Oncoming Fist", "🤛": "Left-Facing Fist", "🤜": "Right-Facing Fist", "👏": "Clapping Hands",
"🙌": "Raising Hands", "🫶": "Heart Hands", "👐": "Open Hands", "🤲": "Palms Up Together", "🤝": "Handshake",
"🙏": "Folded Hands", "✍️": "Writing Hand", "💅": "Nail Polish", "🤳": "Selfie", "💪": "Flexed Biceps",
"👂": "Ear", "👃": "Nose", "🧠": "Brain", "👀": "Eyes", "👁": "Eye", "👅": "Tongue", "👄": "Mouth", "💋": "Kiss Mark",
"🐶": "Dog Face", "🐱": "Cat Face", "🐭": "Mouse Face", "🐹": "Hamster", "🐰": "Rabbit Face", "🦊": "Fox",
"🐻": "Bear", "🐼": "Panda", "🐨": "Koala", "🐯": "Tiger Face", "🦁": "Lion", "🐮": "Cow Face",
"🐷": "Pig Face", "🐸": "Frog", "🐵": "Monkey Face", "🙈": "See-No-Evil Monkey", "🙉": "Hear-No-Evil Monkey",
"🙊": "Speak-No-Evil Monkey", "🐔": "Chicken", "🐧": "Penguin", "🐦": "Bird", "🐤": "Baby Chick",
"🦆": "Duck", "🦅": "Eagle", "🦉": "Owl", "🦇": "Bat", "🐺": "Wolf", "🐗": "Boar", "🐴": "Horse Face",
"🦄": "Unicorn", "🐝": "Honeybee", "🦋": "Butterfly", "🐌": "Snail", "🐞": "Lady Beetle",
"🐢": "Turtle", "🐍": "Snake", "🦎": "Lizard", "🦖": "T-Rex", "🦕": "Sauropod", "🐙": "Octopus",
"🦑": "Squid", "🦐": "Shrimp", "🦞": "Lobster", "🦀": "Crab", "🐡": "Blowfish", "🐠": "Tropical Fish",
"🐟": "Fish", "🐬": "Dolphin", "🐳": "Spouting Whale", "🐋": "Whale", "🦈": "Shark", "🐊": "Crocodile",
"🐅": "Tiger", "🐆": "Leopard", "🦓": "Zebra", "🦍": "Gorilla", "🐘": "Elephant", "🦛": "Hippopotamus",
"🦏": "Rhinoceros", "🐪": "Camel", "🐫": "Two-Hump Camel", "🦒": "Giraffe", "🦘": "Kangaroo",
"🐃": "Water Buffalo", "🐂": "Ox", "🐄": "Cow", "🐎": "Horse", "🐖": "Pig", "🐏": "Ram",
"🐑": "Ewe", "🦙": "Llama", "🐐": "Goat", "🦌": "Deer", "🐕": "Dog", "🐩": "Poodle",
"🐈": "Cat", "🐇": "Rabbit", "🐁": "Mouse", "🐀": "Rat", "🦔": "Hedgehog", "🐾": "Paw Prints",
"🐉": "Dragon", "🐲": "Dragon Face",
"🍏": "Green Apple", "🍎": "Red Apple", "🍐": "Pear", "🍊": "Tangerine", "🍋": "Lemon",
"🍌": "Banana", "🍉": "Watermelon", "🍇": "Grapes", "🍓": "Strawberry", "🫐": "Blueberries",
"🍈": "Melon", "🍒": "Cherries", "🍑": "Peach", "🥭": "Mango", "🍍": "Pineapple",
"🥥": "Coconut", "🥝": "Kiwi Fruit", "🍅": "Tomato", "🍆": "Eggplant", "🥑": "Avocado",
"🥦": "Broccoli", "🥬": "Leafy Green", "🥒": "Cucumber", "🌶": "Hot Pepper", "🫑": "Bell Pepper",
"🌽": "Ear of Corn", "🥕": "Carrot", "🧄": "Garlic", "🧅": "Onion", "🥔": "Potato",
"🥐": "Croissant", "🥯": "Bagel", "🍞": "Bread", "🥖": "Baguette Bread", "🥨": "Pretzel",
"🧀": "Cheese Wedge", "🥚": "Egg", "🍳": "Cooking", "🥞": "Pancakes", "🧇": "Waffle",
"🥓": "Bacon", "🥩": "Cut of Meat", "🍗": "Poultry Leg", "🍖": "Meat on Bone", "🌭": "Hot Dog",
"🍔": "Hamburger", "🍟": "French Fries", "🍕": "Pizza", "🥪": "Sandwich", "🥙": "Stuffed Flatbread",
"🌮": "Taco", "🌯": "Burrito", "🥗": "Green Salad", "🥘": "Shallow Pan of Food", "🍝": "Spaghetti",
"🍜": "Steaming Bowl", "🍲": "Pot of Food", "🍛": "Curry Rice", "🍣": "Sushi", "🍱": "Bento Box",
"🥟": "Dumpling", "🦪": "Oyster", "🍤": "Fried Shrimp", "🍙": "Rice Ball", "🍚": "Cooked Rice",
"🍘": "Rice Cracker", "🥠": "Fortune Cookie", "🍢": "Oden", "🍡": "Dango", "🍧": "Shaved Ice",
"🍨": "Ice Cream", "🍦": "Soft Ice Cream", "🥧": "Pie", "🧁": "Cupcake", "🍰": "Shortcake",
"🎂": "Birthday Cake", "🍮": "Custard", "🍭": "Lollipop", "🍬": "Candy", "🍫": "Chocolate Bar",
"🍿": "Popcorn", "🍩": "Doughnut", "🍪": "Cookie", "🌰": "Chestnut", "🥜": "Peanuts",
"🍯": "Honey Pot", "🥛": "Glass of Milk", "☕️": "Hot Beverage", "🍵": "Teacup Without Handle",
"🧃": "Beverage Box", "🥤": "Cup with Straw", "🧋": "Bubble Tea", "🍶": "Sake", "🍺": "Beer Mug",
"🍻": "Clinking Beer Mugs", "🥂": "Clinking Glasses", "🍷": "Wine Glass", "🥃": "Tumbler Glass",
"🍸": "Cocktail Glass", "🍹": "Tropical Drink", "🧉": "Mate", "🍾": "Bottle with Popping Cork",
"🧊": "Ice", "🥄": "Spoon", "🍴": "Fork and Knife", "🍽": "Fork and Knife with Plate",
"🥣": "Bowl with Spoon", "🥢": "Chopsticks", "🧂": "Salt",
"🚗": "Automobile", "🚕": "Taxi", "🚙": "Sport Utility Vehicle", "🚌": "Bus", "🚎": "Trolleybus",
"🏎": "Racing Car", "🚓": "Police Car", "🚑": "Ambulance", "🚒": "Fire Engine", "🚐": "Minibus",
"🚚": "Delivery Truck", "🚛": "Articulated Lorry", "🚜": "Tractor", "🛴": "Kick Scooter",
"🚲": "Bicycle", "🛵": "Motor Scooter", "🏍": "Motorcycle", "🚨": "Police Car Light",
"✈️": "Airplane", "🛫": "Airplane Departure", "🛬": "Airplane Arrival", "🚀": "Rocket",
"🛸": "Flying Saucer", "🚁": "Helicopter", "🛶": "Canoe", "⛵️": "Sailboat", "🚤": "Speedboat",
"🛳": "Passenger Ship", "🚢": "Ship", "⚓️": "Anchor", "⛽️": "Fuel Pump", "🚧": "Construction",
"🚦": "Vertical Traffic Light", "🚥": "Horizontal Traffic Light", "🗺": "World Map",
"🗿": "Moai", "🗽": "Statue of Liberty", "🗼": "Tokyo Tower", "🏰": "Castle", "🏯": "Japanese Castle",
"🏟": "Stadium", "🎡": "Ferris Wheel", "🎢": "Roller Coaster", "🎠": "Carousel Horse",
"⛲️": "Fountain", "🏖": "Beach with Umbrella", "🏝": "Desert Island", "🏜": "Desert",
"🌋": "Volcano", "⛰": "Mountain", "🏔": "Snow-Capped Mountain", "🗻": "Mount Fuji",
"🏕": "Camping", "⛺️": "Tent", "🏠": "House", "🏡": "House with Garden", "🏘": "Houses",
"🏗": "Building Construction", "🏭": "Factory", "🏢": "Office Building", "🏬": "Department Store",
"🏣": "Japanese Post Office", "🏥": "Hospital", "🏦": "Bank", "🏨": "Hotel", "🏪": "Convenience Store",
"🏫": "School", "🏩": "Love Hotel", "💒": "Wedding", "🏛": "Classical Building",
"⛪️": "Church", "🕌": "Mosque", "🕍": "Synagogue", "🛕": "Hindu Temple", "🕋": "Kaaba",
"⛩": "Shinto Shrine", "🌅": "Sunrise", "🌄": "Sunrise Over Mountains", "🌠": "Shooting Star",
"🎇": "Sparkler", "🎆": "Fireworks", "🌇": "Sunset", "🌆": "Cityscape at Dusk",
"🏙": "Cityscape", "🌃": "Night with Stars", "🌌": "Milky Way", "🌉": "Bridge at Night",
"🌁": "Foggy",
"⚽️": "Soccer Ball", "🏀": "Basketball", "🏈": "American Football", "⚾️": "Baseball",
"🎾": "Tennis", "🏐": "Volleyball", "🎱": "Pool 8 Ball", "🏓": "Ping Pong",
"🏸": "Badminton", "🏒": "Ice Hockey", "🏑": "Field Hockey", "🥍": "Lacrosse",
"🏏": "Cricket Game", "🥅": "Goal Net", "⛳️": "Flag in Hole", "🏹": "Bow and Arrow",
"🎣": "Fishing Pole", "🥊": "Boxing Glove", "🥋": "Martial Arts Uniform", "🛹": "Skateboard",
"🛼": "Roller Skate", "⛸": "Ice Skate", "🎿": "Skis", "⛷": "Skier", "🏂": "Snowboarder",
"🏋️": "Person Lifting Weights", "🤼": "People Wrestling", "🤸": "Person Cartwheeling",
"⛹️": "Person Bouncing Ball", "🤺": "Person Fencing", "🤾": "Person Playing Handball",
"🏌️": "Person Golfing", "🏇": "Horse Racing", "🧘": "Person in Lotus Position",
"🏄": "Person Surfing", "🏊": "Person Swimming", "🤽": "Person Playing Water Polo",
"🚣": "Person Rowing Boat", "🧗": "Person Climbing", "🚵": "Person Mountain Biking",
"🚴": "Person Biking", "🏆": "Trophy", "🥇": "1st Place Medal", "🥈": "2nd Place Medal",
"🥉": "3rd Place Medal", "🏅": "Sports Medal", "🎫": "Ticket", "🎟": "Admission Tickets",
"🎪": "Circus Tent", "🤹": "Person Juggling", "🎭": "Performing Arts", "🎨": "Artist Palette",
"🎬": "Clapper Board", "🎤": "Microphone", "🎧": "Headphone", "🎼": "Musical Score",
"🎹": "Musical Keyboard", "🥁": "Drum", "🎷": "Saxophone", "🎺": "Trumpet",
"🎸": "Guitar", "🎻": "Violin", "🎲": "Game Die", "♟": "Chess Pawn", "🎯": "Bullseye",
"🎳": "Bowling", "🎮": "Video Game", "🎰": "Slot Machine", "🧩": "Puzzle Piece",
"⌚️": "Watch", "📱": "Mobile Phone", "📲": "Mobile Phone with Arrow", "💻": "Laptop",
"⌨️": "Keyboard", "🖥": "Desktop Computer", "🖨": "Printer", "🖱": "Computer Mouse",
"🖲": "Trackball", "🕹": "Joystick", "💽": "Computer Disk", "💾": "Floppy Disk",
"💿": "Optical Disk", "📀": "DVD", "📷": "Camera", "📸": "Camera with Flash",
"📹": "Video Camera", "🎥": "Movie Camera", "📽": "Film Projector", "📞": "Telephone Receiver",
"☎️": "Telephone", "📟": "Pager", "📠": "Fax Machine", "📺": "Television", "📻": "Radio",
"🎙": "Studio Microphone", "⏱": "Stopwatch", "⏲": "Timer Clock", "⏰": "Alarm Clock",
"🕰": "Mantelpiece Clock", "⌛️": "Hourglass Done", "⏳": "Hourglass Not Done",
"📡": "Satellite Antenna", "🔋": "Battery", "🔌": "Electric Plug", "💡": "Light Bulb",
"🔦": "Flashlight", "🕯": "Candle", "💸": "Money with Wings", "💵": "Dollar Banknote",
"💴": "Yen Banknote", "💶": "Euro Banknote", "💷": "Pound Banknote", "🪙": "Coin",
"💰": "Money Bag", "💳": "Credit Card", "💎": "Gem Stone", "⚖️": "Balance Scale",
"🔧": "Wrench", "🔨": "Hammer", "⚒": "Hammer and Pick", "🛠": "Hammer and Wrench",
"⛏": "Pick", "🔩": "Nut and Bolt", "⚙️": "Gear", "⛓": "Chains", "🧲": "Magnet",
"🔫": "Water Pistol", "💣": "Bomb", "🔪": "Kitchen Knife", "🗡": "Dagger", "⚔️": "Crossed Swords",
"🛡": "Shield", "🚬": "Cigarette", "⚰️": "Coffin", "⚱️": "Funeral Urn", "🏺": "Amphora",
"🔮": "Crystal Ball", "📿": "Prayer Beads", "💈": "Barber Pole", "⚗️": "Alembic",
"🔭": "Telescope", "🔬": "Microscope", "💊": "Pill", "💉": "Syringe", "🩸": "Drop of Blood",
"🧬": "DNA", "🦠": "Microbe", "🧪": "Test Tube", "🌡": "Thermometer", "🧹": "Broom",
"🧺": "Basket", "🧻": "Roll of Paper", "🚽": "Toilet", "🚿": "Shower", "🛁": "Bathtub",
"🛀": "Person Taking Bath", "🪥": "Toothbrush", "🧼": "Soap", "🧽": "Sponge",
"🧴": "Lotion Bottle", "🛎": "Bellhop Bell", "🔑": "Key", "🗝": "Old Key", "🚪": "Door",
"🪑": "Chair", "🛋": "Couch and Lamp", "🛏": "Bed", "🛌": "Person in Bed",
"🧸": "Teddy Bear", "🖼": "Framed Picture", "🪞": "Mirror", "🛍": "Shopping Bags",
"🛒": "Shopping Cart", "🎁": "Wrapped Gift", "🎈": "Balloon", "🎏": "Wind Chime",
"🎀": "Ribbon", "🎊": "Confetti Ball", "🎉": "Party Popper", "🎎": "Japanese Dolls",
"🏮": "Red Paper Lantern", "🎐": "Wind Chime", "🧧": "Red Envelope",
"✉️": "Envelope", "📩": "Envelope with Arrow", "📨": "Incoming Envelope",
"📧": "E-Mail", "💌": "Love Letter", "📥": "Inbox Tray", "📤": "Outbox Tray",
"📦": "Package", "🏷": "Label", "🪧": "Placard", "📪": "Closed Mailbox with Lowered Flag",
"📫": "Closed Mailbox with Raised Flag", "📬": "Open Mailbox with Raised Flag",
"📭": "Open Mailbox with Lowered Flag", "📮": "Postbox", "📯": "Postal Horn",
"📜": "Scroll", "📃": "Page with Curl", "📄": "Page Facing Up", "📑": "Bookmark Tabs",
"🧾": "Receipt", "📊": "Bar Chart", "📈": "Chart Increasing", "📉": "Chart Decreasing",
"🗒": "Spiral Notepad", "🗓": "Spiral Calendar", "📆": "Tear-Off Calendar",
"📅": "Calendar", "🗑": "Wastebasket", "📇": "Card Index", "🗃": "Card File Box",
"🗳": "Ballot Box with Ballot", "🗄": "File Cabinet", "📋": "Clipboard",
"📁": "File Folder", "📂": "Open File Folder", "🗂": "Card Index Dividers",
"🗞": "Rolled-Up Newspaper", "📰": "Newspaper", "📓": "Notebook",
"📔": "Notebook with Decorative Cover", "📒": "Ledger", "📕": "Closed Book",
"📗": "Green Book", "📘": "Blue Book", "📙": "Orange Book", "📚": "Books",
"📖": "Open Book", "🔖": "Bookmark", "🧷": "Safety Pin", "🔗": "Link",
"📎": "Paperclip", "🖇": "Linked Paperclips", "📐": "Triangular Ruler",
"📏": "Straight Ruler", "🧮": "Abacus", "📌": "Pushpin", "📍": "Round Pushpin",
"✂️": "Scissors", "🖊": "Pen", "🖋": "Fountain Pen", "✒️": "Black Nib",
"🖌": "Paintbrush", "🖍": "Crayon", "📝": "Memo", "✏️": "Pencil",
"🔍": "Magnifying Glass Tilted Left", "🔎": "Magnifying Glass Tilted Right",
"🔏": "Locked with Pen", "🔐": "Locked with Key", "🔒": "Locked", "🔓": "Unlocked",
"❤️": "Red Heart", "🧡": "Orange Heart", "💛": "Yellow Heart", "💚": "Green Heart",
"💙": "Blue Heart", "💜": "Purple Heart", "🖤": "Black Heart", "🤍": "White Heart",
"🤎": "Brown Heart", "💔": "Broken Heart", "❣️": "Heart Exclamation",
"💕": "Two Hearts", "💞": "Revolving Hearts", "💓": "Beating Heart",
"💗": "Growing Heart", "💖": "Sparkling Heart", "💘": "Heart with Arrow",
"💝": "Heart with Ribbon", "💟": "Heart Decoration",
"☮️": "Peace Symbol", "✝️": "Latin Cross", "☪️": "Star and Crescent",
"🕉": "Om", "☸️": "Wheel of Dharma", "✡️": "Star of David",
"🔯": "Dotted Six-Pointed Star", "🕎": "Menorah", "☯️": "Yin Yang",
"☦️": "Orthodox Cross", "⛎": "Ophiuchus", "♈️": "Aries", "♉️": "Taurus",
"♊️": "Gemini", "♋️": "Cancer", "♌️": "Leo", "♍️": "Virgo", "♎️": "Libra",
"♏️": "Scorpio", "♐️": "Sagittarius", "♑️": "Capricorn", "♒️": "Aquarius",
"♓️": "Pisces", "🆔": "ID Button", "⚛️": "Atom Symbol", "🉑": "Japanese Acceptable Button",
"☢️": "Radioactive", "☣️": "Biohazard", "📴": "Mobile Phone Off",
"📳": "Vibration Mode", "🈶": "Japanese Not Free of Charge Button",
"🈚️": "Japanese Free of Charge Button", "🈸": "Japanese Application Button",
"🈺": "Japanese Open for Business Button", "🈷️": "Japanese Monthly Amount Button",
"✴️": "Eight-Pointed Star", "🆚": "VS Button", "💮": "White Flower",
"🉐": "Japanese Bargain Button", "㊙️": "Japanese Secret Button",
"㊗️": "Japanese Congratulations Button", "🈴": "Japanese Passing Grade Button",
"🈵": "Japanese No Vacancy Button", "🈹": "Japanese Discount Button",
"🈲": "Japanese Prohibited Button", "🅰️": "A Button", "🅱️": "B Button",
"🆎": "AB Button", "🆑": "CL Button", "🅾️": "O Button", "🆘": "SOS Button",
"❌": "Cross Mark", "⭕️": "Heavy Large Circle", "🛑": "Stop Sign",
"⛔️": "No Entry", "📛": "Name Badge", "🚫": "Prohibited", "💯": "Hundred Points",
"💢": "Anger Symbol", "♨️": "Hot Springs", "🚷": "No Pedestrians",
"🚯": "No Littering", "🚳": "No Bicycles", "🚱": "Non-Potable Water",
"🔞": "No One Under Eighteen", "📵": "No Mobile Phones", "🚭": "No Smoking",
"❗️": "Heavy Exclamation Mark", "❕": "White Exclamation Mark",
"❓": "Question Mark", "❔": "White Question Mark", "‼️": "Double Exclamation Mark",
"⁉️": "Exclamation Question Mark", "🔅": "Low Brightness Symbol",
"🔆": "High Brightness Symbol", "〽️": "Part Alternation Mark",
"⚠️": "Warning", "🚸": "Children Crossing", "🔱": "Trident Emblem",
"⚜️": "Fleur-de-lis", "🔰": "Japanese Symbol for Beginner", "♻️": "Recycling Symbol",
"✅": "White Heavy Check Mark", "🈯️": "Japanese Reserved Button", "💹": "Chart Increasing with Yen",
"❇️": "Sparkle", "✳️": "Eight-Spoked Asterisk", "❎": "Negative Squared Cross Mark",
"🌐": "Globe with Meridians", "💠": "Diamond with a Dot", "Ⓜ️": "Circled M",
"🌀": "Cyclone", "💤": "Zzz", "🏧": "ATM Sign", "🚾": "Water Closet",
"♿️": "Wheelchair Symbol", "🅿️": "Parking Button", "🈳": "Japanese Vacancy Button",
"🈂️": "Japanese Service Charge Button", "🛂": "Passport Control",
"🛃": "Customs", "🛄": "Baggage Claim", "🛅": "Left Luggage",
"🛗": "Elevator", "⚧️": "Transgender Symbol", "🚹": "Men's Room",
"🚺": "Women's Room", "🚼": "Baby Symbol", "🚻": "Restroom",
"🚮": "Put Litter in Its Place Symbol", "🎦": "Cinema",
"📶": "Antenna Bars", "🈁": "Japanese Here Button", "🔣": "Input Symbol for Symbols",
"ℹ️": "Information", "🔤": "Input Latin Letters", "🔡": "Input Latin Lowercase",
"🔠": "Input Latin Uppercase", "🆖": "NG Button", "🆗": "OK Button",
"🆙": "UP! Button", "🆒": "Cool Button", "🆕": "New Button", "🆓": "Free Button",
"0️⃣": "Keycap 0", "1️⃣": "Keycap 1", "2️⃣": "Keycap 2", "3️⃣": "Keycap 3",
"4️⃣": "Keycap 4", "5️⃣": "Keycap 5", "6️⃣": "Keycap 6", "7️⃣": "Keycap 7",
"8️⃣": "Keycap 8", "9️⃣": "Keycap 9", "🔟": "Keycap 10", "🔢": "Input Numbers",
"#️⃣": "Keycap #", "*️⃣": "Keycap *", "⏏️": "Eject Button",
"▶️": "Play Button", "⏸": "Pause Button", "⏯": "Play or Pause Button",
"⏹": "Stop Button", "⏺": "Record Button", "⏭": "Next Track Button",
"⏮": "Last Track Button", "⏩": "Fast-Forward Button",
"⏪": "Fast Reverse Button", "⏫": "Fast Up Button", "⏬": "Fast Down Button",
"◀️": "Reverse Button", "🔼": "Upwards Button", "🔽": "Downwards Button",
"➡️": "Right Arrow", "⬅️": "Left Arrow", "⬆️": "Up Arrow", "⬇️": "Down Arrow",
"↗️": "Up-Right Arrow", "↘️": "Down-Right Arrow", "↙️": "Down-Left Arrow",
"↖️": "Up-Left Arrow", "↕️": "Up-Down Arrow", "↔️": "Left-Right Arrow",
"↪️": "Right Arrow Curving Left", "↩️": "Left Arrow Curving Right",
"⤴️": "Right Arrow Curving Up", "⤵️": "Right Arrow Curving Down",
"🔀": "Shuffle Tracks Button", "🔁": "Repeat Button", "🔂": "Repeat Single Button",
"🔄": "Counterclockwise Arrows Button", "🔃": "Clockwise Vertical Arrows",
"🎵": "Musical Note", "🎶": "Musical Notes", "➕": "Heavy Plus Sign",
"➖": "Heavy Minus Sign", "➗": "Heavy Division Sign", "✖️": "Heavy Multiplication X",
"💲": "Heavy Dollar Sign", "💱": "Currency Exchange", "™️": "Trade Mark",
"©️": "Copyright", "®️": "Registered", "🔚": "END Arrow", "🔙": "BACK Arrow",
"🔛": "ON! Arrow", "🔝": "TOP Arrow", "🔜": "SOON Arrow", "〰️": "Wavy Dash",
"➰": "Curly Loop", "➿": "Double Curly Loop", "✔️": "Heavy Check Mark",
"☑️": "Ballot Box with Check", "🔘": "Radio Button", "🔴": "Red Circle",
"🟠": "Orange Circle", "🟡": "Yellow Circle", "🟢": "Green Circle",
"🔵": "Blue Circle", "🟣": "Purple Circle", "⚫️": "Black Circle",
"⚪️": "White Circle", "🟤": "Brown Circle", "🔺": "Red Triangle Pointed Up",
"🔻": "Red Triangle Pointed Down", "🔸": "Small Orange Diamond",
"🔹": "Small Blue Diamond", "🔶": "Large Orange Diamond",
"🔷": "Large Blue Diamond", "🔳": "White Square Button",
"🔲": "Black Square Button", "▪️": "Black Small Square",
"▫️": "White Small Square", "◾️": "Black Medium-Small Square",
"◽️": "White Medium-Small Square", "◼️": "Black Medium Square",
"◻️": "White Medium Square", "🟥": "Red Square", "🟧": "Orange Square",
"🟨": "Yellow Square", "🟩": "Green Square", "🟦": "Blue Square",
"🟪": "Purple Square", "⬛️": "Black Large Square", "⬜️": "White Large Square",
"🟫": "Brown Square", "🔈": "Speaker Low Volume", "🔇": "Muted Speaker",
"🔉": "Speaker Medium Volume", "🔊": "Speaker High Volume",
"🔔": "Bell", "🔕": "Bell with Slash", "📣": "Megaphone", "📢": "Loudspeaker",
"💬": "Speech Balloon", "💭": "Thought Balloon", "🗯": "Right Anger Bubble",
"♠️": "Spade Suit", "♣️": "Club Suit", "♥️": "Heart Suit", "♦️": "Diamond Suit",
"🃏": "Joker", "🎴": "Flower Playing Cards", "🀄️": "Mahjong Red Dragon",
"🕐": "One O'Clock", "🕑": "Two O'Clock", "🕒": "Three O'Clock",
"🕓": "Four O'Clock", "🕔": "Five O'Clock", "🕕": "Six O'Clock",
"🕖": "Seven O'Clock", "🕗": "Eight O'Clock", "🕘": "Nine O'Clock",
"🕙": "Ten O'Clock", "🕚": "Eleven O'Clock", "🕛": "Twelve O'Clock"
};
const EMOJI_NAMES_RU: Record<string, string> = {
"😀": "Улыбающееся лицо", "😃": "Смеющееся лицо", "😄": "Смеющееся лицо с радостными глазами",
"😁": "Сияющее лицо", "😆": "Хихикающее лицо", "😅": "Смеющееся лицо с потом",
"😂": "Лицо со слезами радости", "🤣": "Катаюсь от смеха", "😊": "Улыбающееся лицо с улыбающимися глазами",
"😇": "Улыбающееся лицо с нимбом", "🙂": "Слегка улыбающееся лицо", "🙃": "Перевернутое лицо",
"😉": "Подмигивающее лицо", "😌": "Блаженное лицо", "😍": "Влюбленное лицо",
"🥰": "Лицо с сердечками", "😘": "Целующее лицо", "😗": "Целующее лицо",
"😙": "Целующее лицо с улыбающимися глазами", "😚": "Целующее лицо с закрытыми глазами",
"😋": "Лицо, смакующее еду", "😛": "Лицо с высунутым языком",
"😝": "Хитрое лицо с высунутым языком", "😜": "Подмигивающее лицо с языком",
"🤪": "Чокнутое лицо", "🤨": "Лицо с приподнятой бровью", "🧐": "Лицо с моноклем",
"🤓": "Заучка", "😎": "Крутой в очках", "🤩": "В восторге", "🥳": "Веселящееся лицо",
"😏": "Хитро улыбающееся лицо", "😒": "Недовольное лицо", "😞": "Разочарованное лицо",
"😔": "Задумчивое лицо", "😟": "Обеспокоенное лицо", "😕": "Озадаченное лицо",
"🙁": "Слегка нахмуренное лицо", "☹️": "Нахмуренное лицо", "😣": "Терпящее лицо",
"😖": "Смущенное лицо", "😫": "Уставшее лицо", "😩": "Измученное лицо",
"🥺": "Молящее лицо", "😢": "Плачущее лицо", "😭": "Громко плачущее лицо",
"😤": "Лицо с паром из носа", "😠": "Злое лицо", "😡": "Красное злое лицо",
"🤬": "Лицо с матами", "🤯": "Взрыв мозга", "😳": "Покрасневшее лицо",
"🥵": "Жаркое лицо", "🥶": "Замерзшее лицо", "😱": "Кричащее в ужасе лицо",
"😨": "Испуганное лицо", "😰": "Взволнованное лицо", "😥": "Грустное, но облегченное лицо",
"😓": "Лицо в холодном поту", "🤗": "Обнимающее лицо", "🤔": "Думающее лицо",
"🤭": "Лицо с рукой у рта", "🤫": "Тссс", "🤥": "Врущее лицо",
"😶": "Лицо без рта", "😐": "Нейтральное лицо", "😑": "Безразличное лицо",
"😬": "Гримасничающее лицо", "🙄": "Лицо с закатывающимися глазами",
"😯": "Удивленное лицо", "😦": "Нахмуренное лицо с открытым ртом",
"😧": "Страдающее лицо", "😮": "Лицо с открытым ртом", "😲": "Ошеломленное лицо",
"🥱": "Зевающее лицо", "😴": "Спящее лицо", "🤤": "Пускающее слюни лицо",
"😪": "Сонное лицо", "😵": "Головокружение", "🤐": "Рот на замке",
"🥴": "Одурелое лицо", "🤢": "Тошнотворное лицо", "🤮": "Рвущее лицо",
"🤧": "Чихающее лицо", "😷": "Лицо в маске", "🤒": "Лицо с градусником",
"🤕": "Лицо с повязкой", "🤑": "Лицо с деньгами", "🤠": "Ковбой",
"😈": "Улыбающийся чертенок", "👿": "Злой чертенок", "👹": "Они",
"👺": "Тенгу", "🤡": "Клоун", "💩": "Какашка", "👻": "Привидение",
"💀": "Череп", "☠️": "Череп и кости", "👽": "Пришелец", "👾": "Инопланетный монстр",
"🤖": "Робот", "🎃": "Тыква", "😺": "Улыбающийся кот", "😸": "Смеющийся кот",
"😹": "Кот со слезами", "😻": "Влюбленный кот", "😼": "Кот с ухмылкой",
"😽": "Целующий кот", "🙀": "Уставший кот", "😿": "Плачущий кот", "😾": "Дуующийся кот",
"👍": "Большой палец вверх", "👎": "Большой палец вниз", "👏": "Аплодисменты",
"🙌": "Руки вверх", "🫶": "Сердце из рук", "👐": "Раскрытые луадони",
"🙏": "Сложенные руки", "💪": "Бицепс", "👋": "Махающая рука",
"❤️": "Красное сердце", "🧡": "Оранжевое сердце", "💛": "Желтое сердце",
"💚": "Зеленое сердце", "💙": "Синее сердце", "💜": "Фиолетовое сердце",
"🖤": "Черное сердце", "🤍": "Белое сердце", "🤎": "Коричневое сердце",
"💔": "Разбитое сердце", "💕": "Два сердца", "💖": "Сверкающее сердце",
"💘": "Сердце со стрелой", "💝": "Сердце с лентой",
"🐶": "Собака", "🐱": "Кошка", "🐭": "Мышь", "🐹": "Хомяк",
"🐰": "Кролик", "🦊": "Лиса", "🐻": "Медведь", "🐼": "Панда",
"🐨": "Коала", "🐯": "Тигр", "🦁": "Лев", "🐮": "Корова",
"🐷": "Свинья", "🐸": "Лягушка", "🐵": "Обезьяна", "🐔": "Курица",
"🐧": "Пингвин", "🐦": "Птица", "🐤": "Цыпленок", "🦆": "Утка",
"🦅": "Орел", "🦉": "Сова", "🦇": "Летучая мышь", "🐺": "Волк",
"🐴": "Лошадь", "🦄": "Единорог", "🐝": "Пчела", "🦋": "Бабочка",
"🐌": "Улитка", "🐞": "Божья коровка", "🐢": "Черепаха", "🐍": "Змея",
"🦎": "Ящерица", "🦖": "Ти-Рекс", "🦕": "Динозавр", "🐙": "Осьминог",
"🦑": "Кальмар", "🦐": "Креветка", "🦞": "Лобстер", "🦀": "Краб",
"🐡": "Рыба-фугу", "🐠": "Тропическая рыба", "🐟": "Рыба",
"🐬": "Дельфин", "🐳": "Кит", "🦈": "Акула", "🐊": "Крокодил",
"🐅": "Тигр", "🐆": "Леопард", "🦓": "Зебра", "🦍": "Горилла",
"🐘": "Слон", "🦛": "Бегемот", "🦏": "Носорог", "🐪": "Верблюд",
"🐫": "Двугорбый верблюд", "🦒": "Жираф", "🦘": "Кенгуру",
"🐎": "Лошадь", "🐖": "Свинья", "🐏": "Баран", "🐑": "Овца",
"🦙": "Лама", "🐐": "Коза", "🦌": "Олень", "🐕": "Собака",
"🐈": "Кошка", "🐇": "Кролик", "🐁": "Мышь", "🐀": "Крыса",
"🦔": "Еж", "🐾": "Следы", "🐉": "Дракон", "🐲": "Дракон",
"🍏": "Зеленое яблоко", "🍎": "Красное яблоко", "🍐": "Груша",
"🍊": "Мандарин", "🍋": "Лимон", "🍌": "Банан", "🍉": "Арбуз",
"🍇": "Виноград", "🍓": "Клубника", "🍒": "Вишня", "🍑": "Персик",
"🥭": "Манго", "🍍": "Ананас", "🥥": "Кокос", "🥝": "Киви",
"🍅": "Помидор", "🍆": "Баклажан", "🥑": "Авокадо", "🥦": "Брокколи",
"🥒": "Огурец", "🌶": "Перец чили", "🌽": "Кукуруза", "🥕": "Морковь",
"🧄": "Чеснок", "🧅": "Лук", "🥔": "Картофель", "🥐": "Круассан",
"🍞": "Хлеб", "🥖": "Багет", "🧀": "Сыр", "🥚": "Яйцо",
"🍳": "Яичница", "🥞": "Блины", "🥓": "Бекон", "🥩": "Мясо",
"🍗": "Куриная ножка", "🍖": "Мясо на кости", "🌭": "Хот-дог",
"🍔": "Гамбургер", "🍟": "Картошка фри", "🍕": "Пицца", "🥪": "Сэндвич",
"🌮": "Тако", "🌯": "Буррито", "🥗": "Салат", "🍝": "Спагетти",
"🍜": "Рамен", "🍲": "Суп", "🍛": "Карри", "🍣": "Суши",
"🍱": "Бенто", "🥟": "Пельмени", "🍤": "Креветка темпура",
"🍙": "Онигири", "🍚": "Рис", "🍦": "Мороженое", "🍰": "Торт",
"🎂": "Торт на день рождения", "🍭": "Леденец", "🍬": "Конфета",
"🍫": "Шоколад", "🍿": "Попкорн", "🍩": "Пончик", "🍪": "Печенье",
"🥛": "Молоко", "☕️": "Кофе", "🍵": "Чай", "🧃": "Сок",
"🥤": "Стакан", "🍺": "Пиво", "🍷": "Вино", "🍸": "Коктейль",
"🚗": "Машина", "🚕": "Такси", "🚌": "Автобус", "🚓": "Полицейская машина",
"🚑": "Скорая помощь", "✈️": "Самолет", "🚀": "Ракета",
"🛸": "Летающая тарелка", "🚁": "Вертолет", "🚢": "Корабль",
"⚓️": "Якорь", "🏰": "Замок", "🗽": "Статуя Свободы",
"🗼": "Токийская башня", "🎡": "Колесо обозрения", "🎢": "Американские горки",
"🏖": "Пляж", "🏝": "Остров", "🌋": "Вулкан", "🏔": "Гора со снегом",
"🏠": "Дом", "⛺️": "Палатка", "🏨": "Отель", "🏫": "Школа",
"⛪️": "Церковь", "🕌": "Мечеть", "🌅": "Рассвет", "🎆": "Фейерверк",
"⚽️": "Футбол", "🏀": "Баскетбол", "🏈": "Американский футбол",
"🎾": "Теннис", "🏐": "Волейбол", "🎱": "Бильярд", "🏓": "Настольный теннис",
"🏸": "Бадминтон", "🥊": "Бокс", "🎿": "Лыжи", "🏂": "Сноуборд",
"🏄": "Серфинг", "🏊": "Плавание", "🚴": "Велосипед", "🏆": "Кубок",
"🥇": "Золото", "🥈": "Серебро", "🥉": "Бронза", "🎤": "Микрофон",
"🎧": "Наушники", "🎹": "Пианино", "🥁": "Барабан", "🎷": "Саксофон",
"🎸": "Гитара", "🎻": "Скрипка", "🎲": "Игральная кость",
"🎯": "Дартс", "🎮": "Видеоигра", "🎰": "Игровой автомат",
"📱": "Телефон", "💻": "Ноутбук", "⌨️": "Клавиатура",
"🖥": "Компьютер", "📷": "Фотоаппарат", "📺": "Телевизор",
"⏰": "Будильник", "💡": "Лампочка", "🔋": "Батарейка",
"💰": "Мешок денег", "💎": "Бриллиант", "🔑": "Ключ",
"🚪": "Дверь", "🎁": "Подарок", "🎈": "Шарик",
"✉️": "Конверт", "📦": "Посылка", "📚": "Книги",
"📝": "Записка", "✏️": "Карандаш", "🔍": "Лупа",
"🔒": "Замок", "🔓": "Открытый замок", "❤️‍🔥": "Горящее сердце",
"❤️‍🩹": "Заживающее сердце", "💯": "Сто баллов", "💢": "Знак злости",
"♨️": "Горячие источники", "❗️": "Восклицательный знак",
"❓": "Вопросительный знак", "‼️": "Двойной восклицательный знак",
"⚠️": "Предупреждение", "♻️": "Переработка", "✅": "Галочка",
"❌": "Крестик", "⭕️": "Круг", "🚫": "Запрещено",
"🔴": "Красный круг", "🟠": "Оранжевый круг", "🟡": "Желтый круг",
"🟢": "Зеленый круг", "🔵": "Синий круг", "🟣": "Фиолетовый круг",
"⚫️": "Черный круг", "⚪️": "Белый круг",
"🟥": "Красный квадрат", "🟧": "Оранжевый квадрат",
"🟨": "Желтый квадрат", "🟩": "Зеленый квадрат",
"🟦": "Синий квадрат", "🟪": "Фиолетовый квадрат",
"🔈": "Динамик", "🔔": "Колокольчик",
"💬": "Облачко речи", "♠️": "Пики", "♣️": "Трефы",
"♥️": "Черви", "♦️": "Бубны", "🀄️": "Маджонг",
"🕐": "Час", "➡️": "Стрелка вправо", "⬅️": "Стрелка влево",
"⬆️": "Стрелка вверх", "⬇️": "Стрелка вниз"
};
const categoryEl = document.getElementById("eg-category") as HTMLSelectElement;
const countEl = document.getElementById("eg-count") as HTMLInputElement;
const btn = document.getElementById("eg-btn") as HTMLButtonElement;
const resultEl = document.getElementById("eg-result") as HTMLDivElement;
const copyAllBtn = document.getElementById("eg-copy-all-btn") as HTMLButtonElement;
const copyAllLabel = document.getElementById("eg-copy-all-label") as HTMLSpanElement;
const copyAllIcon = document.getElementById("eg-copy-all-icon") as HTMLSpanElement;
const copyAllCheck = document.getElementById("eg-copy-all-check") as HTMLSpanElement;
const errorEl = document.getElementById("eg-error") as HTMLParagraphElement;
const errors = createErrorDisplay(errorEl);
function shuffleArray<T>(arr: T[]): T[] {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
function getEmojiName(emoji: string): string {
if (isRu) {
return EMOJI_NAMES_RU[emoji] || EMOJI_NAMES_EN[emoji] || emoji;
}
return EMOJI_NAMES_EN[emoji] || emoji;
}
function getPool(category: string): string[] {
if (category === "all") {
return Object.values(EMOJIS).flat();
}
return EMOJIS[category] || [];
}
function getRandomEmojis(count: number, category: string): string[] {
const pool = getPool(category);
const shuffled = shuffleArray(pool);
return shuffled.slice(0, Math.min(count, shuffled.length));
}
function createEmojiCard(emoji: string): HTMLButtonElement {
const wrapper = document.createElement("button");
wrapper.type = "button";
wrapper.className =
"group relative flex items-center justify-center w-16 h-16 sm:w-20 sm:h-20 rounded-2xl bg-zinc-900 border border-zinc-800 hover:border-zinc-600 hover:bg-zinc-800/60 transition-all duration-150 cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950";
wrapper.setAttribute("aria-label", `${getEmojiName(emoji)} (${emoji})`);
wrapper.setAttribute("title", `${getEmojiName(emoji)} (${emoji})`);
const span = document.createElement("span");
span.textContent = emoji;
span.className = "text-3xl sm:text-4xl select-none";
span.style.transition = "transform 0.12s cubic-bezier(0.34,1.56,0.64,1)";
wrapper.appendChild(span);
wrapper.addEventListener("click", async () => {
await navigator.clipboard.writeText(emoji);
// Flash copied state
const originalTitle = wrapper.getAttribute("title") || "";
wrapper.setAttribute("title", TOOLTIP_COPIED);
span.style.transform = "scale(1.25)";
setTimeout(() => {
span.style.transform = "scale(1)";
wrapper.setAttribute("title", originalTitle);
}, 300);
});
return wrapper;
}
function generate() {
const countVal = parseInt(countEl.value, 10);
if (!Number.isInteger(countVal) || countVal < 1 || countVal > 50) {
errors.show(ERR_COUNT_RANGE);
return;
}
errors.clear();
const category = categoryEl.value;
const emojis = getRandomEmojis(countVal, category);
resultEl.innerHTML = "";
emojis.forEach((emoji, i) => {
const card = createEmojiCard(emoji);
resultEl.appendChild(card);
// Stagger pop animation
requestAnimationFrame(() => {
requestAnimationFrame(() => {
popElement(card.querySelector("span") as HTMLElement);
});
});
});
if (emojis.length > 0) {
copyAllBtn.classList.remove("invisible");
copyAllLabel.textContent = COPY_ALL_LABEL;
copyAllCheck.classList.add("hidden");
copyAllIcon.classList.remove("hidden");
}
}
async function copyAll() {
const emojis = Array.from(resultEl.querySelectorAll("button"))
.map((btn) => btn.querySelector("span")?.textContent || "")
.filter(Boolean)
.join("");
if (!emojis) return;
await navigator.clipboard.writeText(emojis);
copyAllLabel.textContent = COPIED_LABEL;
copyAllIcon.classList.add("hidden");
copyAllCheck.classList.remove("hidden");
setTimeout(() => {
copyAllLabel.textContent = COPY_ALL_LABEL;
copyAllCheck.classList.add("hidden");
copyAllIcon.classList.remove("hidden");
}, 1500);
}
btn.addEventListener("click", generate);
copyAllBtn.addEventListener("click", copyAll);
countEl.addEventListener("keydown", (e) => {
if (e.key === "Enter") generate();
});
categoryEl.addEventListener("keydown", (e) => {
if (e.key === "Enter") generate();
});
// Generate on load
generate();
</script>
@@ -0,0 +1,483 @@
---
import { useT } from "../../i18n/translations";
const isRu = Astro.url.pathname.startsWith("/ru");
const T = useT(isRu ? "ru" : "en");
const i18n = {
generate: T.generate,
copied: T.copied,
copy: T.copy,
headingLabel: isRu ? "Заголовок" : "Heading",
bodyLabel: isRu ? "Основной текст" : "Body Text",
copyHtmlCss: isRu ? "Копировать HTML и CSS" : "Copy HTML & CSS",
shuffleHeading: isRu ? "Перемешать заголовок" : "Shuffle heading",
shuffleBody: isRu ? "Перемешать текст" : "Shuffle body",
lockHeading: isRu ? "Закрепить заголовок" : "Lock heading",
lockBody: isRu ? "Закрепить текст" : "Lock body",
unlockHeading: isRu ? "Открепить заголовок" : "Unlock heading",
unlockBody: isRu ? "Открепить текст" : "Unlock body",
googleFonts: isRu ? "Открыть в Google Fonts" : "Open in Google Fonts",
previewHeading: isRu ? "Быстрая коричневая лиса прыгает через ленивого пса" : "The quick brown fox jumps over the lazy dog",
previewBody: isRu
? "Lorem ipsum — это текст-рыба, часто используемый в печати и веб-дизайне. Он демонстрирует, как выглядит шрифт в реальном абзаце."
: "Lorem ipspsum dolor sit amet, consectetur adipiscing elit. This sample text shows how the body font looks in a real paragraph with multiple lines of content.",
categories: {
serif: isRu ? "С засечками" : "Serif",
sans: isRu ? "Без засечек" : "Sans-Serif",
display: isRu ? "Декоративный" : "Display",
handwriting: isRu ? "Рукописный" : "Handwriting",
monospace: isRu ? "Моноширинный" : "Monospace",
},
};
---
<div id="fontpair-generator" class="mt-8">
<!-- Controls -->
<div class="flex flex-col sm:flex-row gap-4 mb-6 items-center justify-center">
<button
id="fp-shuffle-heading"
type="button"
class="px-5 py-2.5 bg-zinc-800 text-zinc-200 font-medium rounded-xl border border-zinc-700 hover:bg-zinc-700 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors duration-100 cursor-pointer text-sm"
title={i18n.shuffleHeading}
>
<span class="inline-flex items-center gap-2">
<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"><path d="M2 18h1.4c1.3 0 2.5-.6 3.3-1.7l14.4-12.6"/><path d="M22 6h-1.4c-1.3 0-2.5.6-3.3 1.7L7.7 18"/><path d="m16 3 6 3-3 6"/><path d="m8 21-6-3 3-6"/></svg>
{i18n.headingLabel}
</span>
</button>
<button
id="fp-shuffle-body"
type="button"
class="px-5 py-2.5 bg-zinc-800 text-zinc-200 font-medium rounded-xl border border-zinc-700 hover:bg-zinc-700 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors duration-100 cursor-pointer text-sm"
title={i18n.shuffleBody}
>
<span class="inline-flex items-center gap-2">
<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"><path d="M2 18h1.4c1.3 0 2.5-.6 3.3-1.7l14.4-12.6"/><path d="M22 6h-1.4c-1.3 0-2.5.6-3.3 1.7L7.7 18"/><path d="m16 3 6 3-3 6"/><path d="m8 21-6-3 3-6"/></svg>
{i18n.bodyLabel}
</span>
</button>
</div>
<!-- Result container -->
<div
id="fp-result"
class="rounded-2xl bg-zinc-900/80 border border-zinc-800/80 p-6 sm:p-8 opacity-0"
style="transition: opacity 0.25s ease;"
aria-live="polite"
>
<!-- Font info row -->
<div class="flex flex-col sm:flex-row gap-4 mb-6 justify-between items-start">
<!-- Heading font info -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="text-xs font-semibold uppercase tracking-wider text-zinc-500">{i18n.headingLabel}</span>
<button
id="fp-lock-heading"
type="button"
class="p-1 rounded-lg hover:bg-zinc-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors cursor-pointer"
title={i18n.lockHeading}
aria-pressed="false"
>
<svg id="fp-lock-heading-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" class="text-zinc-500"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 9.9-1"/><circle cx="12" cy="16" r="1"/></svg>
</button>
</div>
<a
id="fp-heading-link"
href="#"
target="_blank"
rel="noopener noreferrer"
class="text-lg font-semibold text-accent hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 rounded"
>
<span id="fp-heading-name">—</span>
</a>
<span id="fp-heading-tag" class="ml-2 inline-block px-2 py-0.5 text-xs font-medium bg-zinc-800 text-zinc-400 rounded-full border border-zinc-700/50">—</span>
</div>
<!-- Body font info -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="text-xs font-semibold uppercase tracking-wider text-zinc-500">{i18n.bodyLabel}</span>
<button
id="fp-lock-body"
type="button"
class="p-1 rounded-lg hover:bg-zinc-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors cursor-pointer"
title={i18n.lockBody}
aria-pressed="false"
>
<svg id="fp-lock-body-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" class="text-zinc-500"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 9.9-1"/><circle cx="12" cy="16" r="1"/></svg>
</button>
</div>
<a
id="fp-body-link"
href="#"
target="_blank"
rel="noopener noreferrer"
class="text-lg font-semibold text-accent hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 rounded"
>
<span id="fp-body-name">—</span>
</a>
<span id="fp-body-tag" class="ml-2 inline-block px-2 py-0.5 text-xs font-medium bg-zinc-800 text-zinc-400 rounded-full border border-zinc-700/50">—</span>
</div>
</div>
<!-- Preview card -->
<div class="rounded-xl bg-zinc-950/60 border border-zinc-800/60 p-6 sm:p-8 mb-6">
<p
id="fp-preview-heading"
class="text-2xl sm:text-3xl font-bold text-zinc-100 mb-4 leading-tight"
style="font-family: serif;"
>
{i18n.previewHeading}
</p>
<p
id="fp-preview-body"
class="text-base text-zinc-300 leading-relaxed"
style="font-family: sans-serif;"
>
{i18n.previewBody}
</p>
</div>
<!-- Copy button -->
<div class="flex justify-center">
<button
id="fp-copy"
type="button"
class="w-full sm:w-auto px-6 py-2.5 bg-zinc-800 text-zinc-200 font-medium rounded-xl border border-zinc-700 hover:bg-zinc-700 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors duration-100 cursor-pointer"
>
<span class="inline-flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
{i18n.copyHtmlCss}
</span>
</button>
</div>
</div>
<!-- Error display -->
<div id="fp-error" class="hidden mt-4 text-sm text-red-400 text-center" role="alert"></div>
<!-- Generate button -->
<div class="flex justify-center mt-6">
<button
id="fp-btn"
type="button"
class="w-full sm:w-auto px-8 py-3 bg-accent text-white font-semibold rounded-xl shadow-lg shadow-accent/25 hover:bg-accent-hover active:bg-accent-active focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors duration-100 cursor-pointer"
>
{i18n.generate}
</button>
</div>
</div>
<script>
import { createErrorDisplay } from "@/lib/client/validation";
const isRu = document.documentElement.lang === "ru";
const generateBtn = document.getElementById("fp-btn") as HTMLButtonElement;
const shuffleHeadingBtn = document.getElementById("fp-shuffle-heading") as HTMLButtonElement;
const shuffleBodyBtn = document.getElementById("fp-shuffle-body") as HTMLButtonElement;
const resultEl = document.getElementById("fp-result") as HTMLDivElement;
const copyBtn = document.getElementById("fp-copy") as HTMLButtonElement;
const errorEl = document.getElementById("fp-error") as HTMLDivElement;
const errorDisplay = createErrorDisplay(errorEl);
const headingNameEl = document.getElementById("fp-heading-name") as HTMLSpanElement;
const headingLinkEl = document.getElementById("fp-heading-link") as HTMLAnchorElement;
const headingTagEl = document.getElementById("fp-heading-tag") as HTMLSpanElement;
const previewHeadingEl = document.getElementById("fp-preview-heading") as HTMLParagraphElement;
const lockHeadingBtn = document.getElementById("fp-lock-heading") as HTMLButtonElement;
const lockHeadingIcon = document.getElementById("fp-lock-heading-icon") as SVGElement;
const bodyNameEl = document.getElementById("fp-body-name") as HTMLSpanElement;
const bodyLinkEl = document.getElementById("fp-body-link") as HTMLAnchorElement;
const bodyTagEl = document.getElementById("fp-body-tag") as HTMLSpanElement;
const previewBodyEl = document.getElementById("fp-preview-body") as HTMLParagraphElement;
const lockBodyBtn = document.getElementById("fp-lock-body") as HTMLButtonElement;
const lockBodyIcon = document.getElementById("fp-lock-body-icon") as SVGElement;
// Category labels
const categoryLabels: Record<string, string> = isRu
? { serif: "С засечками", sans: "Без засечек", display: "Декоративный", handwriting: "Рукописный", monospace: "Моноширинный" }
: { serif: "Serif", sans: "Sans-Serif", display: "Display", handwriting: "Handwriting", monospace: "Monospace" };
// Font database
interface Font {
name: string;
apiName: string;
category: string;
}
const headingFonts: Font[] = [
{ name: "Playfair Display", apiName: "Playfair+Display:wght@400;700;900&display=swap", category: "serif" },
{ name: "Montserrat", apiName: "Montserrat:wght@400;600;700;800;900&display=swap", category: "sans" },
{ name: "Oswald", apiName: "Oswald:wght@400;500;700&display=swap", category: "sans" },
{ name: "Raleway", apiName: "Raleway:wght@400;600;700;800;900&display=swap", category: "sans" },
{ name: "Bebas Neue", apiName: "Bebas+Neue&display=swap", category: "display" },
{ name: "Lora", apiName: "Lora:wght@400;600;700&display=swap", category: "serif" },
{ name: "Merriweather", apiName: "Merriweather:wght@400;700;900&display=swap", category: "serif" },
{ name: "Poppins", apiName: "Poppins:wght@400;600;700;800;900&display=swap", category: "sans" },
{ name: "Roboto Slab", apiName: "Roboto+Slab:wght@400;700&display=swap", category: "serif" },
{ name: "Nunito", apiName: "Nunito:wght@400;600;700;800;900&display=swap", category: "sans" },
{ name: "Quicksand", apiName: "Quicksand:wght@400;600;700&display=swap", category: "sans" },
{ name: "Work Sans", apiName: "Work+Sans:wght@400;600;700;800;900&display=swap", category: "sans" },
{ name: "Inter", apiName: "Inter:wght@400;600;700;800;900&display=swap", category: "sans" },
{ name: "Fira Sans", apiName: "Fira+Sans:wght@400;600;700&display=swap", category: "sans" },
{ name: "Source Sans Pro", apiName: "Source+Sans+3:wght@400;600;700;900&display=swap", category: "sans" },
{ name: "Open Sans", apiName: "Open+Sans:wght@400;600;700;800&display=swap", category: "sans" },
{ name: "Lato", apiName: "Lato:wght@400;700;900&display=swap", category: "sans" },
{ name: "Space Grotesk", apiName: "Space+Grotesk:wght@400;600;700&display=swap", category: "sans" },
{ name: "DM Sans", apiName: "DM+Sans:wght@400;600;700&display=swap", category: "sans" },
{ name: "Rubik", apiName: "Rubik:wght@400;600;700;800;900&display=swap", category: "sans" },
{ name: "Barlow", apiName: "Barlow:wght@400;600;700;800;900&display=swap", category: "sans" },
{ name: "Karla", apiName: "Karla:wght@400;600;700;800&display=swap", category: "sans" },
{ name: "Josefin Sans", apiName: "Josefin+Sans:wght@400;600;700&display=swap", category: "sans" },
{ name: "Outfit", apiName: "Outfit:wght@400;600;700;800;900&display=swap", category: "sans" },
{ name: "Syne", apiName: "Syne:wght@400;600;700;800&display=swap", category: "display" },
{ name: "Kanit", apiName: "Kanit:wght@400;600;700;800;900&display=swap", category: "sans" },
{ name: "Archivo", apiName: "Archivo:wght@400;600;700;800;900&display=swap", category: "sans" },
{ name: "Teko", apiName: "Teko:wght@400;600;700&display=swap", category: "sans" },
{ name: "Anton", apiName: "Anton&display=swap", category: "display" },
{ name: "Righteous", apiName: "Righteous&display=swap", category: "display" },
{ name: "Fredoka", apiName: "Fredoka:wght@400;600;700&display=swap", category: "display" },
{ name: "Pacifico", apiName: "Pacifico&display=swap", category: "handwriting" },
{ name: "Amatic SC", apiName: "Amatic+SC:wght@400;700&display=swap", category: "handwriting" },
{ name: "Caveat", apiName: "Caveat:wght@400;600;700&display=swap", category: "handwriting" },
{ name: "Dancing Script", apiName: "Dancing+Script:wght@400;600;700&display=swap", category: "handwriting" },
{ name: "Great Vibes", apiName: "Great+Vibes&display=swap", category: "handwriting" },
{ name: "Lobster", apiName: "Lobster&display=swap", category: "display" },
{ name: "Satisfy", apiName: "Satisfy&display=swap", category: "handwriting" },
{ name: "Sacramento", apiName: "Sacramento&display=swap", category: "handwriting" },
{ name: "Allura", apiName: "Allura&display=swap", category: "handwriting" },
{ name: "Cookie", apiName: "Cookie&display=swap", category: "handwriting" },
];
const bodyFonts: Font[] = [
{ name: "Inter", apiName: "Inter:wght@400;500;600&display=swap", category: "sans" },
{ name: "Roboto", apiName: "Roboto:wght@400;500;700&display=swap", category: "sans" },
{ name: "Open Sans", apiName: "Open+Sans:wght@400;500;600&display=swap", category: "sans" },
{ name: "Lato", apiName: "Lato:wght@400;700&display=swap", category: "sans" },
{ name: "Source Sans Pro", apiName: "Source+Sans+3:wght@400;500;600&display=swap", category: "sans" },
{ name: "Noto Sans", apiName: "Noto+Sans:wght@400;500;600&display=swap", category: "sans" },
{ name: "Nunito", apiName: "Nunito:wght@400;500;600&display=swap", category: "sans" },
{ name: "Work Sans", apiName: "Work+Sans:wght@400;500;600&display=swap", category: "sans" },
{ name: "DM Sans", apiName: "DM+Sans:wght@400;500;600&display=swap", category: "sans" },
{ name: "Karla", apiName: "Karla:wght@400;500;600&display=swap", category: "sans" },
{ name: "Barlow", apiName: "Barlow:wght@400;500;600&display=swap", category: "sans" },
{ name: "Nunito Sans", apiName: "Nunito+Sans:wght@400;500;600&display=swap", category: "sans" },
{ name: "Noto Serif", apiName: "Noto+Serif:wght@400;500;600&display=swap", category: "serif" },
{ name: "Merriweather", apiName: "Merriweather:wght@400;700&display=swap", category: "serif" },
{ name: "Lora", apiName: "Lora:wght@400;500;600&display=swap", category: "serif" },
{ name: "Crimson Text", apiName: "Crimson+Text:wght@400;600;700&display=swap", category: "serif" },
{ name: "Quicksand", apiName: "Quicksand:wght@400;500;600&display=swap", category: "sans" },
{ name: "Space Grotesk", apiName: "Space+Grotesk:wght@400;500;600&display=swap", category: "sans" },
{ name: "Rubik", apiName: "Rubik:wght@400;500;600&display=swap", category: "sans" },
{ name: "Outfit", apiName: "Outfit:wght@400;500;600&display=swap", category: "sans" },
{ name: "IBM Plex Sans", apiName: "IBM+Plex+Sans:wght@400;500;600&display=swap", category: "sans" },
{ name: "IBM Plex Serif", apiName: "IBM+Plex+Serif:wght@400;500;600&display=swap", category: "serif" },
{ name: "PT Sans", apiName: "PT+Sans:wght@400;700&display=swap", category: "sans" },
{ name: "PT Serif", apiName: "PT+Serif:wght@400;700&display=swap", category: "serif" },
{ name: "Cantarell", apiName: "Cantarell:wght@400;700&display=swap", category: "sans" },
{ name: "Fira Sans", apiName: "Fira+Sans:wght@400;500;600&display=swap", category: "sans" },
{ name: "Oxygen", apiName: "Oxygen:wght@400;700&display=swap", category: "sans" },
{ name: "Ubuntu", apiName: "Ubuntu:wght@400;500;700&display=swap", category: "sans" },
{ name: "Libre Franklin", apiName: "Libre+Franklin:wght@400;500;600&display=swap", category: "sans" },
{ name: "Libre Baskerville", apiName: "Libre+Baskerville:wght@400;700&display=swap", category: "serif" },
{ name: "Spectral", apiName: "Spectral:wght@400;500;600&display=swap", category: "serif" },
{ name: "Literata", apiName: "Literata:wght@400;500;600&display=swap", category: "serif" },
{ name: "Public Sans", apiName: "Public+Sans:wght@400;500;600&display=swap", category: "sans" },
{ name: "Sen", apiName: "Sen:wght@400;700&display=swap", category: "sans" },
{ name: "Mulish", apiName: "Mulish:wght@400;500;600&display=swap", category: "sans" },
{ name: "Manrope", apiName: "Manrope:wght@400;500;600&display=swap", category: "sans" },
{ name: "Sora", apiName: "Sora:wght@400;500;600&display=swap", category: "sans" },
{ name: "Plus Jakarta Sans", apiName: "Plus+Jakarta+Sans:wght@400;500;600&display=swap", category: "sans" },
{ name: "Geist", apiName: "Geist:wght@400;500;600&display=swap", category: "sans" },
{ name: "Onest", apiName: "Onest:wght@400;500;600&display=swap", category: "sans" },
{ name: "Instrument Sans", apiName: "Instrument+Sans:wght@400;500;600;700&display=swap", category: "sans" },
{ name: "Familjen Grotesk", apiName: "Familjen+Grotesk:wght@400;500;600;700&display=swap", category: "sans" },
{ name: "Readex Pro", apiName: "Readex+Pro:wght@400;500;600;700&display=swap", category: "sans" },
{ name: "Markazi Text", apiName: "Markazi+Text:wght@400;500;600;700&display=swap", category: "serif" },
{ name: "Exo 2", apiName: "Exo+2:wght@400;500;600&display=swap", category: "sans" },
{ name: "Cabin", apiName: "Cabin:wght@400;500;600;700&display=swap", category: "sans" },
{ name: "Raleway", apiName: "Raleway:wght@400;500;600&display=swap", category: "sans" },
{ name: "Heebo", apiName: "Heebo:wght@400;500;600;700&display=swap", category: "sans" },
];
let currentHeading: Font | null = null;
let currentBody: Font | null = null;
let headingLocked = false;
let bodyLocked = false;
let loadedFonts = new Set<string>();
function randomItem<T>(arr: T[]): T {
return arr[Math.floor(Math.random() * arr.length)];
}
function getGoogleFontsUrl(heading: Font, body: Font): string {
const headingFamily = heading.apiName.split(":")[0];
const bodyFamily = body.apiName.split(":")[0];
return `https://fonts.googleapis.com/css2?family=${heading.apiName}&family=${body.apiName}&display=swap`;
}
function getSpecimenUrl(fontName: string): string {
return `https://fonts.google.com/specimen/${fontName.replace(/\s+/g, "+")}`;
}
function loadFont(url: string) {
if (loadedFonts.has(url)) return;
loadedFonts.add(url);
const link = document.createElement("link");
link.href = url;
link.rel = "stylesheet";
document.head.appendChild(link);
}
function getFontFamily(font: Font): string {
return `"${font.name}", ${font.category === "serif" ? "serif" : font.category === "monospace" ? "monospace" : "sans-serif"}`;
}
const lockedIconSvg = `<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" class="text-accent"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 9.9-1"/><circle cx="12" cy="16" r="1"/></svg>`;
const unlockedIconSvg = `<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" class="text-zinc-500"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/><circle cx="12" cy="16" r="1"/></svg>`;
const copyIconSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>`;
const checkIconSvg = `<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" class="text-accent"><path d="M20 6 9 17l-5-5"/></svg>`;
function render() {
if (!currentHeading || !currentBody) return;
// Update heading info
headingNameEl.textContent = currentHeading.name;
headingLinkEl.href = getSpecimenUrl(currentHeading.name);
headingTagEl.textContent = categoryLabels[currentHeading.category] || currentHeading.category;
// Update body info
bodyNameEl.textContent = currentBody.name;
bodyLinkEl.href = getSpecimenUrl(currentBody.name);
bodyTagEl.textContent = categoryLabels[currentBody.category] || currentBody.category;
// Update preview fonts
previewHeadingEl.style.fontFamily = getFontFamily(currentHeading);
previewBodyEl.style.fontFamily = getFontFamily(currentBody);
// Load fonts
const fontUrl = getGoogleFontsUrl(currentHeading, currentBody);
loadFont(fontUrl);
// Show result
resultEl.style.opacity = "1";
errorDisplay.clear();
}
function generatePair() {
if (!headingLocked || !currentHeading) {
currentHeading = randomItem(headingFonts);
}
if (!bodyLocked || !currentBody) {
let nextBody = randomItem(bodyFonts);
// Try to avoid picking the same font for both (if possible)
if (currentHeading && nextBody.name === currentHeading.name && bodyFonts.length > 1) {
let attempts = 0;
while (nextBody.name === currentHeading.name && attempts < 10) {
nextBody = randomItem(bodyFonts);
attempts++;
}
}
currentBody = nextBody;
}
render();
}
function shuffleHeadingOnly() {
const prevName = currentHeading?.name;
let next = randomItem(headingFonts);
// Try to pick a different font
if (prevName && headingFonts.length > 1) {
let attempts = 0;
while (next.name === prevName && attempts < 10) {
next = randomItem(headingFonts);
attempts++;
}
}
currentHeading = next;
render();
}
function shuffleBodyOnly() {
const prevName = currentBody?.name;
let next = randomItem(bodyFonts);
// Try to pick a different font
if (prevName && bodyFonts.length > 1) {
let attempts = 0;
while (next.name === prevName && attempts < 10) {
next = randomItem(bodyFonts);
attempts++;
}
}
// Also try to avoid matching heading
if (currentHeading && next.name === currentHeading.name && bodyFonts.length > 1) {
let attempts = 0;
while (next.name === currentHeading.name && attempts < 10) {
next = randomItem(bodyFonts);
attempts++;
}
}
currentBody = next;
render();
}
// Lock toggle handlers
lockHeadingBtn.addEventListener("click", () => {
headingLocked = !headingLocked;
lockHeadingBtn.setAttribute("aria-pressed", String(headingLocked));
lockHeadingIcon.innerHTML = headingLocked ? lockedIconSvg : unlockedIconSvg;
if (headingLocked) {
lockHeadingIcon.classList.remove("text-zinc-500");
lockHeadingIcon.classList.add("text-accent");
} else {
lockHeadingIcon.classList.remove("text-accent");
lockHeadingIcon.classList.add("text-zinc-500");
}
});
lockBodyBtn.addEventListener("click", () => {
bodyLocked = !bodyLocked;
lockBodyBtn.setAttribute("aria-pressed", String(bodyLocked));
lockBodyIcon.innerHTML = bodyLocked ? lockedIconSvg : unlockedIconSvg;
if (bodyLocked) {
lockBodyIcon.classList.remove("text-zinc-500");
lockBodyIcon.classList.add("text-accent");
} else {
lockBodyIcon.classList.remove("text-accent");
lockBodyIcon.classList.add("text-zinc-500");
}
});
// Generate button
generateBtn.addEventListener("click", generatePair);
// Shuffle buttons
shuffleHeadingBtn.addEventListener("click", shuffleHeadingOnly);
shuffleBodyBtn.addEventListener("click", shuffleBodyOnly);
// Copy button
copyBtn.addEventListener("click", async () => {
if (!currentHeading || !currentBody) return;
const headingFamily = getFontFamily(currentHeading);
const bodyFamily = getFontFamily(currentBody);
const fontUrl = getGoogleFontsUrl(currentHeading, currentBody);
const htmlLink = `<link href="${fontUrl}" rel="stylesheet">`;
const cssCode = `font-family: ${headingFamily}; /* heading */\nfont-family: ${bodyFamily}; /* body */`;
const fullText = `${htmlLink}\n\n/* CSS */\n${cssCode}`;
await navigator.clipboard.writeText(fullText);
const originalHtml = copyBtn.innerHTML;
copyBtn.innerHTML = `<span class="inline-flex items-center gap-2">${checkIconSvg} ${isRu ? "Скопировано!" : "Copied!"}</span>`;
setTimeout(() => {
copyBtn.innerHTML = originalHtml;
}, 1500);
});
// Generate on load
generatePair();
</script>
@@ -0,0 +1,300 @@
---
import { useT } from "../../i18n/translations";
const isRu = Astro.url.pathname.startsWith("/ru");
const T = useT(isRu ? "ru" : "en");
const i18n = {
typeLabel: isRu ? "Тип градиента" : "Gradient type",
stopsLabel: isRu ? "Количество цветов" : "Color stops",
generate: T.generate,
copied: T.copied,
copy: T.copy,
cssCode: isRu ? "CSS-код" : "CSS code",
colorStops: isRu ? "Цветовые точки" : "Color stops",
types: {
linear: isRu ? "Линейный" : "Linear",
radial: isRu ? "Радиальный" : "Radial",
conic: isRu ? "Конический" : "Conic",
},
};
---
<div id="gradient-generator" class="mt-8">
<!-- Controls -->
<div class="flex flex-col sm:flex-row gap-4 mb-6">
<div class="flex-1">
<label for="gg-type" class="block text-sm font-medium text-zinc-400 mb-2">
{i18n.typeLabel}
</label>
<select
id="gg-type"
class="w-full px-4 py-2.5 bg-zinc-900/80 border border-zinc-800/80 rounded-xl text-zinc-100 text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 cursor-pointer"
>
<option value="linear">{i18n.types.linear}</option>
<option value="radial">{i18n.types.radial}</option>
<option value="conic">{i18n.types.conic}</option>
</select>
</div>
<div class="flex-1">
<label for="gg-stops" class="block text-sm font-medium text-zinc-400 mb-2">
{i18n.stopsLabel}
</label>
<select
id="gg-stops"
class="w-full px-4 py-2.5 bg-zinc-900/80 border border-zinc-800/80 rounded-xl text-zinc-100 text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 cursor-pointer"
>
<option value="2" selected>2</option>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
</select>
</div>
</div>
<!-- Result container -->
<div
id="gg-result"
class="rounded-2xl bg-zinc-900/80 border border-zinc-800/80 p-8 opacity-0"
style="transition: opacity 0.25s ease;"
aria-live="polite"
>
<!-- Gradient Preview -->
<div class="mb-6">
<div
id="gg-preview"
class="w-full h-48 sm:h-64 rounded-2xl border border-zinc-800"
style="transition: background 0.35s ease;"
aria-hidden="true"
>
</div>
</div>
<!-- CSS Code -->
<div class="mb-6">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-zinc-400">{i18n.cssCode}</span>
<button
id="gg-copy-css"
type="button"
class="group shrink-0 px-3 py-1.5 bg-zinc-800 text-zinc-300 text-sm font-medium rounded-lg border border-zinc-700 hover:bg-zinc-700 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors duration-100 cursor-pointer inline-flex items-center gap-1.5"
>
<svg id="gg-copy-css-icon" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
<svg id="gg-check-css-icon" class="hidden text-accent" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
<span id="gg-copy-css-label">{i18n.copy}</span>
</button>
</div>
<div class="relative">
<code
id="gg-css-code"
class="block w-full px-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl font-mono text-sm text-zinc-200 whitespace-pre-wrap break-all"
></code>
</div>
</div>
<!-- Color Stops -->
<div>
<span class="text-sm font-medium text-zinc-400 mb-3 block">{i18n.colorStops}</span>
<div id="gg-color-stops" class="flex flex-wrap gap-3">
<!-- Color stop chips injected by JS -->
</div>
</div>
</div>
<!-- Generate button -->
<div class="flex justify-center mt-6">
<button
id="gg-btn"
type="button"
class="w-full sm:w-auto px-8 py-3 bg-accent text-white font-semibold rounded-xl shadow-lg shadow-accent/25 hover:bg-accent-hover active:bg-accent-active focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors duration-100 cursor-pointer"
>
{i18n.generate}
</button>
</div>
</div>
<script>
const isRu = document.documentElement.lang === "ru";
const typeSelect = document.getElementById("gg-type") as HTMLSelectElement;
const stopsSelect = document.getElementById("gg-stops") as HTMLSelectElement;
const resultEl = document.getElementById("gg-result") as HTMLDivElement;
const previewEl = document.getElementById("gg-preview") as HTMLDivElement;
const cssCodeEl = document.getElementById("gg-css-code") as HTMLElement;
const copyCssBtn = document.getElementById("gg-copy-css") as HTMLButtonElement;
const copyCssLabel = document.getElementById("gg-copy-css-label") as HTMLSpanElement;
const copyCssIcon = document.getElementById("gg-copy-css-icon") as SVGElement;
const checkCssIcon = document.getElementById("gg-check-css-icon") as SVGElement;
const colorStopsEl = document.getElementById("gg-color-stops") as HTMLDivElement;
const generateBtn = document.getElementById("gg-btn") as HTMLButtonElement;
const copyIconSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>`;
const checkIconSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="text-accent"><path d="M20 6 9 17l-5-5"/></svg>`;
function randomInt(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function hslToHex(h: number, s: number, l: number): string {
s /= 100;
l /= 100;
const k = (n: number) => (n + h / 30) % 12;
const a = s * Math.min(l, 1 - l);
const f = (n: number) => {
const color = l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
return Math.round(color * 255)
.toString(16)
.padStart(2, "0");
};
return `#${f(0)}${f(8)}${f(4)}`.toUpperCase();
}
function generateHarmoniousColors(count: number): string[] {
const baseH = randomInt(0, 359);
const baseS = randomInt(50, 90);
const baseL = randomInt(35, 65);
if (count === 1) {
return [hslToHex(baseH, baseS, baseL)];
}
const colors: string[] = [];
// Spread hues analogously (30-60 deg apart)
const hueSpread = randomInt(30, 60);
const totalSpread = (count - 1) * hueSpread;
const startH = (baseH - totalSpread / 2 + 360) % 360;
for (let i = 0; i < count; i++) {
const h = Math.round((startH + i * hueSpread + 360) % 360);
// Vary saturation and lightness slightly for visual interest
const s = Math.min(100, Math.max(40, baseS + randomInt(-10, 10)));
const l = Math.min(80, Math.max(25, baseL + randomInt(-10, 10)));
colors.push(hslToHex(h, s, l));
}
return colors;
}
function buildGradientCSS(type: string, colors: string[]): string {
const count = colors.length;
if (type === "linear") {
const angle = randomInt(0, 360);
if (count === 2) {
return `linear-gradient(${angle}deg, ${colors[0]} 0%, ${colors[1]} 100%)`;
}
const stops = colors.map((c, i) => {
const pct = Math.round((i / (count - 1)) * 100);
return `${c} ${pct}%`;
}).join(", ");
return `linear-gradient(${angle}deg, ${stops})`;
}
if (type === "radial") {
const cx = randomInt(20, 80);
const cy = randomInt(20, 80);
if (count === 2) {
return `radial-gradient(circle at ${cx}% ${cy}%, ${colors[0]} 0%, ${colors[1]} 100%)`;
}
const stops = colors.map((c, i) => {
const pct = Math.round((i / (count - 1)) * 100);
return `${c} ${pct}%`;
}).join(", ");
return `radial-gradient(circle at ${cx}% ${cy}%, ${stops})`;
}
// conic
if (count === 2) {
return `conic-gradient(from 0deg, ${colors[0]}, ${colors[1]}, ${colors[0]})`;
}
const stops = colors.join(", ");
return `conic-gradient(from 0deg, ${stops})`;
}
let currentCSS = "";
let cssCopyTimer: ReturnType<typeof setTimeout> | null = null;
const copyTimers = new Map<number, ReturnType<typeof setTimeout>>();
function renderColorStops(colors: string[]) {
colorStopsEl.innerHTML = "";
copyTimers.clear();
colors.forEach((hex, index) => {
const chip = document.createElement("button");
chip.type = "button";
chip.className =
"group flex items-center gap-2 px-3 py-2 bg-zinc-950 border border-zinc-800 rounded-xl hover:border-zinc-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-all duration-100 cursor-pointer";
chip.setAttribute("aria-label", isRu ? `Скопировать ${hex}` : `Copy ${hex}`);
const swatch = document.createElement("span");
swatch.className = "w-5 h-5 rounded-full border border-zinc-700 shrink-0";
swatch.style.backgroundColor = hex;
const label = document.createElement("span");
label.className = "font-mono text-sm text-zinc-300 tabular-nums select-all group-hover:text-zinc-100 transition-colors";
label.textContent = hex;
const iconWrap = document.createElement("span");
iconWrap.className = "text-zinc-600 group-hover:text-zinc-400 transition-colors ml-1";
iconWrap.innerHTML = copyIconSvg;
chip.appendChild(swatch);
chip.appendChild(label);
chip.appendChild(iconWrap);
chip.addEventListener("click", async () => {
await navigator.clipboard.writeText(hex);
iconWrap.innerHTML = checkIconSvg;
label.classList.add("text-accent");
const existing = copyTimers.get(index);
if (existing) clearTimeout(existing);
copyTimers.set(
index,
setTimeout(() => {
iconWrap.innerHTML = copyIconSvg;
label.classList.remove("text-accent");
copyTimers.delete(index);
}, 1500),
);
});
colorStopsEl.appendChild(chip);
});
}
function render(css: string, colors: string[]) {
currentCSS = css;
previewEl.style.background = css;
cssCodeEl.textContent = `background: ${css};`;
renderColorStops(colors);
resultEl.style.opacity = "1";
}
copyCssBtn.addEventListener("click", async () => {
if (!currentCSS) return;
const text = `background: ${currentCSS};`;
await navigator.clipboard.writeText(text);
copyCssIcon.classList.add("hidden");
checkCssIcon.classList.remove("hidden");
copyCssLabel.textContent = isRu ? "Скопировано!" : "Copied!";
if (cssCopyTimer) clearTimeout(cssCopyTimer);
cssCopyTimer = setTimeout(() => {
checkCssIcon.classList.add("hidden");
copyCssIcon.classList.remove("hidden");
copyCssLabel.textContent = isRu ? "Копировать" : "Copy";
}, 1500);
});
function generate() {
const type = typeSelect.value;
const count = parseInt(stopsSelect.value, 10);
const colors = generateHarmoniousColors(count);
const css = buildGradientCSS(type, colors);
render(css, colors);
}
generateBtn.addEventListener("click", generate);
// Generate on load
generate();
</script>
@@ -0,0 +1,592 @@
---
import { useT } from "../../i18n/translations";
const isRu = Astro.url.pathname.startsWith("/ru");
const T = useT(isRu ? "ru" : "en");
---
<div id="hash-generator" class="mt-8">
<!-- Input Mode Toggle -->
<div class="rounded-2xl bg-zinc-900/50 border border-zinc-800/60 p-5 mb-5">
<p class="text-sm font-medium text-zinc-400 mb-3" id="hash-input-label">
{isRu ? "Режим ввода" : "Input mode"}
</p>
<div
class="flex gap-1 bg-zinc-950 rounded-xl p-1 border border-zinc-800/60"
role="group"
aria-labelledby="hash-input-label"
>
<button
type="button"
id="hash-mode-text"
class="flex-1 px-4 py-2.5 text-sm font-medium rounded-lg transition-all duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950"
aria-pressed="true"
>
{isRu ? "Ввести текст" : "Enter text"}
</button>
<button
type="button"
id="hash-mode-random"
class="flex-1 px-4 py-2.5 text-sm font-medium rounded-lg transition-all duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950"
aria-pressed="false"
>
{isRu ? "Случайная строка" : "Generate random"}
</button>
</div>
<!-- Text Input -->
<div id="hash-text-section" class="mt-4">
<textarea
id="hash-text-input"
rows="3"
class="w-full bg-zinc-950 border border-zinc-700 rounded-xl px-4 py-3 text-zinc-100 text-base font-mono focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent transition-colors resize-none"
placeholder={isRu ? "Введите текст для хеширования..." : "Type or paste text to hash..."}
aria-label={isRu ? "Текст для хеширования" : "Text to hash"}
></textarea>
</div>
<!-- Random String Section -->
<div id="hash-random-section" class="mt-4 hidden">
<div class="flex items-center gap-4">
<div class="flex-1">
<label
for="hash-length-slider"
class="block text-sm text-zinc-400 mb-2"
>
<span id="hash-length-label">{isRu ? "Длина строки" : "String length"}</span>
<span class="text-zinc-200 font-medium ml-2" id="hash-length-value">32</span>
</label>
<input
type="range"
id="hash-length-slider"
min="8"
max="128"
value="32"
class="w-full accent-accent h-1.5 bg-zinc-800 rounded-lg appearance-none cursor-pointer"
aria-label={isRu ? "Длина случайной строки" : "Random string length"}
/>
</div>
<button
type="button"
id="hash-refresh-random"
class="mt-5 p-2.5 bg-zinc-800 text-zinc-300 rounded-xl hover:bg-zinc-700 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950"
aria-label={isRu ? "Обновить случайную строку" : "Refresh random string"}
title={isRu ? "Обновить" : "Refresh"}
>
<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"
>
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path>
<path d="M3 3v5h5"></path>
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"></path>
<path d="M16 16h5v5"></path>
</svg>
</button>
</div>
<div
id="hash-random-preview"
class="mt-3 px-4 py-2.5 bg-zinc-950 border border-zinc-800 rounded-xl font-mono text-sm text-zinc-400 break-all"
></div>
</div>
</div>
<!-- Algorithm Selector -->
<div class="rounded-2xl bg-zinc-900/50 border border-zinc-800/60 p-5 mb-5">
<p class="text-sm font-medium text-zinc-400 mb-3">
{isRu ? "Алгоритмы хеширования" : "Hash algorithms"}
</p>
<div class="flex flex-wrap gap-3" role="group" aria-label={isRu ? "Алгоритмы хеширования" : "Hash algorithms"}>
<label
class="inline-flex items-center gap-2 px-4 py-2.5 bg-zinc-950 border border-zinc-700 rounded-xl cursor-pointer hover:border-purple-500/50 transition-colors select-none"
>
<input
type="checkbox"
value="md5"
checked
class="w-4 h-4 rounded border-zinc-600 text-purple-500 bg-zinc-900 focus:ring-purple-500 focus:ring-offset-zinc-950 accent-purple-500"
/>
<span class="text-sm font-medium text-purple-400">MD5</span>
</label>
<label
class="inline-flex items-center gap-2 px-4 py-2.5 bg-zinc-950 border border-zinc-700 rounded-xl cursor-pointer hover:border-blue-500/50 transition-colors select-none"
>
<input
type="checkbox"
value="sha1"
class="w-4 h-4 rounded border-zinc-600 text-blue-500 bg-zinc-900 focus:ring-blue-500 focus:ring-offset-zinc-950 accent-blue-500"
/>
<span class="text-sm font-medium text-blue-400">SHA-1</span>
</label>
<label
class="inline-flex items-center gap-2 px-4 py-2.5 bg-zinc-950 border border-zinc-700 rounded-xl cursor-pointer hover:border-green-500/50 transition-colors select-none"
>
<input
type="checkbox"
value="sha256"
checked
class="w-4 h-4 rounded border-zinc-600 text-green-500 bg-zinc-900 focus:ring-green-500 focus:ring-offset-zinc-950 accent-green-500"
/>
<span class="text-sm font-medium text-green-400">SHA-256</span>
</label>
<label
class="inline-flex items-center gap-2 px-4 py-2.5 bg-zinc-950 border border-zinc-700 rounded-xl cursor-pointer hover:border-orange-500/50 transition-colors select-none"
>
<input
type="checkbox"
value="sha512"
class="w-4 h-4 rounded border-zinc-600 text-orange-500 bg-zinc-900 focus:ring-orange-500 focus:ring-offset-zinc-950 accent-orange-500"
/>
<span class="text-sm font-medium text-orange-400">SHA-512</span>
</label>
</div>
<!-- Case Toggle + Generate -->
<div class="flex flex-col sm:flex-row gap-3 mt-5">
<button
type="button"
id="hash-case-toggle"
class="inline-flex items-center justify-center gap-2 px-4 py-2.5 bg-zinc-800 text-zinc-300 text-sm font-medium rounded-xl hover:bg-zinc-700 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 cursor-pointer"
>
<span id="hash-case-label">{isRu ? "ВЕРХНИЙ РЕГИСТР" : "UPPERCASE"}</span>
</button>
<button
type="button"
id="hash-generate-btn"
class="flex-1 inline-flex items-center justify-center gap-2 px-8 py-3 bg-accent text-white font-semibold rounded-xl shadow-lg shadow-accent/25 hover:bg-accent-hover active:bg-accent-active focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors duration-100 cursor-pointer"
>
<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"
>
<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"></path>
<path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"></path>
<path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"></path>
<path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"></path>
</svg>
<span id="hash-generate-label">{isRu ? "Сгенерировать" : "Generate"}</span>
</button>
</div>
</div>
<!-- Compare Section -->
<div class="rounded-2xl bg-zinc-900/50 border border-zinc-800/60 p-5 mb-5">
<label for="hash-compare-input" class="block text-sm font-medium text-zinc-400 mb-2">
{isRu ? "Сравнить хеш" : "Compare hash"}
</label>
<input
type="text"
id="hash-compare-input"
class="w-full bg-zinc-950 border border-zinc-700 rounded-xl px-4 py-3 text-zinc-100 text-sm font-mono focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent transition-colors"
placeholder={isRu ? "Вставьте хеш для сравнения..." : "Paste a hash to compare..."}
aria-label={isRu ? "Хеш для сравнения" : "Hash to compare"}
/>
<p
id="hash-compare-hint"
class="mt-2 text-xs text-zinc-500"
>
{isRu
? "Вставьте хеш, чтобы проверить совпадение с результатами."
: "Paste a hash to check if it matches any result."}
</p>
</div>
<!-- Results -->
<div
id="hash-results"
class="flex flex-col gap-3"
aria-live="polite"
></div>
</div>
<script>
// ===================== MD5 Implementation =====================
function md5(str: string): string {
const utf8 = new TextEncoder().encode(str);
const msgLen = utf8.length;
const paddingLen = (56 - ((msgLen + 8) % 64) + 64) % 64;
const totalLen = msgLen + 1 + paddingLen + 8;
const buf = new Uint8Array(totalLen);
buf.set(utf8);
buf[msgLen] = 0x80;
const view = new DataView(buf.buffer);
view.setUint32(totalLen - 4, msgLen * 8, true);
view.setUint32(totalLen - 8, (msgLen >>> 29), true);
const k = new Uint32Array([
0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee, 0xf57c0faf, 0x4787c62a,
0xa8304613, 0xfd469501, 0x698098d8, 0x8b44f7af, 0xffff5bb1, 0x895cd7be,
0x6b901122, 0xfd987193, 0xa679438e, 0x49b40821, 0xf61e2562, 0xc040b340,
0x265e5a51, 0xe9b6c7aa, 0xd62f105d, 0x02441453, 0xd8a1e681, 0xe7d3fbc8,
0x21e1cde6, 0xc33707d6, 0xf4d50d87, 0x455a14ed, 0xa9e3e905, 0xfcefa3f8,
0x676f02d9, 0x8d2a4c8a, 0xfffa3942, 0x8771f681, 0x6d9d6122, 0xfde5380c,
0xa4beea44, 0x4bdecfa9, 0xf6bb4b60, 0xbebfbc70, 0x289b7ec6, 0xeaa127fa,
0xd4ef3085, 0x04881d05, 0xd9d4d039, 0xe6db99e5, 0x1fa27cf8, 0xc4ac5665,
0xf4292244, 0x432aff97, 0xab9423a7, 0xfc93a039, 0x655b59c3, 0x8f0ccc92,
0xffeff47d, 0x85845dd1, 0x6fa87e4f, 0xfe2ce6e0, 0xa3014314, 0x4e0811a1,
0xf7537e82, 0xbd3af235, 0x2ad7d2bb, 0xeb86d391,
]);
const s = [7,12,17,22,7,12,17,22,7,12,17,22,7,12,17,22,5,9,14,20,5,9,14,20,5,9,14,20,5,9,14,20,4,11,16,23,4,11,16,23,4,11,16,23,4,11,16,23,6,10,15,21,6,10,15,21,6,10,15,21,6,10,15,21];
let a = 0x67452301, b = 0xefcdab89, c = 0x98badcfe, d = 0x10325476;
for (let i = 0; i < totalLen; i += 64) {
const aa = a, bb = b, cc = c, dd = d;
const w = new Uint32Array(16);
for (let j = 0; j < 16; j++) {
w[j] = view.getUint32(i + j * 4, true);
}
for (let j = 0; j < 64; j++) {
let f: number, g: number;
if (j < 16) { f = (b & c) | (~b & d); g = j; }
else if (j < 32) { f = (d & b) | (~d & c); g = (5 * j + 1) % 16; }
else if (j < 48) { f = b ^ c ^ d; g = (3 * j + 5) % 16; }
else { f = c ^ (b | ~d); g = (7 * j) % 16; }
const temp = d;
d = c;
c = b;
b = b + leftRotate(a + f + k[j] + w[g], s[j]);
a = temp;
}
a += aa; b += bb; c += cc; d += dd;
}
function leftRotate(x: number, c: number) {
return ((x << c) | (x >>> (32 - c))) >>> 0;
}
function toHex(n: number) {
return (n >>> 0).toString(16).padStart(8, "4").slice(-8);
}
const hash = toHex(a) + toHex(b) + toHex(c) + toHex(d);
return hash;
}
// ===================== SHA helpers using Web Crypto =====================
async function sha1(str: string): Promise<string> {
return await sha("SHA-1", str);
}
async function sha256(str: string): Promise<string> {
return await sha("SHA-256", str);
}
async function sha512(str: string): Promise<string> {
return await sha("SHA-512", str);
}
async function sha(algo: string, str: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(str);
const hashBuffer = await crypto.subtle.digest(algo, data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
}
// ===================== Random string generator =====================
function generateRandomString(length: number): string {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let result = "";
const randomValues = new Uint32Array(length);
crypto.getRandomValues(randomValues);
for (let i = 0; i < length; i++) {
result += chars[randomValues[i] % chars.length];
}
return result;
}
// ===================== DOM Elements =====================
const isRu = document.documentElement.lang === "ru";
const TEXTS = {
generate: isRu ? "Сгенерировать" : "Generate",
generated: isRu ? "Сгенерировано" : "Generated",
copy: isRu ? "Копировать" : "Copy",
copied: isRu ? "Скопировано" : "Copied",
match: isRu ? "Совпадение" : "Match",
noMatch: isRu ? "Нет совпадения" : "No match",
enterText: isRu ? "Ввести текст" : "Enter text",
generateRandom: isRu ? "Случайная строка" : "Generate random",
uppercase: isRu ? "ВЕРХНИЙ РЕГИСТР" : "UPPERCASE",
lowercase: isRu ? "нижний регистр" : "lowercase",
compareHint: isRu ? "Вставьте хеш, чтобы проверить совпадение с результатами." : "Paste a hash to check if it matches any result.",
refresh: isRu ? "Обновить" : "Refresh",
inputMode: isRu ? "Режим ввода" : "Input mode",
stringLength: isRu ? "Длина строки" : "String length",
};
const modeTextBtn = document.getElementById("hash-mode-text") as HTMLButtonElement;
const modeRandomBtn = document.getElementById("hash-mode-random") as HTMLButtonElement;
const textSection = document.getElementById("hash-text-section") as HTMLDivElement;
const randomSection = document.getElementById("hash-random-section") as HTMLDivElement;
const textInput = document.getElementById("hash-text-input") as HTMLTextAreaElement;
const lengthSlider = document.getElementById("hash-length-slider") as HTMLInputElement;
const lengthValue = document.getElementById("hash-length-value") as HTMLSpanElement;
const randomPreview = document.getElementById("hash-random-preview") as HTMLDivElement;
const refreshRandomBtn = document.getElementById("hash-refresh-random") as HTMLButtonElement;
const caseToggleBtn = document.getElementById("hash-case-toggle") as HTMLButtonElement;
const caseLabel = document.getElementById("hash-case-label") as HTMLSpanElement;
const generateBtn = document.getElementById("hash-generate-btn") as HTMLButtonElement;
const compareInput = document.getElementById("hash-compare-input") as HTMLInputElement;
const resultsContainer = document.getElementById("hash-results") as HTMLDivElement;
let currentMode: "text" | "random" = "text";
let currentRandomString = "";
let isUppercase = true;
let currentResults: { algo: string; hash: string }[] = [];
// ===================== Algorithm color mapping =====================
const ALGO_COLORS: Record<string, { label: string; text: string; border: string; bg: string; ring: string }> = {
md5: { label: "text-purple-400", text: "text-purple-300", border: "border-purple-500/30", bg: "bg-purple-500/10", ring: "focus-visible:ring-purple-500" },
sha1: { label: "text-blue-400", text: "text-blue-300", border: "border-blue-500/30", bg: "bg-blue-500/10", ring: "focus-visible:ring-blue-500" },
sha256:{ label: "text-green-400", text: "text-green-300", border: "border-green-500/30", bg: "bg-green-500/10", ring: "focus-visible:ring-green-500" },
sha512:{ label: "text-orange-400", text: "text-orange-300", border: "border-orange-500/30", bg: "bg-orange-500/10", ring: "focus-visible:ring-orange-500" },
};
// ===================== UI Helpers =====================
function setActiveMode(mode: "text" | "random") {
currentMode = mode;
if (mode === "text") {
modeTextBtn.classList.add("bg-zinc-800", "text-zinc-100");
modeTextBtn.classList.remove("text-zinc-400", "hover:bg-zinc-800/50");
modeRandomBtn.classList.remove("bg-zinc-800", "text-zinc-100");
modeRandomBtn.classList.add("text-zinc-400", "hover:bg-zinc-800/50");
textSection.classList.remove("hidden");
randomSection.classList.add("hidden");
modeTextBtn.setAttribute("aria-pressed", "true");
modeRandomBtn.setAttribute("aria-pressed", "false");
} else {
modeRandomBtn.classList.add("bg-zinc-800", "text-zinc-100");
modeRandomBtn.classList.remove("text-zinc-400", "hover:bg-zinc-800/50");
modeTextBtn.classList.remove("bg-zinc-800", "text-zinc-100");
modeTextBtn.classList.add("text-zinc-400", "hover:bg-zinc-800/50");
randomSection.classList.remove("hidden");
textSection.classList.add("hidden");
modeRandomBtn.setAttribute("aria-pressed", "true");
modeTextBtn.setAttribute("aria-pressed", "false");
if (!currentRandomString) refreshRandom();
}
}
function refreshRandom() {
const len = parseInt(lengthSlider.value, 10);
currentRandomString = generateRandomString(len);
randomPreview.textContent = currentRandomString;
lengthValue.textContent = String(len);
}
function getSelectedAlgos(): string[] {
return Array.from(document.querySelectorAll<HTMLInputElement>("#hash-generator input[type='checkbox']:checked")).map(
(cb) => cb.value,
);
}
function formatHash(hash: string): string {
return isUppercase ? hash.toUpperCase() : hash.toLowerCase();
}
function getInputText(): string {
if (currentMode === "text") {
return textInput.value;
}
return currentRandomString;
}
function checkCompare(hash: string): { matches: boolean; display: string } {
const compareVal = compareInput.value.trim().toLowerCase();
if (!compareVal) return { matches: false, display: "" };
const matches = compareVal === hash.toLowerCase();
return {
matches,
display: matches
? `<span class="inline-flex items-center gap-1 text-green-400 text-xs font-medium"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg> ${TEXTS.match}</span>`
: `<span class="inline-flex items-center gap-1 text-red-400 text-xs font-medium"><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="M18 6 6 18"/><path d="m6 6 12 12"/></svg> ${TEXTS.noMatch}</span>`,
};
}
async function copyToClipboard(text: string, iconEl: HTMLElement, checkEl: HTMLElement, labelEl: HTMLElement) {
try {
await navigator.clipboard.writeText(text);
labelEl.textContent = TEXTS.copied;
iconEl.classList.add("hidden");
checkEl.classList.remove("hidden");
setTimeout(() => {
labelEl.textContent = TEXTS.copy;
iconEl.classList.remove("hidden");
checkEl.classList.add("hidden");
}, 1500);
} catch {
// Fallback
const ta = document.createElement("textarea");
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
}
}
// ===================== Generate Hashes =====================
async function generateHashes() {
const algos = getSelectedAlgos();
if (algos.length === 0) return;
const input = getInputText();
if (!input) {
resultsContainer.innerHTML = `<p class="text-sm text-red-400 text-center py-4">${isRu ? "Введите текст или сгенерируйте случайную строку." : "Please enter text or generate a random string."}</p>`;
return;
}
resultsContainer.innerHTML = "";
currentResults = [];
const algoPromises = algos.map(async (algo) => {
let hash: string;
switch (algo) {
case "md5": hash = md5(input); break;
case "sha1": hash = await sha1(input); break;
case "sha256": hash = await sha256(input); break;
case "sha512": hash = await sha512(input); break;
default: hash = "";
}
return { algo, hash };
});
const results = await Promise.all(algoPromises);
currentResults = results;
for (const { algo, hash } of results) {
const colors = ALGO_COLORS[algo] || ALGO_COLORS.sha256;
const formatted = formatHash(hash);
const compare = checkCompare(hash);
const card = document.createElement("div");
card.className = `rounded-2xl border ${colors.border} ${colors.bg} p-4 sm:p-5`;
card.innerHTML = `
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<span class="text-xs font-bold uppercase tracking-wider ${colors.label}">${algo === "sha256" ? "SHA-256" : algo === "sha512" ? "SHA-512" : algo === "sha1" ? "SHA-1" : "MD5"}</span>
<span class="text-xs text-zinc-500">(${hash.length * 4} bit)</span>
</div>
<div class="hash-compare-badge">${compare.display}</div>
</div>
<div class="flex items-center gap-3">
<code class="flex-1 min-w-0 font-mono text-sm ${colors.text} break-all truncate sm:whitespace-normal sm:break-all">${formatted}</code>
<button
type="button"
class="hash-copy-btn inline-flex items-center gap-1.5 px-3 py-1.5 bg-zinc-900/80 border border-zinc-700/50 rounded-lg text-xs font-medium text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800 transition-colors focus:outline-none focus-visible:ring-2 ${colors.ring} focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 flex-shrink-0 cursor-pointer"
aria-label="${TEXTS.copy} ${algo.toUpperCase()}"
>
<span class="hash-copy-label">${TEXTS.copy}</span>
<span class="hash-copy-icon" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
</span>
<span class="hash-check-icon hidden text-accent" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
</span>
</button>
</div>
`;
const copyBtn = card.querySelector(".hash-copy-btn") as HTMLButtonElement;
const copyIcon = card.querySelector(".hash-copy-icon") as HTMLSpanElement;
const checkIcon = card.querySelector(".hash-check-icon") as HTMLSpanElement;
const copyLabel = card.querySelector(".hash-copy-label") as HTMLSpanElement;
copyBtn.addEventListener("click", () => {
copyToClipboard(formatted, copyIcon, checkIcon, copyLabel);
});
resultsContainer.appendChild(card);
}
}
function updateCompareBadges() {
const cards = resultsContainer.querySelectorAll<HTMLDivElement>(":scope > div");
cards.forEach((card, i) => {
const result = currentResults[i];
if (!result) return;
const badge = card.querySelector(".hash-compare-badge") as HTMLDivElement;
if (badge) {
const compare = checkCompare(result.hash);
badge.innerHTML = compare.display;
}
});
}
function updateCase() {
const cards = resultsContainer.querySelectorAll<HTMLDivElement>(":scope > div");
cards.forEach((card, i) => {
const result = currentResults[i];
if (!result) return;
const codeEl = card.querySelector("code") as HTMLElement;
const formatted = formatHash(result.hash);
if (codeEl) codeEl.textContent = formatted;
});
// Re-attach copy listeners with new formatted text
cards.forEach((card, i) => {
const result = currentResults[i];
if (!result) return;
const copyBtn = card.querySelector(".hash-copy-btn") as HTMLButtonElement;
const copyIcon = card.querySelector(".hash-copy-icon") as HTMLSpanElement;
const checkIcon = card.querySelector(".hash-check-icon") as HTMLSpanElement;
const copyLabel = card.querySelector(".hash-copy-label") as HTMLSpanElement;
if (copyBtn) {
const formatted = formatHash(result.hash);
// Clone to remove old listeners
const newBtn = copyBtn.cloneNode(true) as HTMLButtonElement;
copyBtn.parentNode?.replaceChild(newBtn, copyBtn);
const newCopyIcon = newBtn.querySelector(".hash-copy-icon") as HTMLSpanElement;
const newCheckIcon = newBtn.querySelector(".hash-check-icon") as HTMLSpanElement;
const newCopyLabel = newBtn.querySelector(".hash-copy-label") as HTMLSpanElement;
newBtn.addEventListener("click", () => {
copyToClipboard(formatted, newCopyIcon, newCheckIcon, newCopyLabel);
});
}
});
}
// ===================== Event Listeners =====================
modeTextBtn.addEventListener("click", () => setActiveMode("text"));
modeRandomBtn.addEventListener("click", () => setActiveMode("random"));
lengthSlider.addEventListener("input", () => {
lengthValue.textContent = lengthSlider.value;
refreshRandom();
});
refreshRandomBtn.addEventListener("click", refreshRandom);
caseToggleBtn.addEventListener("click", () => {
isUppercase = !isUppercase;
caseLabel.textContent = isUppercase ? TEXTS.uppercase : TEXTS.lowercase;
updateCase();
});
generateBtn.addEventListener("click", generateHashes);
compareInput.addEventListener("input", updateCompareBadges);
// ===================== Init =====================
setActiveMode("text");
// Auto-generate on load: fill textarea with random 32-char string, SHA-256 pre-selected
(async () => {
currentRandomString = generateRandomString(32);
textInput.value = currentRandomString;
await generateHashes();
})();
</script>
@@ -0,0 +1,120 @@
---
import { useT } from "../../i18n/translations";
const isRu = Astro.url.pathname.startsWith("/ru");
const T = useT(isRu ? "ru" : "en");
---
<div id="letter-generator" class="mt-8" data-ru={isRu ? "1" : "0"}>
<div class="my-6 rounded-2xl bg-zinc-900/80 border border-zinc-800/80 p-8 h-52 overflow-hidden">
<div class="h-full flex flex-col items-center justify-center gap-3">
<button
id="lg-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-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950"
aria-live="polite"
>
<span
id="lg-result"
class="text-8xl font-bold 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="lg-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="lg-check-icon"
class="absolute -right-7 top-1/2 -translate-y-1/2 hidden text-accent"
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="lg-copied-label"
class="text-xs font-medium text-accent opacity-0 transition-opacity duration-200"
aria-live="polite"
aria-atomic="true">{T.copied}</span
>
</div>
</div>
<div class="flex justify-center">
<button
id="lg-btn"
type="button"
class="w-full sm:w-auto px-8 py-3 bg-accent text-white font-semibold rounded-xl hover:bg-accent-hover active:bg-accent-active focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors duration-100 cursor-pointer shadow-lg shadow-accent/25"
>
{T.generate}
</button>
</div>
</div>
<script>
import { CopyFeedback } from "@/lib/client/clipboard";
import { popElement } from "@/lib/client/animations";
const isRu = document.documentElement.lang === "ru";
const ALPHABET = isRu
? "АБВГДЕЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ"
: "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const btn = document.getElementById("lg-btn") as HTMLButtonElement;
const copyBtn = document.getElementById("lg-copy-btn") as HTMLButtonElement;
const resultEl = document.getElementById("lg-result") as HTMLSpanElement;
const copyIcon = document.getElementById("lg-copy-icon") as HTMLSpanElement;
const checkIcon = document.getElementById("lg-check-icon") as HTMLSpanElement;
const copiedLabel = document.getElementById(
"lg-copied-label",
) as HTMLSpanElement;
const clipboard = new CopyFeedback(copyIcon, checkIcon);
function generate() {
const idx = Math.floor(Math.random() * ALPHABET.length);
const letter = ALPHABET[idx];
resultEl.textContent = letter;
copyBtn.classList.remove("invisible");
popElement(resultEl);
}
async function copyLetter() {
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", copyLetter);
</script>
@@ -0,0 +1,242 @@
---
import { useT } from "../../i18n/translations";
const isRu = Astro.url.pathname.startsWith("/ru");
const T = useT(isRu ? "ru" : "en");
---
<div id="list-generator" class="mt-8">
<div class="rounded-xl bg-zinc-900/50 border border-zinc-800/60 p-5">
<div class="flex flex-col gap-4">
<div>
<label
for="lst-items"
class="block text-sm font-medium text-zinc-400 mb-1"
>
{T.items}
<span class="text-zinc-600">({T.itemsOneLine})</span>
</label>
<textarea
id="lst-items"
rows="6"
placeholder="Alice
Bob
Carol
David"
class="w-full bg-zinc-900 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 text-sm font-mono focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent transition-colors resize-y"
aria-label={T.items}></textarea>
</div>
<div class="flex flex-col sm:flex-row gap-4 items-end">
<div class="flex-1">
<label
for="lst-pick"
class="block text-sm font-medium text-zinc-400 mb-1">{T.pick}</label
>
<input
id="lst-pick"
type="number"
value="1"
min="1"
class="w-full bg-zinc-900 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 text-base focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent 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-accent 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>
<div class="my-6 h-52 overflow-hidden flex flex-col items-center justify-center gap-3 rounded-2xl bg-zinc-900/80 border border-zinc-800/80 p-8">
<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-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 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-accent"
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-accent text-white font-semibold rounded-xl shadow-lg shadow-accent/25 hover:bg-accent-hover active:bg-accent-active focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors duration-100 cursor-pointer"
>
{T.pick}
</button>
</div>
</div>
<script>
import { CopyFeedback } from "@/lib/client/clipboard";
import { createErrorDisplay } from "@/lib/client/validation";
const isRu = document.documentElement.lang === "ru";
const COPY_LABEL = isRu ? "Копировать" : "Copy";
const COPIED_LABEL = isRu ? "Скопировано" : "Copied";
const ERR_ADD_ONE = isRu
? "Добавьте хотя бы один элемент в список."
: "Add at least one item to the list.";
const ERR_PICK_ONE = isRu
? "Выберите хотя бы 1 элемент."
: "Pick at least 1 item.";
const itemsInput = document.getElementById(
"lst-items",
) as HTMLTextAreaElement;
const pickInput = document.getElementById("lst-pick") as HTMLInputElement;
const replaceCb = document.getElementById("lst-replace") as HTMLInputElement;
const btn = document.getElementById("lst-btn") as HTMLButtonElement;
const copyBtn = document.getElementById("lst-copy-btn") as HTMLButtonElement;
const resultEl = document.getElementById("lst-result") as HTMLDivElement;
const errorEl = document.getElementById("lst-error") as HTMLParagraphElement;
const copyLabel = document.getElementById(
"lst-copy-label",
) as HTMLSpanElement;
const copyIcon = document.getElementById("lst-copy-icon") as HTMLSpanElement;
const checkIcon = document.getElementById(
"lst-check-icon",
) as HTMLSpanElement;
let lastPicked: string[] = [];
const errors = createErrorDisplay(errorEl);
const clipboard = new CopyFeedback(copyIcon, checkIcon);
function pick() {
const items = itemsInput.value
.split("\n")
.map((s) => s.trim())
.filter(Boolean);
const count = parseInt(pickInput.value, 10);
const withReplacement = replaceCb.checked;
if (items.length === 0) {
errors.show(ERR_ADD_ONE);
return;
}
if (!Number.isInteger(count) || count < 1) {
errors.show(ERR_PICK_ONE);
return;
}
if (!withReplacement && count > items.length) {
errors.show(
isRu
? `Нельзя выбрать ${count} уникальных элементов из списка ${items.length}.`
: `Cannot pick ${count} unique items from a list of ${items.length}.`,
);
return;
}
errors.clear();
if (withReplacement) {
lastPicked = Array.from(
{ length: count },
() => items[Math.floor(Math.random() * items.length)],
);
} else {
const pool = [...items];
for (let i = 0; i < count; i++) {
const j = i + Math.floor(Math.random() * (pool.length - i));
[pool[i], pool[j]] = [pool[j], pool[i]];
}
lastPicked = pool.slice(0, count);
}
resultEl.innerHTML = "";
lastPicked.forEach((item) => {
const chip = document.createElement("span");
chip.textContent = item;
chip.className =
"inline-flex items-center px-3 py-1.5 rounded-full border border-accent/40 bg-accent/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>
@@ -0,0 +1,252 @@
---
import { useT } from "../../i18n/translations";
const isRu = Astro.url.pathname.startsWith("/ru");
const T = useT(isRu ? "ru" : "en");
const lengthOptions = isRu
? [
{ value: "short", label: "Короткие" },
{ value: "medium", label: "Средние" },
{ value: "long", label: "Длинные" },
]
: [
{ value: "short", label: "Short" },
{ value: "medium", label: "Medium" },
{ value: "long", label: "Long" },
];
const startLabel = isRu
? "Начать с Lorem ipsum dolor sit amet"
: "Start with Lorem ipsum dolor sit amet";
---
<div id="lorem-generator" class="mt-8">
<!-- Controls -->
<div class="my-6 rounded-2xl bg-zinc-900/80 border border-zinc-800/80 p-8">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6 mb-6">
<!-- Paragraph count -->
<div>
<label for="lg-count" class="block text-sm font-medium text-zinc-300 mb-2">
{isRu ? "Количество абзацев" : "Paragraphs"}
</label>
<input
id="lg-count"
type="number"
min="1"
max="20"
value="3"
class="w-full px-4 py-2.5 bg-zinc-950 border border-zinc-800 rounded-xl text-zinc-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950"
/>
</div>
<!-- Length selector -->
<div>
<label for="lg-length" class="block text-sm font-medium text-zinc-300 mb-2">
{isRu ? "Длина абзаца" : "Paragraph length"}
</label>
<select
id="lg-length"
class="w-full px-4 py-2.5 bg-zinc-950 border border-zinc-800 rounded-xl text-zinc-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 appearance-none"
>
{lengthOptions.map((opt) => (
<option value={opt.value}>{opt.label}</option>
))}
</select>
</div>
</div>
<!-- Start with Lorem ipsum checkbox -->
<div class="flex items-center gap-3 mb-6">
<input
id="lg-start"
type="checkbox"
checked
class="w-4 h-4 rounded border-zinc-700 bg-zinc-950 text-accent focus:ring-accent focus:ring-offset-zinc-950"
/>
<label for="lg-start" class="text-sm text-zinc-300 select-none cursor-pointer">
{startLabel}
</label>
</div>
<!-- Generate button -->
<div class="flex justify-center">
<button
id="lg-btn"
type="button"
class="w-full sm:w-auto px-8 py-3 bg-accent text-white font-semibold rounded-xl shadow-lg shadow-accent/25 hover:bg-accent-hover active:bg-accent-active focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors duration-100 cursor-pointer"
>
{T.generate}
</button>
</div>
</div>
<!-- Result -->
<div
id="lg-result-wrap"
class="rounded-2xl bg-zinc-900/80 border border-zinc-800/80 p-8 opacity-0"
style="transition: opacity 0.25s ease;"
aria-live="polite"
>
<div class="flex items-center justify-between gap-3 mb-4">
<span class="text-xs font-semibold uppercase tracking-widest text-zinc-500">
{isRu ? "Результат" : "Result"}
</span>
<button
id="lg-copy"
type="button"
aria-label={T.copy}
class="group shrink-0 p-1.5 rounded-lg text-zinc-600 hover:text-zinc-300 focus:outline-none focus-visible:ring-1 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors cursor-pointer"
>
<svg
id="lg-copy-icon"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
</svg>
<svg
id="lg-check-icon"
class="hidden text-accent"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
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
id="lg-output"
class="text-zinc-100 text-sm leading-relaxed whitespace-pre-wrap font-serif"
/>
</div>
</div>
<script>
const WORDS = [
"lorem", "ipsum", "dolor", "sit", "amet", "consectetur", "adipiscing", "elit",
"sed", "do", "eiusmod", "tempor", "incididunt", "ut", "labore", "et", "dolore",
"magna", "aliqua", "enim", "ad", "minim", "veniam", "quis", "nostrud",
"exercitation", "ullamco", "laboris", "nisi", "aliquip", "ex", "ea", "commodo",
"consequat", "duis", "aute", "irure", "in", "reprehenderit", "voluptate", "velit",
"esse", "cillum", "fugiat", "nulla", "pariatur", "excepteur", "sint", "occaecat",
"cupidatat", "non", "proident", "sunt", "culpa", "qui", "officia", "deserunt",
"mollit", "anim", "id", "est", "laborum",
];
const countInput = document.getElementById("lg-count") as HTMLInputElement;
const lengthSelect = document.getElementById("lg-length") as HTMLSelectElement;
const startCheckbox = document.getElementById("lg-start") as HTMLInputElement;
const btn = document.getElementById("lg-btn") as HTMLButtonElement;
const resultWrap = document.getElementById("lg-result-wrap") as HTMLDivElement;
const output = document.getElementById("lg-output") as HTMLDivElement;
const copyBtn = document.getElementById("lg-copy") as HTMLButtonElement;
const copyIcon = document.getElementById("lg-copy-icon") as SVGElement;
const checkIcon = document.getElementById("lg-check-icon") as SVGElement;
function randInt(min: number, max: number) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function shuffle<T>(arr: T[]): T[] {
const a = arr.slice();
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
function capitalize(word: string) {
return word.charAt(0).toUpperCase() + word.slice(1);
}
function generateSentence(): string {
const length = randInt(8, 18);
const words = shuffle(WORDS).slice(0, length);
words[0] = capitalize(words[0]);
const sentence = words.join(" ") + ".";
return sentence;
}
function generateParagraph(sentenceCount: number, isFirst: boolean, startClassic: boolean): string {
const sentences: string[] = [];
if (isFirst && startClassic) {
const classic = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.";
sentences.push(classic);
for (let i = 1; i < sentenceCount; i++) {
sentences.push(generateSentence());
}
} else {
for (let i = 0; i < sentenceCount; i++) {
sentences.push(generateSentence());
}
}
return sentences.join(" ");
}
function getSentenceRange(): [number, number] {
const val = lengthSelect.value;
if (val === "short") return [2, 4];
if (val === "long") return [6, 10];
return [4, 6];
}
function generate() {
let count = parseInt(countInput.value, 10);
if (isNaN(count) || count < 1) count = 1;
if (count > 20) count = 20;
countInput.value = String(count);
const [minSentences, maxSentences] = getSentenceRange();
const startClassic = startCheckbox.checked;
const paragraphs: string[] = [];
for (let i = 0; i < count; i++) {
const sentenceCount = randInt(minSentences, maxSentences);
paragraphs.push(generateParagraph(sentenceCount, i === 0, startClassic));
}
output.textContent = paragraphs.join("\n\n");
resultWrap.style.opacity = "1";
}
const isRu = document.documentElement.lang === "ru";
const copyLabel = copyBtn.getAttribute("aria-label") || (isRu ? "Копировать" : "Copy");
let copyTimer: ReturnType<typeof setTimeout> | null = null;
copyBtn.addEventListener("click", async () => {
const text = output.textContent?.trim();
if (!text) return;
await navigator.clipboard.writeText(text);
copyIcon.classList.add("hidden");
checkIcon.classList.remove("hidden");
copyBtn.setAttribute("aria-label", isRu ? "Скопировано" : "Copied");
if (copyTimer) clearTimeout(copyTimer);
copyTimer = setTimeout(() => {
checkIcon.classList.add("hidden");
copyIcon.classList.remove("hidden");
copyBtn.setAttribute("aria-label", copyLabel);
}, 1500);
});
btn.addEventListener("click", generate);
// Generate on load
generate();
</script>
@@ -0,0 +1,211 @@
---
import { useT } from "../../i18n/translations";
const isRu = Astro.url.pathname.startsWith("/ru");
const T = useT(isRu ? "ru" : "en");
---
<div id="lottery-generator" class="mt-8">
<div class="rounded-xl bg-zinc-900/50 border border-zinc-800/60 p-5">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label for="lg-from" class="block text-sm font-medium text-zinc-400 mb-1"
>{T.from}</label
>
<input
id="lg-from"
type="number"
value="1"
class="w-full bg-zinc-900 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 text-base focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent 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-accent focus:ring-1 focus:ring-accent 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-accent focus:ring-1 focus:ring-accent transition-colors"
/>
</div>
</div>
<p
id="lg-error"
role="alert"
aria-live="polite"
class="mt-3 text-sm text-red-500 hidden"
>
</p>
</div>
<div class="my-6 h-52 overflow-hidden flex flex-col items-center justify-center gap-4 rounded-2xl bg-zinc-900/80 border border-zinc-800/80 p-8">
<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-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 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-accent" aria-hidden="true"
><svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"><path d="M20 6 9 17l-5-5"></path></svg
></span
>
</button>
</div>
<div class="flex justify-center">
<button
id="lg-btn"
type="button"
class="w-full sm:w-auto px-8 py-3 bg-accent text-white font-semibold rounded-xl shadow-lg shadow-accent/25 hover:bg-accent-hover active:bg-accent-active focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors duration-100 cursor-pointer"
>
{T.draw}
</button>
</div>
</div>
<script>
import { CopyFeedback } from "@/lib/client/clipboard";
import { createErrorDisplay, isInteger } from "@/lib/client/validation";
const isRu = document.documentElement.lang === "ru";
const ERR_INTEGERS = isRu
? "Все значения должны быть целыми числами."
: "All values must be whole numbers.";
const ERR_FROM_TO = isRu
? "«От» должно быть меньше «До»."
: '"From" must be less than "To".';
const ERR_PICK_MIN = isRu
? "«Выбрать» должно быть не менее 1."
: '"Pick" must be at least 1.';
const COPY_LABEL = isRu ? "Копировать" : "Copy";
const COPIED_LABEL = isRu ? "Скопировано" : "Copied";
const fromInput = document.getElementById("lg-from") as HTMLInputElement;
const toInput = document.getElementById("lg-to") as HTMLInputElement;
const pickInput = document.getElementById("lg-pick") as HTMLInputElement;
const btn = document.getElementById("lg-btn") as HTMLButtonElement;
const copyBtn = document.getElementById("lg-copy-btn") as HTMLButtonElement;
const resultEl = document.getElementById("lg-result") as HTMLDivElement;
const errorEl = document.getElementById("lg-error") as HTMLParagraphElement;
const copyLabel = document.getElementById("lg-copy-label") as HTMLSpanElement;
const copyIcon = document.getElementById("lg-copy-icon") as HTMLSpanElement;
const checkIcon = document.getElementById("lg-check-icon") as HTMLSpanElement;
const errors = createErrorDisplay(errorEl);
const clipboard = new CopyFeedback(copyIcon, checkIcon);
function draw() {
if (
!isInteger(fromInput.value) ||
!isInteger(toInput.value) ||
!isInteger(pickInput.value)
) {
errors.show(ERR_INTEGERS);
return;
}
const min = parseInt(fromInput.value, 10);
const max = parseInt(toInput.value, 10);
const pick = parseInt(pickInput.value, 10);
const pool = max - min + 1;
if (min >= max) {
errors.show(ERR_FROM_TO);
return;
}
if (pick < 1) {
errors.show(ERR_PICK_MIN);
return;
}
if (pick > pool) {
errors.show(
isRu
? `Нельзя выбрать ${pick} уникальных чисел из диапазона ${pool}.`
: `Cannot pick ${pick} unique numbers from a range of ${pool}.`,
);
return;
}
errors.clear();
const nums = Array.from({ length: pool }, (_, i) => min + i);
for (let i = 0; i < pick; i++) {
const j = i + Math.floor(Math.random() * (pool - i));
[nums[i], nums[j]] = [nums[j], nums[i]];
}
const picked = nums.slice(0, pick).sort((a, b) => a - b);
resultEl.innerHTML = "";
picked.forEach((n) => {
const ball = document.createElement("span");
ball.textContent = String(n);
ball.className =
"inline-flex items-center justify-center min-w-[2.5rem] h-10 px-2 rounded-full bg-accent/15 border border-accent/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>
@@ -0,0 +1,456 @@
---
import { useT } from "../../i18n/translations";
const isRu = Astro.url.pathname.startsWith("/ru");
const T = useT(isRu ? "ru" : "en");
const askPlaceholder = isRu
? "Задайте вопрос с ответом да/нет..."
: "Ask a yes/no question...";
const askButtonLabel = isRu ? "Спросить шар" : "Ask the Ball";
const historyTitle = isRu ? "История ответов" : "Answer History";
const clearHistoryLabel = isRu ? "Очистить" : "Clear";
const clickToAsk = isRu ? "Нажмите, чтобы спросить" : "Click to ask";
const copyLabel = isRu ? "Копировать" : "Copy";
const copiedLabel = isRu ? "Скопировано" : "Copied";
---
<div id="m8b-generator" class="mt-8">
<!-- Question Input -->
<div class="rounded-xl bg-zinc-900/50 border border-zinc-800/60 p-5">
<label
for="m8b-question"
class="block text-sm font-medium text-zinc-400 mb-1"
>{isRu ? "Ваш вопрос (необязательно)" : "Your question (optional)"}</label
>
<input
id="m8b-question"
type="text"
placeholder={askPlaceholder}
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-accent focus:ring-1 focus:ring-accent transition-colors"
/>
</div>
<!-- Magic 8 Ball Display -->
<div
class="my-6 rounded-2xl bg-zinc-900/80 border border-zinc-800/80 p-8 overflow-hidden"
>
<div class="flex flex-col items-center gap-6">
<!-- The Ball -->
<div id="m8b-ball-wrapper" class="relative">
<div
id="m8b-ball"
class="w-56 h-56 sm:w-64 sm:h-64 rounded-full relative select-none"
style="background: radial-gradient(circle at 35% 30%, #52525b, #18181b 50%, #09090b 85%); box-shadow: inset -12px -12px 30px rgba(0,0,0,0.7), inset 8px 8px 20px rgba(255,255,255,0.06), 0 10px 40px rgba(0,0,0,0.5);"
>
<!-- Number 8 on top -->
<div
class="absolute inset-x-0 top-3 flex justify-center"
aria-hidden="true"
>
<span
class="text-[2.5rem] sm:text-[3rem] font-black text-zinc-500/20 leading-none"
>8</span
>
</div>
<!-- Triangle Window -->
<div class="absolute inset-0 flex items-center justify-center">
<div
id="m8b-triangle"
class="w-28 h-28 sm:w-32 sm:h-32 flex items-center justify-center transition-all duration-300"
style="clip-path: polygon(50% 8%, 92% 92%, 8% 92%);"
>
<!-- Inner blue surface -->
<div
id="m8b-triangle-bg"
class="absolute inset-0 transition-colors duration-300"
style="background: #1a237e;"
>
<!-- Subtle radial gradient overlay for depth -->
<div
class="absolute inset-0"
style="background: radial-gradient(circle at 50% 30%, rgba(255,255,255,0.08), transparent 60%);"
>
</div>
</div>
<!-- Answer Text -->
<span
id="m8b-answer"
class="relative z-10 text-center text-[9px] sm:text-[11px] font-bold text-white leading-[1.15] px-3 sm:px-4 pt-2 max-w-[8rem] sm:max-w-[9rem] transition-opacity duration-200"
style="text-shadow: 0 1px 2px rgba(0,0,0,0.5); opacity: 1;"
>
{clickToAsk}
</span>
</div>
</div>
</div>
</div>
<!-- Copy Button -->
<button
id="m8b-copy-btn"
type="button"
class="group 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-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 rounded"
aria-label="Copy answer to clipboard"
>
<span id="m8b-copy-label">{copyLabel}</span>
<span id="m8b-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="m8b-check-icon"
class="hidden text-accent"
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>
<!-- Copied feedback -->
<span
id="m8b-copied-label"
class="text-xs font-medium text-accent opacity-0 transition-opacity duration-200"
aria-live="polite"
aria-atomic="true">{copiedLabel}</span
>
</div>
</div>
<!-- Ask Button -->
<div class="flex justify-center">
<button
id="m8b-btn"
type="button"
class="w-full sm:w-auto px-8 py-3 bg-accent text-white font-semibold rounded-xl hover:bg-accent-hover active:bg-accent-active focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors duration-100 cursor-pointer shadow-lg shadow-accent/25"
>
{askButtonLabel}
</button>
</div>
<!-- History -->
<div id="m8b-history-panel" class="hidden mt-10 pt-6 border-t border-zinc-800">
<div class="flex items-center justify-between mb-3">
<h3 class="text-xs font-semibold uppercase tracking-widest text-zinc-600">
{historyTitle}
</h3>
<button
id="m8b-clear-btn"
type="button"
class="text-xs text-zinc-600 hover:text-zinc-300 transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 rounded"
>
{clearHistoryLabel}
</button>
</div>
<div id="m8b-history-list" class="space-y-2"></div>
</div>
</div>
<style>
@keyframes shake-ball {
0%,
100% {
transform: translateX(0) rotate(0deg);
}
10% {
transform: translateX(-10px) rotate(-4deg);
}
20% {
transform: translateX(10px) rotate(4deg);
}
30% {
transform: translateX(-12px) rotate(-3deg);
}
40% {
transform: translateX(12px) rotate(3deg);
}
50% {
transform: translateX(-8px) rotate(-2deg);
}
60% {
transform: translateX(8px) rotate(2deg);
}
70% {
transform: translateX(-5px) rotate(-1deg);
}
80% {
transform: translateX(5px) rotate(1deg);
}
90% {
transform: translateX(-2px) rotate(0deg);
}
}
.m8b-shaking {
animation: shake-ball 0.8s ease-in-out;
}
</style>
<script>
import { CopyFeedback } from "@/lib/client/clipboard";
const isRu = document.documentElement.lang === "ru";
// ─── Answer pools ───────────────────────────────────────────────────────────
type AnswerType = "positive" | "neutral" | "negative";
interface Answer {
text: string;
type: AnswerType;
}
const EN_ANSWERS: Answer[] = [
{ text: "It is certain.", type: "positive" },
{ text: "It is decidedly so.", type: "positive" },
{ text: "Without a doubt.", type: "positive" },
{ text: "Yes definitely.", type: "positive" },
{ text: "You may rely on it.", type: "positive" },
{ text: "As I see it, yes.", type: "positive" },
{ text: "Most likely.", type: "positive" },
{ text: "Outlook good.", type: "positive" },
{ text: "Yes.", type: "positive" },
{ text: "Signs point to yes.", type: "positive" },
{ text: "Reply hazy, try again.", type: "neutral" },
{ text: "Ask again later.", type: "neutral" },
{ text: "Better not tell you now.", type: "neutral" },
{ text: "Cannot predict now.", type: "neutral" },
{ text: "Concentrate and ask again.", type: "neutral" },
{ text: "Don't count on it.", type: "negative" },
{ text: "My reply is no.", type: "negative" },
{ text: "My sources say no.", type: "negative" },
{ text: "Outlook not so good.", type: "negative" },
{ text: "Very doubtful.", type: "negative" },
];
const RU_ANSWERS: Answer[] = [
{ text: "Бесспорно.", type: "positive" },
{ text: "Предрешено.", type: "positive" },
{ text: "Никаких сомнений.", type: "positive" },
{ text: "Определённо да.", type: "positive" },
{ text: "Можешь быть уверен в этом.", type: "positive" },
{ text: "Мне кажется — да.", type: "positive" },
{ text: "Вероятнее всего.", type: "positive" },
{ text: "Хорошие перспективы.", type: "positive" },
{ text: "Да.", type: "positive" },
{ text: "Знаки говорят — да.", type: "positive" },
{ text: "Пока не ясно, попробуй снова.", type: "neutral" },
{ text: "Спроси позже.", type: "neutral" },
{ text: "Лучше не рассказывать.", type: "neutral" },
{ text: "Сейчас нельзя предсказать.", type: "neutral" },
{ text: "Сконцентрируйся и спроси опять.", type: "neutral" },
{ text: "Даже не думай.", type: "negative" },
{ text: "Мой ответ — нет.", type: "negative" },
{ text: "По моим данным — нет.", type: "negative" },
{ text: "Перспективы не очень хорошие.", type: "negative" },
{ text: "Весьма сомнительно.", type: "negative" },
];
const ANSWERS = isRu ? RU_ANSWERS : EN_ANSWERS;
const COLOR_MAP: Record<AnswerType, string> = {
positive: "#2563EB",
neutral: "#CA8A04",
negative: "#DC2626",
};
// ─── DOM refs ───────────────────────────────────────────────────────────────
const questionInput = document.getElementById(
"m8b-question",
) as HTMLInputElement;
const btn = document.getElementById("m8b-btn") as HTMLButtonElement;
const ball = document.getElementById("m8b-ball") as HTMLDivElement;
const answerEl = document.getElementById("m8b-answer") as HTMLSpanElement;
const triangleBg = document.getElementById(
"m8b-triangle-bg",
) as HTMLDivElement;
const copyBtn = document.getElementById("m8b-copy-btn") as HTMLButtonElement;
const copyLabel = document.getElementById(
"m8b-copy-label",
) as HTMLSpanElement;
const copyIcon = document.getElementById(
"m8b-copy-icon",
) as HTMLSpanElement;
const checkIcon = document.getElementById(
"m8b-check-icon",
) as HTMLSpanElement;
const copiedLabel = document.getElementById(
"m8b-copied-label",
) as HTMLSpanElement;
const historyPanel = document.getElementById(
"m8b-history-panel",
) as HTMLDivElement;
const historyList = document.getElementById(
"m8b-history-list",
) as HTMLDivElement;
const clearBtn = document.getElementById(
"m8b-clear-btn",
) as HTMLButtonElement;
const clipboard = new CopyFeedback(copyIcon, checkIcon);
let isShaking = false;
let currentAnswer: Answer | null = null;
const HISTORY_KEY = "m8b-history";
// ─── Helpers ────────────────────────────────────────────────────────────────
function getRandomAnswer(): Answer {
return ANSWERS[Math.floor(Math.random() * ANSWERS.length)];
}
function loadHistory(): { question: string; answer: string; type: AnswerType; time: number }[] {
try {
return JSON.parse(sessionStorage.getItem(HISTORY_KEY) ?? "[]");
} catch {
return [];
}
}
function saveHistory(
h: { question: string; answer: string; type: AnswerType; time: number }[],
) {
sessionStorage.setItem(HISTORY_KEY, JSON.stringify(h.slice(0, 5)));
}
function getTypeLabel(type: AnswerType): string {
if (type === "positive") return isRu ? "Положительный" : "Positive";
if (type === "neutral") return isRu ? "Нейтральный" : "Neutral";
return isRu ? "Отрицательный" : "Negative";
}
function getTypeDotClass(type: AnswerType): string {
if (type === "positive") return "bg-blue-500";
if (type === "neutral") return "bg-yellow-500";
return "bg-red-500";
}
function renderHistory() {
const h = loadHistory();
if (h.length === 0) {
historyPanel.classList.add("hidden");
return;
}
historyPanel.classList.remove("hidden");
historyList.innerHTML = h
.map(
(entry) => `
<div class="flex items-start gap-2 text-xs py-1.5 border-b border-zinc-800/40 last:border-b-0">
<span class="w-2 h-2 rounded-full ${getTypeDotClass(entry.type)} mt-0.5 shrink-0" aria-hidden="true"></span>
<div class="flex-1 min-w-0">
${entry.question ? `<div class="text-zinc-500 truncate mb-0.5">${escapeHtml(entry.question)}</div>` : ""}
<div class="text-zinc-300 font-medium">${escapeHtml(entry.answer)}</div>
</div>
</div>
`,
)
.join("");
}
function escapeHtml(str: string): string {
const div = document.createElement("div");
div.textContent = str;
return div.innerHTML;
}
function addToHistory(question: string, answer: Answer) {
const h = loadHistory();
h.unshift({
question,
answer: answer.text,
type: answer.type,
time: Date.now(),
});
saveHistory(h);
renderHistory();
}
function showAnswer(answer: Answer) {
currentAnswer = answer;
answerEl.textContent = answer.text;
triangleBg.style.background = COLOR_MAP[answer.type];
answerEl.style.opacity = "1";
copyBtn.classList.remove("invisible");
}
function shakeAndReveal() {
if (isShaking) return;
isShaking = true;
// Fade out current answer
answerEl.style.opacity = "0";
btn.disabled = true;
btn.classList.add("opacity-60", "cursor-not-allowed");
// Add shake animation
ball.classList.add("m8b-shaking");
setTimeout(() => {
ball.classList.remove("m8b-shaking");
const answer = getRandomAnswer();
showAnswer(answer);
const question = questionInput.value.trim();
addToHistory(question, answer);
isShaking = false;
btn.disabled = false;
btn.classList.remove("opacity-60", "cursor-not-allowed");
}, 800);
}
async function copyAnswer() {
if (!currentAnswer) return;
await navigator.clipboard.writeText(currentAnswer.text);
clipboard.showCopied();
copyLabel.textContent = isRu ? "Скопировано" : "Copied";
copiedLabel.style.opacity = "1";
setTimeout(() => {
copiedLabel.style.opacity = "0";
copyLabel.textContent = isRu ? "Копировать" : "Copy";
}, 1500);
}
// ─── Events ─────────────────────────────────────────────────────────────────
btn.addEventListener("click", shakeAndReveal);
copyBtn.addEventListener("click", copyAnswer);
questionInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") shakeAndReveal();
});
clearBtn.addEventListener("click", () => {
sessionStorage.removeItem(HISTORY_KEY);
renderHistory();
});
// ─── Init ───────────────────────────────────────────────────────────────────
renderHistory();
</script>
@@ -0,0 +1,678 @@
---
import { useT } from "../../i18n/translations";
const isRu = Astro.url.pathname.startsWith("/ru");
const T = useT(isRu ? "ru" : "en");
const mealTypes = isRu
? [
{ value: "all", label: "Все" },
{ value: "breakfast", label: "Завтрак" },
{ value: "lunch", label: "Обед" },
{ value: "dinner", label: "Ужин" },
{ value: "snack", label: "Перекус" },
{ value: "dessert", label: "Десерт" },
]
: [
{ value: "all", label: "All" },
{ value: "breakfast", label: "Breakfast" },
{ value: "lunch", label: "Lunch" },
{ value: "dinner", label: "Dinner" },
{ value: "snack", label: "Snack" },
{ value: "dessert", label: "Dessert" },
];
const cuisines = isRu
? [
{ value: "all", label: "Все" },
{ value: "italian", label: "Итальянская" },
{ value: "asian", label: "Азиатская" },
{ value: "mexican", label: "Мексиканская" },
{ value: "american", label: "Американская" },
{ value: "mediterranean", label: "Средиземноморская" },
{ value: "french", label: "Французская" },
{ value: "indian", label: "Индийская" },
{ value: "russian", label: "Русская" },
{ value: "middle-eastern", label: "Ближневосточная" },
]
: [
{ value: "all", label: "All" },
{ value: "italian", label: "Italian" },
{ value: "asian", label: "Asian" },
{ value: "mexican", label: "Mexican" },
{ value: "american", label: "American" },
{ value: "mediterranean", label: "Mediterranean" },
{ value: "french", label: "French" },
{ value: "indian", label: "Indian" },
{ value: "russian", label: "Russian" },
{ value: "middle-eastern", label: "Middle Eastern" },
];
const difficulties = isRu
? [
{ value: "all", label: "Все" },
{ value: "easy", label: "Легко" },
{ value: "medium", label: "Средне" },
{ value: "hard", label: "Сложно" },
]
: [
{ value: "all", label: "All" },
{ value: "easy", label: "Easy" },
{ value: "medium", label: "Medium" },
{ value: "hard", label: "Hard" },
];
const timeFilters = isRu
? [
{ value: "all", label: "Все" },
{ value: "15", label: "До 15 мин" },
{ value: "30", label: "До 30 мин" },
{ value: "60", label: "До 1 часа" },
{ value: "slow", label: "Долгая готовка" },
]
: [
{ value: "all", label: "All" },
{ value: "15", label: "Under 15 min" },
{ value: "30", label: "Under 30 min" },
{ value: "60", label: "Under 1 hour" },
{ value: "slow", label: "Slow cook" },
];
const cuisineLabelMap: Record<string, { en: string; ru: string }> = {
italian: { en: "Italian", ru: "Итальянская" },
asian: { en: "Asian", ru: "Азиатская" },
mexican: { en: "Mexican", ru: "Мексиканская" },
american: { en: "American", ru: "Американская" },
mediterranean: { en: "Mediterranean", ru: "Средиземноморская" },
french: { en: "French", ru: "Французская" },
indian: { en: "Indian", ru: "Индийская" },
russian: { en: "Russian", ru: "Русская" },
"middle-eastern": { en: "Middle Eastern", ru: "Ближневосточная" },
};
const mealTypeLabelMap: Record<string, { en: string; ru: string }> = {
breakfast: { en: "Breakfast", ru: "Завтрак" },
lunch: { en: "Lunch", ru: "Обед" },
dinner: { en: "Dinner", ru: "Ужин" },
snack: { en: "Snack", ru: "Перекус" },
dessert: { en: "Dessert", ru: "Десерт" },
};
const difficultyLabelMap: Record<string, { en: string; ru: string; color: string }> = {
easy: { en: "Easy", ru: "Легко", color: "bg-emerald-500/15 text-emerald-400 border-emerald-500/25" },
medium: { en: "Medium", ru: "Средне", color: "bg-amber-500/15 text-amber-400 border-amber-500/25" },
hard: { en: "Hard", ru: "Сложно", color: "bg-red-500/15 text-red-400 border-red-500/25" },
};
const dietaryLabelMap: Record<string, { en: string; ru: string; emoji: string }> = {
vegetarian: { en: "Vegetarian", ru: "Вегетарианское", emoji: "🌿" },
vegan: { en: "Vegan", ru: "Веганское", emoji: "🌱" },
"gluten-free": { en: "Gluten-Free", ru: "Без глютена", emoji: "🌾" },
spicy: { en: "Spicy", ru: "Острое", emoji: "🌶" },
};
---
<div id="meal-generator" class="mt-8">
<!-- Filters -->
<div class="mb-6 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
<div class="flex flex-col gap-1.5">
<label for="mg-type" class="text-sm font-medium text-zinc-300">
{isRu ? "Тип блюда" : "Meal Type"}
</label>
<select
id="mg-type"
class="px-3 py-2 rounded-lg bg-zinc-900 border border-zinc-800 text-sm text-zinc-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 cursor-pointer"
>
{mealTypes.map((t) => <option value={t.value}>{t.label}</option>)}
</select>
</div>
<div class="flex flex-col gap-1.5">
<label for="mg-cuisine" class="text-sm font-medium text-zinc-300">
{isRu ? "Кухня" : "Cuisine"}
</label>
<select
id="mg-cuisine"
class="px-3 py-2 rounded-lg bg-zinc-900 border border-zinc-800 text-sm text-zinc-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 cursor-pointer"
>
{cuisines.map((c) => <option value={c.value}>{c.label}</option>)}
</select>
</div>
<div class="flex flex-col gap-1.5">
<label for="mg-difficulty" class="text-sm font-medium text-zinc-300">
{isRu ? "Сложность" : "Difficulty"}
</label>
<select
id="mg-difficulty"
class="px-3 py-2 rounded-lg bg-zinc-900 border border-zinc-800 text-sm text-zinc-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 cursor-pointer"
>
{difficulties.map((d) => <option value={d.value}>{d.label}</option>)}
</select>
</div>
<div class="flex flex-col gap-1.5">
<label for="mg-time" class="text-sm font-medium text-zinc-300">
{isRu ? "Время" : "Time"}
</label>
<select
id="mg-time"
class="px-3 py-2 rounded-lg bg-zinc-900 border border-zinc-800 text-sm text-zinc-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 cursor-pointer"
>
{timeFilters.map((t) => <option value={t.value}>{t.label}</option>)}
</select>
</div>
</div>
<!-- Saved count + favorites toggle -->
<div class="mb-4 flex items-center justify-between">
<div class="text-sm text-zinc-400" id="mg-saved-info" style="display:none;">
<button
id="mg-view-favs"
type="button"
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-zinc-900 border border-zinc-800 text-zinc-300 hover:text-zinc-100 hover:bg-zinc-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors cursor-pointer"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none" class="text-amber-400"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>
<span id="mg-saved-count">0</span>
<span>{isRu ? " избранных" : " saved"}</span>
</button>
</div>
</div>
<!-- Favorites panel -->
<div id="mg-favs-panel" class="mb-6 rounded-xl bg-zinc-900/80 border border-zinc-800/80 p-4 sm:p-5 hidden">
<div class="flex items-center justify-between mb-3">
<h4 class="text-sm font-semibold text-zinc-200">{isRu ? "Избранные рецепты" : "Favorite Recipes"}</h4>
<button
id="mg-close-favs"
type="button"
class="inline-flex items-center justify-center w-7 h-7 rounded-lg bg-zinc-800 text-zinc-400 hover:text-zinc-100 hover:bg-zinc-700 transition-colors cursor-pointer"
aria-label={isRu ? "Закрыть" : "Close"}
>
<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"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</button>
</div>
<div id="mg-favs-list" class="flex flex-col gap-2">
<p class="text-sm text-zinc-500">{isRu ? "Нет сохранённых рецептов." : "No saved recipes."}</p>
</div>
</div>
<!-- Result card -->
<div
id="mg-result"
class="rounded-2xl bg-zinc-900/80 border border-zinc-800/80 p-6 sm:p-8 opacity-0"
style="transition: opacity 0.25s ease;"
aria-live="polite"
>
<div class="flex flex-col gap-4">
<!-- Tags row -->
<div id="mg-tags" class="flex flex-wrap items-center gap-2">
<span id="mg-cuisine-tag" class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium border bg-orange-500/15 text-orange-400 border-orange-500/25" />
<span id="mg-type-tag" class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium border bg-zinc-700/50 text-zinc-300 border-zinc-600/40" />
<span id="mg-difficulty-tag" class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium border" />
<span id="mg-time-tag" class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium border bg-zinc-700/50 text-zinc-300 border-zinc-600/40">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" 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"/><polyline points="12 6 12 12 16 14"/></svg>
<span id="mg-time-value" />
</span>
</div>
<!-- Meal name -->
<h3 id="mg-name" class="text-2xl sm:text-3xl font-bold text-zinc-100" />
<!-- Description -->
<p id="mg-description" class="text-sm sm:text-base text-zinc-400 leading-relaxed" />
<!-- Key ingredients -->
<div>
<span class="text-xs font-medium text-zinc-500 uppercase tracking-wider">
{isRu ? "Ингредиенты" : "Ingredients"}
</span>
<div id="mg-ingredients" class="mt-2 flex flex-wrap gap-1.5" />
</div>
<!-- Calories + Dietary -->
<div class="flex flex-wrap items-center gap-3">
<span id="mg-calories" class="text-sm font-medium text-zinc-300" />
<div id="mg-dietary" class="flex flex-wrap gap-1.5" />
</div>
<!-- Actions -->
<div class="flex flex-wrap items-center gap-3 mt-2">
<!-- Copy button -->
<button
id="mg-copy"
type="button"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-zinc-800 text-zinc-300 hover:text-zinc-100 hover:bg-zinc-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors cursor-pointer"
>
<svg id="mg-copy-icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
<svg id="mg-check-icon" class="hidden text-accent" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
<span id="mg-copy-text">{T.copy}</span>
</button>
<!-- Save favorite button -->
<button
id="mg-save"
type="button"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-zinc-800 text-zinc-300 hover:text-zinc-100 hover:bg-zinc-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors cursor-pointer"
>
<svg id="mg-star-icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>
<svg id="mg-star-filled-icon" class="hidden text-amber-400" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="none"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>
<span id="mg-save-text">{isRu ? "В избранное" : "Save"}</span>
</button>
</div>
</div>
</div>
<!-- Generate button -->
<div class="flex justify-center mt-8">
<button
id="mg-btn"
type="button"
class="w-full sm:w-auto px-8 py-3 bg-accent text-white font-semibold rounded-xl shadow-lg shadow-accent/25 hover:bg-accent-hover active:bg-accent-active focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors duration-100 cursor-pointer"
>
{T.generate}
</button>
</div>
</div>
<script define:vars={{ cuisineLabelMap, mealTypeLabelMap, difficultyLabelMap, dietaryLabelMap }}>
(function() {
// Meals data
const meals = [
{ en: "Shakshuka", ru: "Шакшука", cuisine: "middle-eastern", type: "breakfast", difficulty: "easy", time: 25, desc: "Eggs poached in a simmering tomato sauce with spices.", ruDesc: "Яйца, приготовленные в томатном соусе со специями.", ingredients: ["eggs", "tomatoes", "bell pepper", "onion", "cumin", "paprika"], calories: 320, dietary: ["vegetarian", "gluten-free"] },
{ en: "Spaghetti Carbonara", ru: "Спагетти Карбонара", cuisine: "italian", type: "dinner", difficulty: "medium", time: 20, desc: "Classic Roman pasta with eggs, pecorino, guanciale and black pepper.", ruDesc: "Классическая римская паста с яйцами, пекорино, гуанчиале и черным перцем.", ingredients: ["spaghetti", "eggs", "pecorino", "guanciale", "black pepper"], calories: 650, dietary: [] },
{ en: "Borscht", ru: "Борщ", cuisine: "russian", type: "lunch", difficulty: "medium", time: 90, desc: "Hearty beet soup with vegetables and often beef, served with sour cream.", ruDesc: "Сытный свекольный суп с овощами и говядиной, подаваемый со сметаной.", ingredients: ["beets", "cabbage", "potato", "carrot", "onion", "beef", "sour cream"], calories: 280, dietary: ["gluten-free"] },
{ en: "Pad Thai", ru: "Пад Тай", cuisine: "asian", type: "dinner", difficulty: "medium", time: 30, desc: "Stir-fried rice noodles with shrimp, tofu, peanuts and tamarind sauce.", ruDesc: "Жареная рисовая лапша с креветками, тофу, арахисом и тамариндовым соусом.", ingredients: ["rice noodles", "shrimp", "tofu", "peanuts", "tamarind", "bean sprouts", "egg"], calories: 520, dietary: [] },
{ en: "Tacos al Pastor", ru: "Такос аль Пастор", cuisine: "mexican", type: "dinner", difficulty: "hard", time: 45, desc: "Marinated pork tacos with pineapple, cilantro and onion.", ruDesc: "Такос с маринованной свининой, ананасом, кинзой и луком.", ingredients: ["pork", "pineapple", "corn tortillas", "cilantro", "onion", "chili"], calories: 480, dietary: ["gluten-free"] },
{ en: "Avocado Toast", ru: "Тост с авокадо", cuisine: "american", type: "breakfast", difficulty: "easy", time: 10, desc: "Crispy toast topped with smashed avocado, lime, and chili flakes.", ruDesc: "Хрустящий тост с размятым авокадо, лаймом и чили хлопьями.", ingredients: ["bread", "avocado", "lime", "chili flakes", "olive oil"], calories: 340, dietary: ["vegetarian"] },
{ en: "Omelette", ru: "Омлет", cuisine: "french", type: "breakfast", difficulty: "easy", time: 10, desc: "Fluffy French omelette with butter, herbs, and cheese.", ruDesc: "Воздушный французский омлет со сливочным маслом, зеленью и сыром.", ingredients: ["eggs", "butter", "herbs", "cheese", "salt"], calories: 380, dietary: ["vegetarian", "gluten-free"] },
{ en: "Butter Chicken", ru: "Курица в масле", cuisine: "indian", type: "dinner", difficulty: "medium", time: 40, desc: "Tender chicken in a rich, creamy tomato sauce with aromatic spices.", ruDesc: "Нежная курица в густом сливочно-томатном соусе с ароматными специями.", ingredients: ["chicken", "tomatoes", "cream", "butter", "garam masala", "ginger", "garlic"], calories: 550, dietary: ["gluten-free"] },
{ en: "Greek Salad", ru: "Греческий салат", cuisine: "mediterranean", type: "lunch", difficulty: "easy", time: 10, desc: "Fresh salad with tomatoes, cucumber, olives, feta, and olive oil.", ruDesc: "Свежий салат с помидорами, огурцом, оливками, фетой и оливковым маслом.", ingredients: ["tomatoes", "cucumber", "olives", "feta cheese", "olive oil", "oregano"], calories: 250, dietary: ["vegetarian", "gluten-free"] },
{ en: "Sushi Rolls", ru: "Роллы суши", cuisine: "asian", type: "dinner", difficulty: "hard", time: 60, desc: "Vinegared rice with fresh fish and vegetables, rolled in nori.", ruDesc: "Рис с уксусом со свежей рыбой и овощами, завернутый в нори.", ingredients: ["sushi rice", "salmon", "nori", "avocado", "cucumber", "rice vinegar"], calories: 420, dietary: [] },
{ en: "Hummus Plate", ru: "Хумус", cuisine: "middle-eastern", type: "snack", difficulty: "easy", time: 15, desc: "Creamy chickpea dip with tahini, lemon, and garlic.", ruDesc: "Нежный нутовый дип с тахини, лимоном и чесноком.", ingredients: ["chickpeas", "tahini", "lemon", "garlic", "olive oil", "cumin"], calories: 280, dietary: ["vegetarian", "vegan", "gluten-free"] },
{ en: "Cheeseburger", ru: "Чизбургер", cuisine: "american", type: "lunch", difficulty: "easy", time: 20, desc: "Juicy beef patty with melted cheese, lettuce, tomato, and pickles.", ruDesc: "Сочная говяжья котлета с расплавленным сыром, салатом, помидором и солеными огурцами.", ingredients: ["beef patty", "cheese", "bun", "lettuce", "tomato", "pickles"], calories: 680, dietary: [] },
{ en: "Margherita Pizza", ru: "Пицца Маргарита", cuisine: "italian", type: "dinner", difficulty: "medium", time: 40, desc: "Classic pizza with tomato sauce, mozzarella, and fresh basil.", ruDesc: "Классическая пицца с томатным соусом, моцареллой и свежим базиликом.", ingredients: ["pizza dough", "tomato sauce", "mozzarella", "basil", "olive oil"], calories: 720, dietary: ["vegetarian"] },
{ en: "Tiramisu", ru: "Тирамису", cuisine: "italian", type: "dessert", difficulty: "medium", time: 30, desc: "Coffee-soaked ladyfingers layered with mascarpone cream.", ruDesc: "Печенье савоярди, пропитанное кофе, со слоем крема из маскарпоне.", ingredients: ["ladyfingers", "mascarpone", "coffee", "eggs", "sugar", "cocoa"], calories: 450, dietary: ["vegetarian"] },
{ en: "Falafel Wrap", ru: "Фалафель в лаваше", cuisine: "middle-eastern", type: "lunch", difficulty: "medium", time: 35, desc: "Crispy falafel balls wrapped in pita with tahini sauce and salad.", ruDesc: "Хрустящие шарики фалафеля в пите с соусом тахини и салатом.", ingredients: ["chickpeas", "pita bread", "tahini", "parsley", "cumin", "lettuce", "tomato"], calories: 460, dietary: ["vegetarian", "vegan"] },
{ en: "French Toast", ru: "Французские тосты", cuisine: "american", type: "breakfast", difficulty: "easy", time: 15, desc: "Thick bread soaked in egg custard, fried until golden.", ruDesc: "Толстый хлеб, замоченный в яичной смеси и обжаренный до золотистой корочки.", ingredients: ["bread", "eggs", "milk", "cinnamon", "butter", "maple syrup"], calories: 420, dietary: ["vegetarian"] },
{ en: "Chicken Tikka Masala", ru: "Куриное Тикка Масала", cuisine: "indian", type: "dinner", difficulty: "medium", time: 45, desc: "Marinated chicken in spiced, creamy tomato curry sauce.", ruDesc: "Маринованная курица в пряном сливочном томатном соусе карри.", ingredients: ["chicken", "yogurt", "tomatoes", "cream", "garam masala", "turmeric", "ginger"], calories: 580, dietary: ["gluten-free"] },
{ en: "Ratatouille", ru: "Рататуй", cuisine: "french", type: "lunch", difficulty: "medium", time: 50, desc: "Provençal vegetable stew with eggplant, zucchini, tomatoes, and herbs.", ruDesc: "Прованское рагу из баклажанов, цукини, помидоров и трав.", ingredients: ["eggplant", "zucchini", "tomatoes", "bell pepper", "onion", "garlic", "herbs"], calories: 220, dietary: ["vegetarian", "vegan", "gluten-free"] },
{ en: "Caesar Salad", ru: "Салат Цезарь", cuisine: "american", type: "lunch", difficulty: "easy", time: 15, desc: "Crisp romaine lettuce with croutons, parmesan, and creamy Caesar dressing.", ruDesc: "Хрустящий салат романо с гренками, пармезаном и соусом Цезарь.", ingredients: ["romaine lettuce", "croutons", "parmesan", "Caesar dressing", "chicken"], calories: 420, dietary: [] },
{ en: "Miso Soup", ru: "Мисо-суп", cuisine: "asian", type: "lunch", difficulty: "easy", time: 15, desc: "Traditional Japanese soup with miso paste, tofu, and seaweed.", ruDesc: "Традиционный японский суп с мисо-пастой, тофу и водорослями.", ingredients: ["miso paste", "tofu", "seaweed", "green onions", "dashi"], calories: 90, dietary: ["vegetarian", "gluten-free"] },
{ en: "Guacamole with Chips", ru: "Гуакамоле с чипсами", cuisine: "mexican", type: "snack", difficulty: "easy", time: 10, desc: "Creamy avocado dip with lime, cilantro, and tortilla chips.", ruDesc: "Нежный дип из авокадо с лаймом, кинзой и кукурузными чипсами.", ingredients: ["avocado", "lime", "cilantro", "onion", "jalapeno", "tortilla chips"], calories: 350, dietary: ["vegetarian", "vegan", "gluten-free"] },
{ en: "Croissant", ru: "Круассан", cuisine: "french", type: "breakfast", difficulty: "hard", time: 180, desc: "Flaky, buttery French pastry with golden layers.", ruDesc: "Слоеная французская выпечка со сливочным маслом и золотистой корочкой.", ingredients: ["flour", "butter", "yeast", "milk", "sugar", "salt"], calories: 380, dietary: ["vegetarian"] },
{ en: "Chicken Shawarma", ru: "Куриная шаурма", cuisine: "middle-eastern", type: "dinner", difficulty: "medium", time: 40, desc: "Spiced chicken wrapped in flatbread with garlic sauce and pickles.", ruDesc: "Пряная курица в лепешке с чесночным соусом и солеными огурцами.", ingredients: ["chicken", "flatbread", "garlic sauce", "pickles", "lettuce", "tomato", "spices"], calories: 560, dietary: [] },
{ en: "Pancakes", ru: "Блины", cuisine: "american", type: "breakfast", difficulty: "easy", time: 20, desc: "Fluffy pancakes served with maple syrup and butter.", ruDesc: "Пышные блины с кленовым сиропом и сливочным маслом.", ingredients: ["flour", "milk", "eggs", "butter", "maple syrup", "baking powder"], calories: 460, dietary: ["vegetarian"] },
{ en: "Tom Yum Soup", ru: "Том Ям", cuisine: "asian", type: "lunch", difficulty: "medium", time: 25, desc: "Hot and sour Thai soup with shrimp, lemongrass, and lime.", ruDesc: "Острый и кислый тайский суп с креветками, лемонграссом и лаймом.", ingredients: ["shrimp", "lemongrass", "lime", "chili", "mushrooms", "galangal", "kaffir lime"], calories: 180, dietary: ["gluten-free", "spicy"] },
{ en: "Fettuccine Alfredo", ru: "Феттуччине Альфредо", cuisine: "italian", type: "dinner", difficulty: "easy", time: 20, desc: "Creamy pasta with butter, parmesan, and heavy cream.", ruDesc: "Сливочная паста со сливочным маслом, пармезаном и жирными сливками.", ingredients: ["fettuccine", "butter", "parmesan", "heavy cream", "garlic"], calories: 720, dietary: ["vegetarian"] },
{ en: "Burrito Bowl", ru: "Буррито боул", cuisine: "mexican", type: "lunch", difficulty: "easy", time: 20, desc: "Rice bowl with beans, salsa, guacamole, and grilled chicken.", ruDesc: "Рис с фасолью, сальсой, гуакамоле и жареной курицей.", ingredients: ["rice", "black beans", "salsa", "guacamole", "chicken", "corn", "cheese"], calories: 640, dietary: ["gluten-free"] },
{ en: "Baba Ganoush", ru: "Баба гануш", cuisine: "middle-eastern", type: "snack", difficulty: "easy", time: 20, desc: "Smoky eggplant dip with tahini, lemon, and garlic.", ruDesc: "Дымный дип из баклажанов с тахини, лимоном и чесноком.", ingredients: ["eggplant", "tahini", "lemon", "garlic", "olive oil", "cumin"], calories: 180, dietary: ["vegetarian", "vegan", "gluten-free"] },
{ en: "Boeuf Bourguignon", ru: "Говядина по-бургундски", cuisine: "french", type: "dinner", difficulty: "hard", time: 180, desc: "Slow-cooked beef stew in red wine with mushrooms and onions.", ruDesc: "Тушеная говядина в красном вине с грибами и луком.", ingredients: ["beef", "red wine", "mushrooms", "onion", "bacon", "carrot", "herbs"], calories: 520, dietary: ["gluten-free"] },
{ en: "Chana Masala", ru: "Чана масала", cuisine: "indian", type: "lunch", difficulty: "easy", time: 30, desc: "Chickpeas simmered in spiced tomato and onion gravy.", ruDesc: "Нут, тушеный в пряном томатно-луковом соусе.", ingredients: ["chickpeas", "tomatoes", "onion", "ginger", "cumin", "coriander", "turmeric"], calories: 340, dietary: ["vegetarian", "vegan", "gluten-free", "spicy"] },
{ en: "Risotto", ru: "Ризотто", cuisine: "italian", type: "dinner", difficulty: "medium", time: 35, desc: "Creamy Arborio rice cooked with white wine, parmesan, and butter.", ruDesc: "Сливочный рис арборио с белым вином, пармезаном и сливочным маслом.", ingredients: ["Arborio rice", "white wine", "parmesan", "butter", "onion", "chicken stock"], calories: 480, dietary: ["vegetarian", "gluten-free"] },
{ en: "Gyoza", ru: "Гедза", cuisine: "asian", type: "dinner", difficulty: "hard", time: 50, desc: "Pan-fried Japanese dumplings with pork and cabbage filling.", ruDesc: "Японские жареные пельмени с начинкой из свинины и капусты.", ingredients: ["pork", "cabbage", "ginger", "soy sauce", "gyoza wrappers", "garlic"], calories: 380, dietary: [] },
{ en: "Poke Bowl", ru: "Поке боул", cuisine: "asian", type: "lunch", difficulty: "easy", time: 15, desc: "Fresh Hawaiian raw fish bowl with rice and toppings.", ruDesc: "Гавайский боул с сырой рыбой, рисом и топпингами.", ingredients: ["sashimi-grade fish", "rice", "soy sauce", "sesame", "avocado", "seaweed", "cucumber"], calories: 460, dietary: ["gluten-free"] },
{ en: "BLT Sandwich", ru: "Сэндвич BLT", cuisine: "american", type: "lunch", difficulty: "easy", time: 10, desc: "Classic bacon, lettuce, and tomato sandwich on toasted bread.", ruDesc: "Классический сэндвич с беконом, салатом и помидором на поджаренном хлебе.", ingredients: ["bacon", "lettuce", "tomato", "bread", "mayonnaise"], calories: 450, dietary: [] },
{ en: "Chicken Noodle Soup", ru: "Куриный суп с лапшой", cuisine: "american", type: "lunch", difficulty: "easy", time: 40, desc: "Comforting soup with tender chicken, noodles, and vegetables.", ruDesc: "Согревающий суп с нежной курицей, лапшой и овощами.", ingredients: ["chicken", "egg noodles", "carrot", "celery", "onion", "chicken broth"], calories: 280, dietary: [] },
{ en: "Quiche Lorraine", ru: "Киш Лорен", cuisine: "french", type: "lunch", difficulty: "medium", time: 55, desc: "Savory tart with bacon, cheese, and creamy egg custard.", ruDesc: "Соленый пирог с беконом, сыром и яичной смесью.", ingredients: ["pie crust", "bacon", "eggs", "cream", "gruyere", "nutmeg"], calories: 480, dietary: [] },
{ en: "Tacos", ru: "Такос", cuisine: "mexican", type: "dinner", difficulty: "easy", time: 20, desc: "Soft tortillas filled with seasoned meat, salsa, and toppings.", ruDesc: "Мягкие тортильи с приправленным мясом, сальсой и топпингами.", ingredients: ["corn tortillas", "ground beef", "salsa", "lettuce", "cheese", "sour cream"], calories: 420, dietary: ["gluten-free"] },
{ en: "Dolma", ru: "Долма", cuisine: "mediterranean", type: "lunch", difficulty: "hard", time: 90, desc: "Stuffed grape leaves with rice, herbs, and pine nuts.", ruDesc: "Фаршированные виноградные листья с рисом, зеленью и кедровыми орехами.", ingredients: ["grape leaves", "rice", "onion", "dill", "mint", "pine nuts", "lemon"], calories: 280, dietary: ["vegetarian", "vegan", "gluten-free"] },
{ en: "Shakshuka", ru: "Шакшука", cuisine: "mediterranean", type: "breakfast", difficulty: "easy", time: 25, desc: "Eggs poached in spiced tomato and pepper sauce.", ruDesc: "Яйца в пряном томатно-перечном соусе.", ingredients: ["eggs", "tomatoes", "bell pepper", "onion", "cumin", "paprika"], calories: 320, dietary: ["vegetarian", "gluten-free"] },
{ en: "Veggie Stir-Fry", ru: "Овощное стир-фрай", cuisine: "asian", type: "dinner", difficulty: "easy", time: 20, desc: "Colorful vegetables quickly stir-fried with soy sauce and ginger.", ruDesc: "Разноцветные овощи, быстро обжаренные с соевым соусом и имбирем.", ingredients: ["broccoli", "bell pepper", "carrot", "snap peas", "soy sauce", "ginger", "garlic"], calories: 240, dietary: ["vegetarian", "vegan", "gluten-free"] },
{ en: "Lentil Soup", ru: "Чечевичный суп", cuisine: "mediterranean", type: "lunch", difficulty: "easy", time: 35, desc: "Hearty red lentil soup with carrots, onions, and cumin.", ruDesc: "Сытный суп из красной чечевицы с морковью, луком и тмином.", ingredients: ["red lentils", "carrot", "onion", "cumin", "garlic", "vegetable broth"], calories: 260, dietary: ["vegetarian", "vegan", "gluten-free"] },
{ en: "Bagel with Lox", ru: "Бейгл с лососем", cuisine: "american", type: "breakfast", difficulty: "easy", time: 10, desc: "Toasted bagel with cream cheese, smoked salmon, and capers.", ruDesc: "Поджаренный бейгл с сливочным сыром, копченым лососем и каперсами.", ingredients: ["bagel", "cream cheese", "smoked salmon", "capers", "red onion", "dill"], calories: 480, dietary: [] },
{ en: "Paella", ru: "Паэлья", cuisine: "mediterranean", type: "dinner", difficulty: "hard", time: 60, desc: "Spanish saffron rice with seafood, chicken, and vegetables.", ruDesc: "Испанский шафрановый рис с морепродуктами, курицей и овощами.", ingredients: ["bomba rice", "saffron", "shrimp", "chicken", "peas", "bell pepper", "olive oil"], calories: 520, dietary: ["gluten-free"] },
{ en: "Pho", ru: "Фо", cuisine: "asian", type: "lunch", difficulty: "hard", time: 180, desc: "Vietnamese beef noodle soup with aromatic broth and herbs.", ruDesc: "Вьетнамский суп с говядиной, лапшой и ароматным бульоном.", ingredients: ["rice noodles", "beef", "star anise", "cinnamon", "ginger", "bean sprouts", "basil"], calories: 420, dietary: ["gluten-free"] },
{ en: "Cacio e Pepe", ru: "Качо э пепе", cuisine: "italian", type: "dinner", difficulty: "medium", time: 15, desc: "Simple Roman pasta with pecorino and lots of black pepper.", ruDesc: "Простая римская паста с пекорино и черным перцем.", ingredients: ["spaghetti", "pecorino", "black pepper", "butter"], calories: 580, dietary: ["vegetarian"] },
{ en: "Couscous Salad", ru: "Салат с кускусом", cuisine: "mediterranean", type: "lunch", difficulty: "easy", time: 20, desc: "Light couscous with cucumber, tomato, herbs, and lemon.", ruDesc: "Легкий кускус с огурцом, помидорами, зеленью и лимоном.", ingredients: ["couscous", "cucumber", "tomato", "parsley", "mint", "lemon", "olive oil"], calories: 300, dietary: ["vegetarian", "vegan"] },
{ en: "Enchiladas", ru: "Энчиладас", cuisine: "mexican", type: "dinner", difficulty: "medium", time: 50, desc: "Corn tortillas rolled around chicken, covered in chili sauce and cheese.", ruDesc: "Кукурузные тортильи с курицей, в чили соусе и сыре.", ingredients: ["corn tortillas", "chicken", "enchilada sauce", "cheese", "onion", "sour cream"], calories: 540, dietary: ["gluten-free"] },
{ en: "Soufflé", ru: "Суфле", cuisine: "french", type: "dessert", difficulty: "hard", time: 40, desc: "Delicate, airy baked dessert that rises tall in the oven.", ruDesc: "Нежный воздушный десерт, который поднимается в духовке.", ingredients: ["eggs", "sugar", "vanilla", "butter", "flour"], calories: 280, dietary: ["vegetarian"] },
{ en: "Aloo Gobi", ru: "Алу гоби", cuisine: "indian", type: "lunch", difficulty: "easy", time: 30, desc: "Potato and cauliflower curry with turmeric and spices.", ruDesc: "Карри из картофеля и цветной капусты с куркумой и специями.", ingredients: ["potato", "cauliflower", "turmeric", "cumin", "ginger", "tomatoes"], calories: 240, dietary: ["vegetarian", "vegan", "gluten-free", "spicy"] },
{ en: "Carbonara", ru: "Карбонара", cuisine: "italian", type: "dinner", difficulty: "medium", time: 20, desc: "Silky pasta with eggs, guanciale, pecorino, and black pepper.", ruDesc: "Шелковистая паста с яйцами, гуанчиале, пекорино и черным перцем.", ingredients: ["spaghetti", "eggs", "guanciale", "pecorino", "black pepper"], calories: 680, dietary: [] },
{ en: "Waffles", ru: "Вафли", cuisine: "american", type: "breakfast", difficulty: "easy", time: 20, desc: "Crispy golden waffles served with berries and syrup.", ruDesc: "Хрустящие золотистые вафли с ягодами и сиропом.", ingredients: ["flour", "eggs", "milk", "butter", "baking powder", "berries", "maple syrup"], calories: 440, dietary: ["vegetarian"] },
{ en: "Bibimbap", ru: "Пибимпап", cuisine: "asian", type: "lunch", difficulty: "medium", time: 35, desc: "Korean rice bowl with vegetables, beef, fried egg, and gochujang.", ruDesc: "Корейский рис с овощами, говядиной, яичницей и кочхуджанг.", ingredients: ["rice", "beef", "spinach", "carrot", "bean sprouts", "egg", "gochujang"], calories: 580, dietary: ["gluten-free", "spicy"] },
{ en: "Moussaka", ru: "Мусака", cuisine: "mediterranean", type: "dinner", difficulty: "hard", time: 90, desc: "Layered eggplant, potato, and ground beef topped with béchamel.", ruDesc: "Слоеная запеканка из баклажанов, картофеля и фарша с бешамелем.", ingredients: ["eggplant", "ground beef", "potato", "tomatoes", "béchamel sauce", "cinnamon"], calories: 480, dietary: ["gluten-free"] },
{ en: "Crepes", ru: "Блины", cuisine: "french", type: "breakfast", difficulty: "easy", time: 25, desc: "Thin French pancakes served with Nutella, berries, or sugar.", ruDesc: "Тонкие французские блины с Нутеллой, ягодами или сахаром.", ingredients: ["flour", "milk", "eggs", "butter", "sugar", "Nutella", "berries"], calories: 350, dietary: ["vegetarian"] },
{ en: "Green Curry", ru: "Зеленое карри", cuisine: "asian", type: "dinner", difficulty: "medium", time: 35, desc: "Thai green curry with coconut milk, chicken, and Thai basil.", ruDesc: "Тайское зеленое карри с кокосовым молоком, курицей и тайским базиликом.", ingredients: ["chicken", "coconut milk", "green curry paste", "Thai basil", "bamboo shoots", "bell pepper"], calories: 460, dietary: ["gluten-free", "spicy"] },
{ en: "Fish and Chips", ru: "Рыба с чипсами", cuisine: "american", type: "dinner", difficulty: "medium", time: 35, desc: "Beer-battered fish fillets with crispy golden fries.", ruDesc: "Рыба в пивном кляре с хрустящим картофелем фри.", ingredients: ["white fish", "beer batter", "potatoes", "malt vinegar", "tartar sauce"], calories: 780, dietary: [] },
{ en: "Moussaka", ru: "Мусака", cuisine: "mediterranean", type: "dinner", difficulty: "hard", time: 90, desc: "Greek baked dish with eggplant, minced meat, and béchamel.", ruDesc: "Греческая запеканка с баклажанами, фаршем и бешамелем.", ingredients: ["eggplant", "ground beef", "potato", "béchamel", "tomatoes", "cinnamon"], calories: 490, dietary: ["gluten-free"] },
{ en: "Ramen", ru: "Рамен", cuisine: "asian", type: "dinner", difficulty: "hard", time: 240, desc: "Japanese noodle soup with rich broth, chashu pork, and soft-boiled egg.", ruDesc: "Японский суп с лапшой, насыщенным бульоном, свининой чашу и яйцом.", ingredients: ["ramen noodles", "pork belly", "soy sauce", "miso", "soft-boiled egg", "green onion", "nori"], calories: 620, dietary: [] },
{ en: "Frittata", ru: "Фриттата", cuisine: "italian", type: "breakfast", difficulty: "easy", time: 25, desc: "Italian open-faced omelette with vegetables and cheese.", ruDesc: "Итальянский омлет с овощами и сыром, запеченный в духовке.", ingredients: ["eggs", "zucchini", "bell pepper", "onion", "parmesan", "olive oil"], calories: 320, dietary: ["vegetarian", "gluten-free"] },
{ en: "Tacos de Pescado", ru: "Рыбные такос", cuisine: "mexican", type: "lunch", difficulty: "medium", time: 30, desc: "Baja-style fish tacos with cabbage slaw and lime crema.", ruDesc: "Рыбные такос в стиле Баха с капустным салатом и лаймовым кремом.", ingredients: ["white fish", "corn tortillas", "cabbage", "lime", "crema", "cilantro"], calories: 400, dietary: ["gluten-free"] },
{ en: "Tiramisu", ru: "Тирамису", cuisine: "italian", type: "dessert", difficulty: "medium", time: 30, desc: "Italian coffee-flavoured dessert with layers of mascarpone.", ruDesc: "Итальянский кофейный десерт со слоями маскарпоне.", ingredients: ["mascarpone", "coffee", "ladyfingers", "eggs", "sugar", "cocoa powder"], calories: 420, dietary: ["vegetarian"] },
{ en: "Panna Cotta", ru: "Панна котта", cuisine: "italian", type: "dessert", difficulty: "medium", time: 20, desc: "Silky Italian custard served with berry coulis.", ruDesc: "Шелковистый итальянский десерт с ягодным соусом.", ingredients: ["cream", "sugar", "gelatin", "vanilla", "berries"], calories: 310, dietary: ["vegetarian", "gluten-free"] },
{ en: "Chicken Kiev", ru: "Котлета по-киевски", cuisine: "russian", type: "dinner", difficulty: "hard", time: 50, desc: "Breaded chicken breast stuffed with herbed butter.", ruDesc: "Куриная грудка в панировке с начинкой из сливочного масла с зеленью.", ingredients: ["chicken breast", "butter", "herbs", "breadcrumbs", "egg", "flour"], calories: 540, dietary: [] },
{ en: "Olivier Salad", ru: "Салат Оливье", cuisine: "russian", type: "lunch", difficulty: "easy", time: 30, desc: "Classic Russian potato salad with vegetables and mayonnaise.", ruDesc: "Классический русский картофельный салат с овощами и майонезом.", ingredients: ["potatoes", "carrots", "eggs", "peas", "pickles", "mayonnaise", "bologna"], calories: 380, dietary: [] },
{ en: "Beef Stroganoff", ru: "Бефстроганов", cuisine: "russian", type: "dinner", difficulty: "medium", time: 40, desc: "Tender beef in creamy mushroom sauce served over noodles.", ruDesc: "Нежная говядина в сливочно-грибном соусе с лапшой.", ingredients: ["beef tenderloin", "mushrooms", "sour cream", "onion", "mustard", "butter"], calories: 560, dietary: [] },
{ en: "Syrniki", ru: "Сырники", cuisine: "russian", type: "breakfast", difficulty: "easy", time: 25, desc: "Sweet cheese pancakes served with sour cream and jam.", ruDesc: "Сладкие творожные оладьи со сметаной и вареньем.", ingredients: ["tvorog", "eggs", "flour", "sugar", "sour cream", "jam"], calories: 360, dietary: ["vegetarian"] },
{ en: "Khinkali", ru: "Хинкали", cuisine: "russian", type: "dinner", difficulty: "hard", time: 70, desc: "Georgian dumplings filled with spiced meat and broth.", ruDesc: "Грузинские пельмени с пряным мясом и бульоном внутри.", ingredients: ["flour", "beef", "pork", "onion", "cumin", "black pepper", "broth"], calories: 480, dietary: [] },
{ en: "Solyanka", ru: "Солянка", cuisine: "russian", type: "lunch", difficulty: "medium", time: 60, desc: "Thick, tangy Russian soup with mixed meats, pickles, and olives.", ruDesc: "Густой кислый русский суп с мясом, солеными огурцами и оливками.", ingredients: ["beef", "sausage", "ham", "pickles", "olives", "tomatoes", "lemon"], calories: 340, dietary: ["gluten-free"] },
{ en: "Blini with Red Caviar", ru: "Блины с красной икрой", cuisine: "russian", type: "breakfast", difficulty: "easy", time: 30, desc: "Thin Russian pancakes topped with red caviar and sour cream.", ruDesc: "Тонкие русские блины с красной икрой и сметаной.", ingredients: ["flour", "milk", "eggs", "red caviar", "sour cream", "butter"], calories: 420, dietary: [] },
{ en: "Pelmeni", ru: "Пельмени", cuisine: "russian", type: "dinner", difficulty: "hard", time: 80, desc: "Siberian meat dumplings boiled and served with butter or vinegar.", ruDesc: "Сибирские пельмени, отварные и подаваемые со сливочным маслом или уксусом.", ingredients: ["flour", "beef", "pork", "onion", "black pepper", "butter"], calories: 520, dietary: [] },
{ en: "Vinegret", ru: "Винегрет", cuisine: "russian", type: "lunch", difficulty: "easy", time: 30, desc: "Vibrant beet and vegetable salad with sauerkraut and pickles.", ruDesc: "Яркий свекольно-овощной салат с квашеной капустой и солеными огурцами.", ingredients: ["beets", "potatoes", "carrots", "sauerkraut", "pickles", "onion", "oil"], calories: 200, dietary: ["vegetarian", "vegan", "gluten-free"] },
{ en: "Shashlik", ru: "Шашлык", cuisine: "russian", type: "dinner", difficulty: "medium", time: 120, desc: "Marinated skewered meat grilled over charcoal.", ruDesc: "Маринованное мясо на шампурах, жареное на углях.", ingredients: ["pork", "onion", "vinegar", "spices", "bay leaf"], calories: 580, dietary: ["gluten-free"] },
{ en: "Kasha", ru: "Гречневая каша", cuisine: "russian", type: "breakfast", difficulty: "easy", time: 20, desc: "Buckwheat porridge cooked with milk and topped with butter.", ruDesc: "Гречневая каша, сваренная на молоке с добавлением сливочного масла.", ingredients: ["buckwheat", "milk", "butter", "salt", "sugar"], calories: 280, dietary: ["vegetarian", "gluten-free"] },
{ en: "Escargot", ru: "Эскарго", cuisine: "french", type: "dinner", difficulty: "hard", time: 40, desc: "Burgundy snails baked in garlic herb butter.", ruDesc: "Улитки из Бургундии, запеченные в чесночно-травяном масле.", ingredients: ["snails", "butter", "garlic", "parsley", "shallots", "white wine"], calories: 320, dietary: ["gluten-free"] },
{ en: "Creme Brulee", ru: "Крем-брюле", cuisine: "french", type: "dessert", difficulty: "medium", time: 50, desc: "Rich custard topped with a layer of caramelized sugar.", ruDesc: "Нежный заварной крем с хрустящим слоем карамелизированного сахара.", ingredients: ["cream", "egg yolks", "sugar", "vanilla"], calories: 380, dietary: ["vegetarian", "gluten-free"] },
{ en: "Galette", ru: "Галета", cuisine: "french", type: "dessert", difficulty: "medium", time: 45, desc: "Free-form rustic tart with seasonal fruits.", ruDesc: "Деревенская открытая галета с сезонными фруктами.", ingredients: ["flour", "butter", "apples", "sugar", "cinnamon", "cream"], calories: 340, dietary: ["vegetarian"] },
{ en: "Bruschetta", ru: "Брускетта", cuisine: "italian", type: "snack", difficulty: "easy", time: 10, desc: "Grilled bread rubbed with garlic and topped with tomatoes.", ruDesc: "Поджаренный хлеб, натертый чесноком, с помидорами.", ingredients: ["baguette", "tomatoes", "garlic", "basil", "olive oil"], calories: 220, dietary: ["vegetarian", "vegan"] },
{ en: "Arancini", ru: "Аранчини", cuisine: "italian", type: "snack", difficulty: "medium", time: 40, desc: "Deep-fried risotto balls stuffed with mozzarella.", ruDesc: "Жареные шарики из ризотто с начинкой из моцареллы.", ingredients: ["risotto rice", "mozzarella", "breadcrumbs", "egg", "oil", "peas"], calories: 380, dietary: ["vegetarian"] },
{ en: "Satay Skewers", ru: "Сате на шпажках", cuisine: "asian", type: "snack", difficulty: "medium", time: 30, desc: "Grilled marinated meat skewers with peanut dipping sauce.", ruDesc: "Жареные маринованные шашлычки с арахисовым соусом.", ingredients: ["chicken", "peanut butter", "soy sauce", "coconut milk", "curry paste", "skewers"], calories: 380, dietary: [] },
{ en: "Spring Rolls", ru: "Спринг-роллы", cuisine: "asian", type: "snack", difficulty: "medium", time: 35, desc: "Crispy fried rolls with vegetables and glass noodles.", ruDesc: "Хрустящие жареные роллы с овощами и стеклянной лапшой.", ingredients: ["rice paper", "glass noodles", "carrot", "cabbage", "mushrooms", "soy sauce"], calories: 240, dietary: ["vegetarian", "vegan"] },
{ en: "Loaded Nachos", ru: "Начос с начинкой", cuisine: "mexican", type: "snack", difficulty: "easy", time: 15, desc: "Tortilla chips piled with cheese, jalapenos, beans, and salsa.", ruDesc: "Чипсы начос с сыром, халапеньо, фасолью и сальсой.", ingredients: ["tortilla chips", "cheese", "jalapenos", "black beans", "salsa", "sour cream"], calories: 560, dietary: ["vegetarian", "gluten-free"] },
{ en: "Edamame", ru: "Эдамаме", cuisine: "asian", type: "snack", difficulty: "easy", time: 10, desc: "Steamed young soybeans sprinkled with sea salt.", ruDesc: "Пареные молодые соевые бобы с морской солью.", ingredients: ["edamame", "sea salt"], calories: 180, dietary: ["vegetarian", "vegan", "gluten-free"] },
{ en: "Samosa", ru: "Самоса", cuisine: "indian", type: "snack", difficulty: "medium", time: 50, desc: "Crispy fried pastry filled with spiced potatoes and peas.", ruDesc: "Хрустящая жареная выпечка с пряным картофелем и горошком.", ingredients: ["flour", "potatoes", "peas", "cumin", "garam masala", "ginger", "oil"], calories: 280, dietary: ["vegetarian", "vegan", "spicy"] },
{ en: "Crème Caramel", ru: "Крем-карамель", cuisine: "french", type: "dessert", difficulty: "medium", time: 50, desc: "Silky custard with a layer of soft caramel on top.", ruDesc: "Нежный заварной крем с мягкой карамелью сверху.", ingredients: ["milk", "eggs", "sugar", "vanilla"], calories: 290, dietary: ["vegetarian", "gluten-free"] },
{ en: "Churros", ru: "Чуррос", cuisine: "mexican", type: "dessert", difficulty: "medium", time: 30, desc: "Deep-fried dough pastry dusted with cinnamon sugar.", ruDesc: "Жареное тесто с коричным сахаром и шоколадным соусом.", ingredients: ["flour", "water", "sugar", "cinnamon", "oil", "chocolate"], calories: 380, dietary: ["vegetarian"] },
{ en: "Gulab Jamun", ru: "Гулаб джамун", cuisine: "indian", type: "dessert", difficulty: "hard", time: 60, desc: "Deep-fried milk solids soaked in fragrant sugar syrup.", ruDesc: "Жареные шарики из сухого молока в ароматном сахарном сиропе.", ingredients: ["milk powder", "flour", "ghee", "sugar", "cardamom", "rose water"], calories: 320, dietary: ["vegetarian", "gluten-free"] },
{ en: "Apple Pie", ru: "Яблочный пирог", cuisine: "american", type: "dessert", difficulty: "medium", time: 75, desc: "Classic double-crust pie with cinnamon-spiced apples.", ruDesc: "Классический пирог с двойной корочкой и яблоками с корицей.", ingredients: ["flour", "butter", "apples", "sugar", "cinnamon", "lemon"], calories: 420, dietary: ["vegetarian"] },
{ en: "Brownies", ru: "Брауни", cuisine: "american", type: "dessert", difficulty: "easy", time: 35, desc: "Dense, fudgy chocolate brownies with a crackly top.", ruDesc: "Плотный шоколадный брауни с хрустящей корочкой.", ingredients: ["chocolate", "butter", "eggs", "sugar", "flour", "cocoa"], calories: 380, dietary: ["vegetarian"] },
{ en: "Granola Parfait", ru: "Гранола парфе", cuisine: "american", type: "breakfast", difficulty: "easy", time: 5, desc: "Layers of granola, Greek yogurt, and fresh berries.", ruDesc: "Слои гранолы, греческого йогурта и свежих ягод.", ingredients: ["granola", "Greek yogurt", "berries", "honey"], calories: 320, dietary: ["vegetarian", "gluten-free"] },
{ en: "Smoked Salmon Toast", ru: "Тост с лососем", cuisine: "american", type: "breakfast", difficulty: "easy", time: 10, desc: "Toasted bread with cream cheese, smoked salmon, and dill.", ruDesc: "Поджаренный хлеб с сливочным сыром, лососем и укропом.", ingredients: ["bread", "cream cheese", "smoked salmon", "dill", "lemon", "capers"], calories: 380, dietary: [] },
{ en: "Breakfast Burrito", ru: "Завтрак буррито", cuisine: "american", type: "breakfast", difficulty: "easy", time: 15, desc: "Flour tortilla with scrambled eggs, cheese, and sausage.", ruDesc: "Пшеничная тортилья с яичницей, сыром и колбасой.", ingredients: ["flour tortilla", "eggs", "cheese", "sausage", "salsa", "avocado"], calories: 580, dietary: [] },
{ en: "Acai Bowl", ru: "Асаи боул", cuisine: "american", type: "breakfast", difficulty: "easy", time: 10, desc: "Thick acai smoothie topped with granola and fresh fruit.", ruDesc: "Густой смузи из ягод асаи с гранолой и свежими фруктами.", ingredients: ["acai", "banana", "granola", "berries", "coconut flakes", "honey"], calories: 360, dietary: ["vegetarian", "vegan", "gluten-free"] },
{ en: "Eggs Benedict", ru: "Яйца Бенедикт", cuisine: "american", type: "breakfast", difficulty: "hard", time: 25, desc: "Poached eggs on English muffin with ham and hollandaise sauce.", ruDesc: "Яйца пашот на английском маффине с ветчиной и соусом голландез.", ingredients: ["eggs", "English muffin", "ham", "butter", "lemon", "egg yolks"], calories: 520, dietary: [] },
{ en: "Dim Sum", ru: "Дим сам", cuisine: "asian", type: "lunch", difficulty: "hard", time: 60, desc: "Assorted Chinese bite-sized dumplings and steamed buns.", ruDesc: "Набор китайских пельменек и паровых булочек.", ingredients: ["flour", "pork", "shrimp", "ginger", "soy sauce", "bamboo steamer"], calories: 420, dietary: [] },
{ en: "Lamb Tagine", ru: "Тажин с бараниной", cuisine: "middle-eastern", type: "dinner", difficulty: "hard", time: 120, desc: "Slow-cooked Moroccan lamb with dried fruits and spices.", ruDesc: "Марокканское томленое мясо баранины с сухофруктами и специями.", ingredients: ["lamb", "dried apricots", "couscous", "cinnamon", "ginger", "honey", "almonds"], calories: 580, dietary: ["gluten-free"] },
{ en: "Tandoori Chicken", ru: "Курица тандури", cuisine: "indian", type: "dinner", difficulty: "medium", time: 60, desc: "Chicken marinated in yogurt and spices, roasted in tandoor.", ruDesc: "Курица в маринаде из йогурта и специй, запеченная в тандыре.", ingredients: ["chicken", "yogurt", "tandoori masala", "lemon", "ginger", "garlic"], calories: 440, dietary: ["gluten-free"] },
{ en: "Spanakopita", ru: "Спанакопита", cuisine: "mediterranean", type: "snack", difficulty: "medium", time: 55, desc: "Greek spinach and feta pie wrapped in crispy phyllo pastry.", ruDesc: "Греческий пирог со шпинатом и фетой в хрустящем тесте фило.", ingredients: ["phyllo dough", "spinach", "feta", "onion", "dill", "egg", "butter"], calories: 340, dietary: ["vegetarian"] },
{ en: "Baklava", ru: "Пахлава", cuisine: "middle-eastern", type: "dessert", difficulty: "hard", time: 90, desc: "Layered pastry with chopped nuts and honey syrup.", ruDesc: "Слоеная выпечка с рублеными орехами и медовым сиропом.", ingredients: ["phyllo dough", "walnuts", "butter", "honey", "cinnamon", "sugar"], calories: 420, dietary: ["vegetarian"] },
{ en: "French Onion Soup", ru: "Луковый суп", cuisine: "french", type: "lunch", difficulty: "medium", time: 60, desc: "Caramelized onions in rich beef broth topped with melted cheese.", ruDesc: "Карамелизированный лук в насыщенном говяжьем бульоне с расплавленным сыром.", ingredients: ["onions", "beef broth", "gruyere", "baguette", "butter", "white wine"], calories: 380, dietary: [] },
{ en: "Pulled Pork Sandwich", ru: "Сэндвич с пулд-порк", cuisine: "american", type: "lunch", difficulty: "hard", time: 360, desc: "Slow-smoked shredded pork on a bun with BBQ sauce.", ruDesc: "Медленно копченая рубленая свинина на булочке с соусом барбекю.", ingredients: ["pork shoulder", "BBQ sauce", "bun", "coleslaw", "spices", "apple cider vinegar"], calories: 620, dietary: [] },
{ en: "Ceviche", ru: "Севиче", cuisine: "mexican", type: "lunch", difficulty: "medium", time: 30, desc: "Fresh fish cured in citrus juice with onions and cilantro.", ruDesc: "Свежая рыба, маринованная в цитрусовом соке с луком и кинзой.", ingredients: ["white fish", "lime", "lemon", "red onion", "cilantro", "jalapeno", "corn"], calories: 220, dietary: ["gluten-free"] },
{ en: "Cottage Cheese Pancakes", ru: "Сырники", cuisine: "russian", type: "breakfast", difficulty: "easy", time: 25, desc: "Fluffy pancakes made from cottage cheese, served with jam.", ruDesc: "Пышные оладьи из творога, подаваемые с вареньем.", ingredients: ["cottage cheese", "eggs", "flour", "sugar", "vanilla", "butter"], calories: 340, dietary: ["vegetarian"] },
{ en: "Chilaquiles", ru: "Чилакилес", cuisine: "mexican", type: "breakfast", difficulty: "easy", time: 20, desc: "Tortilla chips simmered in salsa, topped with eggs and crema.", ruDesc: "Чипсы тортильи в сальсе с яйцами и сметаной сверху.", ingredients: ["tortilla chips", "salsa verde", "eggs", "crema", "cheese", "cilantro"], calories: 480, dietary: ["gluten-free"] },
{ en: "Shakshuka", ru: "Шакшука", cuisine: "middle-eastern", type: "breakfast", difficulty: "easy", time: 25, desc: "Eggs poached in a spiced tomato and pepper sauce.", ruDesc: "Яйца, приготовленные в пряном томатно-перечном соусе.", ingredients: ["eggs", "tomatoes", "bell pepper", "onion", "cumin", "paprika"], calories: 320, dietary: ["vegetarian", "gluten-free"] },
{ en: "Protein Smoothie Bowl", ru: "Протеиновый смузи боул", cuisine: "american", type: "breakfast", difficulty: "easy", time: 5, desc: "Thick smoothie with protein powder, fruits, and toppings.", ruDesc: "Густой смузи с протеином, фруктами и топпингами.", ingredients: ["protein powder", "banana", "berries", "almond milk", "chia seeds", "peanut butter"], calories: 380, dietary: ["vegetarian", "gluten-free"] },
{ en: "Chicken Caesar Wrap", ru: "Ролл Цезарь с курицей", cuisine: "american", type: "lunch", difficulty: "easy", time: 15, desc: "Grilled chicken, romaine, parmesan, and Caesar dressing in a wrap.", ruDesc: "Жареная курица, салат романо, пармезан и соус Цезарь в ролле.", ingredients: ["chicken", "tortilla", "romaine", "parmesan", "Caesar dressing"], calories: 480, dietary: [] },
{ en: "Spicy Tuna Roll", ru: "Ролл с острым тунцом", cuisine: "asian", type: "lunch", difficulty: "medium", time: 25, desc: "Sushi roll with spicy tuna, cucumber, and sesame seeds.", ruDesc: "Суши-ролл с острым тунцом, огурцом и кунжутом.", ingredients: ["sushi rice", "tuna", "mayonnaise", "sriracha", "cucumber", "nori", "sesame"], calories: 380, dietary: ["spicy"] },
{ en: "Halloumi Salad", ru: "Салат с халуми", cuisine: "mediterranean", type: "lunch", difficulty: "easy", time: 15, desc: "Grilled halloumi cheese with mixed greens and pomegranate.", ruDesc: "Жареный сыр халуми со смесью зелени и гранатом.", ingredients: ["halloumi", "mixed greens", "pomegranate", "olive oil", "lemon", "mint"], calories: 360, dietary: ["vegetarian", "gluten-free"] },
{ en: "Turkey Meatballs", ru: "Фрикадельки из индейки", cuisine: "italian", type: "dinner", difficulty: "easy", time: 30, desc: "Lean turkey meatballs in marinara sauce with herbs.", ruDesc: "Постные фрикадельки из индейки в соусе маринара с зеленью.", ingredients: ["ground turkey", "breadcrumbs", "egg", "marinara", "parmesan", "herbs"], calories: 380, dietary: [] },
{ en: "Pistachio Ice Cream", ru: "Фисташковое мороженое", cuisine: "italian", type: "dessert", difficulty: "hard", time: 40, desc: "Creamy gelato made with real pistachios.", ruDesc: "Сливочное джелато из настоящих фисташек.", ingredients: ["milk", "cream", "pistachios", "sugar", "egg yolks"], calories: 280, dietary: ["vegetarian", "gluten-free"] },
];
// Deduplicate meals by (en + cuisine + type)
const seen = new Set();
const uniqueMeals = [];
for (const m of meals) {
const key = m.en + "|" + m.cuisine + "|" + m.type;
if (!seen.has(key)) {
seen.add(key);
uniqueMeals.push(m);
}
}
const isRu = document.documentElement.lang === "ru";
// DOM refs
const typeSelect = document.getElementById("mg-type");
const cuisineSelect = document.getElementById("mg-cuisine");
const difficultySelect = document.getElementById("mg-difficulty");
const timeSelect = document.getElementById("mg-time");
const resultEl = document.getElementById("mg-result");
const nameEl = document.getElementById("mg-name");
const descEl = document.getElementById("mg-description");
const cuisineTagEl = document.getElementById("mg-cuisine-tag");
const typeTagEl = document.getElementById("mg-type-tag");
const difficultyTagEl = document.getElementById("mg-difficulty-tag");
const timeValueEl = document.getElementById("mg-time-value");
const ingredientsEl = document.getElementById("mg-ingredients");
const caloriesEl = document.getElementById("mg-calories");
const dietaryEl = document.getElementById("mg-dietary");
const copyBtn = document.getElementById("mg-copy");
const copyIcon = document.getElementById("mg-copy-icon");
const checkIcon = document.getElementById("mg-check-icon");
const copyText = document.getElementById("mg-copy-text");
const saveBtn = document.getElementById("mg-save");
const starIcon = document.getElementById("mg-star-icon");
const starFilledIcon = document.getElementById("mg-star-filled-icon");
const saveText = document.getElementById("mg-save-text");
const btn = document.getElementById("mg-btn");
const savedInfoEl = document.getElementById("mg-saved-info");
const savedCountEl = document.getElementById("mg-saved-count");
const viewFavsBtn = document.getElementById("mg-view-favs");
const favsPanel = document.getElementById("mg-favs-panel");
const closeFavsBtn = document.getElementById("mg-close-favs");
const favsList = document.getElementById("mg-favs-list");
let currentMeal = null;
let copyTimer = null;
const FAV_KEY = "randify-meal-favorites";
function getFavorites() {
try {
const raw = localStorage.getItem(FAV_KEY);
return raw ? JSON.parse(raw) : [];
} catch { return []; }
}
function saveFavorites(favs) {
localStorage.setItem(FAV_KEY, JSON.stringify(favs));
updateSavedCount();
}
function updateSavedCount() {
const favs = getFavorites();
if (favs.length > 0) {
savedInfoEl.style.display = "";
savedCountEl.textContent = String(favs.length);
} else {
savedInfoEl.style.display = "none";
}
}
function isFavorited(meal) {
if (!meal) return false;
const favs = getFavorites();
return favs.some(f => f.en === meal.en);
}
function toggleFavorite() {
if (!currentMeal) return;
const favs = getFavorites();
const idx = favs.findIndex(f => f.en === currentMeal.en);
if (idx >= 0) {
favs.splice(idx, 1);
starIcon.classList.remove("hidden");
starFilledIcon.classList.add("hidden");
saveText.textContent = isRu ? "В избранное" : "Save";
} else {
favs.push({ en: currentMeal.en, ru: currentMeal.ru, cuisine: currentMeal.cuisine, type: currentMeal.type });
starIcon.classList.add("hidden");
starFilledIcon.classList.remove("hidden");
saveText.textContent = isRu ? "Сохранено" : "Saved";
}
saveFavorites(favs);
}
function getFilteredMeals() {
const type = typeSelect.value;
const cuisine = cuisineSelect.value;
const difficulty = difficultySelect.value;
const time = timeSelect.value;
return uniqueMeals.filter(m => {
if (type !== "all" && m.type !== type) return false;
if (cuisine !== "all" && m.cuisine !== cuisine) return false;
if (difficulty !== "all" && m.difficulty !== difficulty) return false;
if (time !== "all") {
if (time === "slow") { if (m.time < 60) return false; }
else if (time === "15") { if (m.time > 15) return false; }
else if (time === "30") { if (m.time > 30) return false; }
else if (time === "60") { if (m.time > 60) return false; }
}
return true;
});
}
function formatTime(t) {
if (t >= 60) {
const h = Math.floor(t / 60);
const m = t % 60;
return h + (isRu ? " ч " : "h ") + (m > 0 ? m + (isRu ? " мин" : "m") : "");
}
return t + (isRu ? " мин" : " min");
}
function generate() {
const list = getFilteredMeals();
if (list.length === 0) {
nameEl.textContent = isRu ? "Ничего не найдено" : "No meals found";
descEl.textContent = isRu ? "Попробуйте изменить фильтры." : "Try adjusting your filters.";
cuisineTagEl.style.display = "none";
typeTagEl.style.display = "none";
difficultyTagEl.style.display = "none";
timeValueEl.parentElement.style.display = "none";
ingredientsEl.innerHTML = "";
caloriesEl.textContent = "";
dietaryEl.innerHTML = "";
resultEl.style.opacity = "1";
currentMeal = null;
return;
}
const meal = list[Math.floor(Math.random() * list.length)];
currentMeal = meal;
// Name
nameEl.textContent = isRu ? meal.ru : meal.en;
// Description
descEl.textContent = isRu ? meal.ruDesc : meal.desc;
// Cuisine tag
const cLabel = cuisineLabelMap[meal.cuisine];
cuisineTagEl.textContent = cLabel ? (isRu ? cLabel.ru : cLabel.en) : meal.cuisine;
cuisineTagEl.style.display = "";
// Type tag
const tLabel = mealTypeLabelMap[meal.type];
typeTagEl.textContent = tLabel ? (isRu ? tLabel.ru : tLabel.en) : meal.type;
typeTagEl.style.display = "";
// Difficulty tag
const dLabel = difficultyLabelMap[meal.difficulty];
difficultyTagEl.textContent = dLabel ? (isRu ? dLabel.ru : dLabel.en) : meal.difficulty;
difficultyTagEl.className = "inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium border " + (dLabel ? dLabel.color : "bg-zinc-700/50 text-zinc-300 border-zinc-600/40");
difficultyTagEl.style.display = "";
// Time
timeValueEl.textContent = formatTime(meal.time);
timeValueEl.parentElement.style.display = "";
// Ingredients
ingredientsEl.innerHTML = meal.ingredients.map(ing =>
'<span class="inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium bg-zinc-800 text-zinc-400 border border-zinc-700/60">' + ing + '</span>'
).join("");
// Calories
caloriesEl.textContent = "~" + meal.calories + (isRu ? " ккал" : " kcal");
// Dietary badges
dietaryEl.innerHTML = meal.dietary.map(d => {
const dl = dietaryLabelMap[d];
return '<span class="inline-flex items-center gap-0.5 px-2 py-0.5 rounded-md text-xs font-medium bg-zinc-800 text-zinc-400 border border-zinc-700/60">' + (dl ? dl.emoji + " " + (isRu ? dl.ru : dl.en) : d) + '</span>';
}).join("");
// Save button state
if (isFavorited(meal)) {
starIcon.classList.add("hidden");
starFilledIcon.classList.remove("hidden");
saveText.textContent = isRu ? "Сохранено" : "Saved";
} else {
starIcon.classList.remove("hidden");
starFilledIcon.classList.add("hidden");
saveText.textContent = isRu ? "В избранное" : "Save";
}
resultEl.style.opacity = "1";
}
// Copy
copyBtn.addEventListener("click", async () => {
if (!currentMeal) return;
const name = isRu ? currentMeal.ru : currentMeal.en;
const desc = isRu ? currentMeal.ruDesc : currentMeal.desc;
const ingredients = currentMeal.ingredients.join(", ");
const cuisine = cuisineLabelMap[currentMeal.cuisine];
const cuisineStr = cuisine ? (isRu ? cuisine.ru : cuisine.en) : currentMeal.cuisine;
const type = mealTypeLabelMap[currentMeal.type];
const typeStr = type ? (isRu ? type.ru : type.en) : currentMeal.type;
const text = name + "\n" + cuisineStr + " • " + typeStr + "\n" + desc + "\n" + (isRu ? "Ингредиенты" : "Ingredients") + ": " + ingredients;
await navigator.clipboard.writeText(text);
copyIcon.classList.add("hidden");
checkIcon.classList.remove("hidden");
copyText.textContent = isRu ? "Скопировано" : "Copied";
if (copyTimer) clearTimeout(copyTimer);
copyTimer = setTimeout(() => {
checkIcon.classList.add("hidden");
copyIcon.classList.remove("hidden");
copyText.textContent = isRu ? "Копировать" : "Copy";
}, 1500);
});
saveBtn.addEventListener("click", toggleFavorite);
btn.addEventListener("click", generate);
function renderFavoritesList() {
const favs = getFavorites();
if (favs.length === 0) {
favsList.innerHTML = '<p class="text-sm text-zinc-500">' + (isRu ? "Нет сохранённых рецептов." : "No saved recipes.") + '</p>';
return;
}
favsList.innerHTML = favs.map((f, i) => {
const cLabel = cuisineLabelMap[f.cuisine];
const cuisineStr = cLabel ? (isRu ? cLabel.ru : cLabel.en) : f.cuisine;
const tLabel = mealTypeLabelMap[f.type];
const typeStr = tLabel ? (isRu ? tLabel.ru : tLabel.en) : f.type;
const name = isRu ? f.ru : f.en;
return '<div class="flex items-center justify-between gap-3 p-3 rounded-lg bg-zinc-800/60 border border-zinc-700/40">' +
'<div class="min-w-0">' +
'<p class="text-sm font-medium text-zinc-200 truncate">' + name + '</p>' +
'<p class="text-xs text-zinc-500">' + cuisineStr + ' • ' + typeStr + '</p>' +
'</div>' +
'<button data-fav-idx="' + i + '" type="button" class="mg-del-fav inline-flex items-center justify-center w-7 h-7 rounded-lg bg-zinc-800 text-zinc-400 hover:text-red-400 hover:bg-zinc-700 transition-colors cursor-pointer shrink-0" aria-label="' + (isRu ? "Удалить" : "Remove") + '">' +
'<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"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>' +
'</button>' +
'</div>';
}).join("");
// Attach remove handlers
favsList.querySelectorAll(".mg-del-fav").forEach(b => {
b.addEventListener("click", () => {
const idx = parseInt(b.dataset.favIdx, 10);
const favs = getFavorites();
if (idx >= 0 && idx < favs.length) {
favs.splice(idx, 1);
saveFavorites(favs);
renderFavoritesList();
if (currentMeal) {
if (isFavorited(currentMeal)) {
starIcon.classList.add("hidden");
starFilledIcon.classList.remove("hidden");
saveText.textContent = isRu ? "Сохранено" : "Saved";
} else {
starIcon.classList.remove("hidden");
starFilledIcon.classList.add("hidden");
saveText.textContent = isRu ? "В избранное" : "Save";
}
}
}
});
});
}
viewFavsBtn.addEventListener("click", () => {
renderFavoritesList();
favsPanel.classList.remove("hidden");
});
closeFavsBtn.addEventListener("click", () => {
favsPanel.classList.add("hidden");
});
// Init
updateSavedCount();
generate();
})();
</script>
@@ -0,0 +1,217 @@
---
import { useT } from "../../i18n/translations";
const isRu = Astro.url.pathname.startsWith("/ru");
const T = useT(isRu ? "ru" : "en");
const genderOptions = [
{ value: "male", label: isRu ? "Мужские" : "Male" },
{ value: "female", label: isRu ? "Женские" : "Female" },
{ value: "any", label: isRu ? "Любые" : "Any" },
];
---
<div id="names-generator" class="mt-8">
<div class="rounded-xl bg-zinc-900/50 border border-zinc-800/60 p-5">
<label class="block text-sm font-medium text-zinc-400 mb-2"
>{isRu ? "Пол" : "Gender"}</label
>
<div
class="inline-flex rounded-lg border border-zinc-700 overflow-hidden"
role="radiogroup"
aria-label={isRu ? "Пол" : "Gender"}
>
{
genderOptions.map(({ value, label }, i) => (
<label class="cursor-pointer">
<input
type="radio"
name="ng-gender"
value={value}
class="sr-only peer"
checked={value === "any"}
/>
<span
class={`inline-flex items-center justify-center px-3 sm:px-4 h-9 text-sm font-medium text-zinc-400 peer-checked:text-accent peer-checked:bg-accent/10 hover:text-zinc-200 transition-colors select-none whitespace-nowrap${i < 2 ? " border-r border-zinc-700" : ""}`}
>
{label}
</span>
</label>
))
}
</div>
</div>
<div
class="my-6 rounded-2xl bg-zinc-900/80 border border-zinc-800/80 p-8 h-52 overflow-hidden"
>
<div class="h-full flex flex-col items-center justify-center gap-3">
<button
id="nm-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-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950"
aria-live="polite"
>
<span
id="nm-result"
class="text-5xl sm:text-6xl font-bold 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="nm-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="nm-check-icon"
class="absolute -right-7 top-1/2 -translate-y-1/2 hidden text-accent"
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="nm-copied-label"
class="text-xs font-medium text-accent opacity-0 transition-opacity duration-200"
aria-live="polite"
aria-atomic="true">{T.copied}</span
>
</div>
</div>
<div class="flex justify-center">
<button
id="nm-btn"
type="button"
class="w-full sm:w-auto px-8 py-3 bg-accent text-white font-semibold rounded-xl hover:bg-accent-hover active:bg-accent-active focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors duration-100 cursor-pointer shadow-lg shadow-accent/25"
>
{T.generate}
</button>
</div>
</div>
<script>
import { CopyFeedback } from "@/lib/client/clipboard";
import { popElement } from "@/lib/client/animations";
const isRu = document.documentElement.lang === "ru";
const EN_MALE = [
"James", "John", "Robert", "Michael", "William", "David", "Richard",
"Joseph", "Thomas", "Charles", "Daniel", "Matthew", "Anthony", "Mark",
"Donald", "Steven", "Paul", "Andrew", "Joshua", "Kenneth", "Kevin",
"Brian", "George", "Timothy", "Ronald", "Jason", "Edward", "Jeffrey",
"Ryan", "Jacob", "Gary", "Nicholas", "Eric", "Jonathan", "Stephen",
"Larry", "Justin", "Scott", "Brandon", "Benjamin", "Samuel", "Gregory",
"Frank", "Alexander", "Raymond", "Patrick", "Jack", "Dennis", "Jerry",
"Tyler",
];
const EN_FEMALE = [
"Mary", "Patricia", "Jennifer", "Linda", "Elizabeth", "Susan", "Jessica",
"Sarah", "Karen", "Nancy", "Lisa", "Betty", "Margaret", "Sandra",
"Ashley", "Kimberly", "Emily", "Donna", "Michelle", "Dorothy", "Carol",
"Amanda", "Melissa", "Deborah", "Stephanie", "Rebecca", "Laura",
"Sharon", "Cynthia", "Kathleen", "Amy", "Shirley", "Angela", "Helen",
"Anna", "Brenda", "Pamela", "Nicole", "Emma", "Samantha", "Katherine",
"Christine", "Debra", "Rachel", "Catherine", "Carolyn", "Janet", "Ruth",
"Maria", "Heather",
];
const RU_MALE = [
"Александр", "Дмитрий", "Максим", "Сергей", "Андрей", "Алексей",
"Артём", "Илья", "Кирилл", "Михаил", "Никита", "Матвей", "Роман",
"Егор", "Арсений", "Иван", "Денис", "Евгений", "Даниил", "Тимофей",
"Владимир", "Павел", "Глеб", "Константин", "Богдан", "Степан",
"Тихон", "Ярослав", "Антон", "Николай", "Григорий", "Олег", "Леонид",
"Савелий", "Виктор", "Пётр", "Семён", "Василий", "Марк", "Георгий",
"Фёдор", "Давид", "Елисей", "Захар", "Артур", "Станислав", "Юрий",
"Борис", "Герман", "Платон",
];
const RU_FEMALE = [
"Анна", "Мария", "Елена", "Анастасия", "Ольга", "Наталья", "Ирина",
"Татьяна", "Екатерина", "Алина", "Виктория", "Дарья", "Полина",
"Юлия", "Елизавета", "Ксения", "София", "Алиса", "Вероника",
"Валерия", "Александра", "Светлана", "Надежда", "Людмила", "Галина",
"Оксана", "Владислава", "Кристина", "Марина", "Софья", "Алёна", "Яна",
"Любовь", "Тамара", "Инна", "Злата", "Милана", "Варвара", "Лариса",
"Нина", "Эвелина", "Изабелла", "Мирослава", "Агата", "Лилия",
"Регина", "Альбина", "Марта", "Жанна", "Виолетта",
];
function getNamesPool(gender: string): string[] {
if (isRu) {
if (gender === "male") return RU_MALE;
if (gender === "female") return RU_FEMALE;
return [...RU_MALE, ...RU_FEMALE];
}
if (gender === "male") return EN_MALE;
if (gender === "female") return EN_FEMALE;
return [...EN_MALE, ...EN_FEMALE];
}
function getSelectedGender(): string {
const radio = document.querySelector(
'input[name="ng-gender"]:checked',
) as HTMLInputElement;
return radio?.value || "any";
}
const btn = document.getElementById("nm-btn") as HTMLButtonElement;
const copyBtn = document.getElementById("nm-copy-btn") as HTMLButtonElement;
const resultEl = document.getElementById("nm-result") as HTMLSpanElement;
const copyIcon = document.getElementById("nm-copy-icon") as HTMLSpanElement;
const checkIcon = document.getElementById(
"nm-check-icon",
) as HTMLSpanElement;
const copiedLabel = document.getElementById(
"nm-copied-label",
) as HTMLSpanElement;
const clipboard = new CopyFeedback(copyIcon, checkIcon);
function generate() {
const pool = getNamesPool(getSelectedGender());
const name = pool[Math.floor(Math.random() * pool.length)];
resultEl.textContent = name;
copyBtn.classList.remove("invisible");
popElement(resultEl);
}
async function copyResult() {
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", copyResult);
</script>
@@ -0,0 +1,178 @@
---
import { useT } from "../../i18n/translations";
const isRu = Astro.url.pathname.startsWith("/ru");
const T = useT(isRu ? "ru" : "en");
---
<div id="number-generator" class="mt-8" data-ru={isRu ? "1" : "0"}>
<div class="rounded-xl bg-zinc-900/50 border border-zinc-800/60 p-5">
<div class="flex flex-col sm:flex-row gap-4">
<div class="flex-1">
<label for="ng-from" class="block text-sm font-medium text-zinc-400 mb-1"
>{T.from}</label
>
<input
id="ng-from"
type="number"
value="1"
class="w-full bg-zinc-900 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 text-base focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent 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-accent focus:ring-1 focus:ring-accent transition-colors"
/>
</div>
</div>
</div>
<p
id="ng-error"
role="alert"
aria-live="polite"
class="mt-2 text-sm text-red-600 hidden"
>
</p>
<div class="my-6 rounded-2xl bg-zinc-900/80 border border-zinc-800/80 p-8 h-52 overflow-hidden">
<div class="h-full 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-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950"
aria-live="polite"
>
<span
id="ng-result"
class="text-8xl font-bold tabular-nums tracking-tight text-zinc-100 select-none group-hover:text-zinc-300"
style="transition: transform 0.12s cubic-bezier(0.34,1.56,0.64,1), opacity 0.08s ease, color 0.15s ease;"
></span>
<span
id="ng-copy-icon"
class="absolute -right-7 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 text-zinc-500"
aria-hidden="true"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect><path
d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"
></path></svg
>
</span>
<span
id="ng-check-icon"
class="absolute -right-7 top-1/2 -translate-y-1/2 hidden text-accent"
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-accent opacity-0 transition-opacity duration-200"
aria-live="polite"
aria-atomic="true">{T.copied}</span
>
</div>
</div>
<div class="flex justify-center">
<button
id="ng-btn"
type="button"
class="w-full sm:w-auto px-8 py-3 bg-accent text-white font-semibold rounded-xl hover:bg-accent-hover active:bg-accent-active focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors duration-100 cursor-pointer shadow-lg shadow-accent/25"
>
{T.generate}
</button>
</div>
</div>
<script>
import { CopyFeedback } from "@/lib/client/clipboard";
import { createErrorDisplay, isInteger } from "@/lib/client/validation";
import { popElement } from "@/lib/client/animations";
const isRu = document.documentElement.lang === "ru";
const ERR_INTEGERS = isRu
? "Оба значения должны быть целыми числами."
: "Both values must be whole numbers.";
const ERR_FROM_TO = isRu
? "«От» должно быть меньше «До»."
: '"From" must be less than "To".';
const fromInput = document.getElementById("ng-from") as HTMLInputElement;
const toInput = document.getElementById("ng-to") as HTMLInputElement;
const btn = document.getElementById("ng-btn") as HTMLButtonElement;
const copyBtn = document.getElementById("ng-copy-btn") as HTMLButtonElement;
const resultEl = document.getElementById("ng-result") as HTMLSpanElement;
const errorEl = document.getElementById("ng-error") as HTMLParagraphElement;
const copyIcon = document.getElementById("ng-copy-icon") as HTMLSpanElement;
const checkIcon = document.getElementById("ng-check-icon") as HTMLSpanElement;
const copiedLabel = document.getElementById(
"ng-copied-label",
) as HTMLSpanElement;
const errors = createErrorDisplay(errorEl);
const clipboard = new CopyFeedback(copyIcon, checkIcon);
function generate() {
if (!isInteger(fromInput.value) || !isInteger(toInput.value)) {
errors.show(ERR_INTEGERS);
return;
}
const min = parseInt(fromInput.value, 10);
const max = parseInt(toInput.value, 10);
if (min >= max) {
errors.show(ERR_FROM_TO);
return;
}
errors.clear();
const result = Math.floor(Math.random() * (max - min + 1)) + min;
resultEl.textContent = String(result);
copyBtn.classList.remove("invisible");
popElement(resultEl);
}
async function copyNumber() {
const value = resultEl.textContent?.trim();
if (!value) return;
await navigator.clipboard.writeText(value);
clipboard.showCopied();
copiedLabel.style.opacity = "1";
setTimeout(() => {
copiedLabel.style.opacity = "0";
}, 1500);
}
btn.addEventListener("click", generate);
copyBtn.addEventListener("click", copyNumber);
[fromInput, toInput].forEach((input) => {
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") generate();
});
});
</script>
@@ -0,0 +1,346 @@
---
import { useT } from "../../i18n/translations";
const isRu = Astro.url.pathname.startsWith("/ru");
const T = useT(isRu ? "ru" : "en");
const i18n = {
harmonyLabel: isRu ? "Гармония" : "Harmony",
countLabel: isRu ? "Количество цветов" : "Number of colors",
copyAll: T.copyAll,
generate: T.generate,
copied: T.copied,
copy: T.copy,
modes: {
random: isRu ? "Случайная" : "Random",
analogous: isRu ? "Аналоговая" : "Analogous",
complementary: isRu ? "Комплементарная" : "Complementary",
triadic: isRu ? "Триадная" : "Triadic",
monochromatic: isRu ? "Монохромная" : "Monochromatic",
splitComplementary: isRu ? "Сплит-комплементарная" : "Split-Complementary",
tetradic: isRu ? "Тетрадная" : "Tetradic",
},
};
---
<div id="palette-generator" class="mt-8">
<!-- Controls -->
<div class="flex flex-col sm:flex-row gap-4 mb-6">
<div class="flex-1">
<label for="pg-harmony" class="block text-sm font-medium text-zinc-400 mb-2">
{i18n.harmonyLabel}
</label>
<select
id="pg-harmony"
class="w-full px-4 py-2.5 bg-zinc-900/80 border border-zinc-800/80 rounded-xl text-zinc-100 text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 cursor-pointer"
>
<option value="random">{i18n.modes.random}</option>
<option value="analogous">{i18n.modes.analogous}</option>
<option value="complementary">{i18n.modes.complementary}</option>
<option value="triadic">{i18n.modes.triadic}</option>
<option value="monochromatic">{i18n.modes.monochromatic}</option>
<option value="split-complementary">{i18n.modes.splitComplementary}</option>
<option value="tetradic">{i18n.modes.tetradic}</option>
</select>
</div>
<div class="flex-1">
<label for="pg-count" class="block text-sm font-medium text-zinc-400 mb-2">
{i18n.countLabel}
</label>
<select
id="pg-count"
class="w-full px-4 py-2.5 bg-zinc-900/80 border border-zinc-800/80 rounded-xl text-zinc-100 text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 cursor-pointer"
>
<option value="3">3</option>
<option value="4">4</option>
<option value="5" selected>5</option>
<option value="6">6</option>
<option value="7">7</option>
<option value="8">8</option>
</select>
</div>
</div>
<!-- Result container -->
<div
id="pg-result"
class="rounded-2xl bg-zinc-900/80 border border-zinc-800/80 p-8 opacity-0"
style="transition: opacity 0.25s ease;"
aria-live="polite"
>
<div id="pg-swatches" class="flex flex-wrap gap-4 justify-center mb-6">
<!-- Swatches injected by JS -->
</div>
<div class="flex flex-col sm:flex-row gap-3 justify-center">
<button
id="pg-copy-all"
type="button"
class="w-full sm:w-auto px-6 py-2.5 bg-zinc-800 text-zinc-200 font-medium rounded-xl border border-zinc-700 hover:bg-zinc-700 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors duration-100 cursor-pointer"
>
{i18n.copyAll}
</button>
</div>
</div>
<!-- Error display -->
<div id="pg-error" class="hidden mt-4 text-sm text-red-400 text-center" role="alert"></div>
<!-- Generate button -->
<div class="flex justify-center mt-6">
<button
id="pg-btn"
type="button"
class="w-full sm:w-auto px-8 py-3 bg-accent text-white font-semibold rounded-xl shadow-lg shadow-accent/25 hover:bg-accent-hover active:bg-accent-active focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors duration-100 cursor-pointer"
>
{i18n.generate}
</button>
</div>
</div>
<script>
import { createErrorDisplay } from "@/lib/client/validation";
const isRu = document.documentElement.lang === "ru";
const harmonySelect = document.getElementById("pg-harmony") as HTMLSelectElement;
const countSelect = document.getElementById("pg-count") as HTMLSelectElement;
const resultEl = document.getElementById("pg-result") as HTMLDivElement;
const swatchesEl = document.getElementById("pg-swatches") as HTMLDivElement;
const copyAllBtn = document.getElementById("pg-copy-all") as HTMLButtonElement;
const generateBtn = document.getElementById("pg-btn") as HTMLButtonElement;
const errorEl = document.getElementById("pg-error") as HTMLDivElement;
const errorDisplay = createErrorDisplay(errorEl);
// Copy icon SVGs
const copyIconSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>`;
const checkIconSvg = `<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" class="text-accent"><path d="M20 6 9 17l-5-5"/></svg>`;
function randomInt(max: number): number {
return Math.floor(Math.random() * max);
}
function hslToHex(h: number, s: number, l: number): string {
s /= 100;
l /= 100;
const k = (n: number) => (n + h / 30) % 12;
const a = s * Math.min(l, 1 - l);
const f = (n: number) => {
const color = l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
return Math.round(color * 255)
.toString(16)
.padStart(2, "0");
};
return `#${f(0)}${f(8)}${f(4)}`.toUpperCase();
}
function generateRandomColors(count: number): string[] {
const colors: string[] = [];
for (let i = 0; i < count; i++) {
const h = randomInt(360);
const s = 50 + randomInt(51);
const l = 40 + randomInt(41);
colors.push(hslToHex(h, s, l));
}
return colors;
}
function generateAnalogousColors(count: number): string[] {
const baseH = randomInt(360);
const s = 55 + randomInt(41);
const l = 40 + randomInt(31);
const colors: string[] = [];
const step = 30;
const start = baseH - ((count - 1) * step) / 2;
for (let i = 0; i < count; i++) {
const h = (start + i * step + 360) % 360;
colors.push(hslToHex(h, s, l));
}
return colors;
}
function generateComplementaryColors(count: number): string[] {
const baseH = randomInt(360);
const s = 55 + randomInt(41);
const l = 40 + randomInt(31);
const colors: string[] = [];
const compH = (baseH + 180) % 360;
if (count <= 2) {
colors.push(hslToHex(baseH, s, l));
colors.push(hslToHex(compH, s, l));
} else {
colors.push(hslToHex(baseH, s, l));
colors.push(hslToHex((baseH + 30) % 360, s, l));
for (let i = 2; i < count; i++) {
colors.push(hslToHex(compH, s, l + (i - 2) * 10));
}
}
return colors.slice(0, count);
}
function generateTriadicColors(count: number): string[] {
const baseH = randomInt(360);
const s = 55 + randomInt(41);
const l = 40 + randomInt(31);
const colors: string[] = [];
const triad = [baseH, (baseH + 120) % 360, (baseH + 240) % 360];
for (let i = 0; i < count; i++) {
const h = triad[i % 3];
const lightness = l + Math.floor(i / 3) * 12;
colors.push(hslToHex(h, s, Math.min(lightness, 90)));
}
return colors;
}
function generateMonochromaticColors(count: number): string[] {
const baseH = randomInt(360);
const s = 55 + randomInt(41);
const colors: string[] = [];
const startL = 25;
const endL = 75;
for (let i = 0; i < count; i++) {
const l = startL + ((endL - startL) * i) / Math.max(count - 1, 1);
colors.push(hslToHex(baseH, s, Math.round(l)));
}
return colors;
}
function generateSplitComplementaryColors(count: number): string[] {
const baseH = randomInt(360);
const s = 55 + randomInt(41);
const l = 40 + randomInt(31);
const colors: string[] = [];
const hues = [baseH, (baseH + 150) % 360, (baseH + 210) % 360];
for (let i = 0; i < count; i++) {
const h = hues[i % 3];
const lightness = l + Math.floor(i / 3) * 12;
colors.push(hslToHex(h, s, Math.min(lightness, 90)));
}
return colors;
}
function generateTetradicColors(count: number): string[] {
const baseH = randomInt(360);
const s = 55 + randomInt(41);
const l = 40 + randomInt(31);
const colors: string[] = [];
const rectHues = [baseH, (baseH + 60) % 360, (baseH + 180) % 360, (baseH + 240) % 360];
for (let i = 0; i < count; i++) {
const h = rectHues[i % 4];
const lightness = l + Math.floor(i / 4) * 12;
colors.push(hslToHex(h, s, Math.min(lightness, 90)));
}
return colors;
}
function generatePalette(mode: string, count: number): string[] {
switch (mode) {
case "analogous":
return generateAnalogousColors(count);
case "complementary":
return generateComplementaryColors(count);
case "triadic":
return generateTriadicColors(count);
case "monochromatic":
return generateMonochromaticColors(count);
case "split-complementary":
return generateSplitComplementaryColors(count);
case "tetradic":
return generateTetradicColors(count);
default:
return generateRandomColors(count);
}
}
let currentColors: string[] = [];
const copyTimers = new Map<number, ReturnType<typeof setTimeout>>();
function renderSwatches(colors: string[]) {
currentColors = colors;
swatchesEl.innerHTML = "";
copyTimers.clear();
colors.forEach((hex, index) => {
const swatch = document.createElement("div");
swatch.className = "flex flex-col items-center gap-2";
const colorBox = document.createElement("button");
colorBox.type = "button";
colorBox.className =
"group relative w-20 h-20 sm:w-24 sm:h-24 rounded-2xl border border-zinc-800 cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-transform duration-150 hover:scale-105";
colorBox.style.backgroundColor = hex;
colorBox.setAttribute("aria-label", isRu ? `Скопировать ${hex}` : `Copy ${hex}`);
colorBox.dataset.hex = hex;
// Copy overlay icon
const overlay = document.createElement("div");
overlay.className =
"absolute inset-0 flex items-center justify-center rounded-2xl bg-black/30 opacity-0 group-hover:opacity-100 transition-opacity duration-150";
overlay.innerHTML = `<span class="text-white">${copyIconSvg}</span>`;
colorBox.appendChild(overlay);
// Attach click handler directly to the button
colorBox.addEventListener("click", async () => {
await navigator.clipboard.writeText(hex);
overlay.innerHTML = `<span class="text-white">${checkIconSvg}</span>`;
overlay.classList.remove("opacity-0", "group-hover:opacity-100");
overlay.classList.add("opacity-100");
const existing = copyTimers.get(index);
if (existing) clearTimeout(existing);
copyTimers.set(
index,
setTimeout(() => {
overlay.innerHTML = `<span class="text-white">${copyIconSvg}</span>`;
overlay.classList.remove("opacity-100");
overlay.classList.add("opacity-0", "group-hover:opacity-100");
copyTimers.delete(index);
}, 1500),
);
});
// HEX label
const label = document.createElement("span");
label.className = "font-mono text-sm text-zinc-300 tabular-nums select-all cursor-pointer hover:text-zinc-100 transition-colors";
label.textContent = hex;
// Attach click handler directly to the label
label.addEventListener("click", () => {
navigator.clipboard.writeText(hex);
});
swatch.appendChild(colorBox);
swatch.appendChild(label);
swatchesEl.appendChild(swatch);
});
resultEl.style.opacity = "1";
errorDisplay.clear();
}
copyAllBtn.addEventListener("click", async () => {
if (currentColors.length === 0) return;
const text = currentColors.join(", ");
await navigator.clipboard.writeText(text);
const originalText = copyAllBtn.textContent;
copyAllBtn.textContent = isRu ? "Скопировано!" : "Copied!";
setTimeout(() => {
copyAllBtn.textContent = originalText;
}, 1500);
});
function generate() {
const mode = harmonySelect.value;
const count = parseInt(countSelect.value, 10);
try {
const colors = generatePalette(mode, count);
renderSwatches(colors);
} catch {
errorDisplay.show(isRu ? "Не удалось сгенерировать палитру." : "Failed to generate palette.");
}
}
generateBtn.addEventListener("click", generate);
// Generate on load
generate();
</script>
@@ -0,0 +1,215 @@
---
import { useT } from "../../i18n/translations";
const isRu = Astro.url.pathname.startsWith("/ru");
const T = useT(isRu ? "ru" : "en");
---
<div id="password-generator" class="mt-8">
<div class="rounded-xl bg-zinc-900/50 border border-zinc-800/60 p-5">
<div class="flex flex-col gap-5">
<div>
<div class="flex items-center justify-between mb-1">
<label for="pg-length" class="text-sm font-medium text-zinc-400"
>{T.length}</label
>
<span
id="pg-length-val"
class="text-sm font-semibold tabular-nums text-zinc-100">16</span
>
</div>
<input
id="pg-length"
type="range"
min="4"
max="64"
value="16"
class="w-full accent-accent 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-accent cursor-pointer"
/>
<span class="text-sm text-zinc-400 group-hover:text-zinc-200 transition-colors">
{label}
</span>
</label>
))
}
</div>
</div>
</div>
<p
id="pg-error"
role="alert"
aria-live="polite"
class="mt-4 text-sm text-red-500 hidden"
>
</p>
<div class="my-6 rounded-2xl bg-zinc-900/80 border border-zinc-800/80 p-8 h-52 overflow-hidden 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-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950"
>
<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-accent"
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-accent 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-accent text-white font-semibold rounded-xl shadow-lg shadow-accent/25 hover:bg-accent-hover active:bg-accent-active focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors duration-100 cursor-pointer"
>
{T.generate}
</button>
</div>
</div>
<script>
import { CopyFeedback } from "@/lib/client/clipboard";
import { createErrorDisplay } from "@/lib/client/validation";
const isRu = document.documentElement.lang === "ru";
const ERR_SELECT_TYPE = isRu
? "Выберите хотя бы один тип символов."
: "Select at least one character type.";
const lengthInput = document.getElementById("pg-length") as HTMLInputElement;
const lengthVal = document.getElementById("pg-length-val") as HTMLSpanElement;
const upperCb = document.getElementById("pg-upper") as HTMLInputElement;
const lowerCb = document.getElementById("pg-lower") as HTMLInputElement;
const digitsCb = document.getElementById("pg-digits") as HTMLInputElement;
const symbolsCb = document.getElementById("pg-symbols") as HTMLInputElement;
const btn = document.getElementById("pg-btn") as HTMLButtonElement;
const copyBtn = document.getElementById("pg-copy-btn") as HTMLButtonElement;
const resultEl = document.getElementById("pg-result") as HTMLSpanElement;
const errorEl = document.getElementById("pg-error") as HTMLParagraphElement;
const copyIcon = document.getElementById("pg-copy-icon") as HTMLSpanElement;
const checkIcon = document.getElementById("pg-check-icon") as HTMLSpanElement;
const copiedLabel = document.getElementById(
"pg-copied-label",
) as HTMLSpanElement;
const UPPER = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const LOWER = "abcdefghijklmnopqrstuvwxyz";
const DIGITS = "0123456789";
const SYMBOLS = "!@#$%^&*()-_=+[]{}|;:,.<>?";
lengthInput.addEventListener("input", () => {
lengthVal.textContent = lengthInput.value;
});
const errors = createErrorDisplay(errorEl);
const clipboard = new CopyFeedback(copyIcon, checkIcon);
function generate() {
const length = parseInt(lengthInput.value, 10);
const pools: string[] = [];
if (upperCb.checked) pools.push(UPPER);
if (lowerCb.checked) pools.push(LOWER);
if (digitsCb.checked) pools.push(DIGITS);
if (symbolsCb.checked) pools.push(SYMBOLS);
if (pools.length === 0) {
errors.show(ERR_SELECT_TYPE);
return;
}
errors.clear();
const required = pools.map((p) => p[Math.floor(Math.random() * p.length)]);
const combined = pools.join("");
const rest = Array.from(
{ length: length - required.length },
() => combined[Math.floor(Math.random() * combined.length)],
);
const chars = [...required, ...rest];
for (let i = chars.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[chars[i], chars[j]] = [chars[j], chars[i]];
}
resultEl.textContent = chars.join("");
resultEl.style.opacity = "0.5";
requestAnimationFrame(() =>
requestAnimationFrame(() => {
resultEl.style.opacity = "1";
}),
);
copyBtn.classList.remove("invisible");
}
async function copyPassword() {
const value = resultEl.textContent?.trim();
if (!value) return;
await navigator.clipboard.writeText(value);
clipboard.showCopied();
copiedLabel.style.opacity = "1";
setTimeout(() => {
copiedLabel.style.opacity = "0";
}, 1500);
}
btn.addEventListener("click", generate);
copyBtn.addEventListener("click", copyPassword);
</script>
@@ -0,0 +1,145 @@
---
import { useT } from "../../i18n/translations";
const isRu = Astro.url.pathname.startsWith("/ru");
const T = useT(isRu ? "ru" : "en");
---
<div id="rps-generator" class="mt-8" data-ru={isRu ? "1" : "0"}>
<div class="rounded-xl bg-zinc-900/50 border border-zinc-800/60 p-5">
<p class="text-sm font-medium text-zinc-400 mb-3 text-center">
{isRu ? "Выберите ваш вариант:" : "Pick your move:"}
</p>
<div class="flex justify-center gap-3">
<button
id="rps-rock"
type="button"
data-move="rock"
class="rps-choice flex-1 sm:flex-none px-4 py-3 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 font-medium hover:bg-zinc-700 hover:border-zinc-600 active:bg-zinc-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors duration-100 cursor-pointer flex flex-col items-center gap-1"
>
<span class="text-2xl" aria-hidden="true">✊</span>
<span class="text-sm">{isRu ? "Камень" : "Rock"}</span>
</button>
<button
id="rps-paper"
type="button"
data-move="paper"
class="rps-choice flex-1 sm:flex-none px-4 py-3 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 font-medium hover:bg-zinc-700 hover:border-zinc-600 active:bg-zinc-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors duration-100 cursor-pointer flex flex-col items-center gap-1"
>
<span class="text-2xl" aria-hidden="true">✋</span>
<span class="text-sm">{isRu ? "Бумага" : "Paper"}</span>
</button>
<button
id="rps-scissors"
type="button"
data-move="scissors"
class="rps-choice flex-1 sm:flex-none px-4 py-3 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 font-medium hover:bg-zinc-700 hover:border-zinc-600 active:bg-zinc-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors duration-100 cursor-pointer flex flex-col items-center gap-1"
>
<span class="text-2xl" aria-hidden="true">✌️</span>
<span class="text-sm">{isRu ? "Ножницы" : "Scissors"}</span>
</button>
</div>
</div>
<div class="my-6 rounded-2xl bg-zinc-900/80 border border-zinc-800/80 p-8 h-52 overflow-hidden">
<div id="rps-result-area" class="h-full flex flex-col items-center justify-center gap-3">
<p id="rps-placeholder" class="text-zinc-500 text-center">
{isRu ? "Выберите ход, чтобы начать игру" : "Pick a move to start the game"}
</p>
<div id="rps-game-result" class="hidden w-full flex flex-col items-center justify-center gap-4">
<div class="flex items-center gap-4 sm:gap-6">
<div class="flex flex-col items-center gap-1">
<span class="text-xs text-zinc-500">{isRu ? "Вы" : "You"}</span>
<span id="rps-player-move" class="text-4xl"></span>
<span id="rps-player-label" class="text-sm text-zinc-300"></span>
</div>
<span class="text-xl text-zinc-600 font-bold">VS</span>
<div class="flex flex-col items-center gap-1">
<span class="text-xs text-zinc-500">{isRu ? "Бот" : "Bot"}</span>
<span id="rps-bot-move" class="text-4xl"></span>
<span id="rps-bot-label" class="text-sm text-zinc-300"></span>
</div>
</div>
<p id="rps-outcome" class="text-2xl font-bold"></p>
</div>
</div>
</div>
</div>
<script>
const isRu = document.documentElement.lang === "ru";
const LABELS: Record<string, { name: string; emoji: string }> = isRu
? {
rock: { name: "Камень", emoji: "✊" },
paper: { name: "Бумага", emoji: "✋" },
scissors: { name: "Ножницы", emoji: "✌️" },
}
: {
rock: { name: "Rock", emoji: "✊" },
paper: { name: "Paper", emoji: "✋" },
scissors: { name: "Scissors", emoji: "✌️" },
};
const OUTCOMES = {
win: { text: isRu ? "Победа!" : "You win!", color: "text-emerald-400" },
lose: { text: isRu ? "Поражение" : "You lose", color: "text-red-400" },
draw: { text: isRu ? "Ничья" : "Draw", color: "text-yellow-400" },
};
const MOVES = ["rock", "paper", "scissors"] as const;
const choices = document.querySelectorAll<HTMLButtonElement>(".rps-choice");
const placeholder = document.getElementById("rps-placeholder") as HTMLParagraphElement;
const gameResult = document.getElementById("rps-game-result") as HTMLDivElement;
const playerMoveEl = document.getElementById("rps-player-move") as HTMLSpanElement;
const playerLabelEl = document.getElementById("rps-player-label") as HTMLSpanElement;
const botMoveEl = document.getElementById("rps-bot-move") as HTMLSpanElement;
const botLabelEl = document.getElementById("rps-bot-label") as HTMLSpanElement;
const outcomeEl = document.getElementById("rps-outcome") as HTMLParagraphElement;
function getWinner(player: string, bot: string): "win" | "lose" | "draw" {
if (player === bot) return "draw";
if (
(player === "rock" && bot === "scissors") ||
(player === "paper" && bot === "rock") ||
(player === "scissors" && bot === "paper")
) {
return "win";
}
return "lose";
}
function play(playerMove: string) {
const botMove = MOVES[Math.floor(Math.random() * MOVES.length)];
const result = getWinner(playerMove, botMove);
choices.forEach((btn) => {
btn.classList.remove("border-accent", "bg-zinc-700");
btn.classList.add("border-zinc-700", "bg-zinc-800");
if (btn.dataset.move === playerMove) {
btn.classList.remove("border-zinc-700", "bg-zinc-800");
btn.classList.add("border-accent", "bg-zinc-700");
}
});
playerMoveEl.textContent = LABELS[playerMove].emoji;
playerLabelEl.textContent = LABELS[playerMove].name;
botMoveEl.textContent = LABELS[botMove].emoji;
botLabelEl.textContent = LABELS[botMove].name;
const outcome = OUTCOMES[result];
outcomeEl.textContent = outcome.text;
outcomeEl.className = `text-2xl font-bold ${outcome.color}`;
placeholder.classList.add("hidden");
gameResult.classList.remove("hidden");
}
choices.forEach((btn) => {
btn.addEventListener("click", () => {
const move = btn.dataset.move;
if (move) play(move);
});
});
</script>
@@ -0,0 +1,225 @@
---
import { useT } from "../../i18n/translations";
const isRu = Astro.url.pathname.startsWith("/ru");
const T = useT(isRu ? "ru" : "en");
---
<div id="shuffler-generator" class="mt-8">
<div class="rounded-2xl bg-zinc-900/80 border border-zinc-800/80 p-8">
<div class="flex flex-col gap-4">
<div>
<label
for="shf-items"
class="block text-sm font-medium text-zinc-400 mb-1"
>
{T.items}
<span class="text-zinc-600">({T.itemsOneLine})</span>
</label>
<textarea
id="shf-items"
rows="8"
placeholder={isRu
? "Введите элементы, по одному на строку"
: "Enter items, one per line"}
class="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-3 text-zinc-100 text-sm font-mono focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors resize-y"
aria-label={T.items}></textarea>
</div>
</div>
<p
id="shf-error"
role="alert"
aria-live="polite"
class="mt-3 text-sm text-red-500 hidden"
>
</p>
</div>
<div class="flex justify-center mt-6">
<button
id="shf-btn"
type="button"
class="w-full sm:w-auto px-8 py-3 bg-accent text-white font-semibold rounded-xl shadow-lg shadow-accent/25 hover:bg-accent-hover active:bg-accent-active focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors duration-100 cursor-pointer"
>
{isRu ? "Перемешать" : "Shuffle"}
</button>
</div>
<div
id="shf-result-area"
class="mt-6 rounded-2xl bg-zinc-900/80 border border-zinc-800/80 p-8 hidden"
>
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-medium text-zinc-400">
{isRu ? "Результат" : "Result"}
</h3>
<button
id="shf-copy-btn"
type="button"
aria-label={isRu ? "Скопировать результат" : "Copy shuffled items to clipboard"}
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-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 rounded"
>
<span id="shf-copy-label">{T.copy}</span>
<span id="shf-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="shf-check-icon"
class="hidden text-accent"
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>
<ol
id="shf-result"
class="flex flex-col gap-2"
aria-live="polite"
aria-label={isRu ? "Перемешанные элементы" : "Shuffled items"}
>
</ol>
</div>
</div>
<script>
import { CopyFeedback } from "@/lib/client/clipboard";
import { createErrorDisplay } from "@/lib/client/validation";
const isRu = document.documentElement.lang === "ru";
const COPY_LABEL = isRu ? "Копировать" : "Copy";
const COPIED_LABEL = isRu ? "Скопировано" : "Copied";
const ERR_MIN_TWO = isRu
? "Добавьте хотя бы 2 элемента."
: "Add at least 2 items.";
const itemsInput = document.getElementById(
"shf-items",
) as HTMLTextAreaElement;
const btn = document.getElementById("shf-btn") as HTMLButtonElement;
const copyBtn = document.getElementById("shf-copy-btn") as HTMLButtonElement;
const resultEl = document.getElementById("shf-result") as HTMLOListElement;
const resultArea = document.getElementById(
"shf-result-area",
) as HTMLDivElement;
const errorEl = document.getElementById("shf-error") as HTMLParagraphElement;
const copyLabel = document.getElementById(
"shf-copy-label",
) as HTMLSpanElement;
const copyIcon = document.getElementById("shf-copy-icon") as HTMLSpanElement;
const checkIcon = document.getElementById(
"shf-check-icon",
) as HTMLSpanElement;
let lastShuffled: string[] = [];
const errors = createErrorDisplay(errorEl);
const clipboard = new CopyFeedback(copyIcon, checkIcon);
function fisherYatesShuffle<T>(array: T[]): T[] {
const arr = [...array];
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
function shuffle() {
const items = itemsInput.value
.split("\n")
.map((s) => s.trim())
.filter(Boolean);
if (items.length < 2) {
errors.show(ERR_MIN_TWO);
return;
}
errors.clear();
lastShuffled = fisherYatesShuffle(items);
resultEl.innerHTML = "";
lastShuffled.forEach((item, index) => {
const li = document.createElement("li");
li.className =
"flex items-center gap-3 rounded-xl bg-zinc-950 border border-zinc-800 px-4 py-3 opacity-0 translate-y-2";
li.style.animation = `shfFadeSlideIn 0.3s ease ${index * 0.05}s forwards`;
const number = document.createElement("span");
number.className =
"flex-shrink-0 inline-flex items-center justify-center w-7 h-7 rounded-lg bg-accent/15 text-accent text-xs font-bold";
number.textContent = String(index + 1);
const text = document.createElement("span");
text.className = "text-zinc-100 text-sm break-all";
text.textContent = item;
li.appendChild(number);
li.appendChild(text);
resultEl.appendChild(li);
});
resultArea.classList.remove("hidden");
copyLabel.textContent = COPY_LABEL;
clipboard.revert();
}
async function copyShuffled() {
if (!lastShuffled.length) return;
await navigator.clipboard.writeText(lastShuffled.join("\n"));
clipboard.showCopied();
copyLabel.textContent = COPIED_LABEL;
setTimeout(() => {
copyLabel.textContent = COPY_LABEL;
}, 1500);
}
btn.addEventListener("click", shuffle);
copyBtn.addEventListener("click", copyShuffled);
itemsInput.addEventListener("keydown", (e) => {
if (e.key === "Enter" && e.ctrlKey) shuffle();
});
</script>
<style is:inline>
@keyframes shfFadeSlideIn {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>
@@ -0,0 +1,313 @@
---
import { useT } from "../../i18n/translations";
const isRu = Astro.url.pathname.startsWith("/ru");
const T = useT(isRu ? "ru" : "en");
const itemsPlaceholder = isRu
? "Алиса\nБоб\nКарол\nДэвид"
: "Alice\nBob\nCarol\nDavid";
const teamCountLabel = isRu ? "Количество команд" : "Number of teams";
const teamPrefix = isRu ? "Команда" : "Team";
const errAddTwo = isRu
? "Добавьте хотя бы 2 участника."
: "Add at least 2 participants.";
const errTeamsMin = isRu
? "Минимум 2 команды."
: "Minimum 2 teams.";
const errTeamsMax = isRu
? "Максимум 20 команд."
: "Maximum 20 teams.";
const errMorePlayers = isRu
? "Участников должно быть больше, чем команд."
: "Participants must outnumber teams.";
---
<div id="teams-generator" class="mt-8">
<div class="rounded-xl bg-zinc-900/50 border border-zinc-800/60 p-5">
<div class="flex flex-col gap-4">
<div>
<label
for="tm-items"
class="block text-sm font-medium text-zinc-400 mb-1"
>
{isRu ? "Участники" : "Participants"}
<span class="text-zinc-600">({T.itemsOneLine})</span>
</label>
<textarea
id="tm-items"
rows="6"
placeholder={itemsPlaceholder}
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-accent focus:ring-1 focus:ring-accent transition-colors resize-y"
aria-label={isRu ? "Участники" : "Participants"}></textarea>
</div>
<div class="flex-1">
<label
for="tm-count"
class="block text-sm font-medium text-zinc-400 mb-1"
>{teamCountLabel}</label
>
<input
id="tm-count"
type="number"
value="2"
min="2"
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-accent focus:ring-1 focus:ring-accent transition-colors"
aria-label={teamCountLabel}
/>
</div>
</div>
</div>
<p
id="tm-error"
role="alert"
aria-live="polite"
class="mt-2 text-sm text-red-600 hidden"
>
</p>
<div
class="my-6 rounded-2xl bg-zinc-900/80 border border-zinc-800/80 p-6 overflow-hidden"
>
<div
id="tm-result"
class="flex flex-col gap-4"
aria-live="polite"
aria-label={isRu ? "Команды" : "Teams"}
>
</div>
<button
id="tm-copy-btn"
type="button"
aria-label={isRu ? "Скопировать команды" : "Copy teams to clipboard"}
class="invisible mt-4 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-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 rounded"
>
<span id="tm-copy-label">{T.copy}</span>
<span id="tm-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="tm-check-icon"
class="hidden text-accent"
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="tm-btn"
type="button"
class="w-full sm:w-auto px-8 py-3 bg-accent text-white font-semibold rounded-xl hover:bg-accent-hover active:bg-accent-active focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors duration-100 cursor-pointer shadow-lg shadow-accent/25"
>
{T.generate}
</button>
</div>
</div>
<script>
import { CopyFeedback } from "@/lib/client/clipboard";
import { createErrorDisplay } from "@/lib/client/validation";
const isRu = document.documentElement.lang === "ru";
const COPY_LABEL = isRu ? "Копировать" : "Copy";
const COPIED_LABEL = isRu ? "Скопировано" : "Copied";
const TEAM_PREFIX = isRu ? "Команда" : "Team";
const ERR_ADD_TWO = isRu
? "Добавьте хотя бы 2 участника."
: "Add at least 2 participants.";
const ERR_TEAMS_MIN = isRu
? "Минимум 2 команды."
: "Minimum 2 teams.";
const ERR_TEAMS_MAX = isRu
? "Максимум 20 команд."
: "Maximum 20 teams.";
const ERR_MORE_PLAYERS = isRu
? "Участников должно быть больше, чем команд."
: "Participants must outnumber teams.";
const DOT_COLORS = [
"bg-emerald-500",
"bg-violet-500",
"bg-amber-500",
"bg-rose-500",
"bg-sky-500",
"bg-lime-500",
"bg-fuchsia-500",
"bg-orange-500",
"bg-cyan-500",
"bg-pink-500",
"bg-teal-500",
"bg-indigo-500",
"bg-yellow-500",
"bg-red-500",
"bg-blue-500",
"bg-green-500",
"bg-purple-500",
"bg-stone-500",
"bg-zinc-500",
"bg-neutral-500",
];
const itemsInput = document.getElementById(
"tm-items",
) as HTMLTextAreaElement;
const countInput = document.getElementById("tm-count") as HTMLInputElement;
const btn = document.getElementById("tm-btn") as HTMLButtonElement;
const copyBtn = document.getElementById("tm-copy-btn") as HTMLButtonElement;
const resultEl = document.getElementById("tm-result") as HTMLDivElement;
const errorEl = document.getElementById("tm-error") as HTMLParagraphElement;
const copyLabel = document.getElementById(
"tm-copy-label",
) as HTMLSpanElement;
const copyIcon = document.getElementById("tm-copy-icon") as HTMLSpanElement;
const checkIcon = document.getElementById(
"tm-check-icon",
) as HTMLSpanElement;
let lastTeams: string[][] = [];
const errors = createErrorDisplay(errorEl);
const clipboard = new CopyFeedback(copyIcon, checkIcon);
function shuffle<T>(arr: T[]): T[] {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
function splitIntoTeams(items: string[], teamCount: number): string[][] {
const shuffled = shuffle(items);
const teams: string[][] = Array.from({ length: teamCount }, () => []);
shuffled.forEach((item, i) => {
teams[i % teamCount].push(item);
});
return teams;
}
function generate() {
const items = itemsInput.value
.split("\n")
.map((s) => s.trim())
.filter(Boolean);
const teamCount = parseInt(countInput.value, 10);
if (items.length < 2) {
errors.show(ERR_ADD_TWO);
return;
}
if (!Number.isInteger(teamCount) || teamCount < 2) {
errors.show(ERR_TEAMS_MIN);
return;
}
if (teamCount > 20) {
errors.show(ERR_TEAMS_MAX);
return;
}
if (items.length <= teamCount) {
errors.show(ERR_MORE_PLAYERS);
return;
}
errors.clear();
lastTeams = splitIntoTeams(items, teamCount);
resultEl.innerHTML = "";
lastTeams.forEach((team, i) => {
const block = document.createElement("div");
block.className = "rounded-lg bg-zinc-900/60 border border-zinc-800/60 p-3";
const header = document.createElement("div");
header.className = "flex items-center gap-2 mb-2";
const dot = document.createElement("span");
dot.className = `inline-block w-2 h-2 rounded-full ${DOT_COLORS[i % DOT_COLORS.length]}`;
dot.setAttribute("aria-hidden", "true");
const title = document.createElement("span");
title.className = "text-sm font-semibold text-zinc-300";
title.textContent = `${TEAM_PREFIX} ${i + 1}`;
header.appendChild(dot);
header.appendChild(title);
const list = document.createElement("div");
list.className = "flex flex-wrap gap-1.5";
team.forEach((member) => {
const chip = document.createElement("span");
chip.className =
"inline-flex items-center px-2 py-1 rounded-md bg-zinc-800 text-zinc-300 text-xs";
chip.textContent = member;
list.appendChild(chip);
});
block.appendChild(header);
block.appendChild(list);
resultEl.appendChild(block);
});
copyBtn.classList.remove("invisible");
copyLabel.textContent = COPY_LABEL;
}
async function copyResults() {
if (!lastTeams.length) return;
const text = lastTeams
.map(
(team, i) =>
`${TEAM_PREFIX} ${i + 1}:\n${team.map((m) => `- ${m}`).join("\n")}`,
)
.join("\n\n");
await navigator.clipboard.writeText(text);
clipboard.showCopied();
copyLabel.textContent = COPIED_LABEL;
setTimeout(() => {
copyLabel.textContent = COPY_LABEL;
}, 1500);
}
btn.addEventListener("click", generate);
copyBtn.addEventListener("click", copyResults);
itemsInput.addEventListener("keydown", (e) => {
if (e.key === "Enter" && e.ctrlKey) generate();
});
countInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") generate();
});
</script>
@@ -0,0 +1,435 @@
---
import { useT } from "../../i18n/translations";
const isRu = Astro.url.pathname.startsWith("/ru");
const T = useT(isRu ? "ru" : "en");
---
<div id="time-generator" class="mt-8" data-ru={isRu ? "1" : "0"}>
<!-- Controls -->
<div class="rounded-xl bg-zinc-900/50 border border-zinc-800/60 p-5 space-y-4">
<!-- Time range -->
<div class="flex flex-col sm:flex-row gap-4">
<div class="flex-1">
<label for="tg-from" class="block text-sm font-medium text-zinc-400 mb-1">{T.from}</label>
<input
id="tg-from"
type="time"
value="00:00"
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-accent focus:ring-1 focus:ring-accent transition-colors"
/>
</div>
<div class="flex-1">
<label for="tg-to" class="block text-sm font-medium text-zinc-400 mb-1">{T.to}</label>
<input
id="tg-to"
type="time"
value="23:59"
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-accent focus:ring-1 focus:ring-accent transition-colors"
/>
</div>
</div>
<!-- Interval and Format -->
<div class="flex flex-col sm:flex-row gap-4">
<div class="flex-1">
<label for="tg-interval" class="block text-sm font-medium text-zinc-400 mb-1">{isRu ? "Интервал" : "Interval"}</label>
<select
id="tg-interval"
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-accent focus:ring-1 focus:ring-accent transition-colors appearance-none cursor-pointer"
>
<option value="1">{isRu ? "Каждую минуту" : "Every minute"}</option>
<option value="5">{isRu ? "Каждые 5 минут" : "Every 5 minutes"}</option>
<option value="15">{isRu ? "Каждые 15 минут" : "Every 15 minutes"}</option>
<option value="30">{isRu ? "Каждые 30 минут" : "Every 30 minutes"}</option>
<option value="60">{isRu ? "Каждый час" : "Hourly"}</option>
</select>
</div>
<div class="flex-1">
<label class="block text-sm font-medium text-zinc-400 mb-1">{isRu ? "Формат" : "Format"}</label>
<div class="flex bg-zinc-900 border border-zinc-700 rounded-lg overflow-hidden">
<button
id="tg-format-24"
type="button"
class="flex-1 px-3 py-2 text-sm font-medium bg-accent text-white transition-colors cursor-pointer"
>
24h
</button>
<button
id="tg-format-12"
type="button"
class="flex-1 px-3 py-2 text-sm font-medium text-zinc-400 hover:text-zinc-200 transition-colors cursor-pointer"
>
12h
</button>
</div>
</div>
</div>
</div>
<!-- Error -->
<p
id="tg-error"
role="alert"
aria-live="polite"
class="mt-2 text-sm text-red-600 hidden"
></p>
<!-- Result / Clock Display -->
<div class="my-6 rounded-2xl bg-zinc-900/80 border border-zinc-800/80 p-6 sm:p-8">
<div class="flex flex-col items-center gap-4">
<!-- SVG Analog Clock -->
<div class="relative">
<svg id="tg-clock" width="220" height="220" viewBox="0 0 220 220" class="drop-shadow-lg">
<!-- Clock face -->
<circle cx="110" cy="110" r="100" fill="transparent" stroke="#3f3f46" stroke-width="2" />
<circle cx="110" cy="110" r="96" fill="transparent" stroke="#27272a" stroke-width="1" />
<!-- Hour markers (generated by JS) -->
<g id="tg-clock-markers"></g>
<!-- Hour hand -->
<line
id="tg-hand-hour"
x1="110" y1="110" x2="110" y2="55"
stroke="#e4e4e7" stroke-width="4" stroke-linecap="round"
/>
<!-- Minute hand -->
<line
id="tg-hand-minute"
x1="110" y1="110" x2="110" y2="30"
stroke="#d4d4d8" stroke-width="2.5" stroke-linecap="round"
/>
<!-- Second hand (accent) -->
<line
id="tg-hand-second"
x1="110" y1="110" x2="110" y2="25"
stroke="#f97316" stroke-width="1.5" stroke-linecap="round"
/>
<!-- Center dot -->
<circle cx="110" cy="110" r="5" fill="#f97316" />
<circle cx="110" cy="110" r="2.5" fill="#18181b" />
</svg>
</div>
<!-- Digital time + description -->
<div class="flex flex-col items-center gap-2 text-center">
<button
id="tg-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-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950"
aria-live="polite"
>
<span
id="tg-result"
class="text-3xl sm:text-4xl font-semibold text-zinc-100 select-none group-hover:text-zinc-300 text-center block"
style="transition: transform 0.12s cubic-bezier(0.34,1.56,0.64,1), opacity 0.08s ease, color 0.15s ease;"
></span>
<!-- Copy icon -->
<span
id="tg-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>
<!-- Check icon -->
<span
id="tg-check-icon"
class="absolute -right-7 top-1/2 -translate-y-1/2 hidden text-accent"
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="tg-copied-label"
class="text-xs font-medium text-accent opacity-0 transition-opacity duration-200"
aria-live="polite"
aria-atomic="true"
>{T.copied}</span>
<!-- Time description -->
<span id="tg-time-desc" class="text-sm text-zinc-500 font-medium"></span>
<!-- Alternate format (12h/24h equivalent) -->
<span id="tg-alt-format" class="text-xs text-zinc-600"></span>
</div>
</div>
</div>
<!-- Generate Button -->
<div class="flex justify-center">
<button
id="tg-btn"
type="button"
class="w-full sm:w-auto px-8 py-3 bg-accent text-white font-semibold rounded-xl hover:bg-accent-hover active:bg-accent-active focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors duration-100 cursor-pointer shadow-lg shadow-accent/25"
>
{T.generate}
</button>
</div>
</div>
<script>
import { CopyFeedback } from "@/lib/client/clipboard";
import { createErrorDisplay } from "@/lib/client/validation";
import { popElement } from "@/lib/client/animations";
const isRu = document.documentElement.lang === "ru";
const ERR_FROM_BEFORE_TO = isRu
? "«От» должно быть раньше «До»."
: '"From" must be before "To".';
const ERR_SAME_TIME = isRu
? "Время «От» и «До» не могут совпадать."
: '"From" and "To" times cannot be the same.';
// Time descriptions by language
const timeDescs: Record<number, string> = isRu ? {
0: "Ночь",
5: "Раннее утро",
9: "Утро",
12: "Полдень",
14: "День",
17: "Вечер",
20: "Поздний вечер",
22: "Ночь",
} : {
0: "Night",
5: "Early morning",
9: "Morning",
12: "Midday",
14: "Afternoon",
17: "Evening",
20: "Late evening",
22: "Night",
};
function getTimeDescription(hour: number): string {
if (hour >= 5 && hour < 9) return timeDescs[5];
if (hour >= 9 && hour < 12) return timeDescs[9];
if (hour >= 12 && hour < 14) return timeDescs[12];
if (hour >= 14 && hour < 17) return timeDescs[14];
if (hour >= 17 && hour < 20) return timeDescs[17];
if (hour >= 20 && hour < 22) return timeDescs[20];
return timeDescs[0]; // 22-04 = Night
}
// DOM refs
const fromInput = document.getElementById("tg-from") as HTMLInputElement;
const toInput = document.getElementById("tg-to") as HTMLInputElement;
const intervalSelect = document.getElementById("tg-interval") as HTMLSelectElement;
const btn = document.getElementById("tg-btn") as HTMLButtonElement;
const copyBtn = document.getElementById("tg-copy-btn") as HTMLButtonElement;
const resultEl = document.getElementById("tg-result") as HTMLSpanElement;
const errorEl = document.getElementById("tg-error") as HTMLParagraphElement;
const copyIcon = document.getElementById("tg-copy-icon") as HTMLSpanElement;
const checkIcon = document.getElementById("tg-check-icon") as HTMLSpanElement;
const copiedLabel = document.getElementById("tg-copied-label") as HTMLSpanElement;
const timeDescEl = document.getElementById("tg-time-desc") as HTMLSpanElement;
const altFormatEl = document.getElementById("tg-alt-format") as HTMLSpanElement;
const format24Btn = document.getElementById("tg-format-24") as HTMLButtonElement;
const format12Btn = document.getElementById("tg-format-12") as HTMLButtonElement;
const clockMarkersGroup = document.getElementById("tg-clock-markers") as SVGGElement;
const handHour = document.getElementById("tg-hand-hour") as SVGLineElement;
const handMinute = document.getElementById("tg-hand-minute") as SVGLineElement;
const handSecond = document.getElementById("tg-hand-second") as SVGLineElement;
const errors = createErrorDisplay(errorEl);
const clipboard = new CopyFeedback(copyIcon, checkIcon);
let is24Hour = true;
let currentHour = 0;
let currentMinute = 0;
let currentSecond = 0;
// Generate clock hour markers
function generateClockMarkers() {
for (let i = 0; i < 12; i++) {
const angle = (i * 30) * Math.PI / 180;
const isCardinal = i % 3 === 0;
const innerR = isCardinal ? 82 : 88;
const outerR = 96;
const x1 = 110 + innerR * Math.sin(angle);
const y1 = 110 - innerR * Math.cos(angle);
const x2 = 110 + outerR * Math.sin(angle);
const y2 = 110 - outerR * Math.cos(angle);
const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
line.setAttribute("x1", String(x1));
line.setAttribute("y1", String(y1));
line.setAttribute("x2", String(x2));
line.setAttribute("y2", String(y2));
line.setAttribute("stroke", isCardinal ? "#a1a1aa" : "#52525b");
line.setAttribute("stroke-width", isCardinal ? "2.5" : "1.5");
line.setAttribute("stroke-linecap", "round");
clockMarkersGroup.appendChild(line);
}
}
generateClockMarkers();
function parseTimeInput(value: string): { h: number; m: number } {
const [h, m] = value.split(":").map(Number);
return { h, m };
}
function timeToMinutes(h: number, m: number): number {
return h * 60 + m;
}
function minutesToTime(totalMinutes: number): { h: number; m: number } {
const h = Math.floor(totalMinutes / 60) % 24;
const m = totalMinutes % 60;
return { h, m };
}
function formatTime12h(h: number, m: number): string {
const period = h >= 12 ? (isRu ? "ПМ" : "PM") : (isRu ? "АМ" : "AM");
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h;
return `${String(h12).padStart(2, "0")}:${String(m).padStart(2, "0")} ${period}`;
}
function formatTime24h(h: number, m: number): string {
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
}
function updateClockFace(hour: number, minute: number, second: number) {
// Hour hand: 30 degrees per hour + 0.5 per minute
const hourAngle = (hour % 12) * 30 + minute * 0.5;
// Minute hand: 6 degrees per minute
const minuteAngle = minute * 6;
// Second hand: 6 degrees per second
const secondAngle = second * 6;
function polarToCartesian(cx: number, cy: number, r: number, angleDeg: number) {
const rad = (angleDeg - 90) * Math.PI / 180;
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
}
const hourEnd = polarToCartesian(110, 110, 55, hourAngle);
handHour.setAttribute("x2", String(hourEnd.x));
handHour.setAttribute("y2", String(hourEnd.y));
const minuteEnd = polarToCartesian(110, 110, 78, minuteAngle);
handMinute.setAttribute("x2", String(minuteEnd.x));
handMinute.setAttribute("y2", String(minuteEnd.y));
const secondEnd = polarToCartesian(110, 110, 82, secondAngle);
handSecond.setAttribute("x2", String(secondEnd.x));
handSecond.setAttribute("y2", String(secondEnd.y));
}
function displayResult(h: number, m: number, s: number) {
currentHour = h;
currentMinute = m;
currentSecond = s;
// Main display
if (is24Hour) {
resultEl.textContent = formatTime24h(h, m);
altFormatEl.textContent = formatTime12h(h, m);
} else {
resultEl.textContent = formatTime12h(h, m);
altFormatEl.textContent = formatTime24h(h, m);
}
// Description
timeDescEl.textContent = getTimeDescription(h);
// Update clock
updateClockFace(h, m, s);
copyBtn.classList.remove("invisible");
popElement(resultEl);
}
function generate() {
const fromVal = fromInput.value;
const toVal = toInput.value;
if (!fromVal || !toVal) {
errors.show(isRu ? "Укажите оба времени." : "Please enter both times.");
return;
}
const fromParsed = parseTimeInput(fromVal);
const toParsed = parseTimeInput(toVal);
const fromMins = timeToMinutes(fromParsed.h, fromParsed.m);
const toMins = timeToMinutes(toParsed.h, toParsed.m);
if (fromMins >= toMins) {
errors.show(fromMins === toMins ? ERR_SAME_TIME : ERR_FROM_BEFORE_TO);
return;
}
errors.clear();
const interval = parseInt(intervalSelect.value, 10);
// Generate random time within range, respecting interval
const rangeMinutes = toMins - fromMins;
const maxSteps = Math.floor(rangeMinutes / interval);
const randomStep = Math.floor(Math.random() * (maxSteps + 1));
const randomTotalMinutes = fromMins + randomStep * interval;
const { h, m } = minutesToTime(randomTotalMinutes);
const s = Math.floor(Math.random() * 60);
displayResult(h, m, s);
}
async function copyTime() {
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);
}
// Format toggle
function setFormat24() {
is24Hour = true;
format24Btn.classList.add("bg-accent", "text-white");
format24Btn.classList.remove("text-zinc-400", "hover:text-zinc-200");
format12Btn.classList.remove("bg-accent", "text-white");
format12Btn.classList.add("text-zinc-400", "hover:text-zinc-200");
// Refresh display
if (resultEl.textContent) {
displayResult(currentHour, currentMinute, currentSecond);
}
}
function setFormat12() {
is24Hour = false;
format12Btn.classList.add("bg-accent", "text-white");
format12Btn.classList.remove("text-zinc-400", "hover:text-zinc-200");
format24Btn.classList.remove("bg-accent", "text-white");
format24Btn.classList.add("text-zinc-400", "hover:text-zinc-200");
// Refresh display
if (resultEl.textContent) {
displayResult(currentHour, currentMinute, currentSecond);
}
}
// Events
btn.addEventListener("click", generate);
copyBtn.addEventListener("click", copyTime);
format24Btn.addEventListener("click", setFormat24);
format12Btn.addEventListener("click", setFormat12);
[fromInput, toInput].forEach((input) => {
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") generate();
});
});
intervalSelect.addEventListener("keydown", (e) => {
if (e.key === "Enter") generate();
});
// Generate on load
generate();
</script>
@@ -0,0 +1,248 @@
---
import { useT } from "../../i18n/translations";
const isRu = Astro.url.pathname.startsWith("/ru");
const T = useT(isRu ? "ru" : "en");
---
<div id="uuid-generator" class="mt-8">
<div class="rounded-xl bg-zinc-900/50 border border-zinc-800/60 p-5">
<div class="flex flex-col gap-4">
<div>
<label class="block text-sm font-medium text-zinc-400 mb-2"
>{T.type}</label
>
<div class="flex flex-wrap gap-2" role="radiogroup" aria-label={T.type}>
{
[
{ value: "uuid", label: "UUID v4" },
{ value: "hex", label: "Hex" },
{ value: "base64", label: "Base64" },
].map(({ value, label }) => (
<label class="cursor-pointer">
<input
type="radio"
name="ug-type"
value={value}
class="sr-only peer"
checked={value === "uuid"}
/>
<span class="inline-flex items-center justify-center px-4 h-9 rounded-lg border border-zinc-700 text-sm font-medium text-zinc-400 peer-checked:border-accent peer-checked:text-accent peer-checked:bg-accent/10 hover:border-zinc-500 transition-colors select-none cursor-pointer">
{label}
</span>
</label>
))
}
</div>
</div>
<div class="flex flex-col sm:flex-row gap-4">
<div id="ug-length-wrap" class="flex-1 hidden">
<label
for="ug-length"
class="block text-sm font-medium text-zinc-400 mb-1"
>{T.lengthBytes}</label
>
<input
id="ug-length"
type="number"
value="16"
min="4"
max="64"
class="w-full bg-zinc-900 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 text-base focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent 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-accent focus:ring-1 focus:ring-accent transition-colors"
aria-label={T.count}
/>
</div>
</div>
</div>
</div>
<div
class="my-6 rounded-2xl bg-zinc-900/80 border border-zinc-800/80 p-8 h-52 overflow-hidden 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-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 rounded mt-1"
>
<span id="ug-copy-label">{T.copyAll}</span>
<span id="ug-copy-icon" aria-hidden="true">
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect>
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"
></path>
</svg>
</span>
<span id="ug-check-icon" class="hidden text-accent" 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-accent text-white font-semibold rounded-xl shadow-lg shadow-accent/25 hover:bg-accent-hover active:bg-accent-active focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors duration-100 cursor-pointer"
>
{T.generate}
</button>
</div>
</div>
<script>
import { CopyFeedback } from "@/lib/client/clipboard";
const isRu = document.documentElement.lang === "ru";
const COPY_ALL_LABEL = isRu ? "Копировать всё" : "Copy all";
const COPIED_LABEL = isRu ? "Скопировано" : "Copied";
const lengthWrap = document.getElementById(
"ug-length-wrap",
) as HTMLDivElement;
const lengthInput = document.getElementById("ug-length") as HTMLInputElement;
const countInput = document.getElementById("ug-count") as HTMLInputElement;
const btn = document.getElementById("ug-btn") as HTMLButtonElement;
const copyBtn = document.getElementById("ug-copy-btn") as HTMLButtonElement;
const resultEl = document.getElementById("ug-result") as HTMLDivElement;
const copyLabel = document.getElementById("ug-copy-label") as HTMLSpanElement;
const copyIcon = document.getElementById("ug-copy-icon") as HTMLSpanElement;
const checkIcon = document.getElementById("ug-check-icon") as HTMLSpanElement;
let lastTokens: string[] = [];
const clipboard = new CopyFeedback(copyIcon, checkIcon);
function getType(): string {
return (
(
document.querySelector(
'input[name="ug-type"]:checked',
) as HTMLInputElement
)?.value ?? "uuid"
);
}
document.querySelectorAll('input[name="ug-type"]').forEach((radio) => {
radio.addEventListener("change", () => {
const type = getType();
lengthWrap.classList.toggle("hidden", type === "uuid");
});
});
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));
});
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();
});
});
</script>
@@ -0,0 +1,365 @@
---
import { useT } from "../../i18n/translations";
const isRu = Astro.url.pathname.startsWith("/ru");
const T = useT(isRu ? "ru" : "en");
---
<div id="weighted-generator" class="mt-8">
<div class="rounded-xl bg-zinc-900/50 border border-zinc-800/60 p-5">
<div id="weighted-rows" class="flex flex-col gap-3">
<!-- Rows will be generated by JS -->
</div>
<div class="flex gap-3 mt-4">
<button
id="weighted-add-btn"
type="button"
class="inline-flex items-center gap-1.5 px-4 py-2 bg-zinc-800 text-zinc-300 text-sm font-medium rounded-lg hover:bg-zinc-700 transition-colors cursor-pointer"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="M12 5v14"/></svg>
{isRu ? "Добавить" : "Add"}
</button>
</div>
<p
id="weighted-error"
role="alert"
aria-live="polite"
class="mt-3 text-sm text-red-500 hidden"
></p>
</div>
<div
id="weighted-result"
class="my-6 hidden flex-col items-center justify-center gap-3 rounded-2xl bg-zinc-900/80 border border-zinc-800/80 p-8"
aria-live="polite"
>
<div class="text-center">
<p class="text-sm text-zinc-500 mb-1">{isRu ? "Выбрано" : "Selected"}</p>
<p id="weighted-result-item" class="text-2xl font-bold text-zinc-100"></p>
<p id="weighted-result-details" class="text-sm text-zinc-400 mt-1"></p>
</div>
<button
id="weighted-copy-btn"
type="button"
aria-label={isRu ? "Скопировать результат" : "Copy result to clipboard"}
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-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 rounded"
>
<span id="weighted-copy-label">{T.copy}</span>
<span id="weighted-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="weighted-check-icon"
class="hidden text-accent"
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
id="weighted-stats"
class="my-6 hidden flex-col gap-3 rounded-2xl bg-zinc-900/80 border border-zinc-800/80 p-6"
aria-live="polite"
>
<p class="text-sm font-medium text-zinc-300">{isRu ? "Статистика" : "Statistics"}</p>
<div id="weighted-stats-body" class="flex flex-col gap-2"></div>
</div>
<div class="flex flex-col sm:flex-row justify-center gap-3 mt-6">
<button
id="weighted-pick-btn"
type="button"
class="w-full sm:w-auto px-8 py-3 bg-accent text-white font-semibold rounded-xl shadow-lg shadow-accent/25 hover:bg-accent-hover active:bg-accent-active focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors duration-100 cursor-pointer"
>
{T.pick}
</button>
<div class="flex gap-2 w-full sm:w-auto">
<input
id="weighted-n-input"
type="number"
value="100"
min="1"
max="10000"
class="w-24 bg-zinc-900 border border-zinc-700 rounded-xl px-3 py-3 text-zinc-100 text-base focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent transition-colors"
aria-label={isRu ? "Количество испытаний" : "Number of trials"}
/>
<button
id="weighted-pick-n-btn"
type="button"
class="flex-1 sm:flex-none px-6 py-3 bg-zinc-800 text-zinc-200 font-semibold rounded-xl hover:bg-zinc-700 active:bg-zinc-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors duration-100 cursor-pointer"
>
{isRu ? "Выбрать N раз" : "Pick N times"}
</button>
</div>
</div>
</div>
<script>
import { CopyFeedback } from "@/lib/client/clipboard";
import { createErrorDisplay } from "@/lib/client/validation";
const isRu = document.documentElement.lang === "ru";
const COPY_LABEL = isRu ? "Копировать" : "Copy";
const COPIED_LABEL = isRu ? "Скопировано" : "Copied";
const ERR_ITEM_EMPTY = isRu ? "Имя элемента не может быть пустым." : "Item name cannot be empty.";
const ERR_WEIGHT_POSITIVE = isRu ? "Вес должен быть положительным числом." : "Weight must be a positive number.";
const ERR_ADD_AT_LEAST_ONE = isRu ? "Добавьте хотя бы один элемент с весом." : "Add at least one item with a weight.";
const LABEL_WEIGHT = isRu ? "Вес" : "Weight";
const LABEL_ITEM = isRu ? "Элемент" : "Item";
const LABEL_REMOVE = isRu ? "Удалить" : "Remove";
const rowsContainer = document.getElementById("weighted-rows") as HTMLDivElement;
const addBtn = document.getElementById("weighted-add-btn") as HTMLButtonElement;
const pickBtn = document.getElementById("weighted-pick-btn") as HTMLButtonElement;
const pickNBtn = document.getElementById("weighted-pick-n-btn") as HTMLButtonElement;
const nInput = document.getElementById("weighted-n-input") as HTMLInputElement;
const resultContainer = document.getElementById("weighted-result") as HTMLDivElement;
const resultItem = document.getElementById("weighted-result-item") as HTMLParagraphElement;
const resultDetails = document.getElementById("weighted-result-details") as HTMLParagraphElement;
const copyBtn = document.getElementById("weighted-copy-btn") as HTMLButtonElement;
const copyLabel = document.getElementById("weighted-copy-label") as HTMLSpanElement;
const copyIcon = document.getElementById("weighted-copy-icon") as HTMLSpanElement;
const checkIcon = document.getElementById("weighted-check-icon") as HTMLSpanElement;
const errorEl = document.getElementById("weighted-error") as HTMLParagraphElement;
const statsContainer = document.getElementById("weighted-stats") as HTMLDivElement;
const statsBody = document.getElementById("weighted-stats-body") as HTMLDivElement;
const errors = createErrorDisplay(errorEl);
const clipboard = new CopyFeedback(copyIcon, checkIcon);
let lastResult: { item: string; weight: number; probability: number } | null = null;
let rowIdCounter = 0;
function createRow(name = "", weight = "") {
const id = ++rowIdCounter;
const row = document.createElement("div");
row.className = "flex gap-2 items-start";
row.dataset.rowId = String(id);
row.innerHTML = `
<div class="flex-1">
<input
type="text"
placeholder="${LABEL_ITEM}"
value="${name}"
class="w-full bg-zinc-900 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 text-sm focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent transition-colors"
aria-label="${LABEL_ITEM}"
/>
</div>
<div class="w-24">
<input
type="number"
placeholder="${LABEL_WEIGHT}"
value="${weight}"
min="0.01"
step="any"
class="w-full bg-zinc-900 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 text-sm focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent transition-colors"
aria-label="${LABEL_WEIGHT}"
/>
</div>
<button
type="button"
class="p-2 text-zinc-500 hover:text-red-400 transition-colors cursor-pointer"
aria-label="${LABEL_REMOVE}"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</button>
`;
const removeBtn = row.querySelector("button") as HTMLButtonElement;
removeBtn.addEventListener("click", () => {
row.remove();
if (rowsContainer.children.length === 0) {
addRow();
}
});
return row;
}
function addRow(name = "", weight = "") {
rowsContainer.appendChild(createRow(name, weight));
}
function getRows(): { nameInput: HTMLInputElement; weightInput: HTMLInputElement }[] {
return Array.from(rowsContainer.children).map((row) => ({
nameInput: row.querySelector('input[type="text"]') as HTMLInputElement,
weightInput: row.querySelector('input[type="number"]') as HTMLInputElement,
}));
}
function getValidItems(): { name: string; weight: number }[] | null {
const rows = getRows();
const items: { name: string; weight: number }[] = [];
for (const row of rows) {
const name = row.nameInput.value.trim();
const weightStr = row.weightInput.value.trim();
if (!name && !weightStr) continue;
if (!name) {
errors.show(ERR_ITEM_EMPTY);
return null;
}
const weight = parseFloat(weightStr);
if (!weightStr || isNaN(weight) || weight <= 0) {
errors.show(ERR_WEIGHT_POSITIVE);
return null;
}
items.push({ name, weight });
}
if (items.length === 0) {
errors.show(ERR_ADD_AT_LEAST_ONE);
return null;
}
errors.clear();
return items;
}
function weightedPick(items: { name: string; weight: number }[]): { name: string; weight: number } {
const totalWeight = items.reduce((sum, item) => sum + item.weight, 0);
let random = Math.random() * totalWeight;
for (const item of items) {
random -= item.weight;
if (random <= 0) {
return item;
}
}
return items[items.length - 1];
}
function pick() {
const items = getValidItems();
if (!items) return;
const totalWeight = items.reduce((sum, item) => sum + item.weight, 0);
const picked = weightedPick(items);
const probability = (picked.weight / totalWeight) * 100;
lastResult = {
item: picked.name,
weight: picked.weight,
probability,
};
resultItem.textContent = picked.name;
resultDetails.textContent = isRu
? `Вес: ${picked.weight} · Вероятность: ${probability.toFixed(2)}%`
: `Weight: ${picked.weight} · Probability: ${probability.toFixed(2)}%`;
resultContainer.classList.remove("hidden");
resultContainer.classList.add("flex");
copyLabel.textContent = COPY_LABEL;
statsContainer.classList.add("hidden");
statsContainer.classList.remove("flex");
}
function pickNTimes() {
const items = getValidItems();
if (!items) return;
const n = parseInt(nInput.value, 10);
if (!Number.isInteger(n) || n < 1 || n > 10000) {
errors.show(isRu ? "N должно быть от 1 до 10000." : "N must be between 1 and 10000.");
return;
}
errors.clear();
const counts = new Map<string, number>();
items.forEach((item) => counts.set(item.name, 0));
for (let i = 0; i < n; i++) {
const picked = weightedPick(items);
counts.set(picked.name, (counts.get(picked.name) || 0) + 1);
}
const totalWeight = items.reduce((sum, item) => sum + item.weight, 0);
statsBody.innerHTML = "";
items.forEach((item) => {
const count = counts.get(item.name) || 0;
const percentage = (count / n) * 100;
const expectedProbability = (item.weight / totalWeight) * 100;
const barWidth = Math.max(percentage, 0.5);
const row = document.createElement("div");
row.className = "flex items-center gap-3";
row.innerHTML = `
<div class="w-24 text-sm text-zinc-300 truncate" title="${item.name}">${item.name}</div>
<div class="flex-1 h-4 bg-zinc-800 rounded-full overflow-hidden">
<div class="h-full bg-accent rounded-full transition-all duration-300" style="width: ${barWidth}%"></div>
</div>
<div class="w-20 text-right text-sm text-zinc-400">${count} <span class="text-zinc-600">(${percentage.toFixed(1)}%)</span></div>
<div class="w-16 text-right text-xs text-zinc-500">~${expectedProbability.toFixed(1)}%</div>
`;
statsBody.appendChild(row);
});
statsContainer.classList.remove("hidden");
statsContainer.classList.add("flex");
resultContainer.classList.add("hidden");
resultContainer.classList.remove("flex");
}
async function copyResult() {
if (!lastResult) return;
const text = `${lastResult.item} (weight: ${lastResult.weight}, probability: ${lastResult.probability.toFixed(2)}%)`;
await navigator.clipboard.writeText(text);
clipboard.showCopied();
copyLabel.textContent = COPIED_LABEL;
setTimeout(() => {
copyLabel.textContent = COPY_LABEL;
}, 1500);
}
// Initialize with 3 rows
addRow("", "");
addRow("", "");
addRow("", "");
addBtn.addEventListener("click", () => addRow());
pickBtn.addEventListener("click", pick);
pickNBtn.addEventListener("click", pickNTimes);
copyBtn.addEventListener("click", copyResult);
</script>
@@ -0,0 +1,422 @@
---
import { useT } from "../../i18n/translations";
const isRu = Astro.url.pathname.startsWith("/ru");
const T = useT(isRu ? "ru" : "en");
---
<div id="wheel-spinner" class="mt-8">
<!-- Items input -->
<div class="rounded-xl bg-zinc-900/50 border border-zinc-800/60 p-5">
<div class="flex flex-col gap-3">
<div>
<label
for="ws-items"
class="block text-sm font-medium text-zinc-400 mb-1"
>
{T.items}
<span class="text-zinc-600">({T.itemsOneLine224})</span>
</label>
<textarea
id="ws-items"
rows="6"
class="w-full bg-zinc-900 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 text-sm font-mono focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent transition-colors resize-y"
>Alice
Bob
Carol
Dave
Eve
Frank</textarea
>
</div>
<p
id="ws-error"
role="alert"
aria-live="polite"
class="text-sm text-red-500 hidden"
>
</p>
</div>
</div>
<!-- Wheel area -->
<div class="flex flex-col items-center mt-8 gap-6">
<div class="relative w-full max-w-[480px]" id="ws-wheel-wrap">
<!-- Pointer: triangle pointing down at top-center of wheel -->
<div
id="ws-pointer"
class="absolute left-1/2 top-0 z-10 -translate-x-1/2 -translate-y-1/2"
style="width:0;height:0;border-left:14px solid transparent;border-right:14px solid transparent;border-top:24px solid #534AB7;filter:drop-shadow(0 2px 8px rgba(83,74,183,0.6));"
aria-hidden="true"
>
</div>
<canvas id="ws-canvas" class="block w-full" aria-label="Wheel of fortune"
></canvas>
</div>
<button
id="ws-spin-btn"
type="button"
class="w-full sm:w-auto px-10 py-3 bg-accent text-white font-semibold rounded-xl shadow-lg shadow-accent/25 hover:bg-accent-hover active:bg-accent-active focus:outline-none focus-visible:ring-2 focus-visible:ring-accent 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 my-6 rounded-2xl bg-zinc-900/80 border border-zinc-800/80 p-8 h-52 overflow-hidden 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-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 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-accent" aria-hidden="true">
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20 6 9 17l-5-5"></path>
</svg>
</span>
</button>
</div>
</div>
<style>
@keyframes ws-pop {
from {
transform: scale(0.6);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
.ws-pop {
animation: ws-pop 0.35s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
</style>
<script>
import { CopyFeedback } from "@/lib/client/clipboard";
import { createErrorDisplay } from "@/lib/client/validation";
const isRu = document.documentElement.lang === "ru";
const COPY_LABEL = isRu ? "Копировать" : "Copy";
const COPIED_LABEL = isRu ? "Скопировано" : "Copied";
const ERR_MIN2 = isRu
? "Добавьте хотя бы 2 элемента."
: "Add at least 2 items.";
const ERR_MAX24 = isRu ? "Максимум 24 элемента." : "Maximum 24 items.";
const COLORS = [
"#534AB7",
"#0F6E56",
"#993C1D",
"#185FA5",
"#854F0B",
"#993556",
"#3B6D11",
"#A32D2D",
];
const TAU = 2 * Math.PI;
const textarea = document.getElementById("ws-items") as HTMLTextAreaElement;
const errorEl = document.getElementById("ws-error") as HTMLParagraphElement;
const canvas = document.getElementById("ws-canvas") as HTMLCanvasElement;
const spinBtn = document.getElementById("ws-spin-btn") as HTMLButtonElement;
const resultCard = document.getElementById(
"ws-result-card",
) as HTMLDivElement;
const resultText = document.getElementById(
"ws-result-text",
) as HTMLSpanElement;
const copyBtn = document.getElementById("ws-copy-btn") as HTMLButtonElement;
const copyLabel = document.getElementById("ws-copy-label") as HTMLSpanElement;
const copyIcon = document.getElementById("ws-copy-icon") as HTMLSpanElement;
const checkIcon = document.getElementById("ws-check-icon") as HTMLSpanElement;
const wheelWrap = document.getElementById("ws-wheel-wrap") as HTMLDivElement;
const ctx = canvas.getContext("2d")!;
let items: string[] = ["Alice", "Bob", "Carol", "Dave", "Eve", "Frank"];
let currentRotation = 0;
let isSpinning = false;
const errors = createErrorDisplay(errorEl);
const clipboard = new CopyFeedback(copyIcon, checkIcon);
// ── Canvas sizing ────────────────────────────────────────────────
function resize() {
const size = Math.min(wheelWrap.offsetWidth, 480);
canvas.width = size;
canvas.height = size;
draw(currentRotation);
}
new ResizeObserver(resize).observe(wheelWrap);
resize();
// ── Drawing ──────────────────────────────────────────────────────
function clampText(text: string, maxWidth: number): string {
if (ctx.measureText(text).width <= maxWidth) return text;
let s = text;
while (s.length > 0 && ctx.measureText(s + "…").width > maxWidth)
s = s.slice(0, -1);
return s + "…";
}
function draw(rotation: number) {
const size = canvas.width;
const r = size / 2 - 6;
const n = items.length;
const seg = TAU / n;
ctx.clearRect(0, 0, size, size);
ctx.save();
ctx.translate(size / 2, size / 2);
for (let i = 0; i < n; i++) {
const a0 = -Math.PI / 2 + i * seg + rotation;
const a1 = a0 + seg;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.arc(0, 0, r, a0, a1);
ctx.closePath();
ctx.fillStyle = COLORS[i % COLORS.length];
ctx.fill();
ctx.strokeStyle = "rgba(255,255,255,0.13)";
ctx.lineWidth = 1.5;
ctx.stroke();
const mid = a0 + seg / 2;
const fontSize = Math.max(9, Math.min(14, Math.floor(r * seg * 0.4)));
ctx.save();
ctx.rotate(mid);
ctx.textAlign = "right";
ctx.fillStyle = "rgba(255,255,255,0.92)";
ctx.font = `bold ${fontSize}px 'Space Grotesk', sans-serif`;
const label = clampText(items[i], r * 0.54);
ctx.fillText(label, r - 10, fontSize * 0.38);
ctx.restore();
}
ctx.beginPath();
ctx.arc(0, 0, r, 0, TAU);
ctx.strokeStyle = "rgba(255,255,255,0.2)";
ctx.lineWidth = 3;
ctx.stroke();
ctx.beginPath();
ctx.arc(0, 0, 15, 0, TAU);
ctx.fillStyle = "#18181b";
ctx.fill();
ctx.strokeStyle = "rgba(255,255,255,0.22)";
ctx.lineWidth = 2;
ctx.stroke();
ctx.restore();
}
// ── Items ────────────────────────────────────────────────────────
function parseItems(): string[] | null {
const lines = textarea.value
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
if (lines.length < 2) {
errors.show(ERR_MIN2);
return null;
}
if (lines.length > 24) {
errors.show(ERR_MAX24);
return null;
}
errors.clear();
return lines;
}
function applyItems() {
const parsed = parseItems();
if (parsed) {
items = parsed;
draw(currentRotation);
}
}
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
textarea.addEventListener("input", () => {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(applyItems, 300);
});
// ── Spin ─────────────────────────────────────────────────────────
spinBtn.addEventListener("click", () => {
if (isSpinning) return;
const parsed = parseItems();
if (!parsed) return;
items = parsed;
draw(currentRotation);
const n = items.length;
const seg = TAU / n;
const winnerIdx = Math.floor(Math.random() * n);
const desiredPhase = (((-(winnerIdx + 0.5) * seg) % TAU) + TAU) % TAU;
const currentPhase = ((currentRotation % TAU) + TAU) % TAU;
let delta = (desiredPhase - currentPhase + TAU) % TAU;
if (delta < 0.5) delta += TAU;
const jitter = (Math.random() - 0.5) * seg * 0.55;
const fullTurns = 5 + Math.floor(Math.random() * 3);
const totalDelta = fullTurns * TAU + delta + jitter;
const startRot = currentRotation;
const duration = 4000 + Math.random() * 2000;
const t0 = performance.now();
isSpinning = true;
spinBtn.disabled = true;
resultCard.classList.add("hidden");
(function animate(now: number) {
const p = Math.min((now - t0) / duration, 1);
const eased = 1 - Math.pow(1 - p, 3);
currentRotation = startRot + totalDelta * eased;
draw(currentRotation);
if (p < 1) {
requestAnimationFrame(animate);
} else {
currentRotation = startRot + totalDelta;
isSpinning = false;
spinBtn.disabled = false;
showResult(items[winnerIdx]);
spawnConfetti();
}
})(t0);
});
// ── Result ───────────────────────────────────────────────────────
function showResult(winner: string) {
resultText.textContent = winner;
resultCard.classList.remove("hidden");
resultText.classList.remove("ws-pop");
void resultText.offsetWidth;
resultText.classList.add("ws-pop");
copyLabel.textContent = COPY_LABEL;
copyIcon.classList.remove("hidden");
checkIcon.classList.add("hidden");
}
async function copyResult() {
const value = resultText.textContent?.trim();
if (!value) return;
await navigator.clipboard.writeText(value);
clipboard.showCopied();
copyLabel.textContent = COPIED_LABEL;
setTimeout(() => {
copyLabel.textContent = COPY_LABEL;
}, 1500);
}
copyBtn.addEventListener("click", copyResult);
// ── Confetti ─────────────────────────────────────────────────────
function spawnConfetti() {
const cr = canvas.getBoundingClientRect();
const cx = cr.left + cr.width / 2;
const cy = cr.top + cr.height / 2;
type Particle = {
el: HTMLDivElement;
x: number;
y: number;
vx: number;
vy: number;
};
const particles: Particle[] = [];
for (let i = 0; i < 60; i++) {
const el = document.createElement("div");
const w = 4 + Math.random() * 6;
const h = 4 + Math.random() * 6;
el.style.cssText =
`position:fixed;width:${w}px;height:${h}px;` +
`background:${COLORS[i % COLORS.length]};border-radius:1px;` +
`pointer-events:none;left:${cx}px;top:${cy}px;will-change:transform,opacity;z-index:9999;`;
document.body.appendChild(el);
const angle = Math.random() * TAU;
const speed = 4 + Math.random() * 7;
particles.push({
el,
x: cx,
y: cy,
vx: Math.cos(angle) * speed,
vy: -(Math.abs(Math.sin(angle)) * speed + 2 + Math.random() * 4),
});
}
const dur = 2500;
const t0 = performance.now();
const gravity = 0.2;
(function tick(now: number) {
const elapsed = now - t0;
if (elapsed >= dur) {
particles.forEach((p) => p.el.remove());
return;
}
const opacity = 1 - elapsed / dur;
particles.forEach((p) => {
p.vy += gravity;
p.vx *= 0.99;
p.x += p.vx;
p.y += p.vy;
p.el.style.transform = `translate(${p.x - cx}px,${p.y - cy}px) rotate(${p.x * 2}deg)`;
p.el.style.opacity = String(Math.max(0, opacity));
});
requestAnimationFrame(tick);
})(t0);
}
</script>
@@ -0,0 +1,182 @@
---
import { useT } from "../../i18n/translations";
const isRu = Astro.url.pathname.startsWith("/ru");
const T = useT(isRu ? "ru" : "en");
const askPlaceholder = isRu ? "Задайте вопрос..." : "Ask anything...";
const getAnswerLabel = isRu ? "Получить ответ" : "Get Answer";
const confidenceLabel = isRu ? "Уверенность" : "Confidence";
---
<div id="yesno-generator" class="mt-8">
<div class="rounded-xl bg-zinc-900/50 border border-zinc-800/60 p-5">
<label for="yn-question" class="block text-sm font-medium text-zinc-400 mb-1"
>{isRu ? "Вопрос (необязательно)" : "Question (optional)"}</label
>
<input
id="yn-question"
type="text"
placeholder={askPlaceholder}
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-accent focus:ring-1 focus:ring-accent transition-colors"
/>
</div>
<div
class="my-6 rounded-2xl bg-zinc-900/80 border border-zinc-800/80 p-8 h-52 overflow-hidden"
>
<div class="h-full flex flex-col items-center justify-center gap-3">
<button
id="yn-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-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950"
aria-live="polite"
>
<span
id="yn-result"
class="text-6xl sm:text-7xl font-bold 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="yn-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="yn-check-icon"
class="absolute -right-7 top-1/2 -translate-y-1/2 hidden text-accent"
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>
<div id="yn-confidence-wrap" class="hidden flex-col items-center gap-1.5">
<span class="text-xs text-zinc-500">{confidenceLabel}</span>
<div class="w-40 h-2 rounded-full bg-zinc-800 overflow-hidden">
<div
id="yn-confidence-bar"
class="h-full rounded-full transition-all duration-300"
>
</div>
</div>
<span id="yn-confidence-val" class="text-xs font-medium text-zinc-400"
></span>
</div>
<span
id="yn-copied-label"
class="text-xs font-medium text-accent opacity-0 transition-opacity duration-200"
aria-live="polite"
aria-atomic="true">{T.copied}</span
>
</div>
</div>
<div class="flex justify-center">
<button
id="yn-btn"
type="button"
class="w-full sm:w-auto px-8 py-3 bg-accent text-white font-semibold rounded-xl hover:bg-accent-hover active:bg-accent-active focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors duration-100 cursor-pointer shadow-lg shadow-accent/25"
>
{getAnswerLabel}
</button>
</div>
</div>
<script>
import { CopyFeedback } from "@/lib/client/clipboard";
import { popElement } from "@/lib/client/animations";
const isRu = document.documentElement.lang === "ru";
const ANSWERS = isRu
? ["ДА", "НЕТ", "ВОЗМОЖНО"]
: ["YES", "NO", "MAYBE"];
const questionInput = document.getElementById(
"yn-question",
) as HTMLInputElement;
const btn = document.getElementById("yn-btn") as HTMLButtonElement;
const copyBtn = document.getElementById("yn-copy-btn") as HTMLButtonElement;
const resultEl = document.getElementById("yn-result") as HTMLSpanElement;
const copyIcon = document.getElementById("yn-copy-icon") as HTMLSpanElement;
const checkIcon = document.getElementById(
"yn-check-icon",
) as HTMLSpanElement;
const copiedLabel = document.getElementById(
"yn-copied-label",
) as HTMLSpanElement;
const confidenceWrap = document.getElementById(
"yn-confidence-wrap",
) as HTMLDivElement;
const confidenceBar = document.getElementById(
"yn-confidence-bar",
) as HTMLDivElement;
const confidenceVal = document.getElementById(
"yn-confidence-val",
) as HTMLSpanElement;
const clipboard = new CopyFeedback(copyIcon, checkIcon);
function getConfidenceColor(val: number): string {
if (val >= 85) return "bg-emerald-500";
if (val >= 70) return "bg-yellow-500";
return "bg-red-500";
}
function generate() {
const answer = ANSWERS[Math.floor(Math.random() * ANSWERS.length)];
const confidence = Math.floor(Math.random() * 51) + 50;
resultEl.textContent = answer;
copyBtn.classList.remove("invisible");
popElement(resultEl);
confidenceWrap.classList.remove("hidden");
confidenceWrap.classList.add("flex");
confidenceBar.style.width = `${confidence}%`;
confidenceBar.className = `h-full rounded-full transition-all duration-300 ${getConfidenceColor(confidence)}`;
confidenceVal.textContent = `${confidence}%`;
}
async function copyResult() {
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", copyResult);
questionInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") generate();
});
</script>
+11
View File
@@ -0,0 +1,11 @@
import { defineCollection } from "astro:content";
import { generatorSchema } from "@/lib/generator-schema";
const generators = defineCollection({
type: "data",
schema: generatorSchema,
});
export const collections = {
generators,
};
+48
View File
@@ -0,0 +1,48 @@
{
"slug": "cards",
"title": "Card",
"description": "Draw a random playing card from a standard deck.",
"icon": "square-stack",
"status": "live",
"seoTitle": "Random Card Generator — Draw from a Deck | Randify",
"seoDescription": "Draw random playing cards from a standard 52-card deck. Pick any number of cards, with or without replacement. Free online card picker.",
"ruTitle": "Карта",
"ruDescription": "Вытащите случайную карту из стандартной колоды.",
"ruSeoTitle": "Генератор случайных карт | Randify",
"ruSeoDescription": "Тяните случайные карты из колоды 52 карт. С повторениями или без. Бесплатный онлайн-генератор карт.",
"pageTitle": "Card Generator",
"ruPageTitle": "Генератор карт",
"howTo": [
"Set how many cards to draw (1–52).",
"Toggle Allow duplicates if you want cards to repeat.",
"Click Draw — cards are shown as mini card tiles.",
"Click Copy to copy the result (e.g. A♠, K♥) to your clipboard."
],
"whenTo": [
"Drawing a random hand for card games.",
"Practising card probability and statistics.",
"Simulating a random card pull for magic tricks or puzzles.",
"Running fair draws without a physical deck."
],
"ruHowTo": [
"Укажите количество карт для розыгрыша.",
"Включите «Разрешить повторения», если нужны дубли.",
"Нажмите «Тянуть» — карты появятся на экране.",
"Нажмите «Копировать», чтобы скопировать результат."
],
"ruWhenTo": [
"Случайная раздача карт для карточных игр.",
"Выбор случайных карт для гаданий.",
"Генерация случайных комбинаций карт для тестирования."
],
"faq": [
{ "q": "What deck is used?", "a": "A standard 52-card deck with four suits: spades ♠, hearts ♥, diamonds ♦, and clubs ♣." },
{ "q": "Can I draw the same card twice?", "a": "Only if Allow duplicates is enabled. By default, each card is drawn at most once." },
{ "q": "Are jokers included?", "a": "No. Only the standard 52 playing cards are used." }
],
"ruFaq": [
{ "q": "Какая колода используется?", "a": "Стандартная колода из 52 карт четырёх мастей: пики ♠, червы ♥, бубны ♦, трефы ♣." },
{ "q": "Можно вытащить одну карту дважды?", "a": "Только если включены повторения. По умолчанию каждая карта вытаскивается не более одного раза." },
{ "q": "Есть ли джокеры?", "a": "Нет. Используется только стандартная колода из 52 карт." }
]
}
+50
View File
@@ -0,0 +1,50 @@
{
"slug": "coin",
"title": "Coin Flip",
"description": "Flip one or more coins and see heads or tails.",
"icon": "circle-dollar-sign",
"status": "live",
"seoTitle": "Coin Flip Online — Heads or Tails | Randify",
"seoDescription": "Flip a virtual coin online. Flip multiple coins at once and see how many heads and tails you get. Free random coin toss simulator.",
"ruTitle": "Монетка",
"ruDescription": "Подбросьте одну или несколько монет — орёл или решка.",
"ruSeoTitle": "Подбросить монетку онлайн — орёл или решка | Randify",
"ruSeoDescription": "Подбрасывайте виртуальную монету онлайн. Одну или несколько сразу. Бесплатный симулятор подбрасывания монеты.",
"pageTitle": "Coin Flip",
"ruPageTitle": "Подбросить монетку",
"howTo": [
"Drag the slider to choose how many coins to flip (1–20).",
"Click Flip — each coin shows H for heads or T for tails.",
"When flipping more than one coin, the heads/tails count is shown below.",
"Click Copy to copy all results to the clipboard.",
"Use a single coin for a classic 50/50 decision."
],
"whenTo": [
"Making a quick heads-or-tails decision.",
"Simulating a coin toss for a sports match or game.",
"Demonstrating probability and the law of large numbers.",
"Settling a fair 50/50 choice between two options."
],
"ruHowTo": [
"Выберите количество монет с помощью ползунка.",
"Нажмите «Подбросить» — орёл или решка.",
"При нескольких монетах отображается итоговый счёт.",
"Нажмите «Копировать», чтобы сохранить результат.",
"Одна монетка — классическое решение «орёл или решка»."
],
"faq": [
{ "q": "Is the coin flip fair?", "a": "Yes. Each flip uses your browser's random function, giving a true 50/50 chance for heads or tails." },
{ "q": "Can I flip multiple coins at once?", "a": "Yes. Use the slider to choose up to 20 coins, and see the total heads and tails count." },
{ "q": "What do H and T stand for?", "a": "H means Heads and T means Tails — the two sides of a coin." }
],
"ruFaq": [
{ "q": "Подбрасывание честное?", "a": "Да. Каждый бросок использует функцию случайности браузера, давая истинный шанс 50/50 на орла или решку." },
{ "q": "Можно подбросить несколько монет сразу?", "a": "Да. С помощью ползунка выберите до 20 монет и увидите общий счёт орлов и решек." },
{ "q": "Что означают О и Р?", "a": "О — орёл, Р — решка. Это две стороны монеты." }
],
"ruWhenTo": [
"Принятие решений по принципу «орёл или решка».",
"Определение очерёдности в игре.",
"Симуляция случайных событий с равной вероятностью."
]
}
+56
View File
@@ -0,0 +1,56 @@
{
"slug": "colors",
"title": "Color",
"description": "Generate a random color in HEX, RGB, or HSL format.",
"icon": "palette",
"status": "live",
"seoTitle": "Random Color Generator — HEX, RGB, HSL | Randify",
"seoDescription": "Get a random color in HEX, RGB or HSL format with one click. Free online color randomizer for designers and developers.",
"ruTitle": "Цвет",
"ruDescription": "Генерируйте случайный цвет в форматах HEX, RGB или HSL.",
"ruSeoTitle": "Генератор случайных цветов — HEX, RGB, HSL | Randify",
"ruSeoDescription": "Получайте случайные цвета в форматах HEX, RGB и HSL одним нажатием. Бесплатный рандомайзер цветов для дизайнеров.",
"pageTitle": "Color Generator",
"ruPageTitle": "Генератор цветов",
"howTo": [
"Click Generate to produce a random color.",
"Switch between HEX, RGB, and HSL tabs to see the value in your preferred format.",
"Click the color value or the copy icon to copy it to the clipboard."
],
"whenTo": [
"Finding inspiration for a new design palette.",
"Picking a placeholder color while prototyping.",
"Selecting a random theme color for a project or presentation.",
"Teaching or exploring color theory and formats."
],
"faq": [
{ "q": "What color formats are supported?", "a": "HEX, RGB, and HSL. All three are shown at once, so you can copy the one you need." },
{ "q": "Can I use the generated color in CSS?", "a": "Yes. Copy the HEX or RGB value and paste it directly into your stylesheet or design tool." },
{ "q": "Are the colors truly random?", "a": "Yes. Each color is generated using a cryptographically secure random number generator, covering the entire color space." }
],
"ruFaq": [
{ "q": "Какие форматы цветов поддерживаются?", "a": "HEX, RGB и HSL. Все три формата отображаются одновременно — скопируйте тот, который нужен." },
{ "q": "Можно ли использовать сгенерированный цвет в CSS?", "a": "Да. Скопируйте значение HEX или RGB и вставьте его прямо в стили или графический редактор." },
{ "q": "Цвета действительно случайные?", "a": "Да. Каждый цвет генерируется с помощью криптографически стойкого генератора случайных чисел, покрывая всё цветовое пространство." }
],
"ruHowTo": [
"Нажмите «Сгенерировать», чтобы получить случайный цвет.",
"Цвет отображается в форматах HEX, RGB и HSL одновременно.",
"Нажмите на иконку копирования рядом с нужным форматом."
],
"ruWhenTo": [
"Поиск вдохновения для цветовой палитры.",
"Случайный выбор цвета для дизайна или иллюстрации.",
"Генерация тестовых данных с цветовыми значениями."
],
"faq": [
{ "q": "What color formats are supported?", "a": "HEX, RGB, and HSL. All three are shown simultaneously for every generated color." },
{ "q": "Can I copy just one format?", "a": "Yes. Click the copy icon next to any format to copy only that value." },
{ "q": "Are the colors truly random?", "a": "Yes. Each color is generated using random values for hue, saturation, and lightness." }
],
"ruFaq": [
{ "q": "Какие форматы цветов поддерживаются?", "a": "HEX, RGB и HSL. Все три отображаются одновременно для каждого сгенерированного цвета." },
{ "q": "Можно скопировать только один формат?", "a": "Да. Нажмите иконку копирования рядом с нужным форматом." },
{ "q": "Цвета действительно случайные?", "a": "Да. Каждый цвет генерируется из случайных значений оттенка, насыщенности и освещённости." }
]
}
+49
View File
@@ -0,0 +1,49 @@
{
"slug": "country",
"title": "Country",
"description": "Generate a random country with flag, capital, region, and population.",
"icon": "user",
"status": "live",
"seoTitle": "Random Country Generator — Flag, Capital, Region | Randify",
"seoDescription": "Get a random country with flag emoji, capital, region, and population. Free online country randomizer for games, quizzes, and travel inspiration.",
"ruTitle": "Страна",
"ruDescription": "Генерируйте случайную страну с флагом, столицей, регионом и населением.",
"ruSeoTitle": "Генератор случайных стран — флаг, столица, регион | Randify",
"ruSeoDescription": "Получайте случайную страну с эмодзи флага, столицей, регионом и населением. Бесплатный рандомайзер стран для игр, викторин и вдохновения.",
"pageTitle": "Random Country Generator",
"ruPageTitle": "Генератор случайных стран",
"howTo": [
"Select a region filter or leave it on All to include every country.",
"Click Generate to pick a random country from the filtered list.",
"View the result card with flag, name, capital, region, and population.",
"Click Copy to copy the country details to your clipboard."
],
"whenTo": [
"Choosing a random destination for a travel challenge or game.",
"Creating quiz questions about capitals, flags, or geography.",
"Picking a random country for a school project or presentation.",
"Settling a friendly debate with a random geographic draw."
],
"ruHowTo": [
"Выберите фильтр по региону или оставьте «Все», чтобы включить все страны.",
"Нажмите «Сгенерировать», чтобы выбрать случайную страну из отфильтрованного списка.",
"Посмотрите карточку результата с флагом, названием, столицей, регионом и населением.",
"Нажмите «Копировать», чтобы скопировать данные о стране в буфер обмена."
],
"ruWhenTo": [
"Выбор случайного направления для путешественнического челленджа или игры.",
"Создание вопросов для викторин о столицах, флагах или географии.",
"Выбор случайной страны для школьного проекта или презентации.",
"Решение спора случайным географическим выбором."
],
"faq": [
{ "q": "How many countries are in the generator?", "a": "The generator includes 40 countries from all inhabited continents, covering a wide range of regions, populations, and cultures." },
{ "q": "Can I filter by region?", "a": "Yes. You can filter by Europe, Asia, Americas, Africa, or Oceania, or choose All to include every country." },
{ "q": "Is the population data accurate?", "a": "Population figures are approximate and based on recent estimates. They are intended for casual use rather than precise research." }
],
"ruFaq": [
{ "q": "Сколько стран в генераторе?", "a": "Генератор включает 40 стран со всех обитаемых континентов, охватывая широкий спектр регионов, населения и культур." },
{ "q": "Можно ли фильтровать по региону?", "a": "Да. Вы можете фильтровать по Европе, Азии, Америке, Африке или Океании, либо выбрать «Все», чтобы включить все страны." },
{ "q": "Данные о населении актуальны?", "a": "Численность населения приблизительная и основана на недавних оценках. Она предназначена для повседневного использования, а не для точных исследований." }
]
}
+47
View File
@@ -0,0 +1,47 @@
{
"slug": "date",
"title": "Random Date",
"description": "Generate a random date between any two dates you choose.",
"icon": "calendar",
"status": "live",
"seoTitle": "Random Date Generator — Between Two Dates | Randify",
"seoDescription": "Generate a random date between any two dates instantly. Free online date picker for planning, games, and creative projects.",
"ruTitle": "Случайная дата",
"ruDescription": "Сгенерируйте случайную дату между любыми двумя датами.",
"ruSeoTitle": "Генератор случайных дат — между двумя датами | Randify",
"ruSeoDescription": "Сгенерируйте случайную дату в любом диапазоне мгновенно. Бесплатный инструмент для планирования, игр и творческих задач.",
"pageTitle": "Random Date Generator",
"ruPageTitle": "Генератор случайных дат",
"howTo": [
"Select the start date in the From field.",
"Select the end date in the To field.",
"Click Generate — a random date within your range appears instantly.",
"Click the date to copy it to your clipboard."
],
"whenTo": [
"Picking a random deadline or milestone for a challenge.",
"Choosing a date for a hypothetical scenario or story setting.",
"Generating random historical dates for a quiz or trivia game."
],
"ruHowTo": [
"Выберите начальную дату в поле «От».",
"Выберите конечную дату в поле «До».",
"Нажмите «Сгенерировать» — случайная дата в диапазоне появится мгновенно.",
"Нажмите на дату, чтобы скопировать её в буфер обмена."
],
"ruWhenTo": [
"Выбор случайного дедлайна или вехи для челленджа.",
"Определение даты для гипотетического сценария или сюжета.",
"Генерация случайных исторических дат для викторины или игры."
],
"faq": [
{ "q": "What date format is used?", "a": "Dates are displayed in a clear, locale-friendly format. You can copy the value and paste it wherever you need." },
{ "q": "Can I generate multiple random dates?", "a": "Currently, one date is generated per click. Click again to get another random date in the same range." },
{ "q": "Is the selection truly random?", "a": "Yes. The date is picked using a cryptographically secure random number generator, so every day in your range has an equal chance." }
],
"ruFaq": [
{ "q": "Какой формат даты используется?", "a": "Даты отображаются в удобном локализованном формате. Вы можете скопировать значение и вставить его куда угодно." },
{ "q": "Можно ли сгенерировать несколько дат сразу?", "a": "Сейчас генерируется одна дата за раз. Нажмите снова, чтобы получить ещё одну случайную дату в том же диапазоне." },
{ "q": "Выбор действительно случайный?", "a": "Да. Дата выбирается с помощью криптографически стойкого генератора случайных чисел, поэтому каждый день в диапазоне имеет равный шанс." }
]
}
+63
View File
@@ -0,0 +1,63 @@
{
"slug": "dice",
"title": "Dice",
"description": "Roll one or more dice with any number of sides.",
"icon": "dice-6",
"status": "live",
"seoTitle": "Dice Roller — d4 d6 d20 + Full Notation | D&D Dice | Randify",
"seoDescription": "Free online dice roller with full XdY+Z notation, keep/drop (4d6dl1), exploding dice (3d6!), reroll, advantage/disadvantage, and roll history. Perfect for D&D, Pathfinder, and Savage Worlds.",
"ruTitle": "Кубик",
"ruDescription": "Бросьте один или несколько кубиков с любым числом граней.",
"ruSeoTitle": "Бросить кубик онлайн — d4 d6 d8 d10 d12 d20 | D&D | Randify",
"ruSeoDescription": "Бесплатный онлайн-бросок кубиков с полной нотацией XdY+Z, keep/drop (4d6dl1), взрывающимися кубиками (3d6!), перебросом, преимуществом/помехой и историей. Для D&D, Pathfinder и Savage Worlds.",
"pageTitle": "Dice Generator",
"ruPageTitle": "Бросить кубик онлайн",
"howTo": [
"Type a notation like 2d6+3 or 1d20 — or use the die buttons and count.",
"Pick a mode: Normal, Advantage, or Disadvantage (single die only).",
"Click Roll — each die result appears with the total sum.",
"Maximum rolls are highlighted in purple; ones are highlighted in red."
],
"whenTo": [
"Rolling dice for D&D, Pathfinder, or any tabletop RPG.",
"Using dice notation shortcuts like 2d6+3 or 1d20 for fast rolling.",
"Rolling with advantage or disadvantage for D&D 5e mechanics.",
"Generating character stats with 4d6 drop-lowest method.",
"Rolling exploding dice for Savage Worlds or house rules (e.g. 3d6!).",
"Using keep/drop modifiers like 4d6kh3 to keep highest rolls."
],
"faq": [
{ "q": "What dice notation can I use?", "a": "You can use standard XdY+Z notation, plus modifiers like advantage, disadvantage, keep/drop (kh/kl/dl/dh), and exploding dice (!)." },
{ "q": "Is this fair for D&D and other RPGs?", "a": "Yes. Randify uses the browser's cryptographically secure random number generator, so every roll is unbiased." },
{ "q": "Can I roll multiple dice at once?", "a": "Absolutely. Enter something like 5d6 or click the die buttons and set the count to roll up to 20 dice in a single throw." }
],
"ruFaq": [
{ "q": "Какую нотацию кубиков поддерживает генератор?", "a": "Стандартную нотацию XdY+Z, а также модификаторы: преимущество, помеха, keep/drop (kh/kl/dl/dh) и взрывающиеся кубики (!)." },
{ "q": "Подходит ли генератор для D&D и других НРИ?", "a": "Да. Randify использует криптографически стойкий генератор случайных чисел браузера, поэтому каждый бросок честный и непредвзятый." },
{ "q": "Можно ли бросать несколько кубиков одновременно?", "a": "Конечно. Введите что-то вроде 5d6 или настройте кубики вручную — можно бросить до 20 кубиков за раз." }
],
"ruHowTo": [
"Введите нотацию, например 2d6+3 или 1d20 — или настройте кубики вручную.",
"Выберите режим: Обычный, Преимущество или Помеха (только для одного кубика).",
"Нажмите «Бросить» — результаты отобразятся мгновенно.",
"Максимумы подсвечены фиолетовым, единицы — красным."
],
"ruWhenTo": [
"Броски в настольных ролевых играх (D&D и другие).",
"Быстрые броски через нотацию: 2d6+3, 1d20 и любые другие.",
"Преимущество и помеха для механик D&D 5e.",
"Бросок характеристик: 4d6, отбросить наименьший (4d6dl1).",
"Взрывающиеся кубики для Savage Worlds или хаус-рулов (например, 3d6!).",
"Использование keep/drop модификаторов типа 4d6kh3 (оставить три лучших)."
],
"faq": [
{ "q": "What dice notation is supported?", "a": "Standard XdY+Z, advantage/disadvantage, keep/drop (kh/kl/dh/dl), reroll (r), and exploding dice (!)." },
{ "q": "Can I roll more than one type of die at once?", "a": "Yes. Enter multiple notations separated by spaces, like 2d6 1d20." },
{ "q": "Is the roll history saved?", "a": "Yes. Your last rolls are shown below the results until you refresh the page." }
],
"ruFaq": [
{ "q": "Какая нотация кубиков поддерживается?", "a": "Стандартная XdY+Z, преимущество/помеха, keep/drop (kh/kl/dh/dl), переброс (r) и взрывающиеся кубики (!)." },
{ "q": "Можно ли бросить несколько типов кубиков сразу?", "a": "Да. Введите несколько нотаций через пробел, например 2d6 1d20." },
{ "q": "История бросков сохраняется?", "a": "Да. Последние броски отображаются под результатами до обновления страницы." }
]
}
+49
View File
@@ -0,0 +1,49 @@
{
"slug": "emoji",
"title": "Random Emoji",
"description": "Generate random emojis by category. Pick smileys, animals, food, travel, and more.",
"icon": "smile",
"status": "live",
"seoTitle": "Random Emoji Generator — Smileys, Animals, Food & More | Randify",
"seoDescription": "Generate random emojis by category or mix them all. Pick smileys, animals, food, travel, activities, objects, and symbols. Click to copy. Free and instant.",
"ruTitle": "Случайные эмодзи",
"ruDescription": "Генерируйте случайные эмодзи по категориям. Смайлики, животные, еда, путешествия и многое другое.",
"ruSeoTitle": "Генератор случайных эмодзи — смайлики, животные, еда и другое | Randify",
"ruSeoDescription": "Генерируйте случайные эмодзи по категории или смешивайте их все. Смайлики, животные, еда, путешествия, активности, предметы и символы. Нажмите, чтобы скопировать. Бесплатно и мгновенно.",
"pageTitle": "Random Emoji Generator",
"ruPageTitle": "Генератор случайных эмодзи",
"howTo": [
"Select a category from the dropdown — or choose \"All\" to mix every category together.",
"Set how many emojis you want to generate (1–50).",
"Click Generate to get your random emojis. Click any individual emoji to copy it, or use Copy All to copy the entire set."
],
"whenTo": [
"Spicing up messages, bios, or social media posts with random emoji combos.",
"Picking a random emoji for games, quizzes, or ice-breakers.",
"Finding inspiration for emoji-based designs or creative projects.",
"Quickly copying a batch of emojis without scrolling through a picker."
],
"ruHowTo": [
"Выберите категорию из выпадающего списка — или «Все», чтобы смешать все категории.",
"Укажите, сколько эмодзи вы хотите сгенерировать (1–50).",
"Нажмите «Сгенерировать», чтобы получить случайные эмодзи. Нажмите на любой эмодзи, чтобы скопировать его, или используйте «Копировать всё», чтобы скопировать весь набор."
],
"ruWhenTo": [
"Разнообразие сообщений, биографий или постов в социальных сетях случайными комбинациями эмодзи.",
"Выбор случайного эмодзи для игр, викторин или разминок.",
"Поиск вдохновения для дизайнов на основе эмодзи или творческих проектов.",
"Быстрое копирование набора эмодзи без прокрутки через пикер."
],
"faq": [
{ "q": "How many emojis can I generate at once?", "a": "You can generate between 1 and 50 emojis in a single click. If you pick more than the category contains, we'll shuffle and return as many unique ones as available." },
{ "q": "Can I mix categories together?", "a": "Yes. Select \"All\" from the category dropdown to get a random mix from every emoji category." },
{ "q": "Are the emojis truly random?", "a": "Yes. Each generation shuffles the emoji pool and picks completely at random using the browser's built-in random number generator." },
{ "q": "Can I copy a single emoji?", "a": "Absolutely. Click any individual emoji in the result grid to copy it instantly to your clipboard." }
],
"ruFaq": [
{ "q": "Сколько эмодзи можно сгенерировать за раз?", "a": "Вы можете сгенерировать от 1 до 50 эмодзи одним нажатием. Если вы выберете больше, чем содержит категория, мы перемешаем и вернём столько уникальных, сколько доступно." },
{ "q": "Можно ли смешивать категории?", "a": "Да. Выберите «Все» в выпадающем списке категорий, чтобы получить случайную смесь из всех категорий эмодзи." },
{ "q": "Эмодзи действительно случайные?", "a": "Да. При каждой генерации набор эмодзи перемешивается и выбирается полностью случайным образом с помощью встроенного генератора случайных чисел браузера." },
{ "q": "Можно ли скопировать одно эмодзи?", "a": "Безусловно. Нажмите на любой эмодзи в сетке результатов, чтобы мгновенно скопировать его в буфер обмена." }
]
}
+67
View File
@@ -0,0 +1,67 @@
{
"slug": "fontpair",
"title": "Random Font Pairing",
"description": "Generate a random pairing of heading and body fonts from Google Fonts.",
"icon": "font",
"status": "live",
"seoTitle": "Random Font Pairing Generator — Google Fonts Combinations | Randify",
"seoDescription": "Generate random font pairings from Google Fonts for headings and body text. Discover beautiful typography combinations with one click. Free online tool for designers and developers.",
"ruTitle": "Случайная пара шрифтов",
"ruDescription": "Сгенерируйте случайную пару шрифтов для заголовков и основного текста из Google Fonts.",
"ruSeoTitle": "Генератор случайных пар шрифтов — комбинации Google Fonts | Randify",
"ruSeoDescription": "Создавайте случайные пары шрифтов из Google Fonts для заголовков и основного текста. Находите красивые типографские сочетания одним нажатием. Бесплатный онлайн-инструмент для дизайнеров и разработчиков.",
"pageTitle": "Random Font Pairing",
"ruPageTitle": "Случайная пара шрифтов",
"howTo": [
"Click Generate to create a new font pairing.",
"Preview shows a heading sample and a body text sample.",
"Use the lock icons to keep a font you like while shuffling the other.",
"Click Copy to get the HTML link tags and CSS font-family declarations."
],
"whenTo": [
"Choosing fonts for a new website or web app.",
"Exploring typography options for a design system.",
"Finding inspiration for a brand identity or logo.",
"Quickly prototyping a landing page or mockup."
],
"ruHowTo": [
"Нажмите «Сгенерировать», чтобы создать новую пару шрифтов.",
"Предпросмотр показывает образец заголовка и образец основного текста.",
"Используйте значки замка, чтобы сохранить понравившийся шрифт, меняя другой.",
"Нажмите «Копировать», чтобы получить HTML-теги link и CSS-объявления font-family."
],
"ruWhenTo": [
"Выбор шрифтов для нового сайта или веб-приложения.",
"Исследование типографских вариантов для дизайн-системы.",
"Поиск вдохновения для фирменного стиля или логотипа.",
"Быстрое прототипирование лендинга или макета."
],
"faq": [
{
"q": "What is a font pairing generator?",
"a": "A font pairing generator randomly combines two complementary fonts from Google Fonts — one for headings and one for body text — to help you discover beautiful typography combinations for your projects."
},
{
"q": "How does font pairing work?",
"a": "Good font pairing typically combines fonts with contrasting styles (e.g., a decorative serif heading with a clean sans-serif body) while maintaining visual harmony. The generator selects from curated heading and body font categories to ensure balanced combinations."
},
{
"q": "Can I use these fonts commercially?",
"a": "Yes. All fonts in this generator are from Google Fonts, which are licensed under open-source licenses (typically SIL Open Font License). You can use them freely in both personal and commercial projects."
}
],
"ruFaq": [
{
"q": "Что такое генератор пар шрифтов?",
"a": "Генератор пар шрифтов случайным образом объединяет два дополняющих друг друга шрифта из Google Fonts — один для заголовков и один для основного текста — чтобы помочь вам найти красивые типографские сочетания для ваших проектов."
},
{
"q": "Как работает подбор пар шрифтов?",
"a": "Хороший подбор пар шрифтов обычно объединяет шрифты с контрастными стилями (например, декоративный заголовочный шрифт с засечками и чистый шрифт без засечек для основного текста), сохраняя при этом визуальную гармонию. Генератор выбирает из курируемых категорий заголовочных и текстовых шрифтов, обеспечивая сбалансированные комбинации."
},
{
"q": "Можно ли использовать эти шрифты в коммерческих проектах?",
"a": "Да. Все шрифты в этом генераторе взяты из Google Fonts и лицензированы под открытыми лицензиями (обычно SIL Open Font License). Вы можете свободно использовать их как в личных, так и в коммерческих проектах."
}
]
}
+47
View File
@@ -0,0 +1,47 @@
{
"slug": "gradient",
"title": "Gradient Generator",
"description": "Generate beautiful random CSS gradients — linear, radial, and conic — with harmonious colors.",
"icon": "paintbrush",
"status": "live",
"seoTitle": "Random Gradient Generator — CSS Linear, Radial & Conic | Randify",
"seoDescription": "Generate beautiful random CSS gradients instantly. Choose linear, radial, or conic type with 2–5 harmonious color stops. Copy ready-to-use CSS code for free.",
"ruTitle": "Генератор градиентов",
"ruDescription": "Создавайте красивые случайные CSS-градиенты — линейные, радиальные и конические — с гармоничными цветами.",
"ruSeoTitle": "Генератор случайных градиентов — CSS линейный, радиальный, конический | Randify",
"ruSeoDescription": "Создавайте красивые случайные CSS-градиенты мгновенно. Выбирайте тип: линейный, радиальный или конический, с 2–5 гармоничными цветами. Копируйте готовый CSS-код бесплатно.",
"pageTitle": "Random Gradient Generator",
"ruPageTitle": "Генератор случайных градиентов",
"howTo": [
"Select the gradient type (Linear, Radial, or Conic) and the number of color stops (2–5).",
"Click Generate to create a new random gradient with harmonious colors.",
"Copy the CSS code to your clipboard, or click any individual color HEX to copy it."
],
"whenTo": [
"Designing website backgrounds, hero sections, or UI overlays.",
"Creating eye-catching social media graphics and banners.",
"Prototyping app interfaces that need vibrant color transitions.",
"Exploring color combinations and gradient styles for presentations."
],
"ruHowTo": [
"Выберите тип градиента (линейный, радиальный или конический) и количество цветовых точек (2–5).",
"Нажмите «Сгенерировать», чтобы создать новый случайный градиент с гармоничными цветами.",
"Скопируйте CSS-код в буфер обмена или нажмите на любой HEX-код цвета, чтобы скопировать его."
],
"ruWhenTo": [
"Дизайн фонов сайтов, шапок страниц или UI-оверлеев.",
"Создание яркой графики и баннеров для социальных сетей.",
"Прототипирование интерфейсов приложений с живыми цветовыми переходами.",
"Изучение сочетаний цветов и стилей градиентов для презентаций."
],
"faq": [
{ "q": "What gradient types are supported?", "a": "Linear (with a random angle), Radial (circular from a random center point), and Conic (sweeping around a center point)." },
{ "q": "How are the colors generated?", "a": "Colors are generated using HSL harmony rules — analogous hue spreads, consistent saturation (50–90%), and balanced lightness (35–65%) — so gradients always look beautiful." },
{ "q": "Can I use the CSS code directly?", "a": "Yes. The generated CSS background value is ready to paste into your stylesheet or inline style attribute." }
],
"ruFaq": [
{ "q": "Какие типы градиентов поддерживаются?", "a": "Линейный (со случайным углом), радиальный (круговой от случайной центральной точки) и конический (охватывающий вокруг центра)." },
{ "q": "Как генерируются цвета?", "a": "Цвета создаются с помощью правил гармонии HSL — распределение оттенков по аналоговой схеме, насыщенность 50–90% и сбалансированная светлота 35–65%, поэтому градиенты всегда выглядят красиво." },
{ "q": "Можно ли использовать CSS-код напрямую?", "a": "Да. Сгенерированное значение background готово для вставки в таблицу стилей или inline-атрибут style." }
]
}
+87
View File
@@ -0,0 +1,87 @@
{
"slug": "hash",
"title": "Hash Generator",
"description": "Generate MD5, SHA-1, SHA-256, or SHA-512 hashes from any text or random string.",
"icon": "hash",
"status": "live",
"seoTitle": "Hash Generator — MD5, SHA-1, SHA-256, SHA-512 Online | Randify",
"seoDescription": "Free online hash generator. Compute MD5, SHA-1, SHA-256, and SHA-512 hashes from any text or random string. Secure, browser-side processing.",
"ruTitle": "Генератор хешей",
"ruDescription": "Генерируйте хеши MD5, SHA-1, SHA-256 или SHA-512 из любого текста или случайной строки.",
"ruSeoTitle": "Генератор хешей — MD5, SHA-1, SHA-256, SHA-512 онлайн | Randify",
"ruSeoDescription": "Бесплатный онлайн-генератор хешей. Вычисляйте хеши MD5, SHA-1, SHA-256 и SHA-512 из любого текста или случайной строки. Безопасная обработка в браузере.",
"pageTitle": "Hash Generator",
"ruPageTitle": "Генератор хешей",
"howTo": [
"Choose input mode: type your own text or generate a random string.",
"Select one or more hash algorithms (MD5, SHA-1, SHA-256, SHA-512).",
"Click Generate to compute the hashes instantly in your browser.",
"Use the Compare field to check if a given hash matches any result.",
"Toggle uppercase/lowercase output format as needed."
],
"whenTo": [
"Verifying file integrity or data checksums.",
"Testing and debugging authentication or hashing logic.",
"Generating quick hash values for development workflows.",
"Comparing hashes to confirm data matches expected values.",
"Learning about different hash algorithms and their outputs."
],
"ruHowTo": [
"Выберите режим ввода: введите свой текст или сгенерируйте случайную строку.",
"Выберите один или несколько алгоритмов хеширования (MD5, SHA-1, SHA-256, SHA-512).",
"Нажмите «Сгенерировать» для мгновенного вычисления хешей в браузере.",
"Используйте поле сравнения, чтобы проверить, совпадает ли заданный хеш с результатом.",
"Переключайте формат вывода: заглавные или строчные буквы."
],
"ruWhenTo": [
"Проверка целостности файлов или контрольных сумм данных.",
"Тестирование и отладка логики аутентификации или хеширования.",
"Быстрое генерирование хешей для рабочих процессов разработки.",
"Сравнение хешей для подтверждения совпадения данных с ожидаемыми значениями.",
"Изучение различных алгоритмов хеширования и их результатов."
],
"faq": [
{
"q": "What is a hash function?",
"a": "A hash function is a mathematical algorithm that converts input data of any size into a fixed-size string of characters. It is deterministic (same input always produces the same output) and designed to be a one-way function."
},
{
"q": "Is hashing reversible?",
"a": "No, hashing is a one-way process. You cannot recover the original input from its hash value. This is what makes hashing suitable for password storage and data integrity verification."
},
{
"q": "Are my inputs sent to a server?",
"a": "No. All hash computations are performed entirely within your browser using the Web Crypto API. Your text never leaves your device, ensuring complete privacy and security."
},
{
"q": "What is the difference between MD5 and SHA-256?",
"a": "MD5 produces a 128-bit hash and is considered cryptographically broken — suitable for checksums but not security. SHA-256 produces a 256-bit hash and is currently considered secure for cryptographic purposes."
},
{
"q": "Why would I compare hashes?",
"a": "Hash comparison is commonly used to verify data integrity — for example, confirming a downloaded file matches the publisher's checksum, or verifying that a password matches a stored hash."
}
],
"ruFaq": [
{
"q": "Что такое хеш-функция?",
"a": "Хеш-функция — это математический алгоритм, который преобразует входные данные любого размера в строку фиксированной длины. Она детерминирована (одинаковый вход всегда даёт одинаковый выход) и работает только в одну сторону."
},
{
"q": "Можно ли расшифровать хеш?",
"a": "Нет, хеширование — это односторонний процесс. Невозможно восстановить исходные данные из хеш-значения. Именно поэтому хеширование используется для хранения паролей и проверки целостности данных."
},
{
"q": "Отправляются ли мои данные на сервер?",
"a": "Нет. Все вычисления хешей выполняются полностью в вашем браузере с помощью Web Crypto API. Ваш текст никогда не покидает устройство, что гарантирует полную конфиденциальность и безопасность."
},
{
"q": "В чём разница между MD5 и SHA-256?",
"a": "MD5 генерирует 128-битный хеш и считается криптографически взломанным — подходит для контрольных сумм, но не для безопасности. SHA-256 генерирует 256-битный хеш и в настоящее время считается криптографически стойким."
},
{
"q": "Зачем сравнивать хеши?",
"a": "Сравнение хешей обычно используется для проверки целостности данных — например, чтобы убедиться, что загруженный файл совпадает с контрольной суммой издателя, или чтобы проверить совпадение пароля с сохранённым хешем."
}
]
}
+47
View File
@@ -0,0 +1,47 @@
{
"slug": "letter",
"title": "Random Letter",
"description": "Pick a random letter from the alphabet.",
"icon": "type",
"status": "live",
"seoTitle": "Random Letter Generator — A to Z | Randify",
"seoDescription": "Pick a random letter from A to Z instantly. Free online letter randomizer for games, learning, and creative projects.",
"ruTitle": "Случайная буква",
"ruDescription": "Выберите случайную букву из алфавита.",
"ruSeoTitle": "Генератор случайных букв — А до Я | Randify",
"ruSeoDescription": "Выберите случайную букву алфавита мгновенно. Бесплатный рандомайзер букв для игр, обучения и творческих проектов.",
"pageTitle": "Random Letter Generator",
"ruPageTitle": "Генератор случайных букв",
"howTo": [
"Click Generate to pick a random letter from A to Z.",
"The letter appears in large type for easy reading.",
"Click the letter to copy it to your clipboard.",
"Click again to generate a new random letter."
],
"whenTo": [
"Choosing a starting letter for a word game or alphabet challenge.",
"Picking a random category in a trivia or party game.",
"Teaching children letters and the alphabet in a fun way."
],
"ruHowTo": [
"Нажмите «Сгенерировать», чтобы выбрать случайную букву от А до Я.",
"Буква отображается крупным шрифтом для удобного чтения.",
"Нажмите на букву, чтобы скопировать её в буфер обмена.",
"Нажмите снова, чтобы сгенерировать новую случайную букву."
],
"ruWhenTo": [
"Выбор стартовой буквы для словесной игры или алфавитного челленджа.",
"Случайный выбор категории в викторине или настольной игре.",
"Обучение детей буквам и алфавиту в игровой форме."
],
"faq": [
{ "q": "Does it include both uppercase and lowercase letters?", "a": "The generator produces uppercase letters by default. Lowercase is not currently supported." },
{ "q": "Can I exclude certain letters?", "a": "Not at the moment. Every letter from A to Z has an equal chance of being selected." },
{ "q": "Is this useful for language learning?", "a": "Yes. Teachers and students use it for alphabet drills, vocabulary games, and pronunciation practice." }
],
"ruFaq": [
{ "q": "Включает ли генератор заглавные и строчные буквы?", "a": "Генератор выдаёт заглавные буквы по умолчанию. Строчные буквы пока не поддерживаются." },
{ "q": "Можно ли исключить определённые буквы?", "a": "Пока нет. Каждая буква от А до Я имеет равный шанс быть выбранной." },
{ "q": "Подходит ли для изучения языка?", "a": "Да. Учителя и ученики используют генератор для алфавитных упражнений, словарных игр и практики произношения." }
]
}
+58
View File
@@ -0,0 +1,58 @@
{
"slug": "list",
"title": "List Picker",
"description": "Paste a list of items and pick random winners.",
"icon": "list",
"status": "live",
"seoTitle": "Random List Picker — Pick a Random Winner | Randify",
"seoDescription": "Paste any list of names or items and pick random winners instantly. Great for giveaways, choosing who goes first, or any random selection.",
"ruTitle": "Список",
"ruDescription": "Вставьте список элементов и выберите случайных победителей.",
"ruSeoTitle": "Случайный выбор из списка | Randify",
"ruSeoDescription": "Вставьте список имён или вариантов и выберите случайных победителей мгновенно. Идеально для розыгрышей и жеребьёвок.",
"pageTitle": "List Picker",
"ruPageTitle": "Случайный выбор из списка",
"howTo": [
"Type or paste your items into the text area — one item per line.",
"Set how many items to pick in the Pick field.",
"Toggle Allow duplicates if the same item can be chosen more than once.",
"Click Pick (or press Ctrl+Enter) — the selected items appear as chips."
],
"whenTo": [
"Choosing a random winner for a giveaway or contest.",
"Picking who presents first in a meeting or class.",
"Organising a Secret Santa or gift exchange draw.",
"Selecting a random movie, restaurant, or activity from a list."
],
"faq": [
{ "q": "Can the same item be picked more than once?", "a": "Only if you enable the 'Allow duplicates' toggle. By default, each item can be chosen only once per draw." },
{ "q": "Is there a limit to how many items I can add?", "a": "You can add up to 1,000 items. For most use cases — giveaways, raffles, or team assignments — this is more than enough." },
{ "q": "Can I pick multiple winners at once?", "a": "Yes. Set the 'Pick' field to any number up to the total count of items, and Randify will select that many winners in one go." }
],
"ruFaq": [
{ "q": "Может ли один и тот же элемент выпасть несколько раз?", "a": "Только если включён переключатель «Разрешить повторения». По умолчанию каждый элемент выбирается не более одного раза." },
{ "q": "Есть ли ограничение на количество элементов в списке?", "a": "Можно добавить до 1 000 элементов. Для розыгрышей, жеребьёвок и распределения задач этого более чем достаточно." },
{ "q": "Можно ли выбрать несколько победителей сразу?", "a": "Да. Укажите в поле «Выбрать» любое число до общего количества элементов — Randify выберет нужное количество победителей за один раз." }
],
"ruHowTo": [
"Введите список элементов — по одному на строку.",
"Укажите, сколько элементов выбрать.",
"При необходимости разрешите повторения.",
"Нажмите «Выбрать» — результаты появятся на экране."
],
"ruWhenTo": [
"Розыгрыш победителей конкурса.",
"Случайное распределение задач между участниками команды.",
"Выбор случайного варианта из списка."
],
"faq": [
{ "q": "Can the same item be picked more than once?", "a": "Only if you toggle Allow duplicates. By default, each item is picked at most once." },
{ "q": "Is there a limit to how many items I can add?", "a": "You can add up to 500 items, and pick up to the total number of unique items." },
{ "q": "Does the order of items matter?", "a": "No. The selection is completely random regardless of the order you enter items." }
],
"ruFaq": [
{ "q": "Может ли один элемент выпасть несколько раз?", "a": "Только если включена опция «Разрешить повторения». По умолчанию каждый элемент выбирается не более одного раза." },
{ "q": "Есть ли ограничение на количество элементов?", "a": "Можно добавить до 500 элементов и выбрать до общего числа уникальных элементов." },
{ "q": "Имеет ли значение порядок элементов?", "a": "Нет. Выбор полностью случайный независимо от порядка ввода." }
]
}
+47
View File
@@ -0,0 +1,47 @@
{
"slug": "lorem",
"title": "Lorem Ipsum",
"description": "Generate random Lorem Ipsum placeholder text in paragraphs.",
"icon": "type",
"status": "live",
"seoTitle": "Lorem Ipsum Generator — Free Placeholder Text | Randify",
"seoDescription": "Generate random Lorem Ipsum placeholder text instantly. Choose paragraph count and length. Free online dummy text generator for designers and developers.",
"ruTitle": "Lorem Ipsum",
"ruDescription": "Генерируйте случайный текст-заполнитель Lorem Ipsum абзацами.",
"ruSeoTitle": "Генератор Lorem Ipsum — бесплатный текст-заполнитель | Randify",
"ruSeoDescription": "Генерируйте текст-заполнитель Lorem Ipsum мгновенно. Выбирайте количество абзацев и длину. Бесплатный генератор текста для дизайнеров и разработчиков.",
"pageTitle": "Lorem Ipsum Generator",
"ruPageTitle": "Генератор Lorem Ipsum",
"howTo": [
"Choose the number of paragraphs (1–20) and the desired length: Short, Medium, or Long.",
"Optionally check \"Start with Lorem ipsum...\" to begin the first paragraph with the classic opening.",
"Click Generate to create the text, then use the Copy button to copy it to your clipboard."
],
"whenTo": [
"Filling mockups and wireframes with realistic placeholder text.",
"Testing typography, line height, and readability in designs.",
"Creating demo content for websites and applications.",
"Needing neutral Latin text that won't distract from layout."
],
"ruHowTo": [
"Выберите количество абзацев (1–20) и желаемую длину: Короткие, Средние или Длинные.",
"При желании отметьте «Начать с Lorem ipsum...», чтобы первый абзац начинался классически.",
"Нажмите «Сгенерировать», а затем «Копировать», чтобы скопировать текст в буфер обмена."
],
"ruWhenTo": [
"Заполнение макетов и каркасов реалистичным текстом-заполнителем.",
"Тестирование типографики, межстрочного интервала и читаемости в дизайнах.",
"Создание демонстрационного контента для сайтов и приложений.",
"Необходимость в нейтральном латинском тексте, который не отвлекает от макета."
],
"faq": [
{ "q": "What is Lorem Ipsum?", "a": "Lorem Ipsum is a classic placeholder text used in publishing and graphic design. It has been the industry's standard dummy text since the 1500s." },
{ "q": "Can I choose how long each paragraph is?", "a": "Yes. You can select Short (2–4 sentences), Medium (4–6 sentences), or Long (6–10 sentences) per paragraph." },
{ "q": "Is the generated text truly random?", "a": "Yes. Each paragraph is built from a shuffled pool of classic Latin words, so every generation produces unique text." }
],
"ruFaq": [
{ "q": "Что такое Lorem Ipsum?", "a": "Lorem Ipsum — это классический текст-заполнитель, используемый в издательском деле и графическом дизайне. Он является стандартным текстом-рыбой с XVI века." },
{ "q": "Можно ли выбрать длину каждого абзаца?", "a": "Да. Вы можете выбрать Короткие (2–4 предложения), Средние (4–6 предложений) или Длинные (6–10 предложений) абзацы." },
{ "q": "Сгенерированный текст действительно случайный?", "a": "Да. Каждый абзац собирается из перемешанного набора классических латинских слов, поэтому каждая генерация уникальна." }
]
}
+48
View File
@@ -0,0 +1,48 @@
{
"slug": "lottery",
"title": "Lottery",
"description": "Draw a set of unique numbers for lottery-style picks.",
"icon": "ticket",
"status": "live",
"seoTitle": "Lottery Number Generator — Random Pick | Randify",
"seoDescription": "Draw a set of unique random lottery numbers from any range. Perfect for lotteries, raffles, and lucky draws. Free online lottery picker.",
"ruTitle": "Лотерея",
"ruDescription": "Вытащите набор уникальных чисел в стиле лотереи.",
"ruSeoTitle": "Генератор лотерейных номеров | Randify",
"ruSeoDescription": "Тяните уникальные случайные числа для лотереи, розыгрыша или жеребьёвки. Бесплатный онлайн-генератор лотерейных номеров.",
"pageTitle": "Lottery Generator",
"ruPageTitle": "Генератор лотереи",
"howTo": [
"Set the From and To fields to define your number range (e.g. 1 to 49).",
"Enter how many numbers to pick in the Pick field.",
"Click Draw — a sorted set of unique numbers appears.",
"Click Copy to copy all numbers to the clipboard."
],
"whenTo": [
"Generating lottery or lotto ticket numbers.",
"Running a fair raffle draw with unique entries.",
"Selecting a random sample from a numbered list.",
"Choosing lottery numbers for Powerball, EuroMillions, or local draws."
],
"ruHowTo": [
"Укажите диапазон чисел в полях «От» и «До».",
"Задайте количество чисел для розыгрыша.",
"Нажмите «Тянуть» — выпавшие числа появятся на экране.",
"Нажмите «Копировать», чтобы сохранить результат."
],
"ruWhenTo": [
"Розыгрыш лотерейных номеров.",
"Жеребьёвка участников конкурса.",
"Случайный выбор нескольких уникальных чисел."
],
"faq": [
{ "q": "Are the numbers guaranteed to be unique?", "a": "Yes. The lottery generator always picks unique numbers within your chosen range." },
{ "q": "Can I use this for real lottery tickets?", "a": "Yes, but remember that no generator can improve your odds — each combination has the same chance." },
{ "q": "What is the maximum range?", "a": "You can set any range where the difference between From and To is at least as large as the number of picks." }
],
"ruFaq": [
{ "q": "Числа гарантированно уникальны?", "a": "Да. Генератор лотереи всегда выбирает уникальные числа в заданном диапазоне." },
{ "q": "Можно ли использовать для реальных лотерей?", "a": "Да, но помните: генератор не улучшает шансы — каждая комбинация имеет равную вероятность." },
{ "q": "Какой максимальный диапазон?", "a": "Любой диапазон, где разница между «От» и «До» не меньше количества выбираемых чисел." }
]
}
+89
View File
@@ -0,0 +1,89 @@
{
"slug": "magic8ball",
"title": "Magic 8 Ball",
"description": "Ask the Magic 8 Ball a yes/no question and get a mystical answer. Classic fortune-telling toy with 20 possible responses.",
"icon": "sparkles",
"status": "live",
"seoTitle": "Magic 8 Ball Online — Ask a Question | Randify",
"seoDescription": "Ask the online Magic 8 Ball any yes/no question and get a classic fortune-telling answer. 20 mystical responses — positive, neutral, and negative. Free and fun!",
"ruTitle": "Магический шар 8",
"ruDescription": "Задайте магическому шару вопрос с ответом да/нет и получите мистический ответ. Классическая игрушка-предсказатель с 20 вариантами ответов.",
"ruSeoTitle": "Магический шар 8 онлайн — задай вопрос | Randify",
"ruSeoDescription": "Задайте онлайн-шару вопрос с ответом да/нет и получите классический мистический ответ. 20 вариантов — положительные, нейтральные и отрицательные. Бесплатно и весело!",
"pageTitle": "Magic 8 Ball",
"ruPageTitle": "Магический шар 8",
"howTo": [
"Type a yes/no question into the input field (optional — just for fun).",
"Click the \"Ask the Ball\" button to shake the Magic 8 Ball.",
"Watch the ball shake and reveal its mystical answer in the blue triangle window.",
"The answer color indicates the type: blue for positive, yellow for neutral, red for negative.",
"Click the copy button to save the answer to your clipboard.",
"Check the history below to see your last 5 questions and answers."
],
"whenTo": [
"Having fun with friends at a party or gathering.",
"Making light-hearted decisions when you are feeling indecisive.",
"Adding a mystical element to a game or role-playing session.",
"Breaking the ice with a fun interactive activity.",
"Recreating the nostalgic experience of the classic Magic 8 Ball toy."
],
"ruHowTo": [
"Введите вопрос с ответом да/нет в поле (по желанию — просто для развлечения).",
"Нажмите кнопку «Спросить шар», чтобы встряхнуть магический шар.",
"Наблюдайте, как шар встряхивается и раскрывает мистический ответ в синем треугольнике.",
"Цвет ответа указывает на тип: синий — положительный, жёлтый — нейтральный, красный — отрицательный.",
"Нажмите кнопку копирования, чтобы сохранить ответ в буфер обмена.",
"Посмотрите историю ниже, чтобы увидеть ваши последние 5 вопросов и ответов."
],
"ruWhenTo": [
"Развлечение с друзьями на вечеринке или собрании.",
"Принятие лёгких решений, когда вы сомневаетесь.",
"Добавление мистического элемента в игру или ролевую сессию.",
"Разрядка обстановки с помощью весёлой интерактивной активности.",
"Воспроизведение ностальгического опыта классической игрушки Магический шар 8."
],
"faq": [
{
"q": "How many answers does the Magic 8 Ball have?",
"a": "The Magic 8 Ball has 20 possible answers: 10 positive (blue), 5 neutral (yellow), and 5 negative (red). Each answer is chosen completely at random."
},
{
"q": "Is the Magic 8 Ball really random?",
"a": "Yes! The answer is selected using a cryptographically secure random number generator, giving each of the 20 responses an equal chance."
},
{
"q": "Do I need to type a question?",
"a": "No, the question field is purely optional and for fun. The ball does not read or process your question — it simply picks a random answer each time you shake it."
},
{
"q": "What do the colors mean?",
"a": "Blue means a positive/affirmative answer, yellow means a neutral or uncertain answer, and red means a negative answer. This mirrors the classic Magic 8 Ball experience."
},
{
"q": "Can I use this for serious decisions?",
"a": "The Magic 8 Ball is intended for entertainment purposes only. For important life decisions, please rely on careful thought and professional advice."
}
],
"ruFaq": [
{
"q": "Сколько ответов у магического шара?",
"a": "Магический шар имеет 20 возможных ответов: 10 положительных (синих), 5 нейтральных (жёлтых) и 5 отрицательных (красных). Каждый ответ выбирается полностью случайным образом."
},
{
"q": "Шар действительно случайный?",
"a": "Да! Ответ выбирается с помощью криптографически стойкого генератора случайных чисел, давая каждому из 20 вариантов равный шанс."
},
{
"q": "Нужно ли вводить вопрос?",
"a": "Нет, поле вопроса является полностью необязательным и существует для развлечения. Шар не читает и не обрабатывает ваш вопрос — он просто выбирает случайный ответ при каждом встряхивании."
},
{
"q": "Что означают цвета?",
"a": "Синий означает положительный/утвердительный ответ, жёлтый — нейтральный или неопределённый ответ, красный — отрицательный ответ. Это повторяет классический опыт магического шара."
},
{
"q": "Можно ли использовать шар для серьёзных решений?",
"a": "Магический шар предназначен только для развлечения. Для важных жизненных решений полагайтесь на внимательное обдумывание и профессиональные консультации."
}
]
}
+57
View File
@@ -0,0 +1,57 @@
{
"slug": "meal",
"title": "Random Meal Idea",
"description": "Get a random meal or recipe idea when you don't know what to cook.",
"icon": "utensils",
"status": "live",
"seoTitle": "Random Meal Idea Generator — Recipe Inspiration | Randify",
"seoDescription": "Discover random meal and recipe ideas with cuisine, difficulty, time, ingredients, and calories. Filters for type, cuisine, difficulty & cooking time.",
"ruTitle": "Идея для блюда",
"ruDescription": "Получите идею для блюда или рецепта, когда не знаете, что приготовить.",
"ruSeoTitle": "Генератор идей для блюд — вдохновение для рецептов | Randify",
"ruSeoDescription": "Открывайте случайные идеи для блюд и рецептов с кухней, сложностью, временем, ингредиентами и калориями. Фильтры по типу, кухне, сложности и времени приготовления.",
"pageTitle": "Random Meal Idea Generator",
"ruPageTitle": "Генератор идей для блюд",
"howTo": [
"Use the filters to narrow down by meal type, cuisine, difficulty, or cooking time.",
"Click the Generate button to get a random meal idea matching your filters.",
"View the result card with description, ingredients, calories, and dietary info.",
"Click Copy to save the meal details to your clipboard.",
"Click the star icon to save meals to your favorites for later."
],
"whenTo": [
"When you don't know what to cook for breakfast, lunch, or dinner.",
"Meal planning for the week ahead.",
"Looking for new recipe ideas from different cuisines.",
"Challenging yourself to try a new dish or cooking technique.",
"Planning a themed dinner party with friends or family."
],
"ruHowTo": [
"Используйте фильтры, чтобы сузить выбор по типу блюда, кухне, сложности или времени приготовления.",
"Нажмите кнопку «Сгенерировать», чтобы получить случайную идею для блюда по вашим фильтрам.",
"Посмотрите карточку результата с описанием, ингредиентами, калориями и информацией о диете.",
"Нажмите «Копировать», чтобы сохранить детали блюда в буфер обмена.",
"Нажмите на иконку звезды, чтобы сохранить блюда в избранное на потом."
],
"ruWhenTo": [
"Когда вы не знаете, что приготовить на завтрак, обед или ужин.",
"Планирование питания на неделю вперед.",
"Поиск новых идей рецептов из разных кухонь мира.",
"Бросая себе вызов попробовать новое блюдо или технику приготовления.",
"Организация тематического ужина с друзьями или семьей."
],
"faq": [
{ "q": "How many meals are in the generator?", "a": "The generator includes over 80 meals across 9 world cuisines, 5 meal types, and 3 difficulty levels. New meals are added regularly." },
{ "q": "Can I filter by dietary restrictions?", "a": "Yes. Meals are tagged with dietary info including Vegetarian, Vegan, Gluten-Free, and Spicy. You can see these badges on each result card." },
{ "q": "How do I save my favorite meals?", "a": "Click the star icon on any result card to save it to your favorites. Your saved meals are stored in your browser's localStorage and persist between visits." },
{ "q": "Can I get a list of ingredients?", "a": "Yes. Each meal result shows 4–6 key ingredients as tags. Click Copy to get the full meal name, description, and ingredient list." },
{ "q": "Are the calorie counts accurate?", "a": "Calorie estimates are approximate and based on standard serving sizes. They are intended as a general guide, not precise nutritional data." }
],
"ruFaq": [
{ "q": "Сколько блюд в генераторе?", "a": "Генератор включает более 80 блюд из 9 кухонь мира, 5 типов приемов пищи и 3 уровней сложности. Новые блюда добавляются регулярно." },
{ "q": "Можно ли фильтровать по диетическим ограничениям?", "a": "Да. Блюда помечены диетической информацией, включая вегетарианские, веганские, безглютеновые и острые. Эти значки видны на каждой карточке результата." },
{ "q": "Как сохранить избранные блюда?", "a": "Нажмите на иконку звезды на любой карточке результата, чтобы сохранить ее в избранное. Сохраненные блюда хранятся в localStorage вашего браузера и сохраняются между визитами." },
{ "q": "Можно ли получить список ингредиентов?", "a": "Да. Каждый результат показывает 4–6 ключевых ингредиентов в виде тегов. Нажмите «Копировать», чтобы получить полное название блюда, описание и список ингредиентов." },
{ "q": "Насколько точны данные о калориях?", "a": "Оценки калорийности приблизительны и основаны на стандартных порциях. Они предназначены для общего ориентира, а не для точных диетических расчетов." }
]
}
+47
View File
@@ -0,0 +1,47 @@
{
"slug": "names",
"title": "Random Name",
"description": "Generate a random first name by gender or any category.",
"icon": "user",
"status": "live",
"seoTitle": "Random Name Generator — First Names | Randify",
"seoDescription": "Generate random first names instantly. Pick male, female, or any names for characters, babies, games, and more.",
"ruTitle": "Случайное имя",
"ruDescription": "Сгенерируйте случайное имя по полу или из общего списка.",
"ruSeoTitle": "Генератор случайных имён | Randify",
"ruSeoDescription": "Генератор случайных имён: мужские, женские, любые. Для персонажей, игр, вдохновения и не только.",
"pageTitle": "Random Name Generator",
"ruPageTitle": "Генератор случайных имён",
"howTo": [
"Select a category: Male, Female, or Any.",
"Click Generate to produce a random name.",
"The name appears instantly on the screen.",
"Click the name to copy it to your clipboard."
],
"whenTo": [
"Naming a character in a story, game, or RPG campaign.",
"Looking for baby name inspiration.",
"Generating placeholder names for mockups or test data."
],
"ruHowTo": [
"Выберите категорию: Мужские, Женские или Любые.",
"Нажмите «Сгенерировать», чтобы получить случайное имя.",
"Имя появится на экране мгновенно.",
"Нажмите на имя, чтобы скопировать его в буфер обмена."
],
"ruWhenTo": [
"Придумывание имени персонажа для рассказа, игры или НРИ.",
"Поиск вдохновения для имени ребёнка.",
"Генерация тестовых данных с реалистичными именами."
],
"faq": [
{ "q": "How many names are in the database?", "a": "Hundreds of popular first names are included, covering common choices in English and Russian." },
{ "q": "Can I generate multiple names at once?", "a": "Currently, one name is shown per click. You can click Generate as many times as you like." },
{ "q": "Are the names culturally specific?", "a": "The list includes widely used names. Some may be more common in certain regions, but all are familiar internationally." }
],
"ruFaq": [
{ "q": "Сколько имён в базе?", "a": "В базе сотни популярных имён, включая распространённые варианты на английском и русском языках." },
{ "q": "Можно ли сгенерировать несколько имён сразу?", "a": "Сейчас показывается одно имя за раз. Вы можете нажимать «Сгенерировать» столько раз, сколько нужно." },
{ "q": "Имена привязаны к конкретной культуре?", "a": "В списке широко распространённые имена. Некоторые чаще встречаются в определённых регионах, но все они международно узнаваемы." }
]
}
+51
View File
@@ -0,0 +1,51 @@
{
"slug": "numbers",
"title": "Number",
"description": "Pick a random number within any range you choose.",
"icon": "hash",
"status": "live",
"seoTitle": "Random Number Generator | Randify",
"seoDescription": "Generate a random number between any two values instantly. Free online random number picker — perfect for giveaways, games, and decisions.",
"ruTitle": "Число",
"ruDescription": "Выберите случайное число в любом диапазоне.",
"ruSeoTitle": "Генератор случайных чисел | Randify",
"ruSeoDescription": "Генерируйте случайное число в любом диапазоне мгновенно. Идеально для розыгрышей, игр и принятия решений.",
"pageTitle": "Number Generator",
"ruPageTitle": "Генератор чисел",
"howTo": [
"Enter the minimum value in the From field.",
"Enter the maximum value in the To field.",
"Click Generate — a random number within your range appears instantly.",
"Click the number to copy it to the clipboard."
],
"whenTo": [
"Picking a winner in a giveaway or raffle.",
"Choosing a random starting player in a board game.",
"Making a quick decision between numbered options.",
"Generating test data with values in a specific range.",
"Assigning random IDs or order numbers to items."
],
"ruHowTo": [
"Введите минимальное значение в поле «От».",
"Введите максимальное значение в поле «До».",
"Нажмите «Сгенерировать» — случайное число появится мгновенно.",
"Нажмите на число, чтобы скопировать его в буфер обмена."
],
"ruWhenTo": [
"Выбор победителя в розыгрыше или конкурсе.",
"Определение случайного первого игрока в настольной игре.",
"Быстрое принятие решения между пронумерованными вариантами.",
"Генерация тестовых данных с числами в заданном диапазоне.",
"Назначение случайных номеров или порядковых идентификаторов."
],
"faq": [
{ "q": "Is the number truly random?", "a": "Yes. Randify uses your browser's built-in random function, which produces unpredictable results every time." },
{ "q": "Can I generate negative numbers?", "a": "Absolutely. Just enter a negative value in the From field and a positive or negative value in the To field." },
{ "q": "Is there a limit to the range?", "a": "You can use any whole numbers that JavaScript supports — safely up to about 9 quadrillion." }
],
"ruFaq": [
{ "q": "Число действительно случайное?", "a": "Да. Randify использует встроенную функцию случайности браузера, которая каждый раз выдаёт непредсказуемый результат." },
{ "q": "Можно ли генерировать отрицательные числа?", "a": "Конечно. Введите отрицательное значение в поле «От» и любое значение в поле «До»." },
{ "q": "Есть ли ограничение на диапазон?", "a": "Можно использовать любые целые числа, которые поддерживает JavaScript — безопасно до примерно 9 квадриллионов." }
]
}
+47
View File
@@ -0,0 +1,47 @@
{
"slug": "palette",
"title": "Color Palette",
"description": "Generate harmonious color palettes with one click.",
"icon": "palette",
"status": "live",
"seoTitle": "Color Palette Generator — Random & Harmonic Palettes | Randify",
"seoDescription": "Generate beautiful color palettes instantly. Choose from Random, Analogous, Complementary, Triadic, and Monochromatic harmony modes. Free online tool for designers.",
"ruTitle": "Палитра цветов",
"ruDescription": "Генерируйте гармоничные палитры цветов одним нажатием.",
"ruSeoTitle": "Генератор палитры цветов — случайные и гармоничные палитры | Randify",
"ruSeoDescription": "Создавайте красивые палитры цветов мгновенно. Выбирайте режимы гармонии: случайный, аналоговый, комплементарный, триадный, монохромный. Бесплатный онлайн-инструмент для дизайнеров.",
"pageTitle": "Color Palette Generator",
"ruPageTitle": "Генератор палитры цветов",
"howTo": [
"Select the number of colors and a harmony mode.",
"Click Generate to create a new palette.",
"Click any color swatch to copy its HEX code, or use Copy All to copy the entire palette."
],
"whenTo": [
"Designing a brand identity or logo color scheme.",
"Choosing colors for a website or app UI.",
"Creating illustrations, presentations, or social media graphics.",
"Exploring color theory and harmony relationships."
],
"ruHowTo": [
"Выберите количество цветов и режим гармонии.",
"Нажмите «Сгенерировать», чтобы создать новую палитру.",
"Нажмите на любой цвет, чтобы скопировать его HEX-код, или используйте «Копировать всё»."
],
"ruWhenTo": [
"Разработка фирменного стиля или цветовой схемы логотипа.",
"Выбор цветов для дизайна сайта или приложения.",
"Создание иллюстраций, презентаций или графики для соцсетей.",
"Изучение теории цвета и цветовых гармоний."
],
"faq": [
{ "q": "What harmony modes are available?", "a": "Random, Analogous, Complementary, Triadic, and Monochromatic. Each mode creates colors based on a different relationship on the color wheel." },
{ "q": "Can I copy the whole palette at once?", "a": "Yes. Click the Copy All button to copy all HEX codes separated by commas to your clipboard." },
{ "q": "How many colors can I generate?", "a": "You can generate palettes from 3 to 8 colors using the count selector." }
],
"ruFaq": [
{ "q": "Какие режимы гармонии доступны?", "a": "Случайный, аналоговый, комплементарный, триадный и монохромный. Каждый режим создаёт цвета на основе разных отношений на цветовом круге." },
{ "q": "Можно ли скопировать всю палитру сразу?", "a": "Да. Нажмите кнопку «Копировать всё», чтобы скопировать все HEX-коды через запятую в буфер обмена." },
{ "q": "Сколько цветов можно сгенерировать?", "a": "Вы можете создавать палитры от 3 до 8 цветов с помощью селектора количества." }
]
}
+50
View File
@@ -0,0 +1,50 @@
{
"slug": "password",
"title": "Password",
"description": "Create a strong, random password with custom rules.",
"icon": "lock",
"status": "live",
"seoTitle": "Random Password Generator | Randify",
"seoDescription": "Create a strong random password with custom length, uppercase, lowercase, digits and symbols. Free and secure — runs entirely in your browser.",
"ruTitle": "Пароль",
"ruDescription": "Создайте надёжный случайный пароль с настраиваемыми правилами.",
"ruSeoTitle": "Генератор случайных паролей | Randify",
"ruSeoDescription": "Создавайте надёжные пароли с заглавными буквами, цифрами и символами. Бесплатно и безопасно — работает прямо в браузере.",
"pageTitle": "Password Generator",
"ruPageTitle": "Генератор паролей",
"howTo": [
"Drag the length slider to set how many characters you need (4–64).",
"Toggle uppercase, lowercase, digits, and symbols on or off.",
"Click Generate — a new password appears instantly.",
"Click the password to copy it to your clipboard."
],
"whenTo": [
"Creating a strong password for a new account.",
"Generating a temporary access credential.",
"Producing a random secret key for development or testing.",
"Replacing a weak or reused password.",
"Setting up a secure Wi-Fi passphrase."
],
"ruHowTo": [
"Задайте длину пароля с помощью ползунка.",
"Выберите типы символов: заглавные, строчные, цифры, спецсимволы.",
"Нажмите «Сгенерировать» — готовый пароль появится на экране.",
"Нажмите на пароль, чтобы скопировать его."
],
"ruWhenTo": [
"Создание надёжного пароля для нового аккаунта.",
"Генерация секретного ключа или токена.",
"Регулярное обновление паролей для повышения безопасности.",
"Создание сложного пароля для Wi-Fi сети."
],
"faq": [
{ "q": "Are the passwords stored anywhere?", "a": "No. Passwords are generated locally in your browser and never leave your device." },
{ "q": "How strong are the generated passwords?", "a": "Strength depends on length and character types. A 16-character password with all types enabled is considered very strong." },
{ "q": "Can I use these passwords for banking?", "a": "Yes, but always follow your bank's specific requirements and consider using a dedicated password manager." }
],
"ruFaq": [
{ "q": "Пароли где-то сохраняются?", "a": "Нет. Пароли генерируются локально в браузере и никогда не покидают ваше устройство." },
{ "q": "Насколько надёжны сгенерированные пароли?", "a": "Надёжность зависит от длины и наборов символов. Пароль из 16 символов со всеми типами считается очень сильным." },
{ "q": "Можно ли использовать эти пароли для банкинга?", "a": "Да, но всегда учитывайте требования вашего банка и рассмотрите использование специального менеджера паролей." }
]
}
+47
View File
@@ -0,0 +1,47 @@
{
"slug": "rps",
"title": "Rock Paper Scissors",
"description": "Play Rock Paper Scissors against a random computer opponent.",
"icon": "hand",
"status": "live",
"seoTitle": "Rock Paper Scissors — Play Online | Randify",
"seoDescription": "Play Rock Paper Scissors online against a random computer opponent. Free, instant, and fair — no account needed.",
"ruTitle": "Камень Ножницы Бумага",
"ruDescription": "Сыграйте в Камень Ножницы Бумага против случайного компьютерного соперника.",
"ruSeoTitle": "Камень Ножницы Бумага — играть онлайн | Randify",
"ruSeoDescription": "Играйте в Камень Ножницы Бумага онлайн против компьютера. Бесплатно, мгновенно и честно — без регистрации.",
"pageTitle": "Rock Paper Scissors",
"ruPageTitle": "Камень Ножницы Бумага",
"howTo": [
"Choose your move: Rock, Paper, or Scissors.",
"Click Play — the computer makes its random choice.",
"The result is shown instantly: win, lose, or draw.",
"Play again as many times as you like."
],
"whenTo": [
"Making a quick decision with a friend when neither wants to choose.",
"Killing time with a simple, familiar game.",
"Settling a tie or breaking a deadlock in any activity."
],
"ruHowTo": [
"Выберите свой ход: Камень, Ножницы или Бумага.",
"Нажмите «Играть» — компьютер делает случайный выбор.",
"Результат отображается мгновенно: победа, поражение или ничья.",
"Играйте снова сколько угодно раз."
],
"ruWhenTo": [
"Быстрое решение спора с другом, когда никто не хочет выбирать первым.",
"Коротание времени в простой и знакомой игре.",
"Разрешение ничьей или выход из тупика в любой активности."
],
"faq": [
{ "q": "Is the computer choice truly random?", "a": "Yes. The computer's move is generated using a cryptographically secure random number generator, making every round fair and unpredictable." },
{ "q": "Can I play multiple rounds and track my score?", "a": "Currently, each round is independent. A score tracker may be added in a future update." },
{ "q": "Does the computer learn from my moves?", "a": "No. Each move is completely independent and random. The computer does not adapt or remember your previous choices." }
],
"ruFaq": [
{ "q": "Выбор компьютера действительно случайный?", "a": "Да. Ход компьютера генерируется с помощью криптографически стойкого генератора случайных чисел, делая каждый раунд честным и непредсказуемым." },
{ "q": "Можно ли играть несколько раундов и вести счёт?", "a": "Сейчас каждый раунд независим. Счётчик побед может появиться в будущем обновлении." },
{ "q": "Учится ли компьютер на моих ходах?", "a": "Нет. Каждый ход полностью независимый и случайный. Компьютер не адаптируется и не запоминает ваши предыдущие выборы." }
]
}
+67
View File
@@ -0,0 +1,67 @@
{
"slug": "shuffler",
"title": "Sequence Shuffler",
"description": "Shuffle any list of items into a random order instantly.",
"icon": "shuffle",
"status": "live",
"seoTitle": "Sequence Shuffler — Randomize Any List | Randify",
"seoDescription": "Paste a list of items and shuffle them into a completely random order. Perfect for randomizing names, tasks, playlist order, or any sequence.",
"ruTitle": "Перемешиватель",
"ruDescription": "Мгновенно перемешайте любой список элементов в случайном порядке.",
"ruSeoTitle": "Перемешиватель списка — случайный порядок | Randify",
"ruSeoDescription": "Вставьте список элементов и перемешайте их в случайном порядке. Идеально для случайного распределения имён, задач, порядка воспроизведения или любой последовательности.",
"pageTitle": "Sequence Shuffler",
"ruPageTitle": "Перемешиватель списка",
"howTo": [
"Type or paste your items into the text area — one item per line.",
"Click Shuffle to randomize the order of all items.",
"The shuffled list appears as a numbered sequence.",
"Click Copy to copy the shuffled list back to your clipboard."
],
"whenTo": [
"Randomizing the order of presentation slides or talking points.",
"Shuffling a playlist, reading list, or queue of items.",
"Creating a random task order for a team or group activity.",
"Fairly deciding the speaking order in meetings or classes."
],
"faq": [
{
"q": "Is the shuffle truly random?",
"a": "Yes. We use the Fisher-Yates shuffle algorithm powered by the browser's cryptographically secure random number generator, giving every item an equal chance to end up in any position."
},
{
"q": "Is there a limit to how many items I can shuffle?",
"a": "You can shuffle up to 1,000 items at once. For most everyday use cases this is more than enough."
},
{
"q": "Can I shuffle the same list multiple times?",
"a": "Absolutely. Click Shuffle as many times as you like — each time you'll get a completely new random order."
}
],
"ruHowTo": [
"Введите или вставьте элементы в текстовое поле — по одному на строку.",
"Нажмите «Перемешать», чтобы случайным образом изменить порядок всех элементов.",
"Перемешанный список отобразится в виде пронумерованной последовательности.",
"Нажмите «Копировать», чтобы скопировать перемешанный список в буфер обмена."
],
"ruWhenTo": [
"Случайный порядок слайдов или тем для обсуждения.",
"Перемешивание плейлиста, списка для чтения или очереди.",
"Создание случайного порядка задач для команды или групповой активности.",
"Честное определение порядка выступлений на собраниях или в классе."
],
"ruFaq": [
{
"q": "Перемешивание действительно случайное?",
"a": "Да. Мы используем алгоритм Фишера-Йетса на основе криптографически стойкого генератора случайных чисел браузера, что даёт каждому элементу равные шансы оказаться на любой позиции."
},
{
"q": "Есть ли ограничение на количество элементов?",
"a": "Можно перемешать до 1 000 элементов за раз. Для большинства задач этого более чем достаточно."
},
{
"q": "Можно ли перемешать один и тот же список несколько раз?",
"a": "Конечно. Нажимайте «Перемешать» столько раз, сколько хотите — каждый раз вы получите совершенно новый случайный порядок."
}
]
}
+47
View File
@@ -0,0 +1,47 @@
{
"slug": "teams",
"title": "Team Splitter",
"description": "Split a list of people into random teams or groups.",
"icon": "users",
"status": "live",
"seoTitle": "Random Team Generator — Split into Groups | Randify",
"seoDescription": "Split any list of names into random teams instantly. Perfect for sports, workshops, classrooms, and party games.",
"ruTitle": "Разделение на команды",
"ruDescription": "Разделите список людей на случайные команды или группы.",
"ruSeoTitle": "Генератор случайных команд — разделение на группы | Randify",
"ruSeoDescription": "Разделите любой список имён на случайные команды мгновенно. Для спорта, занятий, воркшопов и вечеринок.",
"pageTitle": "Random Team Generator",
"ruPageTitle": "Генератор случайных команд",
"howTo": [
"Enter the list of participants — one name per line.",
"Set the number of teams you want to create.",
"Click Generate — the tool shuffles and splits the list evenly.",
"Review the teams and copy or share the results."
],
"whenTo": [
"Organising random teams for a sports match or tournament.",
"Splitting a class or workshop into balanced groups.",
"Creating teams for a party game or team-building activity."
],
"ruHowTo": [
"Введите список участников — по одному имени на строку.",
"Укажите количество команд, которые нужно создать.",
"Нажмите «Сгенерировать» — инструмент перемешает и равномерно разделит список.",
"Просмотрите команды и скопируйте или поделитесь результатами."
],
"ruWhenTo": [
"Формирование случайных команд для спортивной игры или турнира.",
"Разделение класса или воркшопа на сбалансированные группы.",
"Создание команд для игры на вечеринке или тимбилдинга."
],
"faq": [
{ "q": "What happens if the list cannot be divided evenly?", "a": "Teams are created as evenly as possible. If there is a remainder, some teams will have one extra member." },
{ "q": "Is there a limit to the number of participants?", "a": "You can add up to 500 names. For most events and activities, this limit is more than sufficient." },
{ "q": "Can I use this for non-name lists?", "a": "Yes. You can split any text items — tasks, topics, or objects — into random groups." }
],
"ruFaq": [
{ "q": "Что если список нельзя разделить поровну?", "a": "Команды формируются максимально равномерно. Если остаётся остаток, некоторые команды будут на одного участника больше." },
{ "q": "Есть ли ограничение на количество участников?", "a": "Можно добавить до 500 имён. Для большинства мероприятий и активностей этого более чем достаточно." },
{ "q": "Можно ли использовать не только для имён?", "a": "Да. Вы можете разделить на группы любые текстовые элементы — задачи, темы или предметы." }
]
}
+53
View File
@@ -0,0 +1,53 @@
{
"slug": "time",
"title": "Random Time",
"description": "Generate a random time of day with a beautiful analog clock display.",
"icon": "clock",
"status": "live",
"seoTitle": "Random Time Generator — with Analog Clock | Randify",
"seoDescription": "Generate a random time of day between any hours. Choose 12h or 24h format, set intervals, and see a beautiful analog clock display. Free online tool.",
"ruTitle": "Случайное время",
"ruDescription": "Генерируйте случайное время суток с красивым аналоговым циферблатом.",
"ruSeoTitle": "Генератор случайного времени — с аналоговыми часами | Randify",
"ruSeoDescription": "Генерируйте случайное время суток в любом диапазоне. Выбирайте 12-часовой или 24-часовой формат, задавайте интервалы и смотрите на аналоговый циферблат. Бесплатный онлайн-инструмент.",
"pageTitle": "Random Time Generator",
"ruPageTitle": "Генератор случайного времени",
"howTo": [
"Set the start time in the From field and the end time in the To field.",
"Choose the time interval (every minute, 5 min, 15 min, 30 min, or hourly).",
"Toggle between 12-hour and 24-hour format as needed.",
"Click Generate — a random time appears with an analog clock display.",
"Click the time to copy it to your clipboard."
],
"whenTo": [
"Picking a random meeting time or schedule slot.",
"Creating random scenarios for creative writing or games.",
"Generating a random alarm or reminder time for a challenge.",
"Simulating realistic timestamps for testing or prototyping."
],
"ruHowTo": [
"Задайте начальное время в поле «От» и конечное время в поле «До».",
"Выберите интервал времени (каждую минуту, 5 мин, 15 мин, 30 мин или каждый час).",
"Переключайтесь между 12-часовым и 24-часовым форматом при необходимости.",
"Нажмите «Сгенерировать» — случайное время появится с аналоговым циферблатом.",
"Нажмите на время, чтобы скопировать его в буфер обмена."
],
"ruWhenTo": [
"Выбор случайного времени для встречи или расписания.",
"Создание случайных сценариев для творческого письма или игр.",
"Генерация случайного времени будильника или напоминания для челленджа.",
"Имитация реалистичных временных меток для тестирования или прототипирования."
],
"faq": [
{ "q": "Can I switch between 12-hour and 24-hour format?", "a": "Yes. Use the format toggle to switch between 12-hour (AM/PM) and 24-hour display at any time." },
{ "q": "What time intervals are available?", "a": "You can choose between every minute, every 5 minutes, every 15 minutes, every 30 minutes, or hourly intervals." },
{ "q": "Can I set any time range?", "a": "Yes. Set any From and To time, as long as From is earlier than To. The generator will pick a random time within that range." },
{ "q": "Is the generated time truly random?", "a": "Yes. Times are generated using cryptographically secure random numbers, giving every valid time in your range an equal chance." }
],
"ruFaq": [
{ "q": "Можно ли переключаться между 12-часовым и 24-часовым форматом?", "a": "Да. Используйте переключатель формата для выбора между 12-часовым (AM/PM) и 24-часовым отображением в любое время." },
{ "q": "Какие интервалы времени доступны?", "a": "Вы можете выбрать интервалы: каждую минуту, каждые 5 минут, каждые 15 минут, каждые 30 минут или каждый час." },
{ "q": "Можно ли задать любой диапазон времени?", "a": "Да. Задайте любое время «От» и «До», главное чтобы «От» было раньше «До». Генератор выберет случайное время в этом диапазоне." },
{ "q": "Сгенерированное время действительно случайное?", "a": "Да. Время генерируется с помощью криптографически стойких случайных чисел, давая каждому допустимому времени в диапазоне равный шанс." }
]
}
+48
View File
@@ -0,0 +1,48 @@
{
"slug": "uuid",
"title": "UUID / Token",
"description": "Generate UUIDs, hex tokens, or random base64 strings.",
"icon": "fingerprint",
"status": "live",
"seoTitle": "UUID Generator — UUID v4, Hex & Base64 Tokens | Randify",
"seoDescription": "Generate random UUID v4, hex tokens, or base64 strings online. Cryptographically secure, runs in your browser. Free UUID and token generator.",
"ruTitle": "UUID / Токен",
"ruDescription": "Генерируйте UUID, hex-токены или случайные Base64-строки.",
"ruSeoTitle": "Генератор UUID и токенов | Randify",
"ruSeoDescription": "Генерируйте UUID v4, hex-токены и Base64-строки онлайн. Криптографически безопасно, работает в браузере.",
"pageTitle": "UUID / Token",
"ruPageTitle": "Генератор UUID и токенов",
"howTo": [
"Choose a token type: UUID v4, Hex, or Base64.",
"For Hex and Base64, set the byte length (4–64 bytes).",
"Enter how many tokens to generate (1–20).",
"Click Generate, then Copy all to copy every token to the clipboard."
],
"whenTo": [
"Generating unique IDs for database records or API resources.",
"Creating random session tokens or API keys during development.",
"Producing test data with realistic-looking identifiers.",
"Generating a one-time secret for environment variables or config files."
],
"ruHowTo": [
"Выберите тип: UUID v4, Hex или Base64.",
"Для Hex и Base64 задайте длину в байтах.",
"Укажите количество токенов.",
"Нажмите «Сгенерировать» и скопируйте результат."
],
"ruWhenTo": [
"Генерация уникальных идентификаторов для баз данных.",
"Создание токенов для API и сессий.",
"Генерация случайных ключей шифрования."
],
"faq": [
{ "q": "Are these UUIDs cryptographically secure?", "a": "Yes. UUID v4 is generated using the browser's crypto.getRandomValues, which is cryptographically secure." },
{ "q": "What is the difference between UUID, Hex, and Base64?", "a": "UUID v4 is a 128-bit standard format. Hex is raw random bytes in hexadecimal. Base64 is the same bytes encoded for compactness." },
{ "q": "Can I use these tokens in production?", "a": "UUID v4 tokens are safe for production. For Hex and Base64, the security depends on the byte length you choose." }
],
"ruFaq": [
{ "q": "UUID криптографически безопасны?", "a": "Да. UUID v4 генерируется через crypto.getRandomValues браузера, что обеспечивает криптографическую стойкость." },
{ "q": "В чём разница между UUID, Hex и Base64?", "a": "UUID v4 — стандартный 128-битный формат. Hex — сырые случайные байты в шестнадцатеричном виде. Base64 — те же байты в компактном кодировании." },
{ "q": "Можно ли использовать токены в продакшене?", "a": "UUID v4 безопасны для продакшена. Для Hex и Base64 безопасность зависит от выбранной длины в байтах." }
]
}
+47
View File
@@ -0,0 +1,47 @@
{
"slug": "weighted",
"title": "Weighted Random",
"description": "Pick a random item from a list using custom weights for each option.",
"icon": "list",
"status": "live",
"seoTitle": "Weighted Random Generator — Weighted Choice Picker | Randify",
"seoDescription": "Pick random items with custom weights. Perfect for raffles, weighted decisions, and probability experiments. Free online weighted random selector.",
"ruTitle": "Взвешенный случайный выбор",
"ruDescription": "Выбирайте случайный элемент из списка с учётом заданных весов для каждого варианта.",
"ruSeoTitle": "Генератор взвешенного случайного выбора | Randify",
"ruSeoDescription": "Выбирайте случайные элементы с заданными весами. Идеально для розыгрышей, взвешенных решений и экспериментов с вероятностями.",
"pageTitle": "Weighted Random Generator",
"ruPageTitle": "Генератор взвешенного случайного выбора",
"howTo": [
"Add items to the list and assign a weight to each one. Higher weight means higher chance of being picked.",
"Click Pick to select one random item based on the weights.",
"Use Pick N times to run multiple trials and see the distribution statistics."
],
"whenTo": [
"Running a raffle where some participants have more entries than others.",
"Making weighted decisions when options have different priorities.",
"Simulating probability distributions or testing randomness.",
"Creating balanced teams or assignments with different skill weights."
],
"ruHowTo": [
"Добавьте элементы в список и назначьте каждому вес. Чем выше вес, тем больше шанс быть выбранным.",
"Нажмите «Выбрать», чтобы случайно выбрать один элемент с учётом весов.",
"Используйте «Выбрать N раз» для множественных испытаний и просмотра статистики распределения."
],
"ruWhenTo": [
"Проведение розыгрыша, где у некоторых участников больше шансов, чем у других.",
"Принятие взвешенных решений, когда у вариантов разные приоритеты.",
"Моделирование распределений вероятностей или тестирование случайности.",
"Создание сбалансированных команд или назначений с разными весами навыков."
],
"faq": [
{ "q": "How does weighted random selection work?", "a": "Each item's chance of being picked is proportional to its weight. If item A has weight 2 and item B has weight 8, B is 4 times more likely to be selected than A." },
{ "q": "Can I use decimal weights?", "a": "Yes, you can use any positive number as a weight, including decimals. The generator normalizes all weights automatically." },
{ "q": "Is the result truly random?", "a": "Yes. The generator uses a cryptographically secure random number generator to ensure fair and unpredictable results every time." }
],
"ruFaq": [
{ "q": "Как работает взвешенный случайный выбор?", "a": "Шанс выбора каждого элемента пропорционален его весу. Если у элемента А вес 2, а у элемента Б вес 8, то Б в 4 раза чаще будет выбран, чем А." },
{ "q": "Можно ли использовать дробные веса?", "a": "Да, весом может быть любое положительное число, включая десятичные дроби. Генератор автоматически нормализует все веса." },
{ "q": "Результат действительно случайный?", "a": "Да. Генератор использует криптографически стойкий генератор случайных чисел, чтобы гарантировать честный и непредсказуемый результат каждый раз." }
]
}
+48
View File
@@ -0,0 +1,48 @@
{
"slug": "wheel",
"title": "Spin the Wheel",
"description": "Add your items, spin the wheel, and let chance decide.",
"icon": "pie-chart",
"status": "live",
"seoTitle": "Spin the Wheel — Random Picker | Randify",
"seoDescription": "Spin a customizable wheel of fortune to pick a random winner, choice, or option. Add your own items and let the wheel decide.",
"ruTitle": "Колесо фортуны",
"ruDescription": "Добавьте элементы, крутите колесо и пусть случай решит.",
"ruSeoTitle": "Колесо фортуны онлайн — случайный выбор | Randify",
"ruSeoDescription": "Крутите колесо фортуны онлайн. Добавьте свои варианты и пусть случай решит победителя.",
"pageTitle": "Spin the Wheel",
"ruPageTitle": "Колесо фортуны онлайн",
"howTo": [
"Type your items into the text box — one per line.",
"Click \"Update wheel\" to apply changes.",
"Hit \"Spin\" and watch the wheel decide.",
"Copy the result or spin again."
],
"whenTo": [
"Picking a winner for a giveaway or raffle.",
"Deciding whose turn it is in a game or meeting.",
"Choosing a random activity, movie, or restaurant.",
"Any decision you want to leave to chance."
],
"ruHowTo": [
"Введите варианты в поле — по одному на строку (от 2 до 24).",
"Колесо обновляется автоматически.",
"Нажмите «Крутить» и дождитесь результата.",
"Победитель выделяется — скопируйте его кнопкой «Копировать»."
],
"ruWhenTo": [
"Случайный выбор победителя розыгрыша.",
"Принятие решений в команде.",
"Распределение ролей или задач между участниками."
],
"faq": [
{ "q": "How many items can I add?", "a": "Between 2 and 24 items. The wheel automatically resizes to fit all segments." },
{ "q": "Is the spin truly random?", "a": "Yes. The stopping position is determined by a cryptographically secure random value." },
{ "q": "Can I save my wheel?", "a": "Not yet. You can bookmark the page or copy your item list to reuse it later." }
],
"ruFaq": [
{ "q": "Сколько элементов можно добавить?", "a": "От 2 до 24. Колесо автоматически подстраивается под количество сегментов." },
{ "q": "Вращение действительно случайное?", "a": "Да. Конечная позиция определяется криптографически стойким случайным значением." },
{ "q": "Можно ли сохранить колесо?", "a": "Пока нет. Можно добавить страницу в закладки или скопировать список элементов для повторного использования." }
]
}
+47
View File
@@ -0,0 +1,47 @@
{
"slug": "yesno",
"title": "Yes or No",
"description": "Get a random Yes, No, or Maybe answer to any question.",
"icon": "help-circle",
"status": "live",
"seoTitle": "Yes or No Generator — Random Answer | Randify",
"seoDescription": "Get an instant random Yes, No, or Maybe answer to any question. Free online decision maker when you need a quick verdict.",
"ruTitle": "Да или Нет",
"ruDescription": "Получите случайный ответ Да, Нет или Возможно на любой вопрос.",
"ruSeoTitle": "Генератор Да или Нет — случайный ответ | Randify",
"ruSeoDescription": "Мгновенный случайный ответ Да, Нет или Возможно на любой вопрос. Бесплатный онлайн-помощник для быстрых решений.",
"pageTitle": "Yes or No Generator",
"ruPageTitle": "Генератор Да или Нет",
"howTo": [
"Type your question in the field (optional — it is just for context).",
"Click the button to get a random answer.",
"The result appears instantly: Yes, No, or Maybe.",
"A confidence percentage is shown alongside the answer."
],
"whenTo": [
"Making a trivial decision when you are stuck.",
"Settling a friendly debate or bet.",
"Adding a fun random element to a game or challenge."
],
"ruHowTo": [
"Введите свой вопрос в поле (по желанию — это просто для контекста).",
"Нажмите кнопку, чтобы получить случайный ответ.",
"Результат появится мгновенно: Да, Нет или Возможно.",
"Рядом с ответом отображается процент уверенности."
],
"ruWhenTo": [
"Принятие простого решения, когда вы не можете выбрать.",
"Разрешение дружеского спора или пари.",
"Добавление случайного элемента в игру или челлендж."
],
"faq": [
{ "q": "Is the answer really random?", "a": "Yes. The outcome is generated using a cryptographically secure random number generator, giving each option a fair chance." },
{ "q": "What does the confidence percentage mean?", "a": "It is a fun visual indicator showing how 'certain' the random choice feels — it does not affect the actual result." },
{ "q": "Can I use this for serious decisions?", "a": "It is best for light-hearted or trivial choices. For important matters, trust your own judgment." }
],
"ruFaq": [
{ "q": "Ответ действительно случайный?", "a": "Да. Результат генерируется с помощью криптографически стойкого генератора случайных чисел, давая каждому варианту равный шанс." },
{ "q": "Что означает процент уверенности?", "a": "Это забавный визуальный индикатор, показывающий, насколько «уверен» случайный выбор — он не влияет на сам результат." },
{ "q": "Можно ли использовать для серьёзных решений?", "a": "Лучше всего подходит для лёгких или шуточных выборов. В важных вопросах доверяйте собственному мнению." }
]
}
+6
View File
@@ -0,0 +1,6 @@
export const siteUrl = "https://randify.pro";
export const analytics = {
yandexMetrikaId: "109130319",
topMailRuId: "3765043",
} as const;
+21
View File
@@ -0,0 +1,21 @@
import { generatorSchema } from "@/lib/generator-schema";
import type { Generator } from "@/lib/generator-schema";
const modules = import.meta.glob<{ default: unknown }>(
"../content/generators/*.json",
{
eager: true,
},
);
export const generators: Generator[] = Object.values(modules).map((mod) =>
generatorSchema.parse(mod.default),
);
export function findGenerator(slug: string): Generator {
const g = generators.find((gen) => gen.slug === slug);
if (!g) throw new Error(`Generator not found: ${slug}`);
return g;
}
export type { Generator };
+1
View File
@@ -0,0 +1 @@
/// <reference path="../.astro/types.d.ts" />
+198
View File
@@ -0,0 +1,198 @@
export type Lang = "en" | "ru";
export const translations = {
en: {
allGenerators: "All generators",
live: "Live",
comingSoon: "Coming soon",
copy: "Copy",
copied: "Copied",
copyAll: "Copy all",
howToUse: "How to use",
whenToUse: "When to use",
faq: "FAQ",
about: "About",
generate: "Generate",
draw: "Draw",
roll: "Roll",
flip: "Flip",
pick: "Pick",
spin: "Spin",
from: "From",
to: "To",
length: "Length",
count: "Count",
winner: "Winner",
total: "Total",
sides: "Sides",
numberOfDice: "Number of dice",
numberOfCoins: "Number of coins",
cardsToDraw: "Cards to draw",
allowDuplicates: "Allow duplicates",
uppercase: "Uppercase (A–Z)",
lowercase: "Lowercase (a–z)",
digits: "Digits (0–9)",
symbols: "Symbols (!@#…)",
type: "Type",
lengthBytes: "Length (bytes)",
itemsOneLine: "one per line",
itemsOneLine224: "one per line, 2–24",
items: "Items",
headsLabel: "H",
tailsLabel: "T",
headsWord: "heads",
tailsWord: "tails",
errBothIntegers: "Both values must be whole numbers.",
errFromLessThanTo: '"From" must be less than "To".',
errSelectOneType: "Select at least one character type.",
errAllIntegers: "All values must be whole numbers.",
errPickAtLeastOne: '"Pick" must be at least 1.',
errDiceAtLeast: "Number of dice must be at least 1.",
errDiceMax: "Maximum 20 dice at once.",
errDrawAtLeast: "Draw at least 1 card.",
errMaxUniqueCards: "Cannot draw more than 52 unique cards.",
errMax52Cards: "Maximum 52 cards at once.",
errAddAtLeast2: "Add at least 2 items.",
errMax24: "Maximum 24 items.",
errAddAtLeast1: "Add at least one item to the list.",
errPickAtLeast1: "Pick at least 1 item.",
homeTitle: "Random generators for every occasion",
homeSubtitle:
"Simple tools for raffles, games, and everything that needs randomness.",
defaultTitle: "Randify — Random Value Generators",
defaultDesc:
"Simple tools for raffles, games, and everything that needs randomness.",
brandLabel: "randify",
backToAll: "All generators",
aboutTitle: "About Randify",
aboutMission:
"Randify exists to make randomness simple, fast, and accessible to everyone. Whether you are picking a winner, rolling dice, or generating a secure password, you should not need to install an app or create an account. Just open the page and get your result.",
aboutHistory:
"The idea for Randify came from a simple frustration: every time we needed a quick random number or a coin flip, we ended up on cluttered websites full of ads, pop-ups, and unnecessary steps. We wanted something cleaner — a tool that just works, without distractions.\n\nWe started with a single number generator and gradually expanded the collection based on what people actually use: dice for board games, wheels for raffles, passwords for security, and more. Each tool was built with the same philosophy — minimal interface, instant results, zero friction.",
aboutHowItWorks:
"Everything on Randify runs entirely in your browser. No data is sent to any server, no personal information is collected, and no account is required. The random values are generated locally using the Web Crypto API, which provides cryptographically secure randomness. This means your passwords, numbers, and choices stay private — we never see them.",
aboutContact:
"Have a suggestion, found a bug, or just want to say hello? Drop us a line at hello@randify.pro — we read every message.",
aboutCta:
"Ready to give it a spin? Explore the generators and let chance do the work.",
privacyTitle: "Privacy Policy",
privacyIntro:
"This Privacy Policy explains how Randify handles information when you use our website. We believe in transparency: we collect as little data as possible and never ask for personal information.",
privacyData:
"Randify does not collect, store, or process any personal data. All random values — numbers, passwords, dice rolls, card draws, and every other result — are generated entirely inside your browser. Nothing you enter or generate is sent to our servers. We do not require registration, we do not ask for your name or email to use the generators, and we have no access to your inputs or outputs.",
privacyCookies:
"We do not use tracking cookies. The only information stored on your device is your language preference (English or Russian), which is saved in localStorage so the site remembers your choice on future visits. This data never leaves your browser and is not shared with anyone.",
privacyThirdParty:
"We use two third-party services to keep Randify free and improve the experience:\n\n• Yandex.RTB — displays advertisements on some pages. Yandex may use cookies and similar technologies to show relevant ads. You can manage ad preferences through your browser settings or Yandex's opt-out tools.\n\n• Yandex.Metrika — helps us understand how visitors use the site (for example, which generators are most popular). This is anonymous aggregate data: we cannot identify individual users.",
privacyContact:
"If you have any questions about this policy or how we handle data, please contact us at hello@randify.pro.",
privacyChanges:
"We may update this Privacy Policy from time to time. Any changes will be posted on this page with an updated date. Since we do not collect contact information, we cannot notify users individually — please check this page occasionally for updates.",
},
ru: {
allGenerators: "Все генераторы",
live: "Активен",
comingSoon: "Скоро",
copy: "Копировать",
copied: "Скопировано",
copyAll: "Копировать всё",
howToUse: "Как пользоваться",
whenToUse: "Когда использовать",
faq: "Частые вопросы",
about: "О проекте",
generate: "Сгенерировать",
draw: "Тянуть",
roll: "Бросить",
flip: "Подбросить",
pick: "Выбрать",
spin: "Крутить",
from: "От",
to: "До",
length: "Длина",
count: "Количество",
winner: "Победитель",
total: "Итого",
sides: "Грани",
numberOfDice: "Количество кубиков",
numberOfCoins: "Количество монет",
cardsToDraw: "Карт вытащить",
allowDuplicates: "Разрешить повторения",
uppercase: "Заглавные (A–Z)",
lowercase: "Строчные (a–z)",
digits: "Цифры (0–9)",
symbols: "Символы (!@#…)",
type: "Тип",
lengthBytes: "Длина (байты)",
itemsOneLine: "по одному на строку",
itemsOneLine224: "по одному на строку, 2–24",
items: "Элементы",
headsLabel: "О",
tailsLabel: "Р",
headsWord: "орёл",
tailsWord: "решка",
errBothIntegers: "Оба значения должны быть целыми числами.",
errFromLessThanTo: "«От» должно быть меньше «До».",
errSelectOneType: "Выберите хотя бы один тип символов.",
errAllIntegers: "Все значения должны быть целыми числами.",
errPickAtLeastOne: "«Выбрать» должно быть не менее 1.",
errDiceAtLeast: "Количество кубиков должно быть не менее 1.",
errDiceMax: "Максимум 20 кубиков одновременно.",
errDrawAtLeast: "Вытащите хотя бы 1 карту.",
errMaxUniqueCards: "Нельзя вытащить более 52 уникальных карт.",
errMax52Cards: "Максимум 52 карты одновременно.",
errAddAtLeast2: "Добавьте хотя бы 2 элемента.",
errMax24: "Максимум 24 элемента.",
errAddAtLeast1: "Добавьте хотя бы один элемент в список.",
errPickAtLeast1: "Выберите хотя бы 1 элемент.",
homeTitle: "Генераторы случайных значений на любой случай",
homeSubtitle:
"Простые инструменты для розыгрышей, игр и всего, что требует случайности.",
defaultTitle: "Randify — Генераторы случайных значений",
defaultDesc:
"Простые инструменты для розыгрышей, игр и всего, что требует случайности.",
brandLabel: "randify",
backToAll: "Все генераторы",
aboutTitle: "О проекте",
aboutMission:
"Randify создан, чтобы сделать случайность простой, быстрой и доступной каждому. Независимо от того, выбираете ли вы победителя, бросаете кубики или создаёте надёжный пароль — не нужно устанавливать приложение или регистрироваться. Просто откройте страницу и получите результат.",
aboutHistory:
"Идея Randify родилась из простого раздражения: каждый раз, когда нам нужно было быстро сгенерировать случайное число или подбросить монетку, мы попадали на перегруженные рекламой сайты с всплывающими окнами и лишними шагами. Мы хотели создать что-то чище — инструмент, который просто работает, без отвлекающих элементов.\n\nМы начали с одного генератора чисел и постепенно расширяли коллекцию, ориентируясь на реальные потребности: кубики для настольных игр, колесо фортуны для розыгрышей, генератор паролей для безопасности и многое другое. Каждый инструмент создавался по одному принципу — минимальный интерфейс, мгновенный результат, никаких препятствий.",
aboutHowItWorks:
"Всё на Randify работает полностью в вашем браузере. Данные не отправляются на сервер, личная информация не собирается, регистрация не требуется. Случайные значения генерируются локально с помощью Web Crypto API, который обеспечивает криптографически стойкую случайность. Это означает, что ваши пароли, числа и выбор остаются приватными — мы их не видим.",
aboutContact:
"Есть предложение, нашли ошибку или просто хотите поздороваться? Напишите нам на hello@randify.pro — мы читаем каждое сообщение.",
aboutCta:
"Готовы попробовать? Откройте генераторы и позвольте случайности сделать выбор за вас.",
privacyTitle: "Политика конфиденциальности",
privacyIntro:
"Настоящая Политика конфиденциальности объясняет, как Randify работает с информацией при использовании сайта. Мы верим в прозрачность: собираем минимум данных и никогда не запрашиваем персональную информацию.",
privacyData:
"Randify не собирает, не хранит и не обрабатывает персональные данные. Все случайные значения — числа, пароли, броски кубиков, вытягивание карт и любые другие результаты — генерируются полностью внутри вашего браузера. Ничто из того, что вы вводите или генерируете, не отправляется на наши серверы. Мы не требуем регистрации, не просим имя или email для использования генераторов и не имеем доступа к вашим данным.",
privacyCookies:
"Мы не используем cookies для отслеживания. Единственная информация, которая сохраняется на вашем устройстве, — это предпочитаемый язык (английский или русский) в localStorage, чтобы сайт запоминал ваш выбор при следующем посещении. Эти данные не покидают браузер и никому не передаются.",
privacyThirdParty:
"Мы используем два сторонних сервиса, чтобы Randify оставался бесплатным и удобным:\n\n• Yandex.RTB — показывает рекламу на некоторых страницах. Яндекс может использовать cookies и аналогичные технологии для показа релевантной рекламы. Вы можете управлять настройками рекламы через параметры браузера или инструменты отказа Яндекса.\n\n• Яндекс.Метрика — помогает нам понимать, как посетители используют сайт (например, какие генераторы самые популярные). Это анонимные агрегированные данные: мы не можем идентифицировать отдельных пользователей.",
privacyContact:
"Если у вас есть вопросы об этой политике или о том, как мы работаем с данными, напишите нам на hello@randify.pro.",
privacyChanges:
"Мы можем обновлять эту Политику конфиденциальности время от времени. Любые изменения будут опубликованы на этой странице с обновлённой датой. Поскольку мы не собираем контактную информацию, мы не можем уведомлять пользователей индивидуально — пожалуйста, проверяйте эту страницу время от времени.",
},
} as const;
export type T = typeof translations.en;
export function useT(lang: Lang): T {
return translations[lang] as T;
}
+128
View File
@@ -0,0 +1,128 @@
---
import { getRelativeLocaleUrl } from "astro:i18n";
import LanguageSwitcher from "../components/LanguageSwitcher.astro";
import Starfield from "../components/Starfield.astro";
import Analytics from "../components/Analytics.astro";
import { useT } from "../i18n/translations";
import type { Lang } from "../i18n/translations";
interface Props {
title?: string;
description?: string;
}
const lang = (Astro.currentLocale as Lang) || "en";
const T = useT(lang);
const { title = T.defaultTitle, description = T.defaultDesc } = Astro.props;
const pathWithoutLocale = Astro.url.pathname.replace(/^\/ru/, "") || "/";
const enUrl = `https://randify.pro${getRelativeLocaleUrl("en", pathWithoutLocale)}`;
const ruUrl = `https://randify.pro${getRelativeLocaleUrl("ru", pathWithoutLocale)}`;
const canonicalUrl = `https://randify.pro${Astro.url.pathname}`;
const webPageSchema = {
"@context": "https://schema.org",
"@type": "WebPage",
name: title,
description: description,
url: canonicalUrl,
inLanguage: lang,
};
---
<!doctype html>
<html lang={lang} style="scroll-behavior:smooth;scrollbar-gutter:stable">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content={description} />
<link rel="icon" type="image/png" href="/favicon.png" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300..700&display=swap"
rel="stylesheet"
/>
<link rel="canonical" href={canonicalUrl} />
<link rel="alternate" hreflang="en" href={enUrl} />
<link rel="alternate" hreflang="ru" href={ruUrl} />
<meta name="verification" content="er9ndnv9ih7agmh8" />
<script
type="application/ld+json"
set:html={JSON.stringify(webPageSchema)}
/>
<meta name="theme-color" content="#534ab7" />
<link rel="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<!-- Yandex.RTB -->
<script>window.yaContextCb=window.yaContextCb||[]</script>
<script src="https://yandex.ru/ads/system/context.js" async></script>
<slot name="head" />
<title>{title}</title>
<Analytics />
<script>
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/sw.js");
}
</script>
</head>
<body class="bg-zinc-950 text-zinc-100 min-h-screen font-sans antialiased">
<Starfield />
<div class="relative z-10">
<LanguageSwitcher />
<slot />
</div>
</body>
</html>
<!-- Auto-detect language on first visit -->
<script>
if (!localStorage.getItem("lang-pref")) {
const browserLang = (navigator.language || "").toLowerCase();
if (browserLang.startsWith("ru") && !location.pathname.startsWith("/ru")) {
location.replace("/ru" + location.pathname);
}
}
</script>
<style is:global>
@import "tailwindcss";
@theme {
--font-sans: "Space Grotesk", sans-serif;
--color-accent: #534ab7;
--color-accent-hover: #4740a0;
--color-accent-active: #3d3990;
--color-yandex: #fc3f1d;
--color-yandex-hover: #e03518;
}
:root {
--accent: #534ab7;
}
* {
box-sizing: border-box;
}
body {
background-image:
radial-gradient(
ellipse 90% 45% at 50% -5%,
rgba(83, 74, 183, 0.2) 0%,
transparent 65%
),
radial-gradient(
ellipse 90% 45% at 50% 105%,
rgba(83, 74, 183, 0.13) 0%,
transparent 65%
);
background-size: auto, auto;
}
input[type="number"] {
color-scheme: dark;
}
</style>
+137
View File
@@ -0,0 +1,137 @@
---
import BaseLayout from "./BaseLayout.astro";
import YandexRTB from "../components/YandexRTB.astro";
import SeoBlock from "../components/SeoBlock.astro";
import FaqBlock from "../components/FaqBlock.astro";
import { useT } from "../i18n/translations";
import type { Lang } from "../i18n/translations";
import type { Generator } from "../data/generators";
interface Props {
generator: Generator;
}
const { generator } = Astro.props;
const lang = (Astro.currentLocale as Lang) || "en";
const T = useT(lang);
const isRu = lang === "ru";
const canonicalUrl = `https://randify.pro${Astro.url.pathname}`;
const appSchema = {
"@context": "https://schema.org",
"@type": "SoftwareApplication",
name: isRu ? generator.ruTitle : generator.title,
applicationCategory: "UtilityApplication",
operatingSystem: "Any",
offers: {
"@type": "Offer",
price: "0",
priceCurrency: "USD",
},
inLanguage: ["en", "ru"],
description: isRu ? generator.ruDescription : generator.description,
url: canonicalUrl,
};
const breadcrumbSchema = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: [
{
"@type": "ListItem",
position: 1,
name: "Randify.pro",
item: "https://randify.pro/",
},
{
"@type": "ListItem",
position: 2,
name: isRu ? generator.ruTitle : generator.title,
item: canonicalUrl,
},
],
};
---
<BaseLayout
title={isRu ? generator.ruSeoTitle : generator.seoTitle}
description={isRu ? generator.ruSeoDescription : generator.seoDescription}
>
<Fragment slot="head">
<script type="application/ld+json" set:html={JSON.stringify(appSchema)} />
<script
type="application/ld+json"
set:html={JSON.stringify(breadcrumbSchema)}
/>
</Fragment>
<div class="max-w-2xl mx-auto px-4 py-12 sm:py-20">
<nav aria-label="Breadcrumb" class="mb-10">
<a
href={isRu ? "/ru/" : "/"}
class="inline-flex items-center gap-1.5 text-sm text-zinc-600 hover:text-zinc-300 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 rounded"
aria-label={T.backToAll}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="m15 18-6-6 6-6"></path>
</svg>
{T.backToAll}
</a>
</nav>
<header class="mb-2">
<div class="flex items-center gap-2 mb-4">
<span
class="inline-block w-2.5 h-2.5 rounded-full bg-accent"
aria-hidden="true"></span>
<span
class="text-sm font-medium text-zinc-400 uppercase tracking-widest"
>randify</span
>
</div>
<h1 class="text-3xl sm:text-4xl font-bold text-zinc-100">
{isRu ? generator.ruPageTitle : generator.pageTitle}
</h1>
<div class="w-16 h-0.5 bg-accent/60 rounded-full mt-4" aria-hidden="true"></div>
<p class="mt-2 text-base text-zinc-400">
{isRu ? generator.ruDescription : generator.description}
</p>
</header>
<div class="border-t border-zinc-800/60 pt-8 mt-8">
<main>
<slot />
</main>
</div>
<div class="mt-12 flex justify-center">
<YandexRTB />
</div>
<SeoBlock
lang={lang}
howTo={isRu ? generator.ruHowTo : generator.howTo}
whenTo={isRu ? generator.ruWhenTo : generator.whenTo}
/>
{
(isRu ? generator.ruFaq : generator.faq) && (
<FaqBlock
lang={lang}
questions={(isRu ? generator.ruFaq : generator.faq) || []}
/>
)
}
</div>
</BaseLayout>
+10
View File
@@ -0,0 +1,10 @@
export function popElement(el: HTMLElement) {
el.style.transform = "scale(1.18)";
el.style.opacity = "0.7";
requestAnimationFrame(() =>
requestAnimationFrame(() => {
el.style.transform = "scale(1)";
el.style.opacity = "1";
}),
);
}
+25
View File
@@ -0,0 +1,25 @@
export class CopyFeedback {
private timer: ReturnType<typeof setTimeout> | null = null;
constructor(
private copyIcon: HTMLElement,
private checkIcon: HTMLElement,
private duration = 1500,
) {}
showCopied() {
this.copyIcon.classList.add("hidden");
this.checkIcon.classList.remove("hidden");
if (this.timer) clearTimeout(this.timer);
this.timer = setTimeout(() => this.revert(), this.duration);
}
revert() {
this.checkIcon.classList.add("hidden");
this.copyIcon.classList.remove("hidden");
}
cleanup() {
if (this.timer) clearTimeout(this.timer);
}
}
+16
View File
@@ -0,0 +1,16 @@
export function createErrorDisplay(errorEl: HTMLElement) {
return {
show(msg: string) {
errorEl.textContent = msg;
errorEl.classList.remove("hidden");
},
clear() {
errorEl.textContent = "";
errorEl.classList.add("hidden");
},
};
}
export function isInteger(val: string): boolean {
return /^-?\d+$/.test(val.trim());
}
+286
View File
@@ -0,0 +1,286 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import {
parseDiceNotation,
rollDice,
rollAdvantage,
buildNotation,
isAdvancedNotation,
} from "./dice-engine";
describe("parseDiceNotation", () => {
it('parses basic notation "d6"', () => {
const parsed = parseDiceNotation("d6");
expect(parsed).not.toBeNull();
expect(parsed!.count).toBe(1);
expect(parsed!.sides).toBe(6);
expect(parsed!.modifier).toBe(0);
expect(parsed!.keepDrop).toBeNull();
expect(parsed!.explode.active).toBe(false);
expect(parsed!.reroll.active).toBe(false);
});
it('parses "2d20+3"', () => {
const parsed = parseDiceNotation("2d20+3");
expect(parsed).not.toBeNull();
expect(parsed!.count).toBe(2);
expect(parsed!.sides).toBe(20);
expect(parsed!.modifier).toBe(3);
});
it('parses "3d8-1"', () => {
const parsed = parseDiceNotation("3d8-1");
expect(parsed!.count).toBe(3);
expect(parsed!.sides).toBe(8);
expect(parsed!.modifier).toBe(-1);
});
it('parses "4d6kh3"', () => {
const parsed = parseDiceNotation("4d6kh3");
expect(parsed!.keepDrop).toEqual({ type: "kh", count: 3 });
});
it('parses "5d6dl2"', () => {
const parsed = parseDiceNotation("5d6dl2");
expect(parsed!.keepDrop).toEqual({ type: "dl", count: 2 });
});
it('parses exploding "3d6!"', () => {
const parsed = parseDiceNotation("3d6!");
expect(parsed!.explode.active).toBe(true);
expect(parsed!.explode.penetrating).toBe(false);
expect(parsed!.explode.threshold).toBe(6);
});
it('parses penetrating explode "2d10!p"', () => {
const parsed = parseDiceNotation("2d10!p");
expect(parsed!.explode.active).toBe(true);
expect(parsed!.explode.penetrating).toBe(true);
});
it('parses explode with custom threshold "2d6!>4"', () => {
const parsed = parseDiceNotation("2d6!>4");
expect(parsed!.explode.active).toBe(true);
expect(parsed!.explode.threshold).toBe(4);
});
it('parses reroll "2d6r1"', () => {
const parsed = parseDiceNotation("2d6r1");
expect(parsed!.reroll.active).toBe(true);
expect(parsed!.reroll.once).toBe(false);
expect(parsed!.reroll.values).toContain(1);
});
it('parses reroll once "2d6ro1"', () => {
const parsed = parseDiceNotation("2d6ro1");
expect(parsed!.reroll.active).toBe(true);
expect(parsed!.reroll.once).toBe(true);
});
it('parses reroll less-than "2d6r<3"', () => {
const parsed = parseDiceNotation("2d6r<3");
expect(parsed!.reroll.active).toBe(true);
expect(parsed!.reroll.values).toContain(1);
expect(parsed!.reroll.values).toContain(2);
expect(parsed!.reroll.values).not.toContain(3);
});
it("ignores whitespace", () => {
const parsed = parseDiceNotation(" 2 d 20 + 5 ");
expect(parsed!.count).toBe(2);
expect(parsed!.sides).toBe(20);
expect(parsed!.modifier).toBe(5);
});
it("returns null for invalid notation", () => {
expect(parseDiceNotation("")).toBeNull();
expect(parseDiceNotation("abc")).toBeNull();
expect(parseDiceNotation("2d6+1+2")).toBeNull();
expect(parseDiceNotation("0d6")).toBeNull();
expect(parseDiceNotation("21d6")).toBeNull();
expect(parseDiceNotation("2d0")).toBeNull();
expect(parseDiceNotation("2d6kh0")).toBeNull();
expect(parseDiceNotation("2d6kh2")).toBeNull();
});
});
describe("rollDice", () => {
let mathRandomSpy: ReturnType<typeof vi.spyOn>;
afterEach(() => {
mathRandomSpy?.mockRestore();
});
it("rolls basic dice", () => {
mathRandomSpy = vi.spyOn(Math, "random").mockReturnValue(0.5);
const parsed = parseDiceNotation("3d6")!;
const result = rollDice(parsed);
expect(result.dice).toHaveLength(3);
expect(result.kept).toHaveLength(3);
expect(result.dropped).toHaveLength(0);
expect(result.total).toBe(12); // each die: floor(0.5*6)+1 = 4, 3*4 = 12
});
it("applies modifier", () => {
mathRandomSpy = vi.spyOn(Math, "random").mockReturnValue(0.5);
const parsed = parseDiceNotation("1d20+5")!;
const result = rollDice(parsed);
expect(result.total).toBe(16); // 11 + 5
});
it("keeps highest rolls (kh)", () => {
mathRandomSpy = vi
.spyOn(Math, "random")
.mockReturnValueOnce(0.1) // 1
.mockReturnValueOnce(0.9) // 6
.mockReturnValueOnce(0.5); // 4
const parsed = parseDiceNotation("3d6kh2")!;
const result = rollDice(parsed);
expect(result.kept).toHaveLength(2);
expect(result.dropped).toHaveLength(1);
expect(result.dropped[0].dropped).toBe(true);
expect(result.total).toBe(10); // 6 + 4
});
it("drops highest rolls (dh)", () => {
mathRandomSpy = vi
.spyOn(Math, "random")
.mockReturnValueOnce(0.1) // 1
.mockReturnValueOnce(0.9) // 6
.mockReturnValueOnce(0.5); // 4
const parsed = parseDiceNotation("3d6dh1")!;
const result = rollDice(parsed);
expect(result.kept).toHaveLength(2);
expect(result.dropped).toHaveLength(1);
expect(result.dropped[0].value).toBe(6);
expect(result.total).toBe(5); // 1 + 4
});
it("handles exploding dice", () => {
mathRandomSpy = vi
.spyOn(Math, "random")
.mockReturnValueOnce(0.99) // first roll: 6 (max, triggers explode)
.mockReturnValueOnce(0.99) // explosion 1: 6 (triggers again)
.mockReturnValueOnce(0.1); // explosion 2: 1 (stops)
const parsed = parseDiceNotation("1d6!")!;
const result = rollDice(parsed);
expect(result.dice[0].exploded).toBe(true);
expect(result.dice[0].explosions).toEqual([6, 1]);
expect(result.total).toBe(13); // 6 + 6 + 1
});
it("handles penetrating explode", () => {
mathRandomSpy = vi
.spyOn(Math, "random")
.mockReturnValueOnce(0.99) // first roll: 6 (explode)
.mockReturnValueOnce(0.99); // explosion: 6 -> penetrating -> 5, then 5 < 6 stops
const parsed = parseDiceNotation("1d6!p")!;
const result = rollDice(parsed);
expect(result.dice[0].explosions).toEqual([5]);
expect(result.total).toBe(11); // 6 + 5
});
it("handles reroll", () => {
mathRandomSpy = vi
.spyOn(Math, "random")
.mockReturnValueOnce(0.0) // first roll: 1 (reroll)
.mockReturnValueOnce(0.5); // reroll: 4
const parsed = parseDiceNotation("1d6r1")!;
const result = rollDice(parsed);
expect(result.dice[0].rerolledFrom).toBe(1);
expect(result.dice[0].value).toBe(4);
});
it("handles reroll once (ro)", () => {
mathRandomSpy = vi
.spyOn(Math, "random")
.mockReturnValueOnce(0.0) // first: 1 (reroll once)
.mockReturnValueOnce(0.0); // second: 1 (stays, no more rerolls)
const parsed = parseDiceNotation("1d6ro1")!;
const result = rollDice(parsed);
expect(result.dice[0].rerolledFrom).toBe(1);
expect(result.dice[0].value).toBe(1);
});
});
describe("rollAdvantage", () => {
let mathRandomSpy: ReturnType<typeof vi.spyOn>;
afterEach(() => {
mathRandomSpy?.mockRestore();
});
it("returns highest for advantage", () => {
mathRandomSpy = vi
.spyOn(Math, "random")
.mockReturnValueOnce(0.1) // 3
.mockReturnValueOnce(0.9); // 19
const result = rollAdvantage(20, 0, true);
expect(result.total).toBe(19);
expect(result.advantageRolls).toEqual([3, 19]);
});
it("returns lowest for disadvantage", () => {
mathRandomSpy = vi
.spyOn(Math, "random")
.mockReturnValueOnce(0.9) // 19
.mockReturnValueOnce(0.1); // 3
const result = rollAdvantage(20, 2, false);
expect(result.total).toBe(5); // 3 + 2
expect(result.advantageRolls).toEqual([19, 3]);
});
});
describe("buildNotation", () => {
it("builds basic notation", () => {
expect(buildNotation(1, 6, 0)).toBe("1d6");
expect(buildNotation(2, 20, 0)).toBe("2d20");
});
it("adds positive modifier", () => {
expect(buildNotation(2, 6, 3)).toBe("2d6+3");
});
it("adds negative modifier", () => {
expect(buildNotation(2, 6, -2)).toBe("2d6-2");
});
});
describe("isAdvancedNotation", () => {
it("returns false for basic notation", () => {
expect(isAdvancedNotation("2d6")).toBe(false);
expect(isAdvancedNotation("1d20+5")).toBe(false);
expect(isAdvancedNotation("3d8-1")).toBe(false);
});
it("returns true for keep/drop", () => {
expect(isAdvancedNotation("4d6kh3")).toBe(true);
expect(isAdvancedNotation("5d6dl2")).toBe(true);
});
it("returns true for explode", () => {
expect(isAdvancedNotation("3d6!")).toBe(true);
expect(isAdvancedNotation("2d10!p")).toBe(true);
});
it("returns true for reroll", () => {
expect(isAdvancedNotation("2d6r1")).toBe(true);
expect(isAdvancedNotation("2d6ro1")).toBe(true);
expect(isAdvancedNotation("2d6r<3")).toBe(true);
});
});
+327
View File
@@ -0,0 +1,327 @@
// Dice notation engine for Randify.pro
// Supports: XdY±Z, kh/kl/dh/dl, ! exploding, r reroll, advantage/disadvantage
// Caps: explode chain ≤100, reroll recursion ≤1000
type KeepDrop = { type: "kh" | "kl" | "dh" | "dl"; count: number } | null;
type Explode = {
active: boolean;
threshold: number | null; // null = max value
penetrating: boolean;
};
type Reroll = {
active: boolean;
values: Set<number>;
once: boolean; // "ro" = once only; "r" = recursive
operator: "eq" | "lt" | "gt";
};
export interface Parsed {
count: number;
sides: number;
modifier: number;
keepDrop: KeepDrop;
explode: Explode;
reroll: Reroll;
advantage: boolean;
disadvantage: boolean;
}
export interface DieResult {
value: number;
original: number;
exploded: boolean;
explosions: number[]; // chained explosion rolls
rerolledFrom: number | null;
dropped: boolean;
}
export interface RollResult {
dice: DieResult[];
kept: DieResult[];
dropped: DieResult[];
modifier: number;
total: number;
notation: string;
advantageRolls: [number, number] | null; // for adv/disadv display
}
const EXPLODE_CAP = 100;
const REROLL_CAP = 1000;
/** Remove all whitespace and lowercase for parsing */
function normalize(raw: string): string {
return raw.replace(/\s+/g, "").toLowerCase();
}
/**
* Parse dice notation string into structured object.
* Returns null if syntax is invalid.
*/
export function parseDiceNotation(raw: string): Parsed | null {
const s = normalize(raw);
if (!s) return null;
// Main pattern: count d sides [modifiers+operators]
const main = s.match(/^(\d*)d(\d+)(.*)$/);
if (!main) return null;
const count = main[1] === "" ? 1 : parseInt(main[1], 10);
const sides = parseInt(main[2], 10);
const rest = main[3];
if (count < 1 || count > 20 || sides < 1 || sides > 9999) return null;
let remaining = rest;
// Keep/Drop: khN, klN, dhN, dlN
let keepDrop: KeepDrop = null;
const kdMatch = remaining.match(/^(kh|kl|dh|dl)(\d+)/);
if (kdMatch) {
const countKD = parseInt(kdMatch[2], 10);
if (countKD < 1 || countKD >= count) return null;
keepDrop = {
type: kdMatch[1] as "kh" | "kl" | "dh" | "dl",
count: countKD,
};
remaining = remaining.slice(kdMatch[0].length);
}
// Exploding: ! or !>N or !p or !p>N
const explode: Explode = {
active: false,
threshold: null,
penetrating: false,
};
const expMatch = remaining.match(/^(!p|!)(>?)(\d*)/);
if (expMatch) {
explode.active = true;
explode.penetrating = expMatch[1] === "!p";
if (expMatch[2] === ">" && expMatch[3]) {
explode.threshold = parseInt(expMatch[3], 10);
if (explode.threshold < 2 || explode.threshold > sides) return null;
} else {
explode.threshold = sides; // default: explode on max
}
remaining = remaining.slice(expMatch[0].length);
}
// Reroll: rN, roN, r<N, ro<N
const reroll: Reroll = {
active: false,
values: new Set(),
once: false,
operator: "eq",
};
const rrMatch = remaining.match(/^(ro|r)([<>]?)(\d+)/);
if (rrMatch) {
reroll.active = true;
reroll.once = rrMatch[1] === "ro";
const op = rrMatch[2] as "" | "<" | ">";
const val = parseInt(rrMatch[3], 10);
if (op === "<") {
reroll.operator = "lt";
for (let i = 1; i < val && i < sides; i++) reroll.values.add(i);
} else if (op === ">") {
reroll.operator = "gt";
for (let i = val + 1; i <= sides; i++) reroll.values.add(i);
} else {
reroll.operator = "eq";
reroll.values.add(val);
}
remaining = remaining.slice(rrMatch[0].length);
}
// Flat modifier at the end: +N or -N
let modifier = 0;
const modMatch = remaining.match(/^([+-]\d+)$/);
if (modMatch) {
modifier = parseInt(modMatch[1], 10);
if (Math.abs(modifier) > 999) return null;
remaining = remaining.slice(modMatch[0].length);
}
// If anything remains unparsed, it's invalid
if (remaining.length > 0) return null;
return {
count,
sides,
modifier,
keepDrop,
explode,
reroll,
advantage: false,
disadvantage: false,
};
}
/** Roll a single die with optional reroll and explode logic */
function rollDie(sides: number, explode: Explode, reroll: Reroll): DieResult {
let value = Math.floor(Math.random() * sides) + 1;
const original = value;
let rerolledFrom: number | null = null;
const explosions: number[] = [];
let exploded = false;
// Handle reroll
if (reroll.active && reroll.values.has(value)) {
rerolledFrom = value;
let rerollCount = 0;
while (reroll.values.has(value) && rerollCount < REROLL_CAP) {
value = Math.floor(Math.random() * sides) + 1;
rerollCount++;
if (reroll.once) break;
}
}
// Handle exploding
if (explode.active && value >= (explode.threshold ?? sides)) {
exploded = true;
let chain = 0;
while (chain < EXPLODE_CAP) {
let next = Math.floor(Math.random() * sides) + 1;
if (explode.penetrating) {
next = Math.max(1, next - 1); // penetrating subtracts 1
}
explosions.push(next);
if (next < (explode.threshold ?? sides)) break;
chain++;
}
}
return {
value,
original,
exploded,
explosions,
rerolledFrom,
dropped: false,
};
}
/** Execute a roll from parsed notation */
export function rollDice(parsed: Parsed): RollResult {
const dice: DieResult[] = [];
for (let i = 0; i < parsed.count; i++) {
dice.push(rollDie(parsed.sides, parsed.explode, parsed.reroll));
}
// Apply keep/drop
let kept = [...dice];
let dropped: DieResult[] = [];
if (parsed.keepDrop) {
const sorted = [...dice].map((d, i) => ({ die: d, idx: i }));
if (parsed.keepDrop.type === "kh") {
sorted.sort((a, b) => b.die.value - a.die.value);
const keepIndices = new Set(
sorted.slice(0, parsed.keepDrop.count).map((x) => x.idx),
);
kept = dice.filter((_, i) => keepIndices.has(i));
dropped = dice.filter((_, i) => !keepIndices.has(i));
} else if (parsed.keepDrop.type === "kl") {
sorted.sort((a, b) => a.die.value - b.die.value);
const keepIndices = new Set(
sorted.slice(0, parsed.keepDrop.count).map((x) => x.idx),
);
kept = dice.filter((_, i) => keepIndices.has(i));
dropped = dice.filter((_, i) => !keepIndices.has(i));
} else if (parsed.keepDrop.type === "dh") {
sorted.sort((a, b) => b.die.value - a.die.value);
const dropIndices = new Set(
sorted.slice(0, parsed.keepDrop.count).map((x) => x.idx),
);
kept = dice.filter((_, i) => !dropIndices.has(i));
dropped = dice.filter((_, i) => dropIndices.has(i));
} else if (parsed.keepDrop.type === "dl") {
sorted.sort((a, b) => a.die.value - b.die.value);
const dropIndices = new Set(
sorted.slice(0, parsed.keepDrop.count).map((x) => x.idx),
);
kept = dice.filter((_, i) => !dropIndices.has(i));
dropped = dice.filter((_, i) => dropIndices.has(i));
}
}
dropped.forEach((d) => {
d.dropped = true;
});
// Calculate total
let total = kept.reduce((sum, d) => sum + d.value, 0);
total += kept.reduce(
(sum, d) => sum + d.explosions.reduce((s, v) => s + v, 0),
0,
);
total += parsed.modifier;
return {
dice,
kept,
dropped,
modifier: parsed.modifier,
total,
notation: "", // filled by caller
advantageRolls: null,
};
}
/** Roll with advantage or disadvantage (2dX keep highest/lowest) */
export function rollAdvantage(
sides: number,
modifier: number,
advantage: boolean,
): RollResult {
const r1 = Math.floor(Math.random() * sides) + 1;
const r2 = Math.floor(Math.random() * sides) + 1;
const keptVal = advantage ? Math.max(r1, r2) : Math.min(r1, r2);
return {
dice: [
{
value: keptVal,
original: keptVal,
exploded: false,
explosions: [],
rerolledFrom: null,
dropped: false,
},
],
kept: [
{
value: keptVal,
original: keptVal,
exploded: false,
explosions: [],
rerolledFrom: null,
dropped: false,
},
],
dropped: [],
modifier,
total: keptVal + modifier,
notation: "",
advantageRolls: [r1, r2],
};
}
/** Build notation string from parsed object (basic only, for simple sync) */
export function buildNotation(
count: number,
sides: number,
modifier: number,
): string {
let s = `${count}d${sides}`;
if (modifier > 0) s += `+${modifier}`;
else if (modifier < 0) s += modifier;
return s;
}
/** Check if notation contains advanced features beyond basic XdY±Z */
export function isAdvancedNotation(raw: string): boolean {
const s = normalize(raw);
return /(kh|kl|dh|dl|!|ro?[<>]?\d)/.test(s);
}
+27
View File
@@ -0,0 +1,27 @@
import { z } from "zod";
export const generatorSchema = z
.object({
slug: z.string(),
title: z.string(),
description: z.string(),
icon: z.string(),
status: z.enum(["live", "coming-soon"]),
seoTitle: z.string(),
seoDescription: z.string(),
ruTitle: z.string(),
ruDescription: z.string(),
ruSeoTitle: z.string(),
ruSeoDescription: z.string(),
pageTitle: z.string(),
ruPageTitle: z.string(),
howTo: z.array(z.string()),
whenTo: z.array(z.string()),
ruHowTo: z.array(z.string()),
ruWhenTo: z.array(z.string()),
faq: z.array(z.object({ q: z.string(), a: z.string() })).optional(),
ruFaq: z.array(z.object({ q: z.string(), a: z.string() })).optional(),
})
.strict();
export type Generator = z.infer<typeof generatorSchema>;
+95
View File
@@ -0,0 +1,95 @@
---
import BaseLayout from "@/layouts/BaseLayout.astro";
import { useT } from "@/i18n/translations";
import type { Lang } from "@/i18n/translations";
const lang = (Astro.currentLocale as Lang) || "en";
const T = useT(lang);
const aboutSchema = {
"@context": "https://schema.org",
"@type": "AboutPage",
name: T.aboutTitle,
description: T.aboutMission,
url: "https://randify.pro/about/",
inLanguage: lang,
};
---
<BaseLayout title={`${T.aboutTitle} | Randify`} description={T.aboutMission}>
<script type="application/ld+json" set:html={JSON.stringify(aboutSchema)} slot="head" />
<div class="max-w-2xl mx-auto px-4 py-12 sm:py-20">
<nav aria-label="Breadcrumb" class="mb-10">
<a
href="/"
class="inline-flex items-center gap-1.5 text-sm text-zinc-600 hover:text-zinc-300 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 rounded"
aria-label={T.backToAll}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="m15 18-6-6 6-6"></path>
</svg>
{T.backToAll}
</a>
</nav>
<header class="mb-2">
<div class="flex items-center gap-2 mb-4">
<span
class="inline-block w-2.5 h-2.5 rounded-full bg-accent"
aria-hidden="true"></span>
<span
class="text-sm font-medium text-zinc-400 uppercase tracking-widest"
>randify</span
>
</div>
<h1 class="text-3xl sm:text-4xl font-bold text-zinc-100">
{T.aboutTitle}
</h1>
<div class="w-16 h-0.5 bg-accent/60 rounded-full mt-4" aria-hidden="true"></div>
</header>
<div class="border-t border-zinc-800/60 pt-8 mt-8 space-y-8">
<section>
<h2 class="text-lg font-semibold text-zinc-100 mb-2">Mission</h2>
<p class="text-zinc-400 leading-relaxed">{T.aboutMission}</p>
</section>
<section>
<h2 class="text-lg font-semibold text-zinc-100 mb-2">History</h2>
{T.aboutHistory.split("\n\n").map((paragraph) => (
<p class="text-zinc-400 leading-relaxed mb-4 last:mb-0">{paragraph}</p>
))}
</section>
<section>
<h2 class="text-lg font-semibold text-zinc-100 mb-2">How it works</h2>
<p class="text-zinc-400 leading-relaxed">{T.aboutHowItWorks}</p>
</section>
<section>
<h2 class="text-lg font-semibold text-zinc-100 mb-2">Contact</h2>
<p class="text-zinc-400 leading-relaxed">{T.aboutContact}</p>
</section>
<section class="pt-4">
<a
href="/"
class="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-accent text-white font-medium hover:bg-accent-hover active:bg-accent-active transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950"
>
{T.aboutCta}
</a>
</section>
</div>
</div>
</BaseLayout>
+11
View File
@@ -0,0 +1,11 @@
---
import GeneratorLayout from "@/layouts/GeneratorLayout.astro";
import CardGenerator from "@/components/generators/CardGenerator.astro";
import { generators } from "@/data/generators";
const generator = generators.find((g) => g.slug === "cards")!;
---
<GeneratorLayout generator={generator}>
<CardGenerator />
</GeneratorLayout>
+11
View File
@@ -0,0 +1,11 @@
---
import GeneratorLayout from "@/layouts/GeneratorLayout.astro";
import CoinGenerator from "@/components/generators/CoinGenerator.astro";
import { generators } from "@/data/generators";
const generator = generators.find((g) => g.slug === "coin")!;
---
<GeneratorLayout generator={generator}>
<CoinGenerator />
</GeneratorLayout>

Some files were not shown because too many files have changed in this diff Show More