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.
300 lines
6.6 KiB
Vue
300 lines
6.6 KiB
Vue
<script setup lang="ts">
|
|
import type { Category } from '#shared/types/blog'
|
|
|
|
const { locale } = useI18n()
|
|
|
|
const { data: categories } = await useFetch<Category[]>('/api/categories', {
|
|
key: 'nav-categories',
|
|
default: () => [],
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="page">
|
|
<a class="skip-link" href="#main">{{ $t('site.skipToContent') }}</a>
|
|
|
|
<header class="site-header">
|
|
<div class="container">
|
|
<p class="wordmark">
|
|
<NuxtLinkLocale to="/">
|
|
<img src="/logo.png" :alt="SITE_NAME" width="400" height="400">
|
|
</NuxtLinkLocale>
|
|
</p>
|
|
<p class="tagline kicker">{{ $t('site.tagline') }}</p>
|
|
</div>
|
|
|
|
<nav class="site-nav" :aria-label="$t('site.mainNavigation')">
|
|
<div class="container nav-inner">
|
|
<ul>
|
|
<li><NuxtLinkLocale to="/blog">{{ $t('site.allArticles') }}</NuxtLinkLocale></li>
|
|
<li v-for="category in categories" :key="category.slug">
|
|
<NuxtLinkLocale :to="`/category/${category.slug}`">
|
|
{{ category.name }}
|
|
</NuxtLinkLocale>
|
|
</li>
|
|
</ul>
|
|
<LanguageSwitcher />
|
|
</div>
|
|
</nav>
|
|
</header>
|
|
|
|
<main id="main" class="container site-main">
|
|
<slot />
|
|
</main>
|
|
|
|
<footer class="site-footer">
|
|
<div class="container footer-inner">
|
|
<div class="footer-block">
|
|
<img class="footer-logo" src="/logo.png" :alt="SITE_NAME" width="400" height="400">
|
|
<p class="muted">{{ $t('site.tagline') }}</p>
|
|
<address class="contact">
|
|
{{ $t('site.contact') }}:
|
|
<a :href="`mailto:${SITE_EMAIL}`">{{ SITE_EMAIL }}</a>
|
|
</address>
|
|
</div>
|
|
|
|
<nav class="footer-block" :aria-label="$t('site.categories')">
|
|
<p class="kicker">{{ $t('site.categories') }}</p>
|
|
<ul>
|
|
<li v-for="category in categories" :key="category.slug">
|
|
<NuxtLinkLocale :to="`/category/${category.slug}`">
|
|
{{ category.name }}
|
|
</NuxtLinkLocale>
|
|
</li>
|
|
<li v-if="!categories.length" class="muted">{{ $t('site.noCategories') }}</li>
|
|
</ul>
|
|
</nav>
|
|
|
|
<nav class="footer-block" :aria-label="$t('site.browse')">
|
|
<p class="kicker">{{ $t('site.browse') }}</p>
|
|
<ul>
|
|
<li><NuxtLinkLocale to="/">{{ $t('site.home') }}</NuxtLinkLocale></li>
|
|
<li><NuxtLinkLocale to="/blog">{{ $t('site.allArticles') }}</NuxtLinkLocale></li>
|
|
</ul>
|
|
</nav>
|
|
|
|
<nav class="footer-block" :aria-label="$t('site.follow')">
|
|
<p class="kicker">{{ $t('site.follow') }}</p>
|
|
<ul>
|
|
<li v-for="social in SOCIAL_LINKS" :key="social.name">
|
|
<a class="social" :href="social.url" target="_blank" rel="noopener me">
|
|
<SocialIcon :name="social.name" />
|
|
<span>{{ social.name }}</span>
|
|
</a>
|
|
</li>
|
|
</ul>
|
|
</nav>
|
|
</div>
|
|
|
|
<p class="container copyright kicker" :lang="locale">
|
|
© {{ new Date().getFullYear() }} {{ SITE_NAME }}. {{ $t('site.rights') }}
|
|
</p>
|
|
</footer>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.page {
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-height: 100vh;
|
|
}
|
|
|
|
/* Header: centred wordmark over a thin rule of links. */
|
|
.site-header {
|
|
padding-top: 1.5rem;
|
|
text-align: center;
|
|
border-bottom: 1px solid var(--color-border);
|
|
}
|
|
|
|
@media (min-width: 48rem) {
|
|
.site-header {
|
|
padding-top: 2.5rem;
|
|
}
|
|
}
|
|
|
|
.wordmark {
|
|
margin: 0;
|
|
}
|
|
|
|
.wordmark a {
|
|
display: inline-block;
|
|
text-decoration: none;
|
|
}
|
|
|
|
/* The seal carries the name itself, so it stands in for a text wordmark. */
|
|
.wordmark img {
|
|
width: clamp(6rem, 14vw, 8.5rem);
|
|
height: auto;
|
|
}
|
|
|
|
.tagline {
|
|
margin-top: 0.6rem;
|
|
}
|
|
|
|
.site-nav {
|
|
margin-top: 1.25rem;
|
|
border-top: 1px solid var(--color-border);
|
|
}
|
|
|
|
/* Phones stack the links above the language picker, both centred; from tablet
|
|
up they sit on one line with the picker pushed to the right. */
|
|
.nav-inner {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding-block: 0.6rem;
|
|
}
|
|
|
|
.site-nav ul {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0 1.25rem;
|
|
justify-content: center;
|
|
padding: 0;
|
|
margin: 0;
|
|
list-style: none;
|
|
font-size: 0.75rem;
|
|
font-weight: 600;
|
|
letter-spacing: 0.14em;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
@media (min-width: 48rem) {
|
|
.site-nav {
|
|
margin-top: 2rem;
|
|
}
|
|
|
|
.nav-inner {
|
|
flex-direction: row;
|
|
flex-wrap: wrap;
|
|
justify-content: space-between;
|
|
gap: 0.75rem 2rem;
|
|
padding-block: 0.9rem;
|
|
}
|
|
|
|
.site-nav ul {
|
|
gap: 0.25rem 2rem;
|
|
}
|
|
}
|
|
|
|
.site-nav a {
|
|
display: inline-block;
|
|
text-decoration: none;
|
|
color: var(--color-body);
|
|
/* Roomy enough to tap accurately on a phone. */
|
|
padding-block: 0.6rem;
|
|
border-bottom: 1px solid transparent;
|
|
}
|
|
|
|
.site-nav a:hover,
|
|
.site-nav a.router-link-active {
|
|
color: var(--color-ink);
|
|
border-bottom-color: var(--color-ink);
|
|
}
|
|
|
|
.site-main {
|
|
flex: 1;
|
|
padding-block: 2.25rem 3.5rem;
|
|
}
|
|
|
|
@media (min-width: 48rem) {
|
|
.site-main {
|
|
padding-block: 3.5rem 5rem;
|
|
}
|
|
}
|
|
|
|
/* Footer: columns, then a centred copyright line. */
|
|
.site-footer {
|
|
padding-block: 3.5rem 2rem;
|
|
background: var(--color-surface);
|
|
border-top: 1px solid var(--color-border);
|
|
}
|
|
|
|
/* One column on phones, two on tablets, four on desktop — auto-fit would
|
|
otherwise leave the fourth block stranded on its own row on a tablet. */
|
|
.footer-inner {
|
|
display: grid;
|
|
gap: 2rem;
|
|
grid-template-columns: 1fr;
|
|
padding-bottom: 2.5rem;
|
|
}
|
|
|
|
@media (min-width: 48rem) {
|
|
.footer-inner {
|
|
grid-template-columns: repeat(2, 1fr);
|
|
gap: 2.5rem;
|
|
}
|
|
}
|
|
|
|
@media (min-width: 64rem) {
|
|
.footer-inner {
|
|
grid-template-columns: repeat(4, 1fr);
|
|
}
|
|
}
|
|
|
|
.footer-block p:last-of-type {
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
.footer-logo {
|
|
width: 4.5rem;
|
|
height: auto;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.contact {
|
|
margin-top: 0.75rem;
|
|
font-size: 0.95rem;
|
|
font-style: normal;
|
|
}
|
|
|
|
.contact a {
|
|
text-decoration: underline;
|
|
text-underline-offset: 0.2em;
|
|
}
|
|
|
|
.footer-block ul {
|
|
padding: 0;
|
|
margin: 0.9rem 0 0;
|
|
list-style: none;
|
|
font-size: 0.95rem;
|
|
}
|
|
|
|
.footer-block li + li {
|
|
margin-top: 0.35rem;
|
|
}
|
|
|
|
.footer-block a {
|
|
text-decoration: none;
|
|
}
|
|
|
|
.footer-block a:hover {
|
|
text-decoration: underline;
|
|
text-underline-offset: 0.2em;
|
|
}
|
|
|
|
.social {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.55rem;
|
|
}
|
|
|
|
/* Keep the underline on the label, not stretched across the icon. */
|
|
.social:hover {
|
|
text-decoration: none;
|
|
}
|
|
|
|
.social:hover span {
|
|
text-decoration: underline;
|
|
text-underline-offset: 0.2em;
|
|
}
|
|
|
|
.copyright {
|
|
padding-top: 1.75rem;
|
|
text-align: center;
|
|
border-top: 1px solid var(--color-border);
|
|
}
|
|
</style>
|