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:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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]"> & 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>
|
||||
@@ -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>
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user