Header
apps/www/src/components/Header.astro · 193 lines · group Layout & internals
Default locale lives at the site root; other locales are prefixed (gatsby-era URL convention: "/contacts/" is se, "/en/contacts/" is en).
Used by
Rendered by the layout on every page of every www site.
Props
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
| locale | string | yes | ||
| alternates | Record<string, string> | {} | locale → path of this page's translations (README §4.13) |
Source apps/www/src/components/Header.astro @ gitt.one
---
import type { ImageMetadata } from 'astro';
import { Image } from 'astro:assets';
import Bars3 from 'astro-heroicons/outline/Bars3.astro';
import Phone from 'astro-heroicons/outline/Phone.astro';
import pagesConfig from '../../pages.config.js';
import { resolveImage } from '../lib/images';
import { localeHome, localeLabel, localePrefix, locales, perLocale } from '../lib/locales';
interface Props {
locale: string;
/** locale → path of this page's translations (README §4.13) */
alternates?: Record<string, string>;
}
const { locale, alternates = {} } = Astro.props;
// Default locale lives at the site root; other locales are prefixed
// (gatsby-era URL convention: "/contacts/" is se, "/en/contacts/" is en).
const prefix = localePrefix(locale);
let logo: ImageMetadata | null = null;
try {
logo = await resolveImage(pagesConfig.logoImage);
} catch {
// No logo in the engine fixtures; fall back to the title text.
logo = null;
}
// site menu comes from pages.config.js (single-config principle);
// path is locale-relative: '' = home, 'models' = /<locale>/models/,
// '#contacts' = anchor on the home page; cta: true renders as accent button
interface NavConfigItem {
name: string;
path: string;
cta?: boolean;
}
const toHref = (path: string) =>
path === '' ? prefix : path.startsWith('#') ? `${prefix}${path}` : `${prefix}${path}/`;
const currentPath = Astro.url.pathname.replace(/\/+$/, '') || '/';
const navigation = perLocale<NavConfigItem[]>(pagesConfig.navigation, locale, [{ name: 'Home', path: '' }]).map(
({ name, path, cta = false }) => {
const href = toHref(path);
const active = !path.startsWith('#') && (href.replace(/\/+$/, '') || '/') === currentPath;
return { name, href, cta, active };
},
);
// header theme: 'light' (default) or 'dark' — set in pages.config.js
const dark = (pagesConfig.headerTheme ?? 'light') === 'dark';
const linkClass = dark
? 'text-gray-300 hover:text-white'
: 'text-gray-500 hover:text-gray-900';
const navItemClass = (item: { cta: boolean; active: boolean }) => [
'my-2 rounded-md px-4 py-2 text-base font-medium whitespace-nowrap',
item.active && 'bg-primary-600 text-white hover:bg-primary-700',
item.cta && 'border border-primary-200 bg-primary-50 text-primary-700 hover:bg-primary-100',
!item.active && !item.cta && linkClass,
];
// Language switcher: the same page in the other language when it exists
// (alternates), that locale's home otherwise — never a 404.
const localeHref = (l: string) => alternates[l] ?? localeHome(l);
---
<!-- z-30: the open mobile panel must paint above hero sections (z-10) -->
<header class:list={['relative z-30', dark ? 'bg-stone-900' : 'bg-white shadow']}>
<div class="mx-auto max-w-7xl px-4 sm:px-6">
<div class="flex items-center justify-between py-3 md:justify-start md:space-x-10">
<div class="flex justify-start lg:w-0 lg:flex-1">
<a href={prefix} class="my-2 flex items-center">
{
logo ? (
<Image
src={logo}
alt={pagesConfig.title}
height={pagesConfig.logoImageHeight}
densities={[1, 2]}
loading="eager"
class="mr-2"
style={`height: ${pagesConfig.logoImageHeight}px; width: auto;`}
/>
) : (
/* no logo image: the site title becomes the wordmark — a brand
gradient on light headers, plain white on dark ones */
<span
class:list={[
'text-xl font-extrabold tracking-tight',
dark
? 'text-white'
: 'bg-gradient-to-r from-primary-600 to-primary-alt-600 bg-clip-text text-transparent',
]}
>
{pagesConfig.title}
</span>
)
}
</a>
</div>
<nav class="hidden items-center space-x-6 md:flex">
{
Boolean(pagesConfig.defaultPhone) && (
<a
href={`tel:${pagesConfig.defaultPhone.replace(/[^+\d]/g, "")}`}
class:list={['my-2 flex whitespace-nowrap text-base font-medium', linkClass]}
>
<Phone class="h-6 w-6 flex-shrink-0 text-primary-600" aria-hidden="true" />
<span class="ml-3">{pagesConfig.defaultPhone}</span>
</a>
)
}
{
navigation
.filter((item) => !item.cta)
.map((item) => (
<a href={item.href} class:list={navItemClass(item)}>
{item.name}
</a>
))
}
</nav>
<div class="flex flex-1 items-center justify-end gap-2">
{
navigation
.filter((item) => item.cta)
.map((item) => (
<a href={item.href} class:list={[...navItemClass(item), 'hidden md:block']}>
{item.name}
</a>
))
}
{
locales.length > 1 &&
locales.map((l) => (
<a
href={localeHref(l)}
class:list={[
'rounded-md px-4 py-2 text-base font-medium uppercase',
l === locale
? 'bg-primary-600 text-white hover:bg-primary-700'
: 'text-gray-500 hover:text-gray-900',
]}
>
{localeLabel(l)}
</a>
))
}
</div>
<details class="group -my-1 md:hidden">
<summary
class:list={[
'ml-2 inline-flex cursor-pointer list-none items-center justify-center rounded-md p-2 [&::-webkit-details-marker]:hidden',
dark
? 'bg-stone-900 text-gray-300 hover:bg-stone-800 hover:text-white'
: 'bg-white text-gray-400 hover:bg-gray-100 hover:text-gray-500',
]}
>
<span class="sr-only">Open menu</span>
<Bars3 class="h-6 w-6" aria-hidden="true" />
</summary>
<div
class="absolute inset-x-0 top-full z-10 border-t border-gray-100 bg-white shadow-lg"
>
<nav class="grid gap-y-1 px-5 py-4">
{
navigation.map((item) => (
<a
href={item.href}
class="rounded-md px-3 py-2 text-base font-medium text-gray-900 hover:bg-gray-50"
>
{item.name}
</a>
))
}
{
Boolean(pagesConfig.defaultPhone) && (
<a
href={`tel:${pagesConfig.defaultPhone.replace(/[^+\d]/g, "")}`}
class="rounded-md px-3 py-2 text-base font-medium text-gray-500 hover:bg-gray-50"
>
{pagesConfig.defaultPhone}
</a>
)
}
</nav>
</div>
</details>
</div>
</div>
</header>Source links point to engine-astro@e857a859. Samples are cut from the sites' own content by scripts/gallery-extract.mjs; nothing is merged or removed yet.