feat: add 6 new generators + Privacy Policy

New generators:
- Yes or No — random Yes/No/Maybe with confidence bar
- Random Name — male/female/any names (EN + RU databases)
- Team Splitter — split list into random teams
- Random Letter — A-Z / А-Я alphabet picker
- Random Date — date between two dates
- Rock Paper Scissors — play against random bot

Also added:
- Privacy Policy page (/privacy, /ru/privacy)
- FAQ blocks on all 16 generators (3 Q&A each, EN+RU)
- About page (/about, /ru/about)
- Improved Yandex RTB ad placement with min-height placeholder
- Navigation: About + Privacy links in LanguageSwitcher

Build: 38 pages, all passing
This commit is contained in:
emil
2026-05-11 23:34:31 +03:00
parent 6f8264a0fb
commit 02f9ada193
29 changed files with 1795 additions and 0 deletions
+6
View File
@@ -29,6 +29,12 @@ const icons: Record<string, string> = {
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>`,
};
---
+7
View File
@@ -32,6 +32,13 @@ const alternatePath = isRu
>
{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>
@@ -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,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,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,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,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,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>
+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": "Да. Дата выбирается с помощью криптографически стойкого генератора случайных чисел, поэтому каждый день в диапазоне имеет равный шанс." }
]
}
+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": "Да. Учителя и ученики используют генератор для алфавитных упражнений, словарных игр и практики произношения." }
]
}
+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": "В списке широко распространённые имена. Некоторые чаще встречаются в определённых регионах, но все они международно узнаваемы." }
]
}
+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": "Нет. Каждый ход полностью независимый и случайный. Компьютер не адаптируется и не запоминает ваши предыдущие выборы." }
]
}
+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": "Да. Вы можете разделить на группы любые текстовые элементы — задачи, темы или предметы." }
]
}
+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": "Лучше всего подходит для лёгких или шуточных выборов. В важных вопросах доверяйте собственному мнению." }
]
}
+28
View File
@@ -80,6 +80,20 @@ export const translations = {
"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: "Все генераторы",
@@ -160,6 +174,20 @@ export const translations = {
"Есть предложение, нашли ошибку или просто хотите поздороваться? Напишите нам на 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;
+11
View File
@@ -0,0 +1,11 @@
---
import GeneratorLayout from '@/layouts/GeneratorLayout.astro';
import DateGenerator from '@/components/generators/DateGenerator.astro';
import { generators } from '@/data/generators';
const generator = generators.find((g) => g.slug === 'date')!;
---
<GeneratorLayout generator={generator}>
<DateGenerator />
</GeneratorLayout>
+11
View File
@@ -0,0 +1,11 @@
---
import GeneratorLayout from '@/layouts/GeneratorLayout.astro';
import LetterGenerator from '@/components/generators/LetterGenerator.astro';
import { generators } from '@/data/generators';
const generator = generators.find((g) => g.slug === 'letter')!;
---
<GeneratorLayout generator={generator}>
<LetterGenerator />
</GeneratorLayout>
+11
View File
@@ -0,0 +1,11 @@
---
import GeneratorLayout from '@/layouts/GeneratorLayout.astro';
import NamesGenerator from '@/components/generators/NamesGenerator.astro';
import { generators } from '@/data/generators';
const generator = generators.find((g) => g.slug === 'names')!;
---
<GeneratorLayout generator={generator}>
<NamesGenerator />
</GeneratorLayout>
+11
View File
@@ -0,0 +1,11 @@
---
import GeneratorLayout from '@/layouts/GeneratorLayout.astro';
import RpsGenerator from '@/components/generators/RpsGenerator.astro';
import { generators } from '@/data/generators';
const generator = generators.find((g) => g.slug === 'rps')!;
---
<GeneratorLayout generator={generator}>
<RpsGenerator />
</GeneratorLayout>
+11
View File
@@ -0,0 +1,11 @@
---
import GeneratorLayout from '@/layouts/GeneratorLayout.astro';
import TeamsGenerator from '@/components/generators/TeamsGenerator.astro';
import { generators } from '@/data/generators';
const generator = generators.find((g) => g.slug === 'teams')!;
---
<GeneratorLayout generator={generator}>
<TeamsGenerator />
</GeneratorLayout>
+11
View File
@@ -0,0 +1,11 @@
---
import GeneratorLayout from '@/layouts/GeneratorLayout.astro';
import YesNoGenerator from '@/components/generators/YesNoGenerator.astro';
import { generators } from '@/data/generators';
const generator = generators.find((g) => g.slug === 'yesno')!;
---
<GeneratorLayout generator={generator}>
<YesNoGenerator />
</GeneratorLayout>
+85
View File
@@ -0,0 +1,85 @@
---
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);
---
<BaseLayout title={`${T.privacyTitle} | Randify`} description={T.privacyIntro}>
<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.privacyTitle}
</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>
<p class="text-zinc-400 leading-relaxed">{T.privacyIntro}</p>
</section>
<section>
<h2 class="text-lg font-semibold text-zinc-100 mb-2">Data Collection</h2>
<p class="text-zinc-400 leading-relaxed">{T.privacyData}</p>
</section>
<section>
<h2 class="text-lg font-semibold text-zinc-100 mb-2">Cookies & Local Storage</h2>
<p class="text-zinc-400 leading-relaxed">{T.privacyCookies}</p>
</section>
<section>
<h2 class="text-lg font-semibold text-zinc-100 mb-2">Third-Party Services</h2>
{T.privacyThirdParty.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">Contact</h2>
<p class="text-zinc-400 leading-relaxed">{T.privacyContact}</p>
</section>
<section>
<h2 class="text-lg font-semibold text-zinc-100 mb-2">Changes to This Policy</h2>
<p class="text-zinc-400 leading-relaxed">{T.privacyChanges}</p>
</section>
</div>
</div>
</BaseLayout>
+11
View File
@@ -0,0 +1,11 @@
---
import GeneratorLayout from '@/layouts/GeneratorLayout.astro';
import DateGenerator from '@/components/generators/DateGenerator.astro';
import { generators } from '@/data/generators';
const generator = generators.find((g) => g.slug === 'date')!;
---
<GeneratorLayout generator={generator}>
<DateGenerator />
</GeneratorLayout>
+11
View File
@@ -0,0 +1,11 @@
---
import GeneratorLayout from '@/layouts/GeneratorLayout.astro';
import LetterGenerator from '@/components/generators/LetterGenerator.astro';
import { generators } from '@/data/generators';
const generator = generators.find((g) => g.slug === 'letter')!;
---
<GeneratorLayout generator={generator}>
<LetterGenerator />
</GeneratorLayout>
+11
View File
@@ -0,0 +1,11 @@
---
import GeneratorLayout from '@/layouts/GeneratorLayout.astro';
import NamesGenerator from '@/components/generators/NamesGenerator.astro';
import { generators } from '@/data/generators';
const generator = generators.find((g) => g.slug === 'names')!;
---
<GeneratorLayout generator={generator}>
<NamesGenerator />
</GeneratorLayout>
+11
View File
@@ -0,0 +1,11 @@
---
import GeneratorLayout from '@/layouts/GeneratorLayout.astro';
import RpsGenerator from '@/components/generators/RpsGenerator.astro';
import { generators } from '@/data/generators';
const generator = generators.find((g) => g.slug === 'rps')!;
---
<GeneratorLayout generator={generator}>
<RpsGenerator />
</GeneratorLayout>
+11
View File
@@ -0,0 +1,11 @@
---
import GeneratorLayout from '@/layouts/GeneratorLayout.astro';
import TeamsGenerator from '@/components/generators/TeamsGenerator.astro';
import { generators } from '@/data/generators';
const generator = generators.find((g) => g.slug === 'teams')!;
---
<GeneratorLayout generator={generator}>
<TeamsGenerator />
</GeneratorLayout>
+11
View File
@@ -0,0 +1,11 @@
---
import GeneratorLayout from '@/layouts/GeneratorLayout.astro';
import YesNoGenerator from '@/components/generators/YesNoGenerator.astro';
import { generators } from '@/data/generators';
const generator = generators.find((g) => g.slug === 'yesno')!;
---
<GeneratorLayout generator={generator}>
<YesNoGenerator />
</GeneratorLayout>
+85
View File
@@ -0,0 +1,85 @@
---
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);
---
<BaseLayout title={`${T.privacyTitle} | Randify`} description={T.privacyIntro}>
<div class="max-w-2xl mx-auto px-4 py-12 sm:py-20">
<nav aria-label="Breadcrumb" class="mb-10">
<a
href="/ru/"
class="inline-flex items-center gap-1.5 text-sm text-zinc-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.privacyTitle}
</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>
<p class="text-zinc-400 leading-relaxed">{T.privacyIntro}</p>
</section>
<section>
<h2 class="text-lg font-semibold text-zinc-100 mb-2">Сбор данных</h2>
<p class="text-zinc-400 leading-relaxed">{T.privacyData}</p>
</section>
<section>
<h2 class="text-lg font-semibold text-zinc-100 mb-2">Cookies и локальное хранилище</h2>
<p class="text-zinc-400 leading-relaxed">{T.privacyCookies}</p>
</section>
<section>
<h2 class="text-lg font-semibold text-zinc-100 mb-2">Сторонние сервисы</h2>
{T.privacyThirdParty.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">Контакты</h2>
<p class="text-zinc-400 leading-relaxed">{T.privacyContact}</p>
</section>
<section>
<h2 class="text-lg font-semibold text-zinc-100 mb-2">Изменения политики</h2>
<p class="text-zinc-400 leading-relaxed">{T.privacyChanges}</p>
</section>
</div>
</div>
</BaseLayout>