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

NameTypeRequiredDefaultDescription
localestringyes
alternatesRecord<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.