- site.config.json single source of identity (name/url/description/author/social) - scripts/configure.mjs stamps it into app, content-pipeline, and astroagent configs - app/src/lib/blog-data.js SITE + author now read from app/src/config/site.json - astro.config.mjs default site URL from config - index.astro / Footer.astro neutralized hardcoded title, hero alt, social link - sample 'welcome' post + placeholder hero/avatar so the site builds out of the box Verified: cd app && npm ci && npm run build -> 13 pages, Complete. Remaining comiida demo prose in about/faq/services/contact + Newsletter (sample content). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SYHWLHihq3v9nxNwoPCKSn
225 lines
8.9 KiB
Text
225 lines
8.9 KiB
Text
---
|
|
import { Image } from "astro:assets";
|
|
import BaseLayout from "../layouts/BaseLayout.astro";
|
|
import PostCard from "../components/PostCard.astro";
|
|
import Newsletter from "../components/Newsletter.astro";
|
|
import Icon from "../components/Icon.astro";
|
|
import heroImage from "../assets/hero-placeholder.png";
|
|
import {
|
|
SITE,
|
|
categories,
|
|
featuredPost,
|
|
sortedPosts,
|
|
getAuthor,
|
|
getCategory,
|
|
formatDate,
|
|
organizationSchema,
|
|
websiteSchema,
|
|
} from "../lib/blog-data.js";
|
|
|
|
const featured = await featuredPost();
|
|
const featuredAuthor = getAuthor(featured.author);
|
|
const featuredCategory = getCategory(featured.category);
|
|
const latest = (await sortedPosts()).filter((post) => post.slug !== featured.slug);
|
|
const jsonLd = {
|
|
"@context": "https://schema.org",
|
|
"@graph": [organizationSchema(), websiteSchema()],
|
|
};
|
|
---
|
|
|
|
<BaseLayout
|
|
title={`${SITE.name} — ${SITE.tagline}`}
|
|
description={SITE.description}
|
|
canonical="/"
|
|
jsonLd={jsonLd}
|
|
flushFooter
|
|
>
|
|
<section class="relative -mt-[calc(4rem+2px)] min-h-[calc(100svh-2rem)] overflow-hidden border-b border-border/60 pt-[calc(4rem+2px)] sm:min-h-[760px]">
|
|
<Image
|
|
src={heroImage}
|
|
alt="Hero image placeholder — replace in app/src/assets"
|
|
class="absolute inset-0 h-full w-full object-cover object-center"
|
|
loading="eager"
|
|
decoding="async"
|
|
widths={[640, 960, 1280, 1920, 2560]}
|
|
sizes="100vw"
|
|
/>
|
|
<div
|
|
aria-hidden="true"
|
|
class="absolute inset-0 bg-[linear-gradient(90deg,oklch(0.1_0.02_70_/_0.68),oklch(0.16_0.02_80_/_0.38)_46%,oklch(0.98_0.01_90_/_0.1))] dark:bg-[linear-gradient(90deg,oklch(0.08_0.01_70_/_0.78),oklch(0.08_0.01_70_/_0.52)_48%,oklch(0.08_0.01_70_/_0.18))]"
|
|
/>
|
|
<div
|
|
aria-hidden="true"
|
|
class="absolute inset-x-0 top-0 h-40 bg-gradient-to-b from-black/45 via-black/18 to-transparent"
|
|
/>
|
|
<div
|
|
aria-hidden="true"
|
|
class="absolute inset-x-0 bottom-0 h-48 bg-gradient-to-t from-background via-background/72 to-transparent"
|
|
/>
|
|
<div class="relative mx-auto flex min-h-[calc(100svh-6rem)] max-w-6xl items-center px-5 py-20 sm:min-h-[680px] sm:py-24">
|
|
<div class="max-w-4xl text-white drop-shadow-[0_1px_18px_rgba(0,0,0,0.28)]">
|
|
<h1 class="font-serif text-[2.5rem] font-medium leading-[1.02] tracking-[-0.02em] sm:text-6xl lg:text-7xl">
|
|
Medellín’s
|
|
<span class="italic text-white"> restaurants</span>,
|
|
<span class="italic text-white"> food news</span>, and
|
|
<span class="italic text-white"> where to eat</span>.
|
|
</h1>
|
|
<p class="mt-8 max-w-xl text-lg leading-relaxed text-white/86">
|
|
Guides, openings, and data-driven dining picks for English-speaking expats, nomads, and travelers — researched carefully and updated often.
|
|
</p>
|
|
<div class="mt-10 flex flex-wrap items-center gap-x-8 gap-y-4">
|
|
<a
|
|
href={`/blog/${featured.slug}`}
|
|
class="group inline-flex items-center gap-2 border-b border-white pb-1 text-sm font-medium text-white hover:border-white/70 hover:text-white/82"
|
|
>
|
|
Begin with this issue's lead essay
|
|
<Icon name="arrow-right" class="h-3.5 w-3.5 transition-transform group-hover:translate-x-0.5" />
|
|
</a>
|
|
<a href="/blog" class="text-sm text-white/76 hover:text-white">
|
|
Or browse the archive
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="border-b border-border/60">
|
|
<div class="mx-auto max-w-6xl px-5 py-16">
|
|
<div class="mb-6 flex items-center justify-between">
|
|
<div class="text-xs font-medium uppercase tracking-wider text-muted-foreground">Featured</div>
|
|
<a href="/blog" class="text-xs text-muted-foreground hover:text-foreground">All writing →</a>
|
|
</div>
|
|
<article class="grid gap-10 lg:grid-cols-2">
|
|
<a href={`/blog/${featured.slug}`} class="group block" aria-label={`Read ${featured.title}`}>
|
|
<div class="aspect-[4/3] overflow-hidden rounded-md bg-muted">
|
|
{
|
|
featured.thumbnail && (
|
|
<Image
|
|
src={featured.thumbnail}
|
|
alt=""
|
|
class="h-full w-full object-cover transition-transform duration-500 group-hover:scale-[1.02]"
|
|
/>
|
|
)
|
|
}
|
|
</div>
|
|
</a>
|
|
<div class="flex flex-col justify-center">
|
|
<div class="flex items-center gap-3 text-xs uppercase tracking-wider text-muted-foreground">
|
|
{
|
|
featuredCategory && (
|
|
<a href={`/categories/${featuredCategory.slug}`} class="text-primary hover:underline">
|
|
{featuredCategory.name}
|
|
</a>
|
|
)
|
|
}
|
|
<span>·</span>
|
|
<time datetime={featured.date}>{formatDate(featured.date)}</time>
|
|
</div>
|
|
<a href={`/blog/${featured.slug}`}>
|
|
<h2 class="mt-3 font-serif text-3xl font-semibold leading-tight tracking-tight hover:text-primary sm:text-4xl">
|
|
{featured.title}
|
|
</h2>
|
|
</a>
|
|
<p class="mt-4 text-lg text-muted-foreground">{featured.excerpt}</p>
|
|
<div class="mt-6 flex items-center gap-3 text-sm text-muted-foreground">
|
|
{
|
|
featuredAuthor && (
|
|
<>
|
|
<img
|
|
src={featuredAuthor.avatar}
|
|
alt=""
|
|
width="28"
|
|
height="28"
|
|
class="h-7 w-7 rounded-full"
|
|
/>
|
|
<a href={`/authors/${featuredAuthor.slug}`} class="hover:text-foreground">{featuredAuthor.name}</a>
|
|
<span>·</span>
|
|
</>
|
|
)
|
|
}
|
|
<span>{featured.readingTime} min read</span>
|
|
</div>
|
|
<a href={`/blog/${featured.slug}`} class="mt-6 inline-flex items-center gap-1 text-sm font-medium text-primary hover:underline">
|
|
Read the essay <Icon name="arrow-right" class="h-3.5 w-3.5" />
|
|
</a>
|
|
</div>
|
|
</article>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="mx-auto max-w-6xl px-5 py-16" data-home-latest>
|
|
<div class="flex flex-wrap items-center justify-between gap-4">
|
|
<h2 class="font-serif text-2xl font-semibold tracking-tight">Latest</h2>
|
|
<div class="flex flex-wrap gap-2">
|
|
<button
|
|
type="button"
|
|
data-home-filter=""
|
|
class="rounded-full border px-3 py-1 text-xs transition-colors border-foreground bg-foreground text-background"
|
|
>
|
|
All
|
|
</button>
|
|
{
|
|
categories.map((category) => (
|
|
<button
|
|
type="button"
|
|
data-home-filter={category.slug}
|
|
class="rounded-full border px-3 py-1 text-xs transition-colors border-border text-muted-foreground hover:text-foreground"
|
|
>
|
|
{category.name}
|
|
</button>
|
|
))
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
<div data-home-empty class="mt-12 rounded-md border border-dashed border-border py-16 text-center text-muted-foreground" hidden>
|
|
Nothing here yet in this section.
|
|
</div>
|
|
|
|
<div class="mt-10 grid gap-10 sm:grid-cols-2 lg:grid-cols-3">
|
|
{latest.map((post, index) => <PostCard post={post} hidden={index >= 6} />)}
|
|
</div>
|
|
|
|
<div class="mt-12 text-center">
|
|
<a href="/blog" class="inline-flex items-center gap-2 rounded-md border border-border px-5 py-2.5 text-sm font-medium hover:bg-muted">
|
|
Browse the archive <Icon name="arrow-right" class="h-3.5 w-3.5" />
|
|
</a>
|
|
</div>
|
|
</section>
|
|
|
|
<Newsletter />
|
|
</BaseLayout>
|
|
|
|
<script>
|
|
const latest = document.querySelector("[data-home-latest]");
|
|
if (latest) {
|
|
const buttons = [...latest.querySelectorAll("[data-home-filter]")];
|
|
const cards = [...latest.querySelectorAll("[data-post-card]")];
|
|
const empty = latest.querySelector("[data-home-empty]");
|
|
const activeClasses = ["border-foreground", "bg-foreground", "text-background"];
|
|
const inactiveClasses = ["border-border", "text-muted-foreground", "hover:text-foreground"];
|
|
|
|
const setActive = (button, active) => {
|
|
button.classList.toggle(activeClasses[0], active);
|
|
button.classList.toggle(activeClasses[1], active);
|
|
button.classList.toggle(activeClasses[2], active);
|
|
inactiveClasses.forEach((className) => button.classList.toggle(className, !active));
|
|
};
|
|
|
|
const filterCards = (category) => {
|
|
let visible = 0;
|
|
cards.forEach((card) => {
|
|
const match = !category || card.dataset.category === category;
|
|
const show = match && visible < 6;
|
|
card.hidden = !show;
|
|
if (match) visible += 1;
|
|
});
|
|
if (empty) empty.hidden = visible > 0;
|
|
buttons.forEach((button) => setActive(button, button.dataset.homeFilter === category));
|
|
};
|
|
|
|
buttons.forEach((button) => {
|
|
button.addEventListener("click", () => filterCards(button.dataset.homeFilter || ""));
|
|
});
|
|
}
|
|
</script>
|