feat(blog+categories): Wave 3-5 — blog infrastructure, categories, content, navigation
- Install @astrojs/rss and setup content collections (blog + generators) - Create BlogLayout with BlogPosting schema, article OG tags, prev/next nav - Create blog post pages EN/RU ([slug].astro) with dynamic routing - Create blog index pages EN/RU with post cards and filtering - Create RSS feeds EN/RU with auto-discovery links - Create generator category pages EN/RU (5 categories each) - Add category navigation pills to homepage - Create Breadcrumbs, RelatedGenerators, RelatedPosts components - Add breadcrumbs to GeneratorLayout and BlogLayout - Wire RelatedGenerators into GeneratorLayout - Wire RelatedPosts into blog post pages - Create 8 EN + 8 RU blog post templates with placeholder content - Add blog link to LanguageSwitcher navigation - Add category and blog translation keys
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
---
|
||||
interface BreadcrumbItem {
|
||||
label: string;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items: BreadcrumbItem[];
|
||||
}
|
||||
|
||||
const { items } = Astro.props;
|
||||
---
|
||||
|
||||
<nav aria-label="Breadcrumb">
|
||||
<ol class="flex flex-wrap items-center gap-1.5 text-sm">
|
||||
{
|
||||
items.map((item, index) => {
|
||||
const isLast = index === items.length - 1;
|
||||
return (
|
||||
<li class="flex items-center gap-1.5">
|
||||
{index > 0 && (
|
||||
<span class="text-zinc-600" aria-hidden="true">/</span>
|
||||
)}
|
||||
{isLast || !item.href ? (
|
||||
<span
|
||||
class="text-zinc-400"
|
||||
aria-current={isLast ? "page" : undefined}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
) : (
|
||||
<a
|
||||
href={item.href}
|
||||
class="text-zinc-500 hover:text-accent 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"
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})
|
||||
}
|
||||
</ol>
|
||||
</nav>
|
||||
@@ -26,6 +26,13 @@ const alternatePath = isRu
|
||||
data-alternate={isRu ? "" : alternatePath}>RU</a
|
||||
>
|
||||
<span class="w-px h-3 bg-zinc-700 mx-0.5" aria-hidden="true"></span>
|
||||
<a
|
||||
href={isRu ? "/ru/blog/" : "/blog/"}
|
||||
class="px-2 py-1 rounded-md transition-colors text-zinc-400 hover:text-zinc-200"
|
||||
>
|
||||
{isRu ? "Блог" : "Blog"}
|
||||
</a>
|
||||
<span class="w-px h-3 bg-zinc-700 mx-0.5" aria-hidden="true"></span>
|
||||
<a
|
||||
href={isRu ? "/ru/about/" : "/about/"}
|
||||
class="px-2 py-1 rounded-md transition-colors text-zinc-400 hover:text-zinc-200"
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
import { generators } from "@/data/generators";
|
||||
import { useT } from "@/i18n/translations";
|
||||
import type { Lang } from "@/i18n/translations";
|
||||
|
||||
interface Props {
|
||||
currentSlug: string;
|
||||
category: string;
|
||||
lang?: Lang;
|
||||
}
|
||||
|
||||
const { currentSlug, category, lang: langProp } = Astro.props;
|
||||
const lang = langProp || (Astro.currentLocale as Lang) || "en";
|
||||
const T = useT(lang);
|
||||
const isRu = lang === "ru";
|
||||
|
||||
const related = generators
|
||||
.filter((g) => g.category === category && g.slug !== currentSlug)
|
||||
.slice(0, 4);
|
||||
|
||||
if (related.length === 0) return null;
|
||||
|
||||
const icons: Record<string, string> = {
|
||||
hash: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" x2="20" y1="9" y2="9"/><line x1="4" x2="20" y1="15" y2="15"/><line x1="10" x2="8" y1="3" y2="21"/><line x1="16" x2="14" y1="3" y2="21"/></svg>`,
|
||||
palette: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="13.5" cy="6.5" r=".5" fill="currentColor"/><circle cx="17.5" cy="10.5" r=".5" fill="currentColor"/><circle cx="8.5" cy="7.5" r=".5" fill="currentColor"/><circle cx="6.5" cy="12.5" r=".5" fill="currentColor"/><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z"/></svg>`,
|
||||
lock: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>`,
|
||||
ticket: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 9a3 3 0 0 1 0 6v2a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-2a3 3 0 0 1 0-6V7a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2Z"/><path d="M13 5v2"/><path d="M13 17v2"/><path d="M13 11v2"/></svg>`,
|
||||
"dice-6": `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><path d="M16 8h.01"/><path d="M16 12h.01"/><path d="M16 16h.01"/><path d="M8 8h.01"/><path d="M8 12h.01"/><path d="M8 16h.01"/></svg>`,
|
||||
"square-stack": `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 10c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h4c1.1 0 2 .9 2 2"/><path d="M10 16c-1.1 0-2-.9-2-2v-4c0-1.1.9-2 2-2h4c1.1 0 2 .9 2 2"/><rect width="8" height="8" x="14" y="14" rx="2"/></svg>`,
|
||||
"circle-dollar-sign": `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8"/><path d="M12 18V6"/></svg>`,
|
||||
list: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" x2="21" y1="6" y2="6"/><line x1="8" x2="21" y1="12" y2="12"/><line x1="8" x2="21" y1="18" y2="18"/><line x1="3" x2="3.01" y1="6" y2="6"/><line x1="3" x2="3.01" y1="12" y2="12"/><line x1="3" x2="3.01" y1="18" y2="18"/></svg>`,
|
||||
fingerprint: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 10a2 2 0 0 0-2 2c0 1.02-.1 2.51-.26 4"/><path d="M14 13.12c0 2.38 0 6.38-1 8.88"/><path d="M17.29 21.02c.12-.6.43-2.3.5-3.02"/><path d="M2 12a10 10 0 0 1 18-6"/><path d="M2 17c1 .5 2.25 1 4 1 1.5 0 3-.5 4-1"/><path d="M20 12c0 2-.5 4.5-2 6"/><path d="M7 13.02c0 2.38 0 5.5 1 7.48"/><path d="M8.5 8.5A5 5 0 0 1 17 12"/></svg>`,
|
||||
"pie-chart": `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.21 15.89A10 10 0 1 1 8 2.83"/><path d="M22 12A10 10 0 0 0 12 2v10z"/></svg>`,
|
||||
"help-circle": `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/></svg>`,
|
||||
user: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>`,
|
||||
users: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>`,
|
||||
type: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 7 4 4 20 4 20 7"/><line x1="9" x2="15" y1="20" y2="20"/><line x1="12" x2="12" y1="4" y2="20"/></svg>`,
|
||||
calendar: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="4" rx="2" ry="2"/><line x1="16" x2="16" y1="2" y2="6"/><line x1="8" x2="8" y1="2" y2="6"/><line x1="3" x2="21" y1="10" y2="10"/></svg>`,
|
||||
hand: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 11V6a2 2 0 0 0-2-2v0a2 2 0 0 0-2 2v0"/><path d="M14 10V4a2 2 0 0 0-2-2v0a2 2 0 0 0-2 2v2"/><path d="M10 10.5V6a2 2 0 0 0-2-2v0a2 2 0 0 0-2 2v8"/><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/></svg>`,
|
||||
smile: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" x2="9.01" y1="9" y2="9"/><line x1="15" x2="15.01" y1="9" y2="9"/></svg>`,
|
||||
paintbrush: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m14.622 17.897-10.68-2.913"/><path d="M18.376 2.622a1 1 0 1 1 3.002 3.002L17.36 9.643a.5.5 0 0 0 0 .707l.944.944a2.41 2.41 0 0 1 0 3.408l-.944.944a.5.5 0 0 1-.707 0L8.354 7.348a.5.5 0 0 1 0-.707l.944-.944a2.41 2.41 0 0 1 3.408 0l.944.944a.5.5 0 0 0 .707 0z"/><path d="M9 8c-1.804 2.71-3.97 3.46-6.583 3.948a.507.507 0 0 0-.302.819l7.32 8.883a1 1 0 0 0 1.185.204C12.735 20.405 16 16.792 16 15"/></svg>`,
|
||||
shuffle: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 18h1.4c1.3 0 2.5-.6 3.3-1.7l14.4-14.4"/><path d="M16 2h6v6"/><path d="M22 18l-5.8-5.8"/><path d="M22 18h-1.4c-1.3 0-2.5.6-3.3 1.7l-4 5"/><path d="M8 22h-6v-6"/><path d="m2 6 5.8 5.8"/></svg>`,
|
||||
sparkles: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/><path d="M5 3v4"/><path d="M9 5H5"/><path d="M19 10v4"/><path d="M19 17h4"/><path d="M15 19h4"/></svg>`,
|
||||
font: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7V4h16v3"/><path d="M9 20h6"/><path d="M12 4v16"/></svg>`,
|
||||
clock: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>`,
|
||||
utensils: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 2v7c0 1.1.9 2 2 2h4a2 2 0 0 0 2-2V2"/><path d="M7 2v20"/><path d="M21 15V2v0a5 5 0 0 0-5 5v6c0 1.1.9 2 2 2h3Zm0 0v7"/></svg>`,
|
||||
};
|
||||
---
|
||||
|
||||
<section class="mt-12">
|
||||
<h2 class="text-lg font-semibold text-zinc-100 mb-4">{T.relatedGenerators}</h2>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{
|
||||
related.map((g) => {
|
||||
const href = isRu
|
||||
? `/ru/generators/${g.slug}/`
|
||||
: `/generators/${g.slug}/`;
|
||||
const title = isRu ? g.ruTitle : g.title;
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
class="group flex items-center gap-3 p-3 border border-zinc-800 rounded-lg hover:border-accent hover:bg-zinc-900/50 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950"
|
||||
>
|
||||
<div
|
||||
class="inline-flex w-8 h-8 rounded-lg bg-accent/10 items-center justify-center text-accent shrink-0"
|
||||
aria-hidden="true"
|
||||
set:html={icons[g.icon] ?? icons["hash"]}
|
||||
/>
|
||||
<span class="text-sm font-medium text-zinc-300 group-hover:text-accent transition-colors">
|
||||
{title}
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
import { getCollection } from "astro:content";
|
||||
import type { CollectionEntry } from "astro:content";
|
||||
import { useT } from "@/i18n/translations";
|
||||
import type { Lang } from "@/i18n/translations";
|
||||
|
||||
interface Props {
|
||||
post: CollectionEntry<"blog">;
|
||||
}
|
||||
|
||||
const { post } = Astro.props;
|
||||
const lang = post.data.lang as Lang;
|
||||
const T = useT(lang);
|
||||
const isRu = lang === "ru";
|
||||
|
||||
const allPosts = await getCollection(
|
||||
"blog",
|
||||
({ data }) => !data.draft && data.lang === lang,
|
||||
);
|
||||
|
||||
const related = allPosts
|
||||
.filter((p) => p.id !== post.id)
|
||||
.map((p) => ({
|
||||
post: p,
|
||||
score: p.data.tags.filter((t) => post.data.tags.includes(t)).length,
|
||||
}))
|
||||
.filter(({ score }) => score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, 3)
|
||||
.map(({ post }) => post);
|
||||
|
||||
if (related.length === 0) return null;
|
||||
|
||||
function getSlug(p: CollectionEntry<"blog">) {
|
||||
return p.id.replace(/^(en|ru)\//, "").replace(/\.md$/, "");
|
||||
}
|
||||
|
||||
const blogBase = isRu ? "/ru/blog/" : "/blog/";
|
||||
---
|
||||
|
||||
<section class="mt-12 pt-10 border-t border-zinc-800/60">
|
||||
<h2 class="text-lg font-semibold text-zinc-100 mb-4">{T.relatedPosts}</h2>
|
||||
<div class="flex flex-col gap-4">
|
||||
{
|
||||
related.map((p) => {
|
||||
const slug = getSlug(p);
|
||||
const href = `${blogBase}${slug}/`;
|
||||
const dateStr = new Date(p.data.pubDate).toLocaleDateString(
|
||||
isRu ? "ru-RU" : "en-US",
|
||||
{ year: "numeric", month: "long", day: "numeric" },
|
||||
);
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
class="group block p-4 border border-zinc-800 rounded-lg hover:border-accent hover:bg-zinc-900/50 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950"
|
||||
>
|
||||
<span class="block text-sm font-medium text-zinc-300 group-hover:text-accent transition-colors">
|
||||
{p.data.title}
|
||||
</span>
|
||||
<span class="block text-xs text-zinc-500 mt-1">{dateStr}</span>
|
||||
<span class="block text-sm text-zinc-400 mt-2 leading-relaxed line-clamp-2">
|
||||
{p.data.description}
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
title: "Color Theory: How to Generate Harmonious Palettes"
|
||||
description: "Learn the basics of color theory and how to create beautiful, harmonious color palettes for your projects."
|
||||
pubDate: 2025-01-09
|
||||
modDate: 2025-01-09
|
||||
draft: false
|
||||
lang: en
|
||||
category: guide
|
||||
tags: ["color", "design", "palette"]
|
||||
relatedGenerators: ["colors", "gradient", "palette"]
|
||||
---
|
||||
|
||||
This article will explore the principles of color theory that help designers and developers build visually cohesive palettes. From complementary contrasts to analogous harmony, understanding these relationships is the foundation of good design. Stay tuned for the full guide.
|
||||
|
||||
We will break down concepts like hue, saturation, and brightness, and show you how tools like the color wheel translate theory into practical decisions. You will also learn about accessibility considerations, including contrast ratios and color blindness.
|
||||
|
||||
Finally, this guide will walk you through using palette generators to experiment quickly and export ready-to-use color codes. Great color choices are not just about taste, they are about structure, context, and user experience.
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
title: "Dice Notation Explained: From d4 to Exploding Dice"
|
||||
description: "Everything you need to know about dice notation for D&D, Pathfinder, and tabletop RPGs."
|
||||
pubDate: 2025-01-13
|
||||
modDate: 2025-01-13
|
||||
draft: false
|
||||
lang: en
|
||||
category: guide
|
||||
tags: ["dice", "gaming", "rpg"]
|
||||
relatedGenerators: ["dice"]
|
||||
---
|
||||
|
||||
This article will explore the standard dice notation used in tabletop role-playing games and explain what terms like 2d6, d20, and exploding dice really mean. Whether you are new to RPGs or a seasoned dungeon master, a solid grasp of notation makes gameplay smoother. Stay tuned for the full guide.
|
||||
|
||||
We will break down how to read roll expressions, interpret modifiers, and understand advanced mechanics such as advantage, disadvantage, and critical thresholds. Knowing the math behind the rolls can also help game masters design fairer encounters.
|
||||
|
||||
Finally, this guide will show you how to use digital dice rollers when physical dice are not available, and how random number generators can replicate complex roll formulas with perfect accuracy.
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
title: "How to Create Strong Passwords: A Complete Guide"
|
||||
description: "Master the art of creating unbreakable passwords with our comprehensive guide to password security."
|
||||
pubDate: 2025-01-14
|
||||
modDate: 2025-01-14
|
||||
draft: false
|
||||
lang: en
|
||||
category: guide
|
||||
tags: ["password", "security", "guide"]
|
||||
relatedGenerators: ["password", "hash"]
|
||||
---
|
||||
|
||||
This article will explore proven strategies for creating strong, memorable passwords that keep your accounts safe from attackers. We will break down common mistakes and show you how length, complexity, and uniqueness work together. Stay tuned for the full guide.
|
||||
|
||||
You will learn why passphrases can be both easier to remember and harder to crack than traditional passwords. We will also cover the role of password managers and two-factor authentication in building a layered defense.
|
||||
|
||||
Finally, this guide includes a practical checklist you can follow every time you create a new account or update an existing password. Small changes in habit can lead to a dramatic improvement in your overall security posture.
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
title: "The Psychology of Randomness: Why Humans Are Bad at Being Random"
|
||||
description: "Discover the fascinating psychology behind why humans struggle to generate truly random choices and numbers."
|
||||
pubDate: 2025-01-12
|
||||
modDate: 2025-01-12
|
||||
draft: false
|
||||
lang: en
|
||||
category: guide
|
||||
tags: ["randomness", "psychology", "science"]
|
||||
relatedGenerators: ["coin", "yesno", "number"]
|
||||
---
|
||||
|
||||
This article will explore the cognitive biases that make humans surprisingly poor at producing random sequences, choices, and numbers. Our brains are wired to find patterns, which is the exact opposite of what true randomness requires. Stay tuned for the full guide.
|
||||
|
||||
We will look at famous experiments that reveal how people tend to alternate more than they should, avoid repetition, and unconsciously follow hidden rules when asked to act randomly. These findings have real implications for everything from jury selection to lottery number picking.
|
||||
|
||||
Finally, this guide will explain why relying on tools like coin flips, random number generators, and shufflers leads to fairer outcomes than trusting your intuition. When impartiality matters, letting an algorithm decide is often the smartest move.
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
title: "How to Use a Random Team Picker for Sports and Events"
|
||||
description: "Make team selection fair and fun with random team pickers for sports, workshops, and group activities."
|
||||
pubDate: 2025-01-10
|
||||
modDate: 2025-01-10
|
||||
draft: false
|
||||
lang: en
|
||||
category: guide
|
||||
tags: ["teams", "sports", "events"]
|
||||
relatedGenerators: ["teams", "list-shuffler"]
|
||||
---
|
||||
|
||||
This article will explore how random team pickers can eliminate bias, speed up organization, and add a layer of excitement to any group activity. Whether you are coaching a youth league or running a corporate workshop, fair teams lead to better engagement. Stay tuned for the full guide.
|
||||
|
||||
We will discuss different methods for dividing groups, from simple random assignment to balanced algorithms that account for skill levels. You will learn when pure randomness is appropriate and when a little structure improves the experience.
|
||||
|
||||
Finally, this guide includes tips for communicating the process to participants so everyone buys into the fairness of the outcome. Transparency turns a potentially tense moment into a fun and trusted ritual.
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
title: "Random Number Generators in Gaming: Fairness and Algorithms"
|
||||
description: "How RNG works in video games and casinos, and why fairness matters for player trust."
|
||||
pubDate: 2025-01-11
|
||||
modDate: 2025-01-11
|
||||
draft: false
|
||||
lang: en
|
||||
category: guide
|
||||
tags: ["gaming", "rng", "fairness"]
|
||||
relatedGenerators: ["dice", "lottery", "wheel"]
|
||||
---
|
||||
|
||||
This article will explore how random number generators power loot drops, card shuffles, and dice rolls in both video games and casino environments. Understanding the algorithms behind RNG helps players recognize fair systems and spot questionable ones. Stay tuned for the full guide.
|
||||
|
||||
We will compare pseudo-random number generators with hardware-based true randomness, and discuss why most games use the former for performance reasons. You will also learn about seed values and how they can make RNG deterministic for replay verification.
|
||||
|
||||
Finally, this guide covers regulatory standards for casino RNGs and what independent auditing looks like in practice. Fairness is not just a buzzword, it is a technical requirement backed by math and oversight.
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
title: "Test Post"
|
||||
description: "A test blog post for verification."
|
||||
pubDate: 2025-01-15
|
||||
modDate: 2025-01-15
|
||||
draft: false
|
||||
lang: en
|
||||
category: guide
|
||||
tags: ["test"]
|
||||
---
|
||||
|
||||
This is a test blog post.
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
title: "UUID vs Sequential IDs: When to Use What"
|
||||
description: "Compare UUIDs and sequential IDs to choose the right identifier strategy for your application."
|
||||
pubDate: 2025-01-08
|
||||
modDate: 2025-01-08
|
||||
draft: false
|
||||
lang: en
|
||||
category: guide
|
||||
tags: ["uuid", "database", "programming"]
|
||||
relatedGenerators: ["uuid", "hash"]
|
||||
---
|
||||
|
||||
This article will explore the trade-offs between universally unique identifiers and simple sequential IDs in software architecture. Choosing the right identifier affects performance, security, and scalability in ways that are not always obvious at first glance. Stay tuned for the full guide.
|
||||
|
||||
We will compare storage size, indexing behavior, and collision probability across different ID strategies. You will learn why UUIDs excel in distributed systems while sequential IDs remain attractive for single-database applications.
|
||||
|
||||
Finally, this guide will provide decision criteria and practical recommendations based on your specific use case. Whether you are building a small internal tool or a high-scale microservices platform, the right ID strategy saves headaches down the road.
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
title: "What is Cryptographically Secure Randomness and Why It Matters"
|
||||
description: "Learn why cryptographically secure random number generators are essential for passwords, gaming, and security."
|
||||
pubDate: 2025-01-15
|
||||
modDate: 2025-01-15
|
||||
draft: false
|
||||
lang: en
|
||||
category: guide
|
||||
tags: ["randomness", "security", "crypto"]
|
||||
relatedGenerators: ["password", "hash", "uuid"]
|
||||
---
|
||||
|
||||
This article will explore the fundamentals of cryptographically secure randomness and explain why it plays a critical role in modern digital security. From generating passwords to securing communications, understanding the difference between pseudo-random and truly secure randomness is essential. Stay tuned for the full guide.
|
||||
|
||||
We will look at how operating systems gather entropy from hardware events and why developers should never rely on simple Math.random() for anything security-related. The consequences of weak randomness can be severe, ranging from predictable passwords to compromised encryption keys.
|
||||
|
||||
Finally, this guide will walk you through practical examples and tools you can use to ensure your applications are using the strongest sources of randomness available. Whether you are building a web app or managing server infrastructure, these principles apply across the board.
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
title: "Теория цвета: как создавать гармоничные палитры"
|
||||
description: "Изучите основы теории цвета и научитесь создавать красивые, гармоничные цветовые палитры для ваших проектов."
|
||||
pubDate: 2025-01-22
|
||||
modDate: 2025-01-22
|
||||
draft: false
|
||||
lang: ru
|
||||
category: guide
|
||||
tags: ["цвет", "дизайн", "палитра"]
|
||||
relatedGenerators: ["colors", "gradient", "palette"]
|
||||
---
|
||||
|
||||
В этой статье мы рассмотрим основы теории цвета, которая лежит в основе каждого великолепного дизайна. От цветового круга Иттена до современных цифровых палитр — понимание цвета открывает безграничные возможности для творчества. Следите за обновлениями.
|
||||
|
||||
Мы изучим различные схемы гармонии цветов: комплементарные, аналоговые, триадические и сплит-комплементарные. Каждая схема создает уникальное настроение и подходит для разных типов проектов.
|
||||
|
||||
Наконец, вы узнаете, как применять эти принципы на практике при создании веб-сайтов, брендинга и иллюстраций. Случайная генерация палитр может стать отличной отправной точкой для экспериментов и поиска неожиданных сочетаний.
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
title: "Нотация кубиков: от d4 до взрывающихся кубиков"
|
||||
description: "Всё, что нужно знать о нотации кубиков для D&D, Pathfinder и настольных RPG."
|
||||
pubDate: 2025-01-18
|
||||
modDate: 2025-01-18
|
||||
draft: false
|
||||
lang: ru
|
||||
category: guide
|
||||
tags: ["кубики", "игры", "рпг"]
|
||||
relatedGenerators: ["dice"]
|
||||
---
|
||||
|
||||
В этой статье мы рассмотрим мир нотации кубиков, от базовых обозначений d4, d6 и d20 до продвинутых механик вроде взрывающихся кубиков и кубиков с преимуществом. Понимание этой системы открывает дверь к более глубокому погружению в настольные ролевые игры. Следите за обновлениями.
|
||||
|
||||
Мы разберем, как читать запись вида 3d6+2, что означают модификаторы и почему разные игры предпочитают разные типы кубиков. От D&D до Pathfinder и indie-RPG — нотация остается универсальным языком геймеров.
|
||||
|
||||
Наконец, вы узнаете о домашних правилах и вариациях, которые делают броски более интересными. Критические успехи, провалы и специальные механики — всё это становится понятнее, когда вы владеете языком кубиков.
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
title: "Как создавать надёжные пароли: полное руководство"
|
||||
description: "Освойте искусство создания невзламываемых паролей с нашим подробным руководством по безопасности."
|
||||
pubDate: 2025-01-17
|
||||
modDate: 2025-01-17
|
||||
draft: false
|
||||
lang: ru
|
||||
category: guide
|
||||
tags: ["пароль", "безопасность", "гайд"]
|
||||
relatedGenerators: ["password", "hash"]
|
||||
---
|
||||
|
||||
В этой статье мы рассмотрим проверенные стратегии создания сильных, запоминающихся паролей, которые защитят ваши аккаунты от злоумышленников. Мы разберем распространенные ошибки и покажем, как длина, сложность и уникальность работают вместе. Следите за обновлениями.
|
||||
|
||||
Вы узнаете, почему парольные фразы могут быть одновременно легче запомнить и сложнее взломать, чем традиционные пароли. Мы также рассмотрим роль менеджеров паролей и двухфакторной аутентификации в построении многоуровневой защиты.
|
||||
|
||||
Наконец, это руководство включает практический чек-лист, которому вы можете следовать каждый раз при создании нового аккаунта или обновлении существующего пароля. Небольшие изменения в привычках могут привести к значительному улучшению вашей общей безопасности.
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
title: "Психология случайности: почему люди плохо справляются со случайностью"
|
||||
description: "Откройте для себя увлекательную психологию того, почему люди не могут генерировать по-настоящему случайные выборы."
|
||||
pubDate: 2025-01-19
|
||||
modDate: 2025-01-19
|
||||
draft: false
|
||||
lang: ru
|
||||
category: guide
|
||||
tags: ["случайность", "психология", "наука"]
|
||||
relatedGenerators: ["coin", "yesno", "number"]
|
||||
---
|
||||
|
||||
В этой статье мы рассмотрим увлекательную психологию человеческого восприятия случайности. Несмотря на наши убеждения, человеческий мозг плохо приспособлен для генерации или распознавания истинно случайных последовательностей. Следите за обновлениями.
|
||||
|
||||
Мы изучим когнитивные искажения, которые заставляют нас видеть закономерности там, где их нет, и избегать повторений там, где они вполне естественны. Эти механизмы объясняют, почему люди при броске монетки чаще выбирают орел после серии решек.
|
||||
|
||||
Наконец, мы рассмотрим практические применения этих знаний: от улучшения методов шифрования до создания более справедливых игровых механик. Понимание наших ограничений помогает нам строить лучшие инструменты.
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
title: "Как использовать случайный выбор команд для спорта и мероприятий"
|
||||
description: "Сделайте выбор команд честным и весёлым с помощью случайных распределений для спорта и групповых активностей."
|
||||
pubDate: 2025-01-21
|
||||
modDate: 2025-01-21
|
||||
draft: false
|
||||
lang: ru
|
||||
category: guide
|
||||
tags: ["команды", "спорт", "мероприятия"]
|
||||
relatedGenerators: ["teams", "list-shuffler"]
|
||||
---
|
||||
|
||||
В этой статье мы рассмотрим, как случайный выбор команд может сделать спортивные соревнования и групповые мероприятия более честными и увлекательными. Забудьте о спорах и субъективных решениях — случайность решает всё. Следите за обновлениями.
|
||||
|
||||
Мы покажем различные методы распределения участников по командам: от простого случайного разбиения до балансировки по навыкам. Каждый подход имеет свои преимущества в зависимости от типа активности.
|
||||
|
||||
Наконец, вы узнаете, как интегрировать инструменты случайного выбора в организацию школьных турниров, корпоративных тимбилдингов и дружеских встреч. Честное распределение повышает вовлеченность и уменьшает конфликты.
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
title: "Генераторы случайных чисел в играх: честность и алгоритмы"
|
||||
description: "Как работает RNG в видеоиграх и казино, и почему честность важна для доверия игроков."
|
||||
pubDate: 2025-01-20
|
||||
modDate: 2025-01-20
|
||||
draft: false
|
||||
lang: ru
|
||||
category: guide
|
||||
tags: ["игры", "rng", "честность"]
|
||||
relatedGenerators: ["dice", "lottery", "wheel"]
|
||||
---
|
||||
|
||||
В этой статье мы рассмотрим, как генераторы случайных чисел (RNG) формируют современный игровой опыт. От выпадения редких предметов до раздачи карт в онлайн-казино — алгоритмы случайности находятся в центре внимания. Следите за обновлениями.
|
||||
|
||||
Мы разберем разницу между истинно случайными и псевдослучайными генераторами, и почему игровые компании тратят миллионы на сертификацию честности своих алгоритмов. Игроки заслуживают уверенности, что результат не подделан.
|
||||
|
||||
Наконец, мы обсудим прозрачность и регулирование в игровой индустрии. Независимые аудиты и открытые алгоритмы становятся стандартом, который защищает интересы как разработчиков, так и игроков.
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
title: "UUID против последовательных ID: когда что использовать"
|
||||
description: "Сравните UUID и последовательные идентификаторы, чтобы выбрать правильную стратегию для вашего приложения."
|
||||
pubDate: 2025-01-23
|
||||
modDate: 2025-01-23
|
||||
draft: false
|
||||
lang: ru
|
||||
category: guide
|
||||
tags: ["uuid", "база-данных", "программирование"]
|
||||
relatedGenerators: ["uuid", "hash"]
|
||||
---
|
||||
|
||||
В этой статье мы сравним два популярных подхода к идентификации записей в базах данных: UUID и последовательные ID. Каждый метод имеет свои сильные стороны и компромиссы, которые влияют на производительность и безопасность. Следите за обновлениями.
|
||||
|
||||
Мы рассмотрим, когда UUID предпочтительнее: при распределенных системах, публичных API и необходимости скрыть объем данных. Последовательные ID, с другой стороны, проще, компактнее и эффективнее для индексации.
|
||||
|
||||
Наконец, мы обсудим гибридные подходы и лучшие практики выбора стратегии идентификации для разных типов приложений. Правильный выбор на раннем этапе проекта экономит время и ресурсы в будущем.
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
title: "Что такое криптографически стойкая случайность и почему это важно"
|
||||
description: "Узнайте, почему криптографически стойкие генераторы случайных чисел необходимы для паролей, игр и безопасности."
|
||||
pubDate: 2025-01-16
|
||||
modDate: 2025-01-16
|
||||
draft: false
|
||||
lang: ru
|
||||
category: guide
|
||||
tags: ["случайность", "безопасность", "крипто"]
|
||||
relatedGenerators: ["password", "hash", "uuid"]
|
||||
---
|
||||
|
||||
В этой статье мы рассмотрим основы криптографически стойкой случайности и объясним, почему она играет решающую роль в современной цифровой безопасности. От генерации паролей до защиты коммуникаций, понимание разницы между псевдослучайной и по-настоящему безопасной случайностью имеет первостепенное значение. Следите за обновлениями.
|
||||
|
||||
Мы рассмотрим, как операционные системы собирают энтропию из аппаратных событий и почему разработчики никогда не должны полагаться на простой Math.random() для всего, что связано с безопасностью. Последствия слабой случайности могут быть серьезными: от предсказуемых паролей до скомпрометированных ключей шифрования.
|
||||
|
||||
Наконец, это руководство проведет вас через практические примеры и инструменты, которые вы можете использовать, чтобы убедиться, что ваши приложения используют самые надежные источники случайности. Независимо от того, создаете ли вы веб-приложение или управляете серверной инфраструктурой, эти принципы применимы повсеместно.
|
||||
+20
-1
@@ -1,4 +1,4 @@
|
||||
import { defineCollection } from "astro:content";
|
||||
import { defineCollection, z } from "astro:content";
|
||||
import { generatorSchema } from "@/lib/generator-schema";
|
||||
|
||||
const generators = defineCollection({
|
||||
@@ -6,6 +6,25 @@ const generators = defineCollection({
|
||||
schema: generatorSchema,
|
||||
});
|
||||
|
||||
const blog = defineCollection({
|
||||
type: "content",
|
||||
schema: ({ image }) =>
|
||||
z.object({
|
||||
title: z.string().max(120),
|
||||
description: z.string().max(160),
|
||||
pubDate: z.date(),
|
||||
modDate: z.date().optional(),
|
||||
draft: z.boolean().default(false),
|
||||
lang: z.enum(["en", "ru"]),
|
||||
category: z.enum(["tutorial", "guide", "news", "tips"]),
|
||||
tags: z.array(z.string()).default([]),
|
||||
ogImage: image().optional(),
|
||||
relatedGenerators: z.array(z.string()).optional(),
|
||||
relatedPosts: z.array(z.string()).optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const collections = {
|
||||
generators,
|
||||
blog,
|
||||
};
|
||||
|
||||
@@ -70,6 +70,16 @@ export const translations = {
|
||||
backToAll: "All generators",
|
||||
backToHome: "Back to home",
|
||||
blog: "Blog",
|
||||
blogDesc: "Tips, guides, and news about randomness and our generators.",
|
||||
readMore: "Read more",
|
||||
noPosts: "No posts yet.",
|
||||
relatedGenerators: "Related generators",
|
||||
relatedPosts: "Related posts",
|
||||
catGaming: "Gaming",
|
||||
catSecurity: "Security",
|
||||
catDecisionMaking: "Decision Making",
|
||||
catCreative: "Creative",
|
||||
catUtility: "Utility",
|
||||
|
||||
notFoundTitle: "Page Not Found",
|
||||
notFoundMessage:
|
||||
@@ -170,6 +180,16 @@ export const translations = {
|
||||
backToAll: "Все генераторы",
|
||||
backToHome: "Вернуться на главную",
|
||||
blog: "Блог",
|
||||
blogDesc: "Советы, руководства и новости о случайности и наших генераторах.",
|
||||
readMore: "Читать далее",
|
||||
noPosts: "Пока нет записей.",
|
||||
relatedGenerators: "Похожие генераторы",
|
||||
relatedPosts: "Похожие статьи",
|
||||
catGaming: "Игры",
|
||||
catSecurity: "Безопасность",
|
||||
catDecisionMaking: "Принятие решений",
|
||||
catCreative: "Творчество",
|
||||
catUtility: "Утилиты",
|
||||
|
||||
notFoundTitle: "Страница не найдена",
|
||||
notFoundMessage:
|
||||
|
||||
@@ -10,12 +10,15 @@ interface Props {
|
||||
title?: string;
|
||||
description?: string;
|
||||
ogImage?: string;
|
||||
ogType?: string;
|
||||
articlePubDate?: Date;
|
||||
articleModDate?: Date;
|
||||
}
|
||||
|
||||
const lang = (Astro.currentLocale as Lang) || "en";
|
||||
const T = useT(lang);
|
||||
|
||||
const { title = T.defaultTitle, description = T.defaultDesc, ogImage = "/og-default.jpg" } = Astro.props;
|
||||
const { title = T.defaultTitle, description = T.defaultDesc, ogImage = "/og-default.jpg", ogType = "website", articlePubDate, articleModDate } = Astro.props;
|
||||
|
||||
const ogImageUrl = ogImage.startsWith("http") ? ogImage : `https://randify.pro${ogImage}`;
|
||||
|
||||
@@ -61,6 +64,8 @@ const orgSchema = {
|
||||
<link rel="alternate" hreflang="en" href={enUrl} />
|
||||
<link rel="alternate" hreflang="ru" href={ruUrl} />
|
||||
<link rel="alternate" hreflang="x-default" href={enUrl} />
|
||||
<link rel="alternate" type="application/rss+xml" title="Randify Blog (EN)" href="/rss.xml" />
|
||||
<link rel="alternate" type="application/rss+xml" title="Randify Blog (RU)" href="/ru/rss.xml" />
|
||||
<meta name="verification" content="er9ndnv9ih7agmh8" />
|
||||
<script
|
||||
type="application/ld+json"
|
||||
@@ -80,7 +85,9 @@ const orgSchema = {
|
||||
<title>{title}</title>
|
||||
<meta property="og:site_name" content="Randify" />
|
||||
<meta property="og:locale" content={lang === "ru" ? "ru_RU" : "en_US"} />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:type" content={ogType} />
|
||||
{articlePubDate && <meta property="article:published_time" content={articlePubDate.toISOString()} />}
|
||||
{articleModDate && <meta property="article:modified_time" content={articleModDate.toISOString()} />}
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:url" content={canonicalUrl} />
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
---
|
||||
import BaseLayout from "./BaseLayout.astro";
|
||||
import Breadcrumbs from "../components/Breadcrumbs.astro";
|
||||
import type { Lang } from "../i18n/translations";
|
||||
|
||||
interface PostLink {
|
||||
slug: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
pubDate: Date | string;
|
||||
modDate?: Date | string;
|
||||
ogImage?: string;
|
||||
lang?: Lang;
|
||||
prevPost?: PostLink;
|
||||
nextPost?: PostLink;
|
||||
}
|
||||
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
pubDate,
|
||||
modDate,
|
||||
ogImage,
|
||||
lang: langProp,
|
||||
prevPost,
|
||||
nextPost,
|
||||
} = Astro.props;
|
||||
|
||||
const lang = langProp || (Astro.currentLocale as Lang) || "en";
|
||||
const canonicalUrl = `https://randify.pro${Astro.url.pathname}`;
|
||||
const blogBase = lang === "ru" ? "/ru/blog/" : "/blog/";
|
||||
|
||||
const pubDateIso = new Date(pubDate).toISOString();
|
||||
const modDateIso = modDate ? new Date(modDate).toISOString() : pubDateIso;
|
||||
|
||||
const blogSchema = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BlogPosting",
|
||||
headline: title,
|
||||
description: description,
|
||||
url: canonicalUrl,
|
||||
inLanguage: lang,
|
||||
author: {
|
||||
"@type": "Organization",
|
||||
name: "Randify",
|
||||
},
|
||||
publisher: {
|
||||
"@type": "Organization",
|
||||
name: "Randify",
|
||||
logo: {
|
||||
"@type": "ImageObject",
|
||||
url: "https://randify.pro/favicon.png",
|
||||
},
|
||||
},
|
||||
datePublished: pubDateIso,
|
||||
dateModified: modDateIso,
|
||||
};
|
||||
---
|
||||
|
||||
<BaseLayout title={title} description={description} ogImage={ogImage}>
|
||||
<Fragment slot="head">
|
||||
<meta property="og:type" content="article" />
|
||||
<meta property="article:published_time" content={pubDateIso} />
|
||||
<meta property="article:modified_time" content={modDateIso} />
|
||||
<script type="application/ld+json" set:html={JSON.stringify(blogSchema)} />
|
||||
</Fragment>
|
||||
|
||||
<article class="max-w-2xl mx-auto px-4 py-12 sm:py-20">
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: lang === "ru" ? "Главная" : "Home", href: lang === "ru" ? "/ru/" : "/" },
|
||||
{ label: lang === "ru" ? "Блог" : "Blog", href: blogBase },
|
||||
{ label: title },
|
||||
]}
|
||||
/>
|
||||
|
||||
<header class="mb-10">
|
||||
<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">{title}</h1>
|
||||
<div class="w-16 h-0.5 bg-accent/60 rounded-full mt-4" aria-hidden="true">
|
||||
</div>
|
||||
<div class="mt-4 flex items-center gap-3 text-sm text-zinc-500">
|
||||
<time datetime={pubDateIso}>
|
||||
{
|
||||
new Date(pubDate).toLocaleDateString(lang === "ru" ? "ru-RU" : "en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})
|
||||
}
|
||||
</time>
|
||||
{
|
||||
modDate && modDate !== pubDate && (
|
||||
<>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>
|
||||
{lang === "ru" ? "Обновлено" : "Updated"}
|
||||
<time datetime={modDateIso} class="ml-1">
|
||||
{new Date(modDate).toLocaleDateString(
|
||||
lang === "ru" ? "ru-RU" : "en-US",
|
||||
{
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}
|
||||
)}
|
||||
</time>
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="prose prose-invert prose-zinc max-w-none">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<slot name="related" />
|
||||
|
||||
{
|
||||
(prevPost || nextPost) && (
|
||||
<nav
|
||||
aria-label={lang === "ru" ? "Навигация по статьям" : "Post navigation"}
|
||||
class="mt-16 pt-10 border-t border-zinc-800/60"
|
||||
>
|
||||
<div class="flex flex-col sm:flex-row gap-6 justify-between">
|
||||
{prevPost ? (
|
||||
<a
|
||||
href={`${blogBase}${prevPost.slug}/`}
|
||||
class="group flex items-start gap-3 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 rounded-lg p-2 -ml-2"
|
||||
>
|
||||
<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"
|
||||
class="mt-0.5 text-zinc-500 group-hover:text-zinc-300 transition-colors shrink-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</svg>
|
||||
<div>
|
||||
<span class="block text-xs text-zinc-500 uppercase tracking-wider mb-1">
|
||||
{lang === "ru" ? "Предыдущая" : "Previous"}
|
||||
</span>
|
||||
<span class="block text-sm font-medium text-zinc-300 group-hover:text-zinc-100 transition-colors">
|
||||
{prevPost.title}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
|
||||
{nextPost ? (
|
||||
<a
|
||||
href={`${blogBase}${nextPost.slug}/`}
|
||||
class="group flex items-start gap-3 text-right sm:flex-row-reverse focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 rounded-lg p-2 -mr-2"
|
||||
>
|
||||
<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"
|
||||
class="mt-0.5 text-zinc-500 group-hover:text-zinc-300 transition-colors shrink-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
<div>
|
||||
<span class="block text-xs text-zinc-500 uppercase tracking-wider mb-1">
|
||||
{lang === "ru" ? "Следующая" : "Next"}
|
||||
</span>
|
||||
<span class="block text-sm font-medium text-zinc-300 group-hover:text-zinc-100 transition-colors">
|
||||
{nextPost.title}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
</article>
|
||||
</BaseLayout>
|
||||
@@ -3,9 +3,11 @@ import BaseLayout from "./BaseLayout.astro";
|
||||
import YandexRTB from "../components/YandexRTB.astro";
|
||||
import SeoBlock from "../components/SeoBlock.astro";
|
||||
import FaqBlock from "../components/FaqBlock.astro";
|
||||
import RelatedGenerators from "../components/RelatedGenerators.astro";
|
||||
import { useT } from "../i18n/translations";
|
||||
import type { Lang } from "../i18n/translations";
|
||||
import type { Lang, T as TType } from "../i18n/translations";
|
||||
import type { Generator } from "../data/generators";
|
||||
import Breadcrumbs from "../components/Breadcrumbs.astro";
|
||||
|
||||
interface Props {
|
||||
generator: Generator;
|
||||
@@ -16,6 +18,15 @@ const lang = (Astro.currentLocale as Lang) || "en";
|
||||
const T = useT(lang);
|
||||
const isRu = lang === "ru";
|
||||
|
||||
const categoryKeyMap: Record<Generator["category"], keyof TType> = {
|
||||
gaming: "catGaming",
|
||||
security: "catSecurity",
|
||||
"decision-making": "catDecisionMaking",
|
||||
creative: "catCreative",
|
||||
utility: "catUtility",
|
||||
};
|
||||
const categoryLabel = T[categoryKeyMap[generator.category]];
|
||||
|
||||
const canonicalUrl = `https://randify.pro${Astro.url.pathname}`;
|
||||
|
||||
const appSchema = {
|
||||
@@ -90,6 +101,17 @@ const breadcrumbSchema = {
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: isRu ? "Главная" : "Home", href: isRu ? "/ru/" : "/" },
|
||||
{
|
||||
label: categoryLabel,
|
||||
href: `${isRu ? "/ru" : ""}/generators/category/${generator.category}/`,
|
||||
},
|
||||
{ label: isRu ? generator.ruTitle : generator.title },
|
||||
]}
|
||||
/>
|
||||
|
||||
<header class="mb-2">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<span
|
||||
@@ -133,5 +155,11 @@ const breadcrumbSchema = {
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
<RelatedGenerators
|
||||
currentSlug={generator.slug}
|
||||
category={generator.category}
|
||||
lang={lang}
|
||||
/>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
import { getCollection } from "astro:content";
|
||||
import BlogLayout from "@/layouts/BlogLayout.astro";
|
||||
import RelatedPosts from "@/components/RelatedPosts.astro";
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const posts = await getCollection("blog");
|
||||
const enPosts = posts.filter((p) => p.data.lang === "en" && !p.data.draft);
|
||||
const sorted = enPosts.sort(
|
||||
(a, b) => a.data.pubDate.valueOf() - b.data.pubDate.valueOf(),
|
||||
);
|
||||
|
||||
return sorted.map((post, index) => {
|
||||
const prev = sorted[index - 1] ?? null;
|
||||
const next = sorted[index + 1] ?? null;
|
||||
return {
|
||||
params: { slug: post.id.replace(/^en\//, "").replace(/\.md$/, "") },
|
||||
props: {
|
||||
post,
|
||||
prev: prev
|
||||
? { slug: prev.id.replace(/^en\//, "").replace(/\.md$/, ""), title: prev.data.title }
|
||||
: null,
|
||||
next: next
|
||||
? { slug: next.id.replace(/^en\//, "").replace(/\.md$/, ""), title: next.data.title }
|
||||
: null,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const { post, prev, next } = Astro.props;
|
||||
const { Content } = await post.render();
|
||||
---
|
||||
|
||||
<BlogLayout
|
||||
title={post.data.title}
|
||||
description={post.data.description}
|
||||
pubDate={post.data.pubDate}
|
||||
modDate={post.data.modDate}
|
||||
ogImage={post.data.ogImage?.src}
|
||||
lang="en"
|
||||
prevPost={prev}
|
||||
nextPost={next}
|
||||
>
|
||||
<article>
|
||||
<Content />
|
||||
</article>
|
||||
<RelatedPosts post={post} slot="related" />
|
||||
</BlogLayout>
|
||||
@@ -0,0 +1,109 @@
|
||||
---
|
||||
import { getCollection } from "astro:content";
|
||||
import BaseLayout from "@/layouts/BaseLayout.astro";
|
||||
import { useT } from "@/i18n/translations";
|
||||
import type { Lang } from "@/i18n/translations";
|
||||
|
||||
const lang = (Astro.currentLocale as Lang) || "en";
|
||||
const T = useT(lang);
|
||||
|
||||
const allPosts = await getCollection("blog");
|
||||
const posts = allPosts
|
||||
.filter((post) => post.data.lang === "en" && post.data.draft !== true)
|
||||
.sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime());
|
||||
|
||||
const pageTitle = `${T.blog} | Randify`;
|
||||
const pageDesc = T.blogDesc;
|
||||
|
||||
const blogSchema = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Blog",
|
||||
name: pageTitle,
|
||||
description: pageDesc,
|
||||
url: `https://randify.pro${Astro.url.pathname}`,
|
||||
inLanguage: lang,
|
||||
blogPost: posts.map((post) => ({
|
||||
"@type": "BlogPosting",
|
||||
headline: post.data.title,
|
||||
description: post.data.description,
|
||||
datePublished: post.data.pubDate.toISOString(),
|
||||
url: `https://randify.pro/blog/${post.slug}/`,
|
||||
})),
|
||||
};
|
||||
---
|
||||
|
||||
<BaseLayout title={pageTitle} description={pageDesc}>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(blogSchema)}
|
||||
slot="head"
|
||||
/>
|
||||
<div class="max-w-4xl mx-auto px-4 py-12 sm:py-20">
|
||||
<header class="mb-12">
|
||||
<div class="relative flex items-center gap-2 mb-6">
|
||||
<div
|
||||
class="absolute -left-16 -top-12 w-64 h-64 rounded-full bg-accent/20 blur-3xl pointer-events-none"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
<span
|
||||
class="inline-block w-3 h-3 rounded-full bg-accent"
|
||||
aria-hidden="true"></span>
|
||||
<span class="text-xl font-bold tracking-tight text-zinc-100"
|
||||
>{T.brandLabel}</span
|
||||
>
|
||||
</div>
|
||||
<h1 class="text-3xl sm:text-4xl font-bold text-zinc-100 leading-tight">
|
||||
{T.blog}
|
||||
</h1>
|
||||
<p class="mt-3 text-base text-zinc-300 max-w-md">
|
||||
{T.blogDesc}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<main class="mt-8">
|
||||
{
|
||||
posts.length === 0 ? (
|
||||
<p class="text-zinc-400">{T.noPosts}</p>
|
||||
) : (
|
||||
<ul class="space-y-6" role="list" aria-label="Blog posts">
|
||||
{posts.map((post) => (
|
||||
<li>
|
||||
<a
|
||||
href={`/blog/${post.slug}/`}
|
||||
class="group block rounded-xl border border-zinc-800/60 bg-zinc-900/40 p-6 hover:border-accent/40 hover:bg-zinc-900/60 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2 mb-3">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-accent/10 text-accent border border-accent/20">
|
||||
{post.data.category}
|
||||
</span>
|
||||
{post.data.tags.map((tag) => (
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-zinc-800 text-zinc-400 border border-zinc-700/60">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<h2 class="text-xl font-semibold text-zinc-100 group-hover:text-accent transition-colors mb-2">
|
||||
{post.data.title}
|
||||
</h2>
|
||||
<p class="text-zinc-400 text-sm leading-relaxed mb-3">
|
||||
{post.data.description}
|
||||
</p>
|
||||
<time
|
||||
class="text-xs text-zinc-500"
|
||||
datetime={post.data.pubDate.toISOString()}
|
||||
>
|
||||
{post.data.pubDate.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
</time>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
</main>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,141 @@
|
||||
---
|
||||
import BaseLayout from "@/layouts/BaseLayout.astro";
|
||||
import GeneratorCard from "@/components/GeneratorCard.astro";
|
||||
import { generators } from "@/data/generators";
|
||||
import { useT } from "@/i18n/translations";
|
||||
import type { Lang } from "@/i18n/translations";
|
||||
|
||||
export function getStaticPaths() {
|
||||
const categories = [
|
||||
"gaming",
|
||||
"security",
|
||||
"decision-making",
|
||||
"creative",
|
||||
"utility",
|
||||
] as const;
|
||||
|
||||
return categories.map((category) => ({
|
||||
params: { category },
|
||||
}));
|
||||
}
|
||||
|
||||
const { category } = Astro.params;
|
||||
const lang: Lang = "en";
|
||||
const T = useT(lang);
|
||||
|
||||
const categoryDescriptions: Record<string, string> = {
|
||||
gaming: "Gaming generators for tabletop RPGs, card games, and lotteries",
|
||||
security: "Security tools for passwords, hashes, and unique identifiers",
|
||||
"decision-making": "Decision-making helpers for coins, teams, and weighted choices",
|
||||
creative: "Creative generators for colors, gradients, palettes, and typography",
|
||||
utility: "Utility generators for dates, names, lists, and everyday randomness",
|
||||
};
|
||||
|
||||
const categoryGenerators = generators.filter((g) => g.category === category);
|
||||
|
||||
function formatCategoryName(cat: string): string {
|
||||
return cat
|
||||
.split("-")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
const categoryName = formatCategoryName(category);
|
||||
const description = categoryDescriptions[category] || "";
|
||||
const title = `${categoryName} — Randify`;
|
||||
const canonicalUrl = `https://randify.pro${Astro.url.pathname}`;
|
||||
|
||||
const breadcrumbSchema = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: [
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 1,
|
||||
name: "Randify.pro",
|
||||
item: "https://randify.pro/",
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 2,
|
||||
name: "Categories",
|
||||
item: "https://randify.pro/generators/category/",
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 3,
|
||||
name: categoryName,
|
||||
item: canonicalUrl,
|
||||
},
|
||||
],
|
||||
};
|
||||
---
|
||||
|
||||
<BaseLayout title={title} description={description}>
|
||||
<Fragment slot="head">
|
||||
<script
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(breadcrumbSchema)}
|
||||
/>
|
||||
</Fragment>
|
||||
<div class="max-w-4xl mx-auto px-4 py-12 sm:py-20">
|
||||
<nav aria-label="Breadcrumb" class="mb-10">
|
||||
<ol class="flex items-center gap-2 text-sm text-zinc-400">
|
||||
<li>
|
||||
<a
|
||||
href="/"
|
||||
class="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"
|
||||
>
|
||||
Home
|
||||
</a>
|
||||
</li>
|
||||
<li aria-hidden="true" class="text-zinc-600">/</li>
|
||||
<li>
|
||||
<span class="text-zinc-500">Categories</span>
|
||||
</li>
|
||||
<li aria-hidden="true" class="text-zinc-600">/</li>
|
||||
<li>
|
||||
<span class="text-zinc-200 font-medium">{categoryName}</span>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<header class="mb-12">
|
||||
<div class="relative flex items-center gap-2 mb-6">
|
||||
<div
|
||||
class="absolute -left-16 -top-12 w-64 h-64 rounded-full bg-accent/20 blur-3xl pointer-events-none"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
<span
|
||||
class="inline-block w-3 h-3 rounded-full bg-accent"
|
||||
aria-hidden="true"></span>
|
||||
<span class="text-xl font-bold tracking-tight text-zinc-100"
|
||||
>{T.brandLabel}</span
|
||||
>
|
||||
</div>
|
||||
<h1 class="text-3xl sm:text-4xl font-bold text-zinc-100 leading-tight">
|
||||
{categoryName}
|
||||
</h1>
|
||||
<p class="mt-3 text-base text-zinc-300 max-w-md">
|
||||
{description}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<main class="mt-8">
|
||||
<div
|
||||
class="grid gap-3"
|
||||
style="grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); grid-auto-rows: 1fr;"
|
||||
role="list"
|
||||
aria-label={`${categoryName} generators`}
|
||||
>
|
||||
{
|
||||
categoryGenerators.map((generator) => (
|
||||
<div role="listitem">
|
||||
<GeneratorCard generator={generator} />
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
@@ -47,6 +47,29 @@ const webSiteSchema = {
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<nav aria-label="Categories" class="mb-8">
|
||||
<ul class="flex flex-wrap gap-2">
|
||||
{
|
||||
[
|
||||
{ slug: "gaming", label: T.catGaming },
|
||||
{ slug: "security", label: T.catSecurity },
|
||||
{ slug: "decision-making", label: T.catDecisionMaking },
|
||||
{ slug: "creative", label: T.catCreative },
|
||||
{ slug: "utility", label: T.catUtility },
|
||||
].map((cat) => (
|
||||
<li>
|
||||
<a
|
||||
href={`/generators/category/${cat.slug}/`}
|
||||
class="inline-flex items-center px-4 py-2 rounded-full bg-zinc-800/60 border border-zinc-700/50 text-zinc-300 text-sm font-medium hover:bg-accent/20 hover:text-accent hover:border-accent/30 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950"
|
||||
>
|
||||
{cat.label} ({generators.filter((g) => g.category === cat.slug).length})
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<main class="mt-8">
|
||||
<div
|
||||
class="grid gap-3"
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import rss from "@astrojs/rss";
|
||||
import { getCollection } from "astro:content";
|
||||
import type { APIContext } from "astro";
|
||||
|
||||
export async function GET(context: APIContext) {
|
||||
const posts = await getCollection("blog");
|
||||
const enPosts = posts.filter(
|
||||
(post) => post.data.lang === "en" && post.data.draft !== true
|
||||
);
|
||||
|
||||
return rss({
|
||||
title: "Randify Blog",
|
||||
description:
|
||||
"Latest updates, tutorials, and tips from Randify — tools that help you make random decisions.",
|
||||
site: context.site!,
|
||||
items: enPosts.map((post) => ({
|
||||
title: post.data.title,
|
||||
description: post.data.description,
|
||||
pubDate: post.data.pubDate,
|
||||
link: `/blog/${post.slug}/`,
|
||||
})),
|
||||
customData: "<language>en</language>",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
import { getCollection } from "astro:content";
|
||||
import BlogLayout from "@/layouts/BlogLayout.astro";
|
||||
import RelatedPosts from "@/components/RelatedPosts.astro";
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const posts = await getCollection("blog");
|
||||
const ruPosts = posts.filter((p) => p.data.lang === "ru" && !p.data.draft);
|
||||
const sorted = ruPosts.sort(
|
||||
(a, b) => a.data.pubDate.valueOf() - b.data.pubDate.valueOf(),
|
||||
);
|
||||
|
||||
return sorted.map((post, index) => {
|
||||
const prev = sorted[index - 1] ?? null;
|
||||
const next = sorted[index + 1] ?? null;
|
||||
return {
|
||||
params: { slug: post.id.replace(/^ru\//, "").replace(/\.md$/, "") },
|
||||
props: {
|
||||
post,
|
||||
prev: prev
|
||||
? { slug: prev.id.replace(/^ru\//, "").replace(/\.md$/, ""), title: prev.data.title }
|
||||
: null,
|
||||
next: next
|
||||
? { slug: next.id.replace(/^ru\//, "").replace(/\.md$/, ""), title: next.data.title }
|
||||
: null,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const { post, prev, next } = Astro.props;
|
||||
const { Content } = await post.render();
|
||||
---
|
||||
|
||||
<BlogLayout
|
||||
title={post.data.title}
|
||||
description={post.data.description}
|
||||
pubDate={post.data.pubDate}
|
||||
modDate={post.data.modDate}
|
||||
ogImage={post.data.ogImage?.src}
|
||||
lang="ru"
|
||||
prevPost={prev}
|
||||
nextPost={next}
|
||||
>
|
||||
<article>
|
||||
<Content />
|
||||
</article>
|
||||
<RelatedPosts post={post} slot="related" />
|
||||
</BlogLayout>
|
||||
@@ -0,0 +1,109 @@
|
||||
---
|
||||
import { getCollection } from "astro:content";
|
||||
import BaseLayout from "@/layouts/BaseLayout.astro";
|
||||
import { useT } from "@/i18n/translations";
|
||||
import type { Lang } from "@/i18n/translations";
|
||||
|
||||
const lang = (Astro.currentLocale as Lang) || "en";
|
||||
const T = useT(lang);
|
||||
|
||||
const allPosts = await getCollection("blog");
|
||||
const posts = allPosts
|
||||
.filter((post) => post.data.lang === "ru" && post.data.draft !== true)
|
||||
.sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime());
|
||||
|
||||
const pageTitle = `${T.blog} | Randify`;
|
||||
const pageDesc = T.blogDesc;
|
||||
|
||||
const blogSchema = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Blog",
|
||||
name: pageTitle,
|
||||
description: pageDesc,
|
||||
url: `https://randify.pro${Astro.url.pathname}`,
|
||||
inLanguage: lang,
|
||||
blogPost: posts.map((post) => ({
|
||||
"@type": "BlogPosting",
|
||||
headline: post.data.title,
|
||||
description: post.data.description,
|
||||
datePublished: post.data.pubDate.toISOString(),
|
||||
url: `https://randify.pro/ru/blog/${post.slug}/`,
|
||||
})),
|
||||
};
|
||||
---
|
||||
|
||||
<BaseLayout title={pageTitle} description={pageDesc}>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(blogSchema)}
|
||||
slot="head"
|
||||
/>
|
||||
<div class="max-w-4xl mx-auto px-4 py-12 sm:py-20">
|
||||
<header class="mb-12">
|
||||
<div class="relative flex items-center gap-2 mb-6">
|
||||
<div
|
||||
class="absolute -left-16 -top-12 w-64 h-64 rounded-full bg-accent/20 blur-3xl pointer-events-none"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
<span
|
||||
class="inline-block w-3 h-3 rounded-full bg-accent"
|
||||
aria-hidden="true"></span>
|
||||
<span class="text-xl font-bold tracking-tight text-zinc-100"
|
||||
>{T.brandLabel}</span
|
||||
>
|
||||
</div>
|
||||
<h1 class="text-3xl sm:text-4xl font-bold text-zinc-100 leading-tight">
|
||||
{T.blog}
|
||||
</h1>
|
||||
<p class="mt-3 text-base text-zinc-300 max-w-md">
|
||||
{T.blogDesc}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<main class="mt-8">
|
||||
{
|
||||
posts.length === 0 ? (
|
||||
<p class="text-zinc-400">{T.noPosts}</p>
|
||||
) : (
|
||||
<ul class="space-y-6" role="list" aria-label="Blog posts">
|
||||
{posts.map((post) => (
|
||||
<li>
|
||||
<a
|
||||
href={`/ru/blog/${post.slug}/`}
|
||||
class="group block rounded-xl border border-zinc-800/60 bg-zinc-900/40 p-6 hover:border-accent/40 hover:bg-zinc-900/60 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2 mb-3">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-accent/10 text-accent border border-accent/20">
|
||||
{post.data.category}
|
||||
</span>
|
||||
{post.data.tags.map((tag) => (
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-zinc-800 text-zinc-400 border border-zinc-700/60">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<h2 class="text-xl font-semibold text-zinc-100 group-hover:text-accent transition-colors mb-2">
|
||||
{post.data.title}
|
||||
</h2>
|
||||
<p class="text-zinc-400 text-sm leading-relaxed mb-3">
|
||||
{post.data.description}
|
||||
</p>
|
||||
<time
|
||||
class="text-xs text-zinc-500"
|
||||
datetime={post.data.pubDate.toISOString()}
|
||||
>
|
||||
{post.data.pubDate.toLocaleDateString("ru-RU", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
</time>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
</main>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,145 @@
|
||||
---
|
||||
import BaseLayout from "@/layouts/BaseLayout.astro";
|
||||
import GeneratorCard from "@/components/GeneratorCard.astro";
|
||||
import { generators } from "@/data/generators";
|
||||
import { useT } from "@/i18n/translations";
|
||||
import type { Lang } from "@/i18n/translations";
|
||||
|
||||
export function getStaticPaths() {
|
||||
const categories = [
|
||||
"gaming",
|
||||
"security",
|
||||
"decision-making",
|
||||
"creative",
|
||||
"utility",
|
||||
] as const;
|
||||
|
||||
return categories.map((category) => ({
|
||||
params: { category },
|
||||
}));
|
||||
}
|
||||
|
||||
const { category } = Astro.params;
|
||||
const lang: Lang = "ru";
|
||||
const T = useT(lang);
|
||||
|
||||
const categoryDescriptions: Record<string, string> = {
|
||||
gaming: "Игровые генераторы для настольных RPG, карточных игр и лотерей",
|
||||
security: "Инструменты безопасности для паролей, хешей и уникальных идентификаторов",
|
||||
"decision-making": "Помощники в принятии решений: монетки, команды, взвешенный выбор",
|
||||
creative: "Творческие генераторы для цветов, градиентов, палитр и типографики",
|
||||
utility: "Утилитарные генераторы для дат, имён, списков и повседневной случайности",
|
||||
};
|
||||
|
||||
const categoryGenerators = generators.filter((g) => g.category === category);
|
||||
|
||||
function formatCategoryName(cat: string): string {
|
||||
const names: Record<string, string> = {
|
||||
gaming: "Игры",
|
||||
security: "Безопасность",
|
||||
"decision-making": "Принятие решений",
|
||||
creative: "Творчество",
|
||||
utility: "Утилиты",
|
||||
};
|
||||
return names[cat] || cat;
|
||||
}
|
||||
|
||||
const categoryName = formatCategoryName(category);
|
||||
const description = categoryDescriptions[category] || "";
|
||||
const title = `${categoryName} — Randify`;
|
||||
const canonicalUrl = `https://randify.pro${Astro.url.pathname}`;
|
||||
|
||||
const breadcrumbSchema = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: [
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 1,
|
||||
name: "Randify.pro",
|
||||
item: "https://randify.pro/",
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 2,
|
||||
name: "Категории",
|
||||
item: "https://randify.pro/ru/generators/category/",
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 3,
|
||||
name: categoryName,
|
||||
item: canonicalUrl,
|
||||
},
|
||||
],
|
||||
};
|
||||
---
|
||||
|
||||
<BaseLayout title={title} description={description}>
|
||||
<Fragment slot="head">
|
||||
<script
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(breadcrumbSchema)}
|
||||
/>
|
||||
</Fragment>
|
||||
<div class="max-w-4xl mx-auto px-4 py-12 sm:py-20">
|
||||
<nav aria-label="Breadcrumb" class="mb-10">
|
||||
<ol class="flex items-center gap-2 text-sm text-zinc-400">
|
||||
<li>
|
||||
<a
|
||||
href="/ru/"
|
||||
class="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"
|
||||
>
|
||||
Главная
|
||||
</a>
|
||||
</li>
|
||||
<li aria-hidden="true" class="text-zinc-600">/</li>
|
||||
<li>
|
||||
<span class="text-zinc-500">Категории</span>
|
||||
</li>
|
||||
<li aria-hidden="true" class="text-zinc-600">/</li>
|
||||
<li>
|
||||
<span class="text-zinc-200 font-medium">{categoryName}</span>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<header class="mb-12">
|
||||
<div class="relative flex items-center gap-2 mb-6">
|
||||
<div
|
||||
class="absolute -left-16 -top-12 w-64 h-64 rounded-full bg-accent/20 blur-3xl pointer-events-none"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
<span
|
||||
class="inline-block w-3 h-3 rounded-full bg-accent"
|
||||
aria-hidden="true"></span>
|
||||
<span class="text-xl font-bold tracking-tight text-zinc-100"
|
||||
>{T.brandLabel}</span
|
||||
>
|
||||
</div>
|
||||
<h1 class="text-3xl sm:text-4xl font-bold text-zinc-100 leading-tight">
|
||||
{categoryName}
|
||||
</h1>
|
||||
<p class="mt-3 text-base text-zinc-300 max-w-md">
|
||||
{description}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<main class="mt-8">
|
||||
<div
|
||||
class="grid gap-3"
|
||||
style="grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); grid-auto-rows: 1fr;"
|
||||
role="list"
|
||||
aria-label={`Генераторы ${categoryName}`}
|
||||
>
|
||||
{
|
||||
categoryGenerators.map((generator) => (
|
||||
<div role="listitem">
|
||||
<GeneratorCard generator={generator} />
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
@@ -43,6 +43,29 @@ const webSiteSchema = {
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<nav aria-label="Категории" class="mb-8">
|
||||
<ul class="flex flex-wrap gap-2">
|
||||
{
|
||||
[
|
||||
{ slug: "gaming", label: T.catGaming },
|
||||
{ slug: "security", label: T.catSecurity },
|
||||
{ slug: "decision-making", label: T.catDecisionMaking },
|
||||
{ slug: "creative", label: T.catCreative },
|
||||
{ slug: "utility", label: T.catUtility },
|
||||
].map((cat) => (
|
||||
<li>
|
||||
<a
|
||||
href={`/ru/generators/category/${cat.slug}/`}
|
||||
class="inline-flex items-center px-4 py-2 rounded-full bg-zinc-800/60 border border-zinc-700/50 text-zinc-300 text-sm font-medium hover:bg-accent/20 hover:text-accent hover:border-accent/30 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950"
|
||||
>
|
||||
{cat.label} ({generators.filter((g) => g.category === cat.slug).length})
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<YandexRTB />
|
||||
|
||||
<main class="mt-8">
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import rss from "@astrojs/rss";
|
||||
import { getCollection } from "astro:content";
|
||||
import type { APIContext } from "astro";
|
||||
|
||||
export async function GET(context: APIContext) {
|
||||
const posts = await getCollection("blog");
|
||||
const ruPosts = posts.filter(
|
||||
(post) => post.data.lang === "ru" && post.data.draft !== true
|
||||
);
|
||||
|
||||
return rss({
|
||||
title: "Блог Randify",
|
||||
description:
|
||||
"Последние обновления, руководства и советы от Randify — инструментов, которые помогают принимать случайные решения.",
|
||||
site: context.site!,
|
||||
items: ruPosts.map((post) => ({
|
||||
title: post.data.title,
|
||||
description: post.data.description,
|
||||
pubDate: post.data.pubDate,
|
||||
link: `/ru/blog/${post.slug}/`,
|
||||
})),
|
||||
customData: "<language>ru</language>",
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user