feat: add 4 new generators (Palette, Lorem, Country, Weighted)
- Color Palette: 7 harmony modes (Random, Analogous, Complementary, Triadic, Monochromatic, Split-Complementary, Tetradic), 3-8 colors, individual swatch copy + copy all - Lorem Ipsum: 1-20 paragraphs, Short/Medium/Long length, optional classic start, full a11y support - Random Country: 40 countries, region filter, flag emoji + capital + population, bilingual data - Weighted Random: dynamic item-weight rows, validation, single pick + N-trials with statistics bars All generators include EN/RU i18n, SEO metadata, FAQ, and follow project patterns.
This commit is contained in:
@@ -3,3 +3,4 @@ dist/
|
||||
.env
|
||||
*.log
|
||||
.astro/
|
||||
.kimi/
|
||||
|
||||
@@ -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,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,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,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,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": "Численность населения приблизительная и основана на недавних оценках. Она предназначена для повседневного использования, а не для точных исследований." }
|
||||
]
|
||||
}
|
||||
@@ -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": "Да. Каждый абзац собирается из перемешанного набора классических латинских слов, поэтому каждая генерация уникальна." }
|
||||
]
|
||||
}
|
||||
@@ -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 цветов с помощью селектора количества." }
|
||||
]
|
||||
}
|
||||
@@ -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": "Да. Генератор использует криптографически стойкий генератор случайных чисел, чтобы гарантировать честный и непредсказуемый результат каждый раз." }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
import GeneratorLayout from "@/layouts/GeneratorLayout.astro";
|
||||
import CountryGenerator from "@/components/generators/CountryGenerator.astro";
|
||||
import { generators } from "@/data/generators";
|
||||
|
||||
const generator = generators.find((g) => g.slug === "country")!;
|
||||
---
|
||||
|
||||
<GeneratorLayout generator={generator}>
|
||||
<CountryGenerator />
|
||||
</GeneratorLayout>
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
import GeneratorLayout from "@/layouts/GeneratorLayout.astro";
|
||||
import LoremGenerator from "@/components/generators/LoremGenerator.astro";
|
||||
import { generators } from "@/data/generators";
|
||||
|
||||
const generator = generators.find((g) => g.slug === "lorem")!;
|
||||
---
|
||||
|
||||
<GeneratorLayout generator={generator}>
|
||||
<LoremGenerator />
|
||||
</GeneratorLayout>
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
import GeneratorLayout from "@/layouts/GeneratorLayout.astro";
|
||||
import PaletteGenerator from "@/components/generators/PaletteGenerator.astro";
|
||||
import { generators } from "@/data/generators";
|
||||
|
||||
const generator = generators.find((g) => g.slug === "palette")!;
|
||||
---
|
||||
|
||||
<GeneratorLayout generator={generator}>
|
||||
<PaletteGenerator />
|
||||
</GeneratorLayout>
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
import GeneratorLayout from "@/layouts/GeneratorLayout.astro";
|
||||
import WeightedGenerator from "@/components/generators/WeightedGenerator.astro";
|
||||
import { generators } from "@/data/generators";
|
||||
|
||||
const generator = generators.find((g) => g.slug === "weighted")!;
|
||||
---
|
||||
|
||||
<GeneratorLayout generator={generator}>
|
||||
<WeightedGenerator />
|
||||
</GeneratorLayout>
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
import GeneratorLayout from "@/layouts/GeneratorLayout.astro";
|
||||
import CountryGenerator from "@/components/generators/CountryGenerator.astro";
|
||||
import { generators } from "@/data/generators";
|
||||
|
||||
const generator = generators.find((g) => g.slug === "country")!;
|
||||
---
|
||||
|
||||
<GeneratorLayout generator={generator}>
|
||||
<CountryGenerator />
|
||||
</GeneratorLayout>
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
import GeneratorLayout from "@/layouts/GeneratorLayout.astro";
|
||||
import LoremGenerator from "@/components/generators/LoremGenerator.astro";
|
||||
import { generators } from "@/data/generators";
|
||||
|
||||
const generator = generators.find((g) => g.slug === "lorem")!;
|
||||
---
|
||||
|
||||
<GeneratorLayout generator={generator}>
|
||||
<LoremGenerator />
|
||||
</GeneratorLayout>
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
import GeneratorLayout from "@/layouts/GeneratorLayout.astro";
|
||||
import PaletteGenerator from "@/components/generators/PaletteGenerator.astro";
|
||||
import { generators } from "@/data/generators";
|
||||
|
||||
const generator = generators.find((g) => g.slug === "palette")!;
|
||||
---
|
||||
|
||||
<GeneratorLayout generator={generator}>
|
||||
<PaletteGenerator />
|
||||
</GeneratorLayout>
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
import GeneratorLayout from "@/layouts/GeneratorLayout.astro";
|
||||
import WeightedGenerator from "@/components/generators/WeightedGenerator.astro";
|
||||
import { generators } from "@/data/generators";
|
||||
|
||||
const generator = generators.find((g) => g.slug === "weighted")!;
|
||||
---
|
||||
|
||||
<GeneratorLayout generator={generator}>
|
||||
<WeightedGenerator />
|
||||
</GeneratorLayout>
|
||||
Reference in New Issue
Block a user