feat: Armarium full customization and 4-language i18n (v0.8.0)

Replaces Astro Rocket demo content with Armarium branding and adds
complete DE/FR/IT/EN translations across all pages.

Branding & content (v0.7.0):
- Add horizontal SVG logo to navbar with currentColor dark mode support
- Rewrite homepage with Armarium hero, 6 feature cards, trust bar,
  Zürich coat of arms SVG, and CTA; shared HomePage.astro component
- Add privacy page (/datenschutz) with 6 Infomaniak certification cards
  and 8-section policy (ISO 27001:2022, Swiss Hosting, nDSG/GDPR, etc.)
- Add legal notice page (/impressum)
- Rewrite about, contact, 404 pages with Armarium content
- Add features page (/projects) from projects content collection
- Add language switcher dropdown (LanguageSwitcherDropdown.astro)
- Add single launch blog post; remove all demo blog/project content
- Set up i18n foundation: astro.config.mjs, ui.ts, utils.ts

Full i18n (v0.8.0):
- Add all pages in FR/IT/EN: about, contact, blog, features, privacy,
  legal notice — 28 locale variants total
- Language switcher visible in every layout (PageLayout, BlogLayout,
  ProjectLayout, LandingLayout) with translated nav items
- Locale-aware nav and footer hrefs via nav.*.href keys in ui.ts
- Shared page components (AboutPage, ContactPage, FeaturesIndexPage,
  BlogIndexPage) accept locale prop; locale pages are 4-line wrappers
- Extend content.config.ts locale enum with de and it

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Daniel Krähenbühl
2026-04-13 21:51:21 +02:00
parent 4053bdfbc5
commit d668aa0fdf
75 changed files with 3126 additions and 3619 deletions
+167
View File
@@ -0,0 +1,167 @@
---
import PageLayout from '@/layouts/PageLayout.astro';
import Icon from '@/components/ui/primitives/Icon/Icon.astro';
import Badge from '@/components/ui/data-display/Badge/Badge.astro';
import Card from '@/components/ui/data-display/Card/Card.astro';
import Button from '@/components/ui/form/Button/Button.astro';
import { Hero } from '@/components/hero';
import { useTranslations } from '@/i18n/utils';
import type { Locale } from '@/i18n/ui';
interface Props {
locale: Locale;
}
const { locale } = Astro.props;
const t = useTranslations(locale);
---
<PageLayout
title={`${t('about.title.pre')}${t('about.title.accent')} — Armarium`}
description={t('about.desc')}
locale={locale}
>
<Hero layout="centered" size="sm">
<Badge slot="badge" variant="brand" pill>
<Icon name="info" size="sm" />
{t('about.badge')}
</Badge>
<h1 slot="title">
<span class="text-foreground [-webkit-text-fill-color:currentColor]">{t('about.title.pre')}</span>
<span class="text-brand-500 [-webkit-text-fill-color:var(--color-brand-500)]">{t('about.title.accent')}</span>
</h1>
<p slot="description">{t('about.desc')}</p>
</Hero>
<!-- Mission -->
<section class="py-[var(--space-section-md)] bg-brand-500/8 dark:bg-background-secondary">
<div class="mx-auto max-w-6xl px-6">
<div class="grid gap-12 lg:grid-cols-2">
<div class="flex flex-col gap-4" data-reveal>
<div class="flex flex-col gap-6">
<Badge variant="brand" pill class="self-start">{t('about.mission.badge')}</Badge>
<h2 class="font-display text-3xl md:text-4xl font-bold text-foreground">
{t('about.mission.title')}
</h2>
</div>
<p class="text-lg text-foreground-muted leading-relaxed">{t('about.mission.p1')}</p>
<p class="text-lg text-foreground-muted leading-relaxed">{t('about.mission.p2')}</p>
</div>
<div class="flex flex-col gap-4 h-full" data-reveal data-reveal-delay="1">
<Card variant="elevated" hover class="flex-1 flex flex-col justify-center">
<div class="flex items-start gap-4">
<div class="w-11 h-11 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-500/5 flex items-center justify-center text-brand-500 shrink-0">
<Icon name="wallet" size="md" />
</div>
<div class="flex-1 min-w-0">
<p class="text-xs font-bold tracking-wide text-foreground-muted mb-1">{t('about.app.label')}</p>
<h3 class="text-base font-semibold text-foreground">{t('about.app.title')}</h3>
<p class="text-sm text-foreground-muted leading-relaxed mt-1.5">{t('about.app.desc')}</p>
<div class="flex flex-wrap gap-1.5 mt-3">
<Badge size="sm" variant="brand">Angular</Badge>
<Badge size="sm" variant="brand">Budget</Badge>
<Badge size="sm" variant="brand">{t('about.privacy.label')}</Badge>
</div>
</div>
</div>
</Card>
<Card variant="elevated" hover class="flex-1 flex flex-col justify-center">
<div class="flex items-start gap-4">
<div class="w-11 h-11 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-500/5 flex items-center justify-center text-brand-500 shrink-0">
<Icon name="shield-check" size="md" />
</div>
<div class="flex-1 min-w-0">
<p class="text-xs font-bold tracking-wide text-foreground-muted mb-1">{t('about.privacy.label')}</p>
<h3 class="text-base font-semibold text-foreground">{t('about.privacy.title')}</h3>
<p class="text-sm text-foreground-muted leading-relaxed mt-1.5">{t('about.privacy.desc')}</p>
</div>
</div>
</Card>
</div>
</div>
</div>
</section>
<!-- Values -->
<section class="py-[var(--space-section-md)] border-t border-border">
<div class="mx-auto max-w-6xl px-6">
<div class="mb-8 text-center flex flex-col items-center gap-4" data-reveal>
<div class="flex flex-col items-center gap-6">
<Badge variant="brand" pill>{t('about.values.badge')}</Badge>
<h2 class="font-display text-4xl font-bold text-foreground">{t('about.values.title')}</h2>
</div>
</div>
<div class="grid gap-8 sm:grid-cols-2 lg:grid-cols-4" data-reveal data-reveal-delay="1">
<Card hover>
<div class="flex items-start gap-4">
<div class="w-11 h-11 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-500/5 flex items-center justify-center text-brand-500 shrink-0">
<Icon name="eye" size="md" />
</div>
<div class="flex-1 min-w-0">
<h3 class="text-base font-semibold text-foreground">{t('about.v1.title')}</h3>
<p class="text-sm text-foreground-muted leading-relaxed mt-1.5">{t('about.v1.desc')}</p>
</div>
</div>
</Card>
<Card hover>
<div class="flex items-start gap-4">
<div class="w-11 h-11 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-500/5 flex items-center justify-center text-brand-500 shrink-0">
<Icon name="zap" size="md" />
</div>
<div class="flex-1 min-w-0">
<h3 class="text-base font-semibold text-foreground">{t('about.v2.title')}</h3>
<p class="text-sm text-foreground-muted leading-relaxed mt-1.5">{t('about.v2.desc')}</p>
</div>
</div>
</Card>
<Card hover>
<div class="flex items-start gap-4">
<div class="w-11 h-11 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-500/5 flex items-center justify-center text-brand-500 shrink-0">
<Icon name="lock" size="md" />
</div>
<div class="flex-1 min-w-0">
<h3 class="text-base font-semibold text-foreground">{t('about.v3.title')}</h3>
<p class="text-sm text-foreground-muted leading-relaxed mt-1.5">{t('about.v3.desc')}</p>
</div>
</div>
</Card>
<!-- Made in Zürich -->
<Card hover>
<div class="flex items-start gap-4">
<div class="w-11 h-11 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-500/5 flex items-center justify-center shrink-0">
<svg class="w-6 h-7" viewBox="0 0 32 36" fill="none" xmlns="http://www.w3.org/2000/svg" aria-label="Zürich" role="img">
<defs><clipPath id="zh-about"><path d="M16 1L1 7V23L6 30L16 34L26 30L31 23V7L16 1Z"/></clipPath></defs>
<path d="M16 1L1 7V23L6 30L16 34L26 30L31 23V7L16 1Z" fill="white"/>
<polygon points="1,7 16,1 31,7 1,34" fill="#003DA5" clip-path="url(#zh-about)"/>
<path d="M16 1L1 7V23L6 30L16 34L26 30L31 23V7L16 1Z" fill="none" stroke="#003DA5" stroke-width="1.5"/>
</svg>
</div>
<div class="flex-1 min-w-0">
<h3 class="text-base font-semibold text-foreground">{t('about.v4.title')}</h3>
<p class="text-sm text-foreground-muted leading-relaxed mt-1.5">{t('about.v4.desc')}</p>
</div>
</div>
</Card>
</div>
</div>
</section>
<!-- CTA -->
<section class="py-[var(--space-section-md)] bg-background">
<div class="mx-auto max-w-2xl px-6 text-center" data-reveal>
<h2 class="font-display text-4xl font-bold text-foreground mb-4 text-balance">{t('about.cta.title')}</h2>
<p class="text-lg text-foreground-muted mb-8 text-balance">{t('about.cta.desc')}</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<Button size="lg" href="/register">
{t('cta.register')}
<Icon name="arrow-right" size="sm" />
</Button>
<Button size="lg" variant="outline" href={locale === 'de' ? '/' : `/${locale}/`}>
{t('about.cta.back')}
</Button>
</div>
</div>
</section>
</PageLayout>
+255
View File
@@ -0,0 +1,255 @@
---
import PageLayout from '@/layouts/PageLayout.astro';
import BlogCard from '@/components/blog/BlogCard.astro';
import Badge from '@/components/ui/data-display/Badge/Badge.astro';
import Icon from '@/components/ui/primitives/Icon/Icon.astro';
import CTA from '@/components/ui/marketing/CTA/CTA.astro';
import { Hero } from '@/components/hero';
import { getCollection } from 'astro:content';
import siteConfig from '@/config/site.config';
import { resolveSocialLinks } from '@/lib/utils';
import { useTranslations } from '@/i18n/utils';
import type { Locale } from '@/i18n/ui';
interface Props {
locale: Locale;
}
const { locale } = Astro.props;
const t = useTranslations(locale);
const socialLinks = resolveSocialLinks(siteConfig.socialLinks);
// Get all published posts (all locales)
const allPosts = await getCollection('blog', ({ data }) => {
return import.meta.env.PROD ? data.draft !== true : true;
});
const posts = allPosts.sort((a, b) => b.data.publishedAt.valueOf() - a.data.publishedAt.valueOf());
const featuredPosts = posts.filter((post) => post.data.featured);
const nonFeaturedPosts = posts.filter((post) => !post.data.featured);
const regularPosts = nonFeaturedPosts.length > 0 ? nonFeaturedPosts : posts;
const allTags = [...new Set(posts.flatMap((post) => post.data.tags))].sort();
const getPostUrl = (postId: string) => {
// Strip locale folder prefix (e.g., "en/post-slug" → "post-slug")
const slug = postId.replace(/^[a-z]{2}\//, '');
return `/blog/${slug}`;
};
---
<PageLayout
title={`${t('blog.title')} — Armarium`}
description={t('blog.desc')}
showScrollProgress
locale={locale}
>
<Hero layout="centered" size="sm">
<Badge slot="badge" variant="brand" pill>
<Icon name="book" size="sm" />
{t('blog.badge')}
</Badge>
<h1 slot="title">{t('blog.title')}</h1>
<p slot="description">{t('blog.desc')}</p>
</Hero>
<!-- Featured Posts Section -->
{
featuredPosts.length > 0 && (
<section id="featured-section" class="bg-background-secondary py-[var(--space-section-md)] border-t border-border">
<div class="mx-auto max-w-6xl px-6 flex flex-col gap-8">
<h2 class="font-display text-foreground text-3xl md:text-4xl font-bold" data-reveal>
{t('blog.featured')}
</h2>
<div class="grid gap-6 md:grid-cols-2" data-reveal data-reveal-delay="1">
{featuredPosts.map((post) => (
<div class="post-card" data-tags={JSON.stringify(post.data.tags)}>
<BlogCard
title={post.data.title}
description={post.data.description}
href={getPostUrl(post.id)}
publishedAt={post.data.publishedAt}
tags={post.data.tags}
author={post.data.author}
image={post.data.image}
svgSlug={post.data.svgSlug}
/>
</div>
))}
</div>
</div>
</section>
)
}
<!-- All Posts Section -->
<section class:list={[featuredPosts.length > 0 ? 'bg-background' : 'bg-background-secondary', 'py-[var(--space-section-md)] border-t border-border']}>
<div class="mx-auto max-w-6xl px-6 flex flex-col gap-8">
<div class="flex flex-wrap items-center justify-between gap-4" data-reveal>
{
featuredPosts.length > 0 && nonFeaturedPosts.length > 0 && (
<h2 class="font-display text-foreground text-3xl md:text-4xl font-bold">{t('blog.allposts')}</h2>
)
}
{allTags.length > 0 && (
<div class="flex items-center gap-3 ml-auto">
<label for="tag-filter" class="text-sm font-medium text-foreground-muted whitespace-nowrap">
{t('blog.filter.label')}
</label>
<div class="relative">
<select
id="tag-filter"
class="tag-select appearance-none pl-3 pr-8 py-1.5 rounded-full border border-border bg-background text-sm font-medium text-foreground cursor-pointer focus:outline-none focus:border-brand-500 transition-colors"
>
<option value="all">{t('blog.filter.all')}</option>
{allTags.map((tag) => (
<option value={tag}>{tag}</option>
))}
</select>
<span class="pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 text-foreground-muted">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="m6 9 6 6 6-6"/>
</svg>
</span>
</div>
<span id="filter-count" class="text-sm text-foreground-muted hidden"></span>
</div>
)}
</div>
{
regularPosts.length > 0 ? (
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3" data-reveal data-reveal-delay="1">
{regularPosts.map((post) => (
<div class="post-card" data-tags={JSON.stringify(post.data.tags)}>
<BlogCard
title={post.data.title}
description={post.data.description}
href={getPostUrl(post.id)}
publishedAt={post.data.publishedAt}
tags={post.data.tags}
author={post.data.author}
image={post.data.image}
svgSlug={post.data.svgSlug}
/>
</div>
))}
</div>
) : (
<div class="py-16 text-center">
<div class="bg-background mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full">
<Icon name="file-text" size="lg" class="text-foreground-muted" />
</div>
<p class="text-foreground-muted text-lg">{t('blog.noposts')}</p>
</div>
)
}
<div id="no-results" class="hidden py-16 text-center">
<div class="bg-background mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full">
<Icon name="file-text" size="lg" class="text-foreground-muted" />
</div>
<p class="text-foreground-muted text-lg">{t('blog.noposts.tag')}</p>
</div>
</div>
</section>
<!-- Follow CTA Section -->
<CTA variant="default" size="md" maxWidth="lg" glow={false} data-reveal class:list={[featuredPosts.length > 0 && '!bg-background-secondary']}>
<h2 slot="heading" class="!text-4xl">{t('blog.follow.title')}</h2>
<p slot="description" class="!text-lg text-balance">{t('blog.follow.desc')}</p>
<div class="flex flex-wrap items-center justify-center gap-3">
<a
href="/rss.xml"
class="inline-flex items-center gap-2 rounded-full border border-border bg-background px-4 py-2 text-sm font-medium text-foreground transition-colors hover:border-brand-500 hover:text-brand-500"
>
<Icon name="rss" size="sm" />
RSS Feed
</a>
{socialLinks.map((link) => (
<a
href={link.href}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 rounded-full border border-border bg-background px-4 py-2 text-sm font-medium text-foreground transition-colors hover:border-brand-500 hover:text-brand-500"
>
<Icon name={link.icon} size="sm" />
{link.label}
</a>
))}
<a
href={`mailto:${siteConfig.email}`}
class="inline-flex items-center gap-2 rounded-full border border-border bg-background px-4 py-2 text-sm font-medium text-foreground transition-colors hover:border-brand-500 hover:text-brand-500"
>
<Icon name="mail" size="sm" />
Email
</a>
</div>
</CTA>
</PageLayout>
<style>
.tag-select {
background-color: var(--color-background);
color: var(--color-foreground);
}
</style>
<script>
function initTagFilter() {
const select = document.getElementById('tag-filter') as HTMLSelectElement;
const cards = document.querySelectorAll<HTMLElement>('.post-card');
const featuredSection = document.getElementById('featured-section');
const noResults = document.getElementById('no-results');
const filterCount = document.getElementById('filter-count');
if (!select) return;
select.addEventListener('change', () => {
const tag = select.value;
if (tag === 'all') {
cards.forEach((card) => card.style.removeProperty('display'));
if (featuredSection) featuredSection.style.removeProperty('display');
noResults?.classList.add('hidden');
if (filterCount) filterCount.classList.add('hidden');
return;
}
let visibleCount = 0;
let featuredVisible = 0;
cards.forEach((card) => {
const tags: string[] = JSON.parse(card.dataset.tags || '[]');
if (tags.includes(tag)) {
card.style.removeProperty('display');
visibleCount++;
if (featuredSection?.contains(card)) featuredVisible++;
} else {
card.style.display = 'none';
}
});
if (featuredSection) {
featuredSection.style.display = featuredVisible === 0 ? 'none' : '';
}
if (noResults) {
noResults.classList.toggle('hidden', visibleCount > 0);
}
if (filterCount) {
filterCount.textContent = `${visibleCount}`;
filterCount.classList.remove('hidden');
}
});
}
document.addEventListener('astro:page-load', initTagFilter);
</script>
+121
View File
@@ -0,0 +1,121 @@
---
import PageLayout from '@/layouts/PageLayout.astro';
import Icon from '@/components/ui/primitives/Icon/Icon.astro';
import Badge from '@/components/ui/data-display/Badge/Badge.astro';
import Card from '@/components/ui/data-display/Card/Card.astro';
import { Hero } from '@/components/hero';
import ContactForm from '@/components/patterns/ContactForm.astro';
import siteConfig from '@/config/site.config';
import { resolveSocialLinks } from '@/lib/utils';
import { useTranslations } from '@/i18n/utils';
import type { Locale } from '@/i18n/ui';
interface Props {
locale: Locale;
}
const { locale } = Astro.props;
const t = useTranslations(locale);
const socialLinks = resolveSocialLinks(siteConfig.socialLinks);
const channels = [
{
icon: 'mail',
label: 'Email',
value: siteConfig.email,
note: t('contact.follow'),
href: `mailto:${siteConfig.email}`,
external: false,
},
...socialLinks.map((link) => ({
icon: link.icon,
label: link.label,
value: link.href.replace(/^https?:\/\/(www\.)?/, ''),
note: t('contact.follow'),
href: link.href,
external: true,
})),
];
---
<PageLayout
title={`${t('contact.badge')} — Armarium`}
description={t('contact.desc')}
locale={locale}
>
<Hero layout="centered" size="sm">
<Badge slot="badge" variant="brand" pill>
<Icon name="mail" size="sm" />
{t('contact.badge')}
</Badge>
<h1 slot="title">
{t('contact.title').replace('.', '')} <span class="text-brand-500">{t('contact.title').slice(-1)}</span>
</h1>
<p slot="description">{t('contact.desc')}</p>
</Hero>
<!-- Contact Section -->
<section class="py-[var(--space-section-md)] bg-background-secondary border-t border-border">
<div class="mx-auto max-w-5xl px-6">
<div class="grid grid-cols-1 lg:grid-cols-5 gap-10 lg:gap-16 items-start">
<!-- Left column: contact form -->
<div class="lg:col-span-3" data-reveal>
<Card padding="lg">
<h2 class="font-display text-xl font-bold text-foreground mb-6">{t('contact.form.title')}</h2>
<ContactForm />
</Card>
</div>
<!-- Right column: contact channels -->
<div class="lg:col-span-2 space-y-4" data-reveal data-reveal-delay="1">
<div>
<h2 class="font-display text-lg font-bold text-foreground mb-1">{t('contact.direct.title')}</h2>
<p class="text-sm text-foreground-muted">{t('contact.direct.desc')}</p>
</div>
<!-- Channel list -->
<div class="space-y-2">
{channels.map((ch) => (
<a
href={ch.href}
{...ch.external ? { target: '_blank', rel: 'noopener noreferrer' } : {}}
class="block group"
>
<Card hover padding="sm" class="h-full">
<div class="flex items-center gap-3">
<div class="w-10 h-10 shrink-0 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-500/5 flex items-center justify-center text-brand-500">
<Icon name={ch.icon} size="sm" />
</div>
<div class="min-w-0 flex-1">
<p class="font-semibold text-sm text-foreground">{ch.label}</p>
<p class="text-xs text-foreground-muted truncate">{ch.value}</p>
</div>
<Icon name="arrow-up-right" size="sm" class="text-foreground-muted group-hover:text-brand-500 transition-colors shrink-0" />
</div>
</Card>
</a>
))}
</div>
<!-- Location card -->
<Card hover padding="sm" class="bg-brand-500/5 border-brand-500/20">
<div class="flex items-center gap-3">
<div class="w-10 h-10 shrink-0 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-500/5 flex items-center justify-center text-brand-500">
<Icon name="map-pin" size="sm" />
</div>
<div>
<p class="font-semibold text-sm text-foreground">{siteConfig.address?.city ?? 'Zürich'}</p>
<p class="text-xs text-foreground-muted mt-0.5">{siteConfig.address?.country ?? 'Switzerland'}</p>
</div>
</div>
</Card>
</div>
</div>
</div>
</section>
</PageLayout>
@@ -0,0 +1,97 @@
---
import PageLayout from '@/layouts/PageLayout.astro';
import Icon from '@/components/ui/primitives/Icon/Icon.astro';
import Badge from '@/components/ui/data-display/Badge/Badge.astro';
import Card from '@/components/ui/data-display/Card/Card.astro';
import Button from '@/components/ui/form/Button/Button.astro';
import { Hero } from '@/components/hero';
import { getCollection } from 'astro:content';
import { useTranslations } from '@/i18n/utils';
import type { Locale } from '@/i18n/ui';
interface Props {
locale: Locale;
}
const { locale } = Astro.props;
const t = useTranslations(locale);
const items = await getCollection('projects', ({ data }) => !data.draft);
const features = items.sort((a, b) => a.data.order - b.data.order);
const iconMap: Record<string, string> = {
'budget-uebersicht': 'layout-dashboard',
'transaktionen': 'list',
'kategorien-berichte': 'pie-chart',
'mehrere-konten': 'wallet',
'sparziele': 'target',
'datenschutz-sicherheit': 'shield-check',
};
const featuresHref = t('nav.features.href');
---
<PageLayout
title={`${t('nav.features')} — Armarium`}
description={t('features.description')}
locale={locale}
>
<Hero layout="centered" size="sm">
<Badge slot="badge" variant="brand" pill>
<Icon name="zap" size="sm" />
{t('features.badge')}
</Badge>
<h1 slot="title">
{t('features.title').split(' ').slice(0, -1).join(' ')} <span class="text-brand-500 [-webkit-text-fill-color:var(--color-brand-500)]">{t('features.title').split(' ').slice(-1)[0]}</span>
</h1>
<p slot="description">{t('features.description')}</p>
</Hero>
<!-- Feature cards -->
<section class="py-[var(--space-section-md)] bg-background-secondary border-t border-border">
<div class="mx-auto max-w-6xl px-6">
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3" data-reveal>
{features.map((feature) => {
const slug = feature.id.replace(/\.mdx?$/, '');
const icon = iconMap[slug] ?? 'check-circle';
return (
<Card variant="elevated" hover padding="lg" href={`/projects/${slug}`} class="group flex flex-col">
<div class="flex flex-1 flex-col">
<div class="mb-4 flex items-start justify-between gap-4">
<div class="w-11 h-11 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-500/5 flex items-center justify-center text-brand-500 shrink-0">
<Icon name={icon} size="md" />
</div>
<Icon name="arrow-up-right" size="sm" class="text-foreground-muted group-hover:text-brand-500 transition-colors shrink-0" />
</div>
<h3 class="font-display text-lg font-bold text-foreground mb-2">{feature.data.title}</h3>
<p class="text-sm text-foreground-muted leading-relaxed flex-1">{feature.data.description}</p>
<div class="flex flex-wrap gap-1.5 mt-4">
{feature.data.tags.map((tag) => (
<Badge variant="brand">{tag}</Badge>
))}
</div>
</div>
</Card>
);
})}
</div>
</div>
</section>
<!-- CTA -->
<section class="py-[var(--space-section-md)] bg-background border-t border-border">
<div class="mx-auto max-w-2xl px-6 text-center" data-reveal>
<h2 class="font-display text-4xl font-bold text-foreground mb-4 text-balance">{t('features.cta.title')}</h2>
<p class="text-lg text-foreground-muted mb-8 text-balance">{t('features.cta.desc')}</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<Button size="lg" href="/register">
{t('cta.register')}
<Icon name="arrow-right" size="sm" />
</Button>
<Button size="lg" variant="outline" href="/login">
{t('features.cta.login')}
</Button>
</div>
</div>
</section>
</PageLayout>
+131
View File
@@ -0,0 +1,131 @@
---
/**
* Shared home page component — renders in all 4 locales.
*/
import { Hero } from '@/components/hero';
import Button from '@/components/ui/form/Button/Button.astro';
import Badge from '@/components/ui/data-display/Badge/Badge.astro';
import Card from '@/components/ui/data-display/Card/Card.astro';
import Icon from '@/components/ui/primitives/Icon/Icon.astro';
import { useTranslations } from '@/i18n/utils';
import type { Locale } from '@/i18n/ui';
interface Props {
locale: Locale;
}
const { locale } = Astro.props;
const t = useTranslations(locale);
const features = [
{ key: 'f1', icon: 'layout-dashboard' },
{ key: 'f2', icon: 'list' },
{ key: 'f3', icon: 'pie-chart' },
{ key: 'f4', icon: 'wallet' },
{ key: 'f5', icon: 'target' },
{ key: 'f6', icon: 'shield-check' },
] as const;
---
<!-- Hero -->
<Hero layout="centered" size="xl" class="hero-dark-gradient">
<Badge slot="badge" variant="brand" pill pulse class="dark:text-brand-200">
{t('hero.badge')}
</Badge>
<h1 slot="title">
<span class="text-foreground [-webkit-text-fill-color:currentColor]">Armarium Suite —</span><br />
<span class="text-brand-500 [-webkit-text-fill-color:var(--color-brand-500)] dark:text-foreground dark:[-webkit-text-fill-color:currentColor]">Budget</span><span class="text-foreground [-webkit-text-fill-color:currentColor]"> &amp; More</span>
</h1>
<p slot="description">{t('hero.description')}</p>
<Fragment slot="actions">
<Button size="lg" href="/register">
{t('hero.register')}
<Icon name="arrow-right" size="sm" />
</Button>
<Button size="lg" variant="outline" href="#features">
{t('hero.login')}
</Button>
</Fragment>
</Hero>
<!-- Trust bar -->
<div class="relative z-10 py-5 bg-background border-t border-border">
<div class="mx-auto max-w-6xl px-6">
<div class="flex flex-wrap justify-center gap-x-8 gap-y-3 text-sm text-foreground-muted">
<span class="flex items-center gap-2">
<svg class="w-5 h-6 shrink-0" viewBox="0 0 32 36" fill="none" xmlns="http://www.w3.org/2000/svg" aria-label="Zürich" role="img">
<defs><clipPath id="zh-home"><path d="M16 1L1 7V23L6 30L16 34L26 30L31 23V7L16 1Z"/></clipPath></defs>
<path d="M16 1L1 7V23L6 30L16 34L26 30L31 23V7L16 1Z" fill="white"/>
<polygon points="1,7 16,1 31,7 1,34" fill="#003DA5" clip-path="url(#zh-home)"/>
<path d="M16 1L1 7V23L6 30L16 34L26 30L31 23V7L16 1Z" fill="none" stroke="#003DA5" stroke-width="1.5"/>
</svg>
Made in Zürich, Switzerland
</span>
<span class="flex items-center gap-2">
<Icon name="shield-check" size="sm" class="text-brand-500" />
{t('trust.privacy')}
</span>
<span class="flex items-center gap-2">
<Icon name="check-circle" size="sm" class="text-brand-500" />
{t('trust.free')}
</span>
</div>
</div>
</div>
<!-- Features -->
<section id="features" class="relative z-10 py-[var(--space-section-md)] bg-background-secondary border-t border-border">
<div class="mx-auto max-w-6xl px-6 flex flex-col gap-8">
<div class="flex flex-col items-center gap-4 text-center" data-reveal>
<div class="flex flex-col items-center gap-6">
<Badge variant="brand" pill>{t('features.badge')}</Badge>
<h2 class="font-display text-3xl md:text-4xl font-bold text-foreground">
{t('features.title')}
</h2>
</div>
<p class="text-lg text-foreground-muted max-w-2xl mx-auto">
{t('features.description')}
</p>
</div>
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3" data-reveal data-reveal-delay="1">
{features.map(({ key, icon }) => (
<Card hover>
<div class="flex items-start gap-4">
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-500/5 flex items-center justify-center text-brand-500 shrink-0">
<Icon name={icon} size="sm" />
</div>
<div class="flex-1 min-w-0">
<h3 class="text-base font-semibold text-foreground">{t(`${key}.title` as any)}</h3>
<p class="text-sm text-foreground-muted leading-relaxed mt-1.5">{t(`${key}.desc` as any)}</p>
</div>
</div>
</Card>
))}
</div>
</div>
</section>
<!-- CTA -->
<section class="relative z-10 py-[var(--space-section-md)] bg-background border-t border-border">
<div class="mx-auto max-w-2xl px-6 text-center" data-reveal>
<h2 class="font-display text-4xl font-bold text-foreground mb-4 text-balance">
{t('cta.title')}
</h2>
<p class="text-lg text-foreground-muted mb-8 text-balance">
{t('cta.desc')}
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<Button size="lg" href="/register">
{t('cta.register')}
<Icon name="arrow-right" size="sm" />
</Button>
<Button size="lg" variant="outline" href="/login">
{t('cta.login')}
</Button>
</div>
</div>
</section>
+6 -15
View File
@@ -140,11 +140,8 @@ function getSocialIcon(platform: string): string {
{hasLogoSlot ? (
<slot name="logo" />
) : (
<a href="/" class="flex items-center gap-2">
<Logo size="md" />
<span class="font-display text-base font-bold text-brand-500">
{siteConfig.name}
</span>
<a href="/" class="flex items-center">
<Logo variant="full" size="md" />
</a>
)}
{(hasTaglineSlot || tagline) && (
@@ -207,11 +204,8 @@ function getSocialIcon(platform: string): string {
{hasLogoSlot ? (
<slot name="logo" />
) : (
<a href="/" class="flex items-center gap-2">
<Logo size="md" />
<span class="font-display text-base font-bold text-brand-500">
{siteConfig.name}
</span>
<a href="/" class="flex items-center">
<Logo variant="full" size="md" />
</a>
)}
{(hasTaglineSlot || tagline) && (
@@ -310,11 +304,8 @@ function getSocialIcon(platform: string): string {
hasLogoSlot ? (
<slot name="logo" />
) : (
<a href="/" class="flex items-center gap-2">
<Logo size="md" />
<span class="font-display text-xl font-bold text-brand-500">
{siteConfig.name}
</span>
<a href="/" class="flex items-center">
<Logo variant="full" size="md" />
</a>
)
)}
+28 -10
View File
@@ -31,7 +31,9 @@ import Logo from '@/components/ui/marketing/Logo/Logo.astro';
import ThemeToggle from '@/components/layout/ThemeToggle.astro';
import ThemeSelector from '@/components/layout/ThemeSelector.astro';
import ThemeSelectorDropdown from '@/components/layout/ThemeSelectorDropdown.astro';
import LanguageSwitcherDropdown from '@/components/layout/LanguageSwitcherDropdown.astro';
import siteConfig from '@/config/site.config';
import type { Locale } from '@/i18n/ui';
export interface NavItem {
label: string;
@@ -83,6 +85,8 @@ interface Props extends HTMLAttributes<'header'> {
hideLogo?: boolean;
/** Show language switcher */
showLanguageSwitcher?: boolean;
/** Current locale for language switcher */
currentLocale?: Locale;
/** Show social icon links (desktop/tablet only, reads from siteConfig.socialLinks) */
showSocialLinks?: boolean;
/** Show scroll progress bar at the bottom of the header */
@@ -110,6 +114,8 @@ const {
showActiveState = true,
showScrollProgress = false,
scrollProgressPosition = 'bottom',
showLanguageSwitcher = false,
currentLocale = 'de',
logoText,
hideLogo = false,
class: className,
@@ -190,16 +196,13 @@ const buttonId = `${menuId}-button`;
(hasLogoSlot ? (
<slot name="logo" />
) : (
<a href="/" class="flex items-center gap-2">
<Logo size={size === 'lg' ? 'lg' : 'md'} forceDark={isInvert} />
<span
class={cn(
'font-display text-xl font-bold tracking-tight',
isFloating ? 'hdr-logo-text' : (isInvert ? 'text-on-invert' : 'text-brand-500')
)}
>
{logoText || siteConfig.name}
</span>
<a href="/" class="flex items-center">
<Logo
variant="full"
size={size === 'lg' ? 'lg' : 'md'}
forceDark={isInvert}
class={cn(isFloating ? 'hdr-logo-text' : (isInvert ? 'text-on-invert' : 'text-foreground'))}
/>
</a>
))
}
@@ -254,6 +257,12 @@ const buttonId = `${menuId}-button`;
</div>
)}
{showLanguageSwitcher && (
<div class="hidden md:flex">
<LanguageSwitcherDropdown currentLocale={currentLocale as Locale} />
</div>
)}
{showSocialLinks && siteConfig.socialLinks.length > 0 && (
<div class="hidden md:flex items-center gap-0.5">
{siteConfig.socialLinks.map((url) => {
@@ -416,6 +425,15 @@ const buttonId = `${menuId}-button`;
</div>
</div>
)}
{showLanguageSwitcher && (
<div class="border-border mt-3 border-t pt-3">
<div class="flex items-center justify-between px-1">
<span class="text-sm text-foreground-muted">Language</span>
<LanguageSwitcherDropdown currentLocale={currentLocale as Locale} />
</div>
</div>
)}
</div>
</div>
))
@@ -0,0 +1,155 @@
---
/**
* LanguageSwitcherDropdown
* Desktop language selector following the ThemeSelectorDropdown pattern.
* Navigates to the equivalent page in the selected locale.
*/
import { cn } from '@/lib/cn';
import { languages, type Locale } from '@/i18n/ui';
interface Props {
currentLocale?: Locale;
class?: string;
}
const { currentLocale = 'de', class: className } = Astro.props;
const current = languages[currentLocale];
const allLanguages = Object.values(languages);
---
<div class={cn('relative lang-dropdown-wrapper', className)} data-current-locale={currentLocale}>
<!-- Trigger -->
<button
type="button"
id="lang-dropdown-trigger"
aria-haspopup="true"
aria-expanded="false"
aria-label="Select language"
class={cn(
'inline-flex items-center gap-1.5 rounded-full border border-border-strong px-2.5 py-1.5',
'text-foreground-muted hover:text-foreground hover:bg-secondary',
'transition-colors duration-(--transition-fast)',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2'
)}
>
<span class="text-xs font-semibold tracking-wide uppercase">{current.code}</span>
<svg
class="lang-chevron h-3 w-3 shrink-0 transition-transform duration-200"
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2.5"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"
>
<path d="m6 9 6 6 6-6"/>
</svg>
</button>
<!-- Dropdown panel -->
<div
id="lang-dropdown-panel"
role="listbox"
aria-label="Language"
class={cn(
'absolute right-0 top-full mt-2 z-50',
'w-40 rounded-xl border border-border bg-background shadow-lg',
'p-1.5',
'hidden'
)}
>
{allLanguages.map((lang) => (
<button
type="button"
role="option"
class="lang-option w-full flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-left
hover:bg-secondary transition-colors duration-(--transition-fast)
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
data-locale={lang.code}
aria-selected={lang.code === currentLocale ? 'true' : 'false'}
>
<span class="text-xs font-bold tracking-wide uppercase text-foreground-muted">{lang.code}</span>
<span class="font-medium text-foreground">{lang.name}</span>
</button>
))}
</div>
</div>
<style>
.lang-option[aria-selected="true"] {
background-color: var(--color-secondary);
}
.lang-option[aria-selected="true"] span:last-child {
color: var(--color-brand-500);
font-weight: 600;
}
</style>
<script>
function getLocalePath(targetLocale: string, currentPath: string): string {
const nonDefault = ['fr', 'it', 'en'];
let basePath = currentPath;
for (const loc of nonDefault) {
if (currentPath.startsWith(`/${loc}/`)) {
basePath = currentPath.slice(loc.length + 1);
break;
} else if (currentPath === `/${loc}`) {
basePath = '/';
break;
}
}
if (targetLocale === 'de') return basePath || '/';
return basePath === '/' ? `/${targetLocale}/` : `/${targetLocale}${basePath}`;
}
function closeLangDropdown() {
const panel = document.getElementById('lang-dropdown-panel');
const trigger = document.getElementById('lang-dropdown-trigger');
panel?.classList.add('hidden');
trigger?.setAttribute('aria-expanded', 'false');
trigger?.querySelector('.lang-chevron')?.classList.remove('rotate-180');
}
function initLangDropdown() {
const trigger = document.getElementById('lang-dropdown-trigger');
const panel = document.getElementById('lang-dropdown-panel');
const wrapper = trigger?.closest('.lang-dropdown-wrapper') as HTMLElement | null;
if (!trigger || !panel || !wrapper) return;
if (trigger.dataset.langInit) return;
trigger.dataset.langInit = 'true';
// Bind language option clicks
panel.querySelectorAll<HTMLButtonElement>('.lang-option').forEach((btn) => {
btn.addEventListener('click', () => {
const targetLocale = btn.dataset.locale!;
const path = getLocalePath(targetLocale, window.location.pathname);
closeLangDropdown();
window.location.href = path;
});
});
// Toggle dropdown
trigger.addEventListener('click', (e) => {
e.stopPropagation();
const isOpen = !panel.classList.contains('hidden');
if (isOpen) {
closeLangDropdown();
} else {
panel.classList.remove('hidden');
trigger.setAttribute('aria-expanded', 'true');
trigger.querySelector('.lang-chevron')?.classList.add('rotate-180');
}
});
// Close on outside click
document.addEventListener('click', (e) => {
if (!wrapper.contains(e.target as Node)) closeLangDropdown();
});
// Close on Escape
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeLangDropdown();
});
}
initLangDropdown();
document.addEventListener('astro:page-load', initLangDropdown);
document.addEventListener('astro:after-swap', initLangDropdown);
</script>
+39 -18
View File
@@ -1,28 +1,30 @@
---
/**
* Logo Component — first-letter monogram badge
* Logo Component
*
* Renders a brand-coloured square badge with the first letter of the site
* name, used in the navbar, footer, and blog post bylines.
* - variant='logomark' (default): brand-coloured square badge with first letter
* - variant='full': horizontal SVG logo (icon + wordmark)
*/
import { cn } from '@/lib/cn';
import siteConfig from '@/config/site.config';
import logoHorizontal from '@/assets/logo-horizontal.svg?raw';
interface Props {
/** Size preset */
size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl';
/** Force dark mode logo (for dark backgrounds) — no-op here, kept for API compat */
forceDark?: boolean;
/** Use full logo instead of logomark — no-op here, kept for API compat */
/** 'logomark': monogram badge | 'full': horizontal SVG logo */
variant?: 'logomark' | 'full';
/** Additional CSS classes */
class?: string;
/** Override the displayed letter (e.g. for author avatars). Defaults to first letter of site name. */
/** Override the displayed letter (logomark only). Defaults to first letter of site name. */
letter?: string;
}
const {
size = 'md',
variant = 'logomark',
class: className,
letter: letterProp,
} = Astro.props;
@@ -37,18 +39,37 @@ const sizeClasses: Record<string, { box: string; text: string; radius: string }>
const { box, text, radius } = sizeClasses[size] ?? sizeClasses.md;
const letter = letterProp ?? siteConfig.name.charAt(0).toUpperCase();
const fullHeights: Record<string, string> = {
sm: 'h-4',
md: 'h-5',
lg: 'h-7',
xl: 'h-10',
'2xl': 'h-16',
};
const fullHeight = fullHeights[size] ?? fullHeights.md;
---
<span
class={cn(
'inline-flex items-center justify-center shrink-0',
'bg-brand-500 text-white',
'font-display font-bold leading-none select-none',
box, text, radius,
className
)}
aria-label={siteConfig.branding.logo.alt}
role="img"
>
{letter}
</span>
{variant === 'full' ? (
<span
class={cn('inline-flex items-center shrink-0 text-foreground', fullHeight, className)}
aria-label={siteConfig.branding.logo.alt}
role="img"
>
<Fragment set:html={logoHorizontal} />
</span>
) : (
<span
class={cn(
'inline-flex items-center justify-center shrink-0',
'bg-brand-500 text-white',
'font-display font-bold leading-none select-none',
box, text, radius,
className
)}
aria-label={siteConfig.branding.logo.alt}
role="img"
>
{letter}
</span>
)}