Mobile-first layout with breakpoints at 48rem (tablet) and 64rem (desktop): stacked nav on phones, progressive column counts for the article grid and footer, scrollable markdown tables, larger tap targets on nav links.
188 lines
4.6 KiB
Vue
188 lines
4.6 KiB
Vue
<script setup lang="ts">
|
|
import type { ArticleSummary, Paginated } from '#shared/types/blog'
|
|
|
|
const { t, locale } = useI18n()
|
|
|
|
const { data, error } = await useFetch<Paginated<ArticleSummary>>('/api/articles', {
|
|
key: 'home-articles',
|
|
query: { page: 1 },
|
|
})
|
|
|
|
/** The newest article leads the page; the next six fill the grid below it. */
|
|
const featured = computed(() => data.value?.items[0] ?? null)
|
|
const rest = computed(() => data.value?.items.slice(1, 7) ?? [])
|
|
|
|
const mediaUrl = useMediaUrl()
|
|
const featuredCover = computed(() => mediaUrl(featured.value?.cover))
|
|
|
|
const { public: config } = useRuntimeConfig()
|
|
const route = useRoute()
|
|
const canonical = computed(() => `${config.siteUrl}${route.path}`)
|
|
|
|
// The home page is the site itself, so it carries the bare name as its title.
|
|
useHead({ titleTemplate: '%s', link: [{ rel: 'canonical', href: canonical }] })
|
|
|
|
useSeoMeta({
|
|
title: SITE_NAME,
|
|
description: () => t('site.tagline'),
|
|
ogTitle: SITE_NAME,
|
|
ogDescription: () => t('site.tagline'),
|
|
ogType: 'website',
|
|
ogUrl: canonical,
|
|
ogImage: `${config.siteUrl}/logo.png`,
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div>
|
|
<p v-if="error">{{ $t('list.failed') }}</p>
|
|
|
|
<p v-else-if="!featured">{{ $t('list.empty') }}</p>
|
|
|
|
<template v-else>
|
|
<article class="featured" :class="{ 'has-cover': featuredCover }">
|
|
<NuxtLinkLocale
|
|
v-if="featuredCover"
|
|
class="featured-cover"
|
|
:to="`/blog/${featured.slug}`"
|
|
tabindex="-1"
|
|
aria-hidden="true"
|
|
>
|
|
<img
|
|
:src="featuredCover"
|
|
:alt="featured.cover?.alternativeText ?? ''"
|
|
:width="featured.cover?.width ?? undefined"
|
|
:height="featured.cover?.height ?? undefined"
|
|
>
|
|
</NuxtLinkLocale>
|
|
|
|
<div class="featured-text">
|
|
<p class="kicker">
|
|
{{ featured.category ? featured.category.name : $t('home.latest') }}
|
|
</p>
|
|
<h1>
|
|
<NuxtLinkLocale :to="`/blog/${featured.slug}`">{{ featured.title }}</NuxtLinkLocale>
|
|
</h1>
|
|
<p class="kicker date">
|
|
<time :datetime="isoDate(featured.publishedAt)">
|
|
{{ formatDate(featured.publishedAt, locale) }}
|
|
</time>
|
|
</p>
|
|
<p class="read-more">
|
|
<NuxtLinkLocale :to="`/blog/${featured.slug}`">
|
|
{{ $t('home.readArticle') }}
|
|
</NuxtLinkLocale>
|
|
</p>
|
|
</div>
|
|
</article>
|
|
|
|
<template v-if="rest.length">
|
|
<h2 class="section-heading kicker">{{ $t('home.moreStories') }}</h2>
|
|
|
|
<ul class="card-grid">
|
|
<li v-for="article in rest" :key="article.slug">
|
|
<ArticleCard :article="article" />
|
|
</li>
|
|
</ul>
|
|
</template>
|
|
|
|
<p class="more">
|
|
<NuxtLinkLocale to="/blog">{{ $t('home.viewAll') }}</NuxtLinkLocale>
|
|
</p>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.featured {
|
|
display: grid;
|
|
gap: 1.5rem;
|
|
align-items: center;
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
/* Stacked on phones and portrait tablets — a side-by-side split below this
|
|
width leaves both the image and the headline too cramped to read. The split
|
|
also needs a cover to sit in it, or one column would just be empty. */
|
|
@media (min-width: 64rem) {
|
|
.featured.has-cover {
|
|
grid-template-columns: 1.35fr 1fr;
|
|
gap: 3.5rem;
|
|
}
|
|
}
|
|
|
|
/* Without a cover the headline is the whole lead, so it gets the full width
|
|
but stays inside a readable measure. */
|
|
.featured:not(.has-cover) .featured-text {
|
|
max-width: 46rem;
|
|
}
|
|
|
|
.featured-cover {
|
|
display: block;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.featured-cover img {
|
|
width: 100%;
|
|
aspect-ratio: 3 / 2;
|
|
object-fit: cover;
|
|
background: var(--color-surface);
|
|
}
|
|
|
|
.featured h1 {
|
|
margin-top: 0.6rem;
|
|
font-size: clamp(2rem, 4.5vw, 3.1rem);
|
|
}
|
|
|
|
.featured h1 a {
|
|
text-decoration: none;
|
|
}
|
|
|
|
.featured h1 a:hover {
|
|
text-decoration: underline;
|
|
text-underline-offset: 0.12em;
|
|
}
|
|
|
|
.date {
|
|
margin-top: 1rem;
|
|
font-weight: 400;
|
|
}
|
|
|
|
.read-more {
|
|
margin-top: 1.75rem;
|
|
font-size: 0.75rem;
|
|
font-weight: 600;
|
|
letter-spacing: 0.14em;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.read-more a {
|
|
padding-bottom: 0.35rem;
|
|
text-decoration: none;
|
|
border-bottom: 1px solid var(--color-ink);
|
|
}
|
|
|
|
.more {
|
|
margin-top: var(--gap-section);
|
|
text-align: center;
|
|
font-size: 0.75rem;
|
|
font-weight: 600;
|
|
letter-spacing: 0.14em;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.more a {
|
|
padding: 0.85rem 2.5rem;
|
|
text-decoration: none;
|
|
border: 1px solid var(--color-ink);
|
|
color: var(--color-ink);
|
|
display: inline-block;
|
|
transition: background 0.2s ease, color 0.2s ease;
|
|
}
|
|
|
|
.more a:hover {
|
|
background: var(--color-ink);
|
|
color: var(--color-bg);
|
|
}
|
|
</style>
|