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:
@@ -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']}>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user