feat: build home page from home.json sections

- Hero with callsign/locator tags and CTAs on khaki grid
- Feature sections for ARD (YM1KTC), DMR Türkiye, QRV73!, QRZ73!
- Telegram groups and resource site card grids
- Latest posts block with empty state (wired to blog in stage 4)
- Section wrapper, decorative numerals, external Button variant

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-16 00:55:33 +03:00
parent d8a79ccbe3
commit f2d943c31b
10 changed files with 349 additions and 15 deletions
+11 -4
View File
@@ -1,13 +1,13 @@
# PROJECT_MEMORY — ta1sph.info
> Claude'un bu proje için kalıcı hafızası. Her görev sonunda güncellenir. Kısa ve güncel tutulur.
> Son güncelleme: 2026-10-16 · Güncelleyen: Claude (Siteyi Kur — Aşama 2)
> Son güncelleme: 2026-10-16 · Güncelleyen: Claude (Siteyi Kur — Aşama 3)
## 1. Durum
- **Aşama:** 2 tamamlandı (tasarım sistemi + layout). Sıradaki: **Aşama 3 — Ana Sayfa**.
- **Tamamlanan:** Aşama 0, 1, 2. Stil önizleme yalnızca dev'de: `http://localhost:4321/styleguide/`.
- **Açık işler:** Aşama 3–8. Footer'daki yasal sayfalar ve `/rss.xml` henüz yok (Aşama 4/6). Favicon hâlâ Astro varsayılanı (Aşama 7). `LICENSE` dosyaları Aşama 6.
- **Aşama:** 3 tamamlandı (ana sayfa). Sıradaki: **Aşama 4 — Blog altyapısı**.
- **Tamamlanan:** Aşama 0–3. Stil önizleme yalnızca dev'de: `http://localhost:4321/styleguide/`.
- **Açık işler:** Aşama 4–8. Ana sayfa `LatestPosts` şimdilik `hasPosts={false}` (boş durum) → Aşama 4'te PostCard'larla bağlanacak. Footer yasal sayfalar + `/rss.xml` yok (A4/A6). Favicon varsayılan (A7). `LICENSE` dosyaları A6.
- **Bilinen sorunlar:** —
## 2. Ortam
@@ -25,6 +25,10 @@
- `src/data/site.json` (ad, açıklama, nav, footer, `ui` metinleri, postsPerPage) · `src/data/social.json` (boş `href` → gizli) · `src/lib/data.ts` (zod; hata build'i kırar)
- `src/layouts/BaseLayout.astro` — head (ThemeInit, Seo, RSS alternate), skip link, Header, `main#main`, Footer
- `src/components/` — `Seo` (title/desc/canonical/OG/Twitter + WebSite/Person JSON-LD graph, `jsonLd` prop'u ile ek düğüm), `ThemeInit` (FOUC önleyici inline), `ThemeToggle`, `Header` (mobil menü = native `popover`, JS yok), `Footer` (4 sütun), `Container`, `GridLines`, `SectionHeading` (eyebrow + kırmızı çizgi), `Button` (primary/secondary/link), `Badge` (solid/outline/category), `Card`, `ExternalLink`
- `src/data/home.json` — `seo`, `hero`, `sections[]` (`feature` | `cards` | `latestPosts`); şema `data.ts` → `home`, `HomeSection`. Tanıtım metinleri sitelerin kendi tanıtımlarından (2026-10-16 tarandı)
- `src/pages/index.astro` — sections'ı sırayla render eder; arka plan dönüşümlü (hero khaki → paper, khaki…)
- `src/components/Section.astro` (tam genişlik bölüm + GridLines, `alt` = khaki) · `src/components/home/` — `Hero`, `FeatureSection` (numara + ikon | çağrı işareti/alan adı | başlık+metin+CTA; 3 sütun grid çizgileriyle hizalı), `CardGrid`, `LatestPosts` (slot), `Numeral` (dev rakam `::before` ile → a11y/kontrast dışı)
- `Button` — `external` prop'u (target/rel + sr-only "yeni sekmede açılır")
- `src/dev/Styleguide.astro` — `astro.config.mjs`'deki `devRoutes` entegrasyonu ile sadece `astro dev`'de `/styleguide/` (`_` önekli sayfalar Astro'da rota olmaz)
- `src/lib/slug.ts` — Türkçe karakter dönüştürücü `slugify`
- `scripts/new-post.mjs` — `npm run new:post -- "Başlık"` → `src/content/blog/<slug>/index.md` (draft: true); `slug.ts`'yi Node type-stripping ile import eder
@@ -45,6 +49,8 @@
| 2026-10-15 | Kanonik alan: apex `ta1sph.info`, www → 301 | SEO tekilleştirme |
| 2026-10-15 | Fontlar self-host (Fontsource: Rubik + Inter) | Performans + gizlilik |
| 2026-10-16 | Ek token'lar: `accent-fill`/`on-accent-fill`, `accent-text` (khaki'de fg'ye döner) | Ölçüm: ink/red 4.46, red-strong/khaki 3.85 → küçük metin AA'yı geçmiyordu. Kırmızı zeminli butonlar ≥19px bold |
| 2026-10-16 | Dekoratif dev rakamlar CSS `::before` içeriği | axe/Lighthouse aria-hidden metinde bile kontrast hatası veriyordu |
| 2026-10-16 | Kuruluş/site tanıtımları yalnızca sitelerin kendi ifadelerinden | Uydurma bilgi yok; Telegram kartlarında açıklama yok (doğrulanmadı) |
| 2026-10-16 | Mobil menü native `popover` | Escape/odak/light-dismiss JS'siz |
| 2026-10-16 | Pagefind CLI postbuild (`astro-pagefind` yok) | Daha az bağımlılık; node adapter çıktısı `dist/client` |
| 2026-10-16 | Skill repoya kopyalanmadı, kullanıcı düzeyinde | Çift kopya senkron riski |
@@ -58,6 +64,7 @@
## 6. Değişiklik Günlüğü (en yeni üstte)
- 2026-10-16 | Siteyi Kur A3 | Ana sayfa: hero, 4 kuruluş, Telegram, son yazılar (boş durum), site tanıtımları; axe temiz | `src/data/home.json`, `src/components/home/`, `src/components/Section.astro`, `src/pages/index.astro`, `src/lib/data.ts`
- 2026-10-16 | Siteyi Kur A2 | Token'lar, tema anahtarı, SEO bileşeni, layout, header/footer, temel bileşenler, site/social JSON, dev styleguide; axe temiz (2 tema) | `src/styles/`, `src/components/`, `src/layouts/`, `src/data/`, `src/lib/data.ts`, `src/dev/`, `astro.config.mjs`
- 2026-10-16 | Siteyi Kur A1 | İskelet: Tailwind 4, mdx, sitemap, node, icon, rss, fontlar, pagefind, prettier, new:post | `astro.config.mjs`, `package.json`, `scripts/`, `src/lib/slug.ts`, `src/styles/`, `README.md`
- 2026-10-16 | Siteyi Kur A0 | Ortam kontrolü; hafıza + docs kitten projeye kopyalandı | `CLAUDE.md`, `PROJECT_MEMORY.md`, `docs/`
+15 -2
View File
@@ -1,6 +1,7 @@
---
import type { HTMLAttributes } from 'astro/types';
import { Icon } from 'astro-icon/components';
import { site } from '../lib/data';
type Variant = 'primary' | 'secondary' | 'link';
@@ -9,9 +10,20 @@ export interface Props extends HTMLAttributes<'a'> {
variant?: Variant;
icon?: string;
type?: 'button' | 'submit';
/** Opens in a new tab with rel=noopener and a screen-reader hint. */
external?: boolean;
}
const { href, variant = 'primary', icon, type = 'button', class: className, ...rest } = Astro.props;
const {
href,
variant = 'primary',
icon,
type = 'button',
external = false,
class: className,
...rest
} = Astro.props;
const externalAttrs = external ? { target: '_blank', rel: 'noopener noreferrer' } : {};
// Primary uses ink on red at ≥19px bold (large text, ratio 4.46 ≥ 3).
const variants: Record<Variant, string> = {
@@ -29,9 +41,10 @@ const classes = [
---
{href ? (
<a href={href} class:list={classes} {...rest}>
<a href={href} class:list={classes} {...externalAttrs} {...rest}>
<slot />
{icon && <Icon name={icon} class="size-5" aria-hidden="true" />}
{external && <span class="sr-only">{site.ui.newTab}</span>}
</a>
) : (
<button type={type} class:list={[classes, 'cursor-pointer']}>
+25
View File
@@ -0,0 +1,25 @@
---
// Full-bleed page section: paper or khaki background with the magazine grid lines behind content.
import Container from './Container.astro';
import GridLines from './GridLines.astro';
export interface Props {
id?: string;
alt?: boolean;
labelledby?: string;
class?: string;
}
const { id, alt = false, labelledby, class: className } = Astro.props;
---
<section
id={id}
aria-labelledby={labelledby}
class:list={['relative overflow-hidden', alt && 'surface-alt']}
>
<GridLines />
<Container class:list={['relative py-16 md:py-24', className]}>
<slot />
</Container>
</section>
+51
View File
@@ -0,0 +1,51 @@
---
import { Icon } from 'astro-icon/components';
import type { HomeSection } from '../../lib/data';
import Card from '../Card.astro';
import ExternalLink from '../ExternalLink.astro';
import Section from '../Section.astro';
import SectionHeading from '../SectionHeading.astro';
export interface Props {
section: Extract<HomeSection, { type: 'cards' }>;
alt: boolean;
}
const { section, alt } = Astro.props;
const headingId = `${section.id}-title`;
const display = (href: string) => {
const u = new URL(href);
return (u.host + u.pathname).replace(/^www\./, '').replace(/\/$/, '');
};
---
<Section id={section.id} alt={alt} labelledby={headingId}>
<SectionHeading id={headingId} eyebrow={section.eyebrow} title={section.title} />
<ul class="mt-10 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{section.items.map((item) => (
<li class="flex">
<Card interactive class="w-full">
<div class="flex flex-1 flex-col gap-4 p-6">
<span class="bg-bg-alt text-fg inline-flex size-12 items-center justify-center">
<Icon name={item.icon} class="size-6" aria-hidden="true" />
</span>
<h3 class="text-xl leading-snug font-bold">
<ExternalLink
href={item.href}
icon={false}
class="after:absolute after:inset-0 hover:underline"
>
{item.title}
</ExternalLink>
</h3>
{item.description && <p class="text-fg-muted text-sm">{item.description}</p>}
<p class="text-accent-text mt-auto inline-flex items-center gap-1 text-sm font-semibold">
{display(item.href)}
<Icon name="tabler:external-link" class="size-4" aria-hidden="true" />
</p>
</div>
</Card>
</li>
))}
</ul>
</Section>
+60
View File
@@ -0,0 +1,60 @@
---
import { Icon } from 'astro-icon/components';
import type { HomeSection } from '../../lib/data';
import Badge from '../Badge.astro';
import Button from '../Button.astro';
import ExternalLink from '../ExternalLink.astro';
import Section from '../Section.astro';
import SectionHeading from '../SectionHeading.astro';
import Numeral from './Numeral.astro';
export interface Props {
section: Extract<HomeSection, { type: 'feature' }>;
index: number;
alt: boolean;
}
const { section, index, alt } = Astro.props;
const headingId = `${section.id}-title`;
const host = new URL(section.link.href).host.replace(/^www\./, '');
---
<Section id={section.id} alt={alt} labelledby={headingId}>
<div class="grid gap-8 md:grid-cols-[1.4fr_1.6fr_4.2fr] md:gap-0">
<div class="flex flex-col gap-6 md:col-start-3 md:row-start-1 md:pl-8">
<SectionHeading id={headingId} eyebrow={section.eyebrow} title={section.title} />
<p class="max-w-prose text-lg">{section.body}</p>
<Button href={section.link.href} external icon="tabler:arrow-up-right" class="self-start">
{section.link.label}
</Button>
</div>
<div class="flex items-center gap-4 md:col-start-1 md:row-start-1 md:flex-col md:items-start">
<Numeral
value={String(index).padStart(2, '0')}
alt={alt}
class="hidden text-[clamp(4rem,8vw,7rem)] md:block"
/>
<Icon name={section.icon} class="size-10" aria-hidden="true" />
</div>
<dl class="flex flex-col gap-4 text-sm md:col-start-2 md:row-start-1 md:px-6 md:pt-2">
{section.callsign && (
<div class="flex flex-col gap-1">
<dt class="font-display font-bold tracking-wide uppercase">{section.callsignLabel}</dt>
<dd>
<Badge icon="tabler:antenna">{section.callsign}</Badge>
</dd>
</div>
)}
<div class="flex flex-col gap-1">
<dt class="sr-only">Web</dt>
<dd>
<ExternalLink href={section.link.href} class="link-strong text-base">
{host}
</ExternalLink>
</dd>
</div>
</dl>
</div>
</Section>
+46
View File
@@ -0,0 +1,46 @@
---
import { Icon } from 'astro-icon/components';
import { home } from '../../lib/data';
import Button from '../Button.astro';
import Section from '../Section.astro';
import SectionHeading from '../SectionHeading.astro';
import Numeral from './Numeral.astro';
const { hero } = home;
---
<Section alt labelledby="hero-title" class="md:py-28">
<div class="grid gap-10 md:grid-cols-[1.4fr_1.6fr_4.2fr] md:gap-0">
<div class="flex flex-col gap-8 md:col-start-3 md:row-start-1 md:pl-8">
<SectionHeading level={1} id="hero-title" eyebrow={hero.eyebrow} title={hero.title} />
<p class="max-w-xl text-lg">{hero.lead}</p>
<div class="flex flex-wrap gap-4">
{hero.ctas.map((cta) => (
<Button href={cta.href} variant={cta.variant} icon={cta.icon}>
{cta.label}
</Button>
))}
</div>
</div>
<div class="flex flex-col gap-6 md:col-start-1 md:row-start-1 md:pr-6">
<Numeral value={hero.numeral} alt class="hidden text-[clamp(5rem,9vw,8rem)] md:block" />
<ul class="flex flex-wrap gap-2 md:flex-col md:items-start">
{hero.tags.map((tag) => (
<li class="font-display border-fg inline-flex items-center gap-1.5 border px-2 py-1 text-xs font-bold tracking-wide uppercase">
<Icon name={tag.icon} class="size-4" aria-hidden="true" />
{tag.label}
</li>
))}
</ul>
</div>
<div class="flex flex-col gap-4 md:col-start-2 md:row-start-1 md:px-6">
<p class="font-display text-2xl leading-tight font-bold">{hero.asideTitle}</p>
<p class="text-sm">{hero.asideBody}</p>
<Button href={hero.asideLink.href} variant="link" class="self-start">
{hero.asideLink.label}
</Button>
</div>
</div>
</Section>
+34
View File
@@ -0,0 +1,34 @@
---
// Latest posts block. Post cards are passed through the default slot (wired up in stage 4).
import type { HomeSection } from '../../lib/data';
import Button from '../Button.astro';
import Section from '../Section.astro';
import SectionHeading from '../SectionHeading.astro';
export interface Props {
section: Extract<HomeSection, { type: 'latestPosts' }>;
alt: boolean;
hasPosts: boolean;
}
const { section, alt, hasPosts } = Astro.props;
const headingId = `${section.id}-title`;
---
<Section id={section.id} alt={alt} labelledby={headingId}>
<div class="flex flex-wrap items-end justify-between gap-6">
<SectionHeading id={headingId} eyebrow={section.eyebrow} title={section.title} />
{hasPosts && (
<Button href={section.allLink.href} variant="link" icon="tabler:arrow-right">
{section.allLink.label}
</Button>
)}
</div>
{hasPosts ? (
<div class="mt-10">
<slot />
</div>
) : (
<p class="border-fg/30 mt-10 border-2 border-dashed p-8 text-center text-lg">{section.empty}</p>
)}
</Section>
+20
View File
@@ -0,0 +1,20 @@
---
// Giant decorative numeral (reference: Paper-coloured "2" on khaki).
// Rendered via ::before so it is not text content (no a11y/contrast noise).
export interface Props {
value: string;
alt?: boolean;
class?: string;
}
const { value, alt = false, class: className } = Astro.props;
---
<span
aria-hidden="true"
data-numeral={value}
class:list={[
'font-display block leading-[0.8] font-black tracking-tighter select-none before:content-[attr(data-numeral)]',
alt ? 'text-paper dark:text-fg/15' : 'text-bg-alt dark:text-fg/15',
className,
]}
></span>
+62 -1
View File
@@ -5,8 +5,10 @@
import { z } from 'astro/zod';
import siteJson from '../data/site.json';
import socialJson from '../data/social.json';
import homeJson from '../data/home.json';
const link = z.object({ label: z.string().min(1), href: z.string().min(1) });
const icon = z.string().startsWith('tabler:');
const siteSchema = z.object({
name: z.string(),
@@ -47,11 +49,68 @@ const socialSchema = z.array(
label: z.string(),
// Empty string = account not set → hidden in the UI.
href: z.union([z.url(), z.literal('')]),
icon: z.string().startsWith('tabler:'),
icon,
handle: z.string().optional(),
}),
);
const homeSchema = z.object({
seo: z.object({ description: z.string().max(160) }),
hero: z.object({
numeral: z.string(),
tags: z.array(z.object({ icon, label: z.string() })),
asideTitle: z.string(),
asideBody: z.string(),
asideLink: link,
eyebrow: z.string(),
title: z.string(),
lead: z.string(),
ctas: z.array(
link.extend({ variant: z.enum(['primary', 'secondary', 'link']), icon: icon.optional() }),
),
}),
sections: z.array(
z.discriminatedUnion('type', [
z.object({
type: z.literal('feature'),
id: z.string(),
eyebrow: z.string(),
title: z.string(),
body: z.string(),
callsign: z.string().optional(),
callsignLabel: z.string().optional(),
icon,
link: link.extend({ href: z.url() }),
}),
z.object({
type: z.literal('cards'),
id: z.string(),
eyebrow: z.string(),
title: z.string(),
items: z
.array(
z.object({
title: z.string(),
description: z.string().optional(),
href: z.url(),
icon,
}),
)
.min(1),
}),
z.object({
type: z.literal('latestPosts'),
id: z.string(),
eyebrow: z.string(),
title: z.string(),
count: z.number().int().positive(),
allLink: link,
empty: z.string(),
}),
]),
),
});
function parse<T>(name: string, schema: z.ZodType<T>, data: unknown): T {
const result = schema.safeParse(data);
if (!result.success) {
@@ -61,7 +120,9 @@ function parse<T>(name: string, schema: z.ZodType<T>, data: unknown): T {
}
export const site = parse('site.json', siteSchema, siteJson);
export const home = parse('home.json', homeSchema, homeJson);
export const social = parse('social.json', socialSchema, socialJson).filter((s) => s.href);
export type Site = z.infer<typeof siteSchema>;
export type HomeSection = (typeof home.sections)[number];
export type SocialLink = (typeof social)[number];
+25 -8
View File
@@ -1,13 +1,30 @@
---
// Temporary placeholder; replaced by the real home page in stage 3.
import Container from '../components/Container.astro';
import SectionHeading from '../components/SectionHeading.astro';
import CardGrid from '../components/home/CardGrid.astro';
import FeatureSection from '../components/home/FeatureSection.astro';
import Hero from '../components/home/Hero.astro';
import LatestPosts from '../components/home/LatestPosts.astro';
import BaseLayout from '../layouts/BaseLayout.astro';
import { site } from '../lib/data';
import { home } from '../lib/data';
// Hero is khaki, so sections alternate paper → khaki → … after it.
let featureIndex = 0;
const sections = home.sections.map((section, i) => ({
section,
alt: i % 2 === 1,
index: section.type === 'feature' ? ++featureIndex : 0,
}));
---
<BaseLayout>
<Container class="py-20">
<SectionHeading level={1} eyebrow={site.author.locator} title={site.name} />
</Container>
<BaseLayout description={home.seo.description}>
<Hero />
{sections.map(({ section, alt, index }) => {
switch (section.type) {
case 'feature':
return <FeatureSection section={section} index={index} alt={alt} />;
case 'cards':
return <CardGrid section={section} alt={alt} />;
case 'latestPosts':
return <LatestPosts section={section} alt={alt} hasPosts={false} />;
}
})}
</BaseLayout>