First Release v1.0.0
@@ -0,0 +1,64 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Astro Rocket - Environment Configuration
|
||||||
|
# =============================================================================
|
||||||
|
# Copy this file to .env and fill in your values
|
||||||
|
# Variables prefixed with PUBLIC_ are exposed to the browser - NEVER prefix secrets
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Required
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Your production site URL (used for sitemap, canonical URLs, OG images)
|
||||||
|
SITE_URL=https://example.com
|
||||||
|
|
||||||
|
# Deployment target — set to "netlify" when deploying to Netlify, leave unset for Vercel
|
||||||
|
# DEPLOY_TARGET=netlify
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Analytics (optional)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Google Analytics 4 Measurement ID
|
||||||
|
# PUBLIC_GA_MEASUREMENT_ID=G-XXXXXXXXXX
|
||||||
|
|
||||||
|
# Google Tag Manager Container ID
|
||||||
|
# PUBLIC_GTM_ID=GTM-XXXXXXX
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Form Handling (optional)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Resend API key for contact form emails and newsletter (server-side only)
|
||||||
|
# Get your key at https://resend.com
|
||||||
|
RESEND_API_KEY=your-resend-api-key
|
||||||
|
|
||||||
|
# Sender address for contact form emails — must be a verified Resend domain
|
||||||
|
# Defaults to the email set in site.config.ts if not provided
|
||||||
|
# RESEND_FROM_EMAIL=noreply@yourdomain.com
|
||||||
|
|
||||||
|
# Resend Audience ID for newsletter subscriptions (server-side only)
|
||||||
|
# Create an audience at resend.com/audiences and paste its ID here
|
||||||
|
# RESEND_AUDIENCE_ID=your-audience-id
|
||||||
|
|
||||||
|
# Newsletter API key (server-side only) — used by the /api/newsletter endpoint
|
||||||
|
# NEWSLETTER_API_KEY=your-newsletter-api-key
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Search Engine Verification (optional)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Google Search Console verification code
|
||||||
|
# GOOGLE_SITE_VERIFICATION=your-verification-code
|
||||||
|
|
||||||
|
# Bing Webmaster Tools verification code
|
||||||
|
# BING_SITE_VERIFICATION=your-verification-code
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Consent / Privacy (optional)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Enable the cookie consent banner (requires analytics to be configured)
|
||||||
|
# PUBLIC_CONSENT_ENABLED=true
|
||||||
|
|
||||||
|
# Link to your privacy policy page (shown in consent banner when set)
|
||||||
|
# PUBLIC_PRIVACY_POLICY_URL=/privacy
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Code of Conduct
|
||||||
|
|
||||||
|
## Our Standards
|
||||||
|
|
||||||
|
Astro Rocket is an open-source project and everyone who participates — whether through issues, pull requests, discussions, or any other channel — is expected to treat others with respect and professionalism.
|
||||||
|
|
||||||
|
**Expected behaviour:**
|
||||||
|
- Be welcoming and considerate in your language and actions
|
||||||
|
- Offer and accept constructive feedback graciously
|
||||||
|
- Focus on what is best for the project and the community
|
||||||
|
- Show patience with contributors of all experience levels
|
||||||
|
|
||||||
|
**Unacceptable behaviour:**
|
||||||
|
- Personal attacks, insults, or derogatory comments
|
||||||
|
- Public or private intimidation of any kind
|
||||||
|
- Sharing others' private information without permission
|
||||||
|
- Any conduct that would be considered unprofessional in a work setting
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This code of conduct applies to all project spaces — GitHub issues, pull requests, discussions, and any other official communication channel.
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
Instances of unacceptable behaviour may be reported to [hello@hansmartens.dev](mailto:hello@hansmartens.dev). All reports will be reviewed and handled with discretion. Maintainers reserve the right to remove, edit, or reject contributions that do not align with this code of conduct, and to temporarily or permanently exclude anyone whose behaviour is deemed harmful to the project.
|
||||||
|
|
||||||
|
## Attribution
|
||||||
|
|
||||||
|
This code of conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.1.
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# Contributing to Astro Rocket
|
||||||
|
|
||||||
|
Thank you for your interest in contributing. Astro Rocket is a free, open-source Astro 6 starter theme — every improvement, however small, makes it better for everyone who builds with it.
|
||||||
|
|
||||||
|
## Ways to contribute
|
||||||
|
|
||||||
|
- **Report a bug** — something broken or behaving unexpectedly
|
||||||
|
- **Suggest a feature** — an idea for a new component, page, or configuration option
|
||||||
|
- **Fix a bug** — pick up an open issue and submit a pull request
|
||||||
|
- **Improve documentation** — fix a typo, clarify an instruction, or improve an example
|
||||||
|
- **Improve accessibility** — help make the theme usable for everyone
|
||||||
|
|
||||||
|
## Before you start
|
||||||
|
|
||||||
|
- Check the [open issues](https://github.com/hansmartens68/Astro-Rocket/issues) to avoid duplicating work
|
||||||
|
- For significant changes, open an issue first to discuss the approach before writing code
|
||||||
|
- All contributions are released under the [MIT License](../LICENSE)
|
||||||
|
|
||||||
|
## Development setup
|
||||||
|
|
||||||
|
**Prerequisites:** Node.js ≥ 22.12.0 and pnpm
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Fork and clone the repo
|
||||||
|
git clone https://github.com/YOUR_USERNAME/Astro-Rocket.git
|
||||||
|
cd Astro-Rocket
|
||||||
|
|
||||||
|
# Copy the environment file
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
# Start the dev server
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Branch naming
|
||||||
|
|
||||||
|
| Type | Pattern | Example |
|
||||||
|
|---|---|---|
|
||||||
|
| New feature | `feature/short-description` | `feature/add-breadcrumbs` |
|
||||||
|
| Bug fix | `fix/short-description` | `fix/mobile-nav-overflow` |
|
||||||
|
| Content or config | `update/short-description` | `update/readme-installation` |
|
||||||
|
|
||||||
|
Always branch from `main`.
|
||||||
|
|
||||||
|
## Before submitting a pull request
|
||||||
|
|
||||||
|
All three checks must pass with zero errors:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm lint # ESLint
|
||||||
|
pnpm check # Astro type checking
|
||||||
|
pnpm build # Production build
|
||||||
|
```
|
||||||
|
|
||||||
|
If any check fails, fix the errors before opening the PR. The CI workflow runs the same checks automatically.
|
||||||
|
|
||||||
|
## Commit messages
|
||||||
|
|
||||||
|
Write clear, specific commit messages that describe what changed and why.
|
||||||
|
|
||||||
|
```
|
||||||
|
# Good
|
||||||
|
Add keyboard navigation to theme selector dropdown
|
||||||
|
Fix contact form validation on mobile Safari
|
||||||
|
Update installation docs to reflect pnpm lockfile requirement
|
||||||
|
|
||||||
|
# Too vague
|
||||||
|
fix
|
||||||
|
update
|
||||||
|
changes
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pull request guidelines
|
||||||
|
|
||||||
|
- Keep pull requests focused — one concern per PR
|
||||||
|
- Reference the issue number if applicable: `Fixes #42`
|
||||||
|
- Fill in the PR description template — describe what changed, why, and how to test it
|
||||||
|
- Be responsive to review feedback
|
||||||
|
|
||||||
|
## Code style
|
||||||
|
|
||||||
|
- TypeScript is required for all `.astro` and `.ts` files
|
||||||
|
- Tailwind utility classes follow the existing ordering conventions
|
||||||
|
- No inline styles unless unavoidable
|
||||||
|
- Components go in `src/components/`, pages in `src/pages/`, layouts in `src/layouts/`
|
||||||
|
- Follow the existing file and folder naming conventions
|
||||||
|
|
||||||
|
## Questions
|
||||||
|
|
||||||
|
Open a [GitHub Discussion](https://github.com/hansmartens68/Astro-Rocket/discussions) or reach out on X at [@hansmartens_dev](https://x.com/hansmartens_dev).
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
github: [hansmartens68]
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
name: Bug report
|
||||||
|
about: Something is broken or not working as expected
|
||||||
|
title: "[Bug] "
|
||||||
|
labels: bug
|
||||||
|
assignees: ''
|
||||||
|
---
|
||||||
|
|
||||||
|
## Describe the bug
|
||||||
|
|
||||||
|
A clear description of what the bug is.
|
||||||
|
|
||||||
|
## Steps to reproduce
|
||||||
|
|
||||||
|
1. Go to '...'
|
||||||
|
2. Click on '...'
|
||||||
|
3. See error
|
||||||
|
|
||||||
|
## Expected behaviour
|
||||||
|
|
||||||
|
What you expected to happen.
|
||||||
|
|
||||||
|
## Actual behaviour
|
||||||
|
|
||||||
|
What actually happened.
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
- OS: [e.g. macOS 14, Windows 11]
|
||||||
|
- Node version: [e.g. 22.12.0]
|
||||||
|
- pnpm version: [e.g. 9.x]
|
||||||
|
- Browser (if relevant): [e.g. Chrome 120, Safari 17]
|
||||||
|
|
||||||
|
## Screenshots or error output
|
||||||
|
|
||||||
|
If applicable, add screenshots or paste any error messages here.
|
||||||
|
|
||||||
|
## Additional context
|
||||||
|
|
||||||
|
Any other context that might be relevant.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
name: Feature request
|
||||||
|
about: Suggest a new component, page, or improvement for Astro Rocket
|
||||||
|
title: "[Feature] "
|
||||||
|
labels: enhancement
|
||||||
|
assignees: ''
|
||||||
|
---
|
||||||
|
|
||||||
|
## What problem does this solve?
|
||||||
|
|
||||||
|
A clear description of the problem or gap this feature would address.
|
||||||
|
|
||||||
|
## Describe the solution you'd like
|
||||||
|
|
||||||
|
What would you like to see added or changed? Be as specific as you can.
|
||||||
|
|
||||||
|
## Alternatives you've considered
|
||||||
|
|
||||||
|
Any other approaches or workarounds you've thought about.
|
||||||
|
|
||||||
|
## Additional context
|
||||||
|
|
||||||
|
Screenshots, links, or examples that illustrate your idea.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
## What does this PR do?
|
||||||
|
|
||||||
|
A clear description of the change and why it was made.
|
||||||
|
|
||||||
|
Fixes # (issue number, if applicable)
|
||||||
|
|
||||||
|
## Type of change
|
||||||
|
|
||||||
|
- [ ] Bug fix
|
||||||
|
- [ ] New feature or component
|
||||||
|
- [ ] Documentation update
|
||||||
|
- [ ] Refactor or code quality improvement
|
||||||
|
- [ ] Other: ___
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] `pnpm lint` passes with 0 errors
|
||||||
|
- [ ] `pnpm check` passes with 0 errors
|
||||||
|
- [ ] `pnpm build` completes successfully
|
||||||
|
- [ ] I have tested this change in the browser
|
||||||
|
- [ ] No unnecessary files are included in this PR
|
||||||
|
|
||||||
|
## Screenshots (if relevant)
|
||||||
|
|
||||||
|
Add before/after screenshots for visual changes.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
name: Deploy to Azure Static Web Apps
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize, reopened, closed]
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build_and_deploy:
|
||||||
|
if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
run: corepack enable pnpm
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: 'pnpm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: pnpm run build
|
||||||
|
env:
|
||||||
|
SITE_URL: ${{ secrets.SITE_URL }}
|
||||||
|
|
||||||
|
- name: Deploy to Azure Static Web Apps
|
||||||
|
run: npx --yes @azure/static-web-apps-cli@latest deploy dist --deployment-token "${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }}" --env production
|
||||||
|
|
||||||
|
close_pull_request:
|
||||||
|
if: github.event_name == 'pull_request' && github.event.action == 'closed'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Close preview environment
|
||||||
|
run: npx --yes @azure/static-web-apps-cli@latest deploy --deployment-token "${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }}" --env close
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
.vercel/
|
||||||
|
.netlify/
|
||||||
|
|
||||||
|
# Astro
|
||||||
|
.astro/
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
!.vscode/settings.json
|
||||||
|
.idea/
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
coverage/
|
||||||
|
playwright-report/
|
||||||
|
test-results/
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
*.local
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Pagefind
|
||||||
|
public/pagefind/
|
||||||
|
|
||||||
|
# Claude Code
|
||||||
|
.claude/
|
||||||
|
|
||||||
|
# npm lockfile (pnpm only)
|
||||||
|
package-lock.json
|
||||||
|
|
||||||
|
# Internal docs
|
||||||
|
southwell-astro-boilerplate-docs.md
|
||||||
|
southwell-astro-boilerplate-prd.md
|
||||||
|
claude-ai-web-design.png
|
||||||
|
dark-mode-sessionstorage.png
|
||||||
|
domain-email-vercel-setup.png
|
||||||
|
why-i-build-with-astro.png
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
dist/
|
||||||
|
node_modules/
|
||||||
|
.astro/
|
||||||
|
public/pagefind/
|
||||||
|
pnpm-lock.yaml
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"printWidth": 100,
|
||||||
|
"plugins": ["prettier-plugin-astro", "prettier-plugin-tailwindcss"],
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "*.astro",
|
||||||
|
"options": {
|
||||||
|
"parser": "astro"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 36 KiB |
@@ -0,0 +1,147 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.9.1] — 2026-05-21
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **HomePage** — device tabs section: removed iOS/Android tab buttons and Android panel; content now static; removed tab-switching JS; replaced phone mockup with `armarium_image.jpg` (`rounded-2xl shadow-md`, 75% width)
|
||||||
|
- **HomePage** — bottom split section: replaced Flowbite CDN images with `content_image.jpg` (75% width, centred)
|
||||||
|
- **HomePage** — CTA section: updated description to "Kostenlos mitmachen und sofort loslegen."; removed Login button, register-only
|
||||||
|
- **AboutPage** — full i18n of team and FAQ sections (all 4 locales): new keys `about.team.title`, `about.team.desc1/2`, `about.founder.role`, `about.founder.bio`, `about.faq.title`, `about.faq.q1–q4`, `about.faq.a1–a4`
|
||||||
|
- **AboutPage** — replaced Flowbite placeholder avatar with local `about_photo.jpg`; updated bio text and role to "Founder"; replaced all social icons with Armarium LinkedIn only
|
||||||
|
- **AboutPage** — replaced all Flowbite placeholder text (team description, FAQ) with Armarium-specific content via i18n
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `src/assets/armarium_image.jpg` — app screenshot used in homepage feature section
|
||||||
|
- `src/assets/content_image.jpg` — feature illustration used in homepage bottom split
|
||||||
|
- `src/assets/about_photo.jpg` — founder profile photo used on about page
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.9.0] — 2026-05-20
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **HomePage** — complete redesign with Flowbite layout:
|
||||||
|
- Hero: Flowbite split-section (text left, `header_img.jpg` right with rounded corners + shadow), removed trust bar and Badge chip
|
||||||
|
- Added device tabs section (iOS/Android, vanilla JS tab switching, phone mockup, feature list + split image)
|
||||||
|
- Added blog preview section (3-column: featured post with image left, 2× 3 posts right, dummy fallback entries)
|
||||||
|
- **FeaturesIndexPage** — replaced old Card grid with Flowbite 4-column feature card grid; removed Badge chip from hero
|
||||||
|
- **AboutPage** — removed "Unsere Mission" and "Unsere Werte" sections; removed hero Badge chip; added Flowbite team section (3 profiles) and native `<details>/<summary>` FAQ
|
||||||
|
- **ContactPage** — replaced custom form with Flowbite contact form + 3 contact info blocks; removed hero Badge chip
|
||||||
|
- **BlogIndexPage** — complete rewrite with Flowbite 3-column card grid; restored hero; added CTA section before footer; removed Badge chip, tag filter, featured/regular split
|
||||||
|
- **Footer** — renamed "App" → "Produkt" (all 4 locales); added LinkedIn round icon link; restructured to 2-column layout (Produkt + Legal)
|
||||||
|
- **URL slug** — `/projects` renamed to `/features` for DE locale (`nav.features.href` in `ui.ts`, `nav.config.ts`)
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- Feature detail pages (`/features/[slug].astro`) and all 6 MDX files in `src/content/projects/`
|
||||||
|
- Trust bar ("Made in Zürich", privacy/free badges) from HomePage
|
||||||
|
- Badge/chip elements from hero sections on HomePage, FeaturesIndexPage, AboutPage, ContactPage
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.8.1] — 2026-04-14
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Login and register button links now point to `https://app.armarium.ch/login` and `https://app.armarium.ch/register` respectively — affects hero section, CTA section, footer, and all locale variants
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.8.0] — 2026-04-13
|
||||||
|
|
||||||
|
### Added — Full i18n translation
|
||||||
|
|
||||||
|
- **All pages available in 4 languages** (DE/FR/IT/EN): About, Contact, Blog, Features, Privacy, Legal Notice — 28 locale variants total
|
||||||
|
- **Language switcher in every navbar** — `PageLayout`, `BlogLayout`, `ProjectLayout` and `LandingLayout` render the language switcher with the correct `currentLocale`
|
||||||
|
- **Locale-aware nav and footer links** — new `nav.*.href` and `footer.privacy.href` / `footer.imprint.href` keys in `ui.ts`
|
||||||
|
- **Shared page components** — `AboutPage`, `ContactPage`, `FeaturesIndexPage`, `BlogIndexPage` accept a `locale` prop; each locale page file is a 4-line wrapper with no code duplication
|
||||||
|
- **Translated privacy pages** — full translated privacy policy on FR (`/fr/privacy`), IT (`/it/privacy`), EN (`/en/privacy`) including Infomaniak certification cards
|
||||||
|
- **Translated legal notice pages** — Mentions légales (FR), Note legali (IT), Legal Notice (EN)
|
||||||
|
- **`ui.ts`** — ~40 new translation keys per language: `about.*`, `contact.*`, `blog.*`, `features.cta.*`, `nav.*.href`, `footer.*.href`
|
||||||
|
|
||||||
|
### Changed (2026-04-13)
|
||||||
|
|
||||||
|
- `PageLayout` accepts `locale` prop and passes translated nav items to the header
|
||||||
|
- `BlogLayout` / `ProjectLayout` show language switcher with translated nav and breadcrumbs
|
||||||
|
- `LandingLayout` footer links (privacy, legal notice, features) are locale-aware
|
||||||
|
- Blog index shows all posts across locales (locale filter removed)
|
||||||
|
- Blog post `armarium-1-0.mdx` locale corrected from `en` to `de`
|
||||||
|
- `content.config.ts` locale enum extended with `de` and `it`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.7.0] — 2026-04-13 — Armarium customization
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Armarium branding** — horizontal SVG logo in navbar (`logo-horizontal.svg`), `fill="currentColor"` for dark mode support
|
||||||
|
- **Homepage** (`/`, `/fr/`, `/it/`, `/en/`) — hero ("Armarium Suite — Budget & More"), 6 feature cards, trust bar with Zürich coat of arms, CTA; shared `HomePage.astro` component
|
||||||
|
- **Swiss identity** — Zürich coat of arms (SVG, #003DA5/white) in trust bar, about page and privacy page
|
||||||
|
- **Privacy page** (`/datenschutz`) — 6 Infomaniak certification cards (ISO 27001:2022, Swiss Hosting Label, Swiss Made Software, nDSG/GDPR, ISO 14001 + B Corp™, physical security) + 8-section privacy policy
|
||||||
|
- **Legal notice** (`/impressum`) — operator: Armarium, Zürich; hosting: Infomaniak
|
||||||
|
- **About page** — mission section, app info cards, 4 value cards (transparency, simplicity, privacy, made in Zürich)
|
||||||
|
- **Features page** (`/projects`) — 6 feature cards from `projects` collection with icon mapping
|
||||||
|
- **Blog** — single launch post "Armarium Suite 1.0 ist da"
|
||||||
|
- **Language switcher** (`LanguageSwitcherDropdown.astro`) — locale code (DE/FR/IT/EN) without flag emojis; desktop + mobile
|
||||||
|
- **i18n foundation** — `astro.config.mjs` with `prefixDefaultLocale: false`; `ui.ts` for DE/FR/IT/EN (hero, trust bar, features, CTA, footer)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Header: Login + Register buttons instead of GitHub / "Get Started"
|
||||||
|
- Navbar: "Projects" → "Features"
|
||||||
|
- Footer: columns layout with app and legal link groups, Armarium tagline and copyright
|
||||||
|
- `contact.astro`, `about.astro`, `404.astro` — updated with Armarium content
|
||||||
|
- `authors/team.json` — name set to "Armarium"
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Unescaped apostrophe in French translation (`d\'épargne`) causing esbuild parse error
|
||||||
|
- Flag emojis removed from language switcher (no font support on Linux)
|
||||||
|
- Duplicate `Locale` type import in `LandingLayout.astro`
|
||||||
|
- Footer copyright HTML rendering (PR #28)
|
||||||
|
- Stripped `www.` prefix from contact social link display values (PR #27)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.0.0] — 2026-04-04
|
||||||
|
|
||||||
|
Initial public release of Astro Rocket.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Production-ready Astro 6 starter theme built on Tailwind CSS v4 and TypeScript
|
||||||
|
- 57 UI and pattern components (buttons, forms, cards, badges, inputs, selects, etc.)
|
||||||
|
- 12 live colour themes (Orange, Amber, Lime, Emerald, Teal, Cyan, Sky, Blue, Indigo, Violet, Purple, Magenta) switchable at runtime without a rebuild
|
||||||
|
- Full blog with MDX support, syntax highlighting (Shiki), and RSS feed
|
||||||
|
- Auto-generated SVG favicon and monogram logo badge from `site.config.ts`
|
||||||
|
- Unified `Icon` component via Iconify (350+ Lucide icons + 3000+ Simple Icons)
|
||||||
|
- Animated typing effect in hero section
|
||||||
|
- Contact form with Zod validation, honeypot bot detection, and Resend integration
|
||||||
|
- Newsletter signup form with Resend Audiences integration
|
||||||
|
- Cookie consent banner with Google Consent Mode v2 support
|
||||||
|
- Google Analytics 4 and Google Tag Manager integration (consent-aware)
|
||||||
|
- Built-in SEO layer: JSON-LD structured data, Open Graph, sitemap, robots.txt
|
||||||
|
- Dark mode via `sessionStorage` (resets to dark on each new session)
|
||||||
|
- Search powered by Pagefind
|
||||||
|
- Multiple deployment targets: Vercel, Netlify, Cloudflare Pages
|
||||||
|
- Security headers configured for all deployment targets
|
||||||
|
- GitHub Actions CI/CD workflow (lint, type-check, build)
|
||||||
|
- Vitest unit tests for API endpoint validation schemas
|
||||||
|
|
||||||
|
### Changed (from Velocity)
|
||||||
|
|
||||||
|
- Forked and extended [Velocity](https://github.com/southwellmedia/velocity) by Southwell Media
|
||||||
|
- Added theme switching, 12 colour themes, typed logo badge, auto favicon
|
||||||
|
- Replaced localStorage with sessionStorage for dark mode preference
|
||||||
|
- Added blog image gradients that update with the active theme
|
||||||
|
- Upgraded icon system to Iconify
|
||||||
|
- Targeted at complete, ready-to-launch sites rather than a bare boilerplate
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Hans Martens
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
[ZoneTransfer]
|
||||||
|
ZoneId=3
|
||||||
|
ReferrerUrl=https://pixabay.com/de/photos/z%c3%bcrich-bahngeleise-schienenverkehr-5267550/
|
||||||
|
HostUrl=https://pixabay.com/get/gfaa32ac532b3c750d56712bfe236c8f846493d84e77ff4cc0010cfb183c48b9bee255e9f41145c9ffa3f9e69e65038441a2a75fe27c1bbd2cf145b7c4f6c9fbd64a1160c6356ea324db1047e1d6254ff.jpg?attachment=
|
||||||
|
After Width: | Height: | Size: 4.7 MiB |
@@ -0,0 +1,58 @@
|
|||||||
|
import { defineConfig, envField } from 'astro/config';
|
||||||
|
import mdx from '@astrojs/mdx';
|
||||||
|
import sitemap from '@astrojs/sitemap';
|
||||||
|
import react from '@astrojs/react';
|
||||||
|
import icon from 'astro-icon';
|
||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
output: 'static',
|
||||||
|
site: process.env.SITE_URL || 'https://example.com',
|
||||||
|
|
||||||
|
env: {
|
||||||
|
schema: {
|
||||||
|
SITE_URL: envField.string({ context: 'server', access: 'public', optional: true }),
|
||||||
|
PUBLIC_GA_MEASUREMENT_ID: envField.string({ context: 'client', access: 'public', optional: true }),
|
||||||
|
PUBLIC_GTM_ID: envField.string({ context: 'client', access: 'public', optional: true }),
|
||||||
|
RESEND_API_KEY: envField.string({ context: 'server', access: 'secret', optional: true }),
|
||||||
|
RESEND_FROM_EMAIL: envField.string({ context: 'server', access: 'secret', optional: true }),
|
||||||
|
NEWSLETTER_API_KEY: envField.string({ context: 'server', access: 'secret', optional: true }),
|
||||||
|
GOOGLE_SITE_VERIFICATION: envField.string({ context: 'server', access: 'public', optional: true }),
|
||||||
|
BING_SITE_VERIFICATION: envField.string({ context: 'server', access: 'public', optional: true }),
|
||||||
|
PUBLIC_GOOGLE_MAPS_API_KEY: envField.string({ context: 'client', access: 'public', optional: true, default: '' }),
|
||||||
|
PUBLIC_CONSENT_ENABLED: envField.boolean({ context: 'client', access: 'public', optional: true, default: false }),
|
||||||
|
PUBLIC_PRIVACY_POLICY_URL: envField.string({ context: 'client', access: 'public', optional: true, default: '' }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
image: {
|
||||||
|
layout: 'constrained',
|
||||||
|
},
|
||||||
|
|
||||||
|
integrations: [
|
||||||
|
react(),
|
||||||
|
mdx(),
|
||||||
|
sitemap(),
|
||||||
|
icon(),
|
||||||
|
],
|
||||||
|
|
||||||
|
vite: {
|
||||||
|
plugins: [tailwindcss()],
|
||||||
|
},
|
||||||
|
|
||||||
|
i18n: {
|
||||||
|
defaultLocale: 'de',
|
||||||
|
locales: ['de', 'fr', 'it', 'en'],
|
||||||
|
routing: {
|
||||||
|
prefixDefaultLocale: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
markdown: {
|
||||||
|
shikiConfig: {
|
||||||
|
theme: 'github-dark',
|
||||||
|
wrap: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
});
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
[ZoneTransfer]
|
||||||
|
ZoneId=3
|
||||||
|
ReferrerUrl=https://pixabay.com/de/photos/uhr-standuhr-pendeluhr-tischuhr-2663147/
|
||||||
|
HostUrl=https://pixabay.com/get/g6392b6a09c1ae47ca7f6a894668ad94b74db35cc46485fb9326e1e7f69905ea3070c907fc9387c66bc70e717311e7a027a9ce9c740dc52c78b5374e9e85f951b2898e1c5087a2b2bdf69e302349d008e.jpg?attachment=
|
||||||
|
After Width: | Height: | Size: 1.6 MiB |
@@ -0,0 +1,39 @@
|
|||||||
|
import eslint from '@eslint/js';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
import eslintPluginAstro from 'eslint-plugin-astro';
|
||||||
|
import globals from 'globals';
|
||||||
|
|
||||||
|
export default [
|
||||||
|
eslint.configs.recommended,
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
...eslintPluginAstro.configs.recommended,
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...globals.browser,
|
||||||
|
...globals.node,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.astro'],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
parser: tseslint.parser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ignores: ['dist/', 'node_modules/', '.astro/', '.vercel/', 'public/pagefind/'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-unused-vars': [
|
||||||
|
'warn',
|
||||||
|
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
|
||||||
|
],
|
||||||
|
'@typescript-eslint/no-explicit-any': 'warn',
|
||||||
|
'no-console': ['warn', { allow: ['warn', 'error'] }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
After Width: | Height: | Size: 1.7 MiB |
@@ -0,0 +1,93 @@
|
|||||||
|
{
|
||||||
|
"name": "astro-rocket",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Astro Rocket — A production-ready Astro 6 theme built on Tailwind CSS v4",
|
||||||
|
"type": "module",
|
||||||
|
"author": "Hans Martens",
|
||||||
|
"license": "MIT",
|
||||||
|
"keywords": [
|
||||||
|
"astro",
|
||||||
|
"astro-theme",
|
||||||
|
"theme",
|
||||||
|
"tailwind",
|
||||||
|
"tailwindcss",
|
||||||
|
"typescript",
|
||||||
|
"blog",
|
||||||
|
"starter",
|
||||||
|
"boilerplate",
|
||||||
|
"seo"
|
||||||
|
],
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/hansmartens68/astro-rocket"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/hansmartens68/astro-rocket#readme",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/hansmartens68/astro-rocket/issues"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "astro dev",
|
||||||
|
"build": "astro build",
|
||||||
|
"preview": "astro preview",
|
||||||
|
"check": "astro check",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"lint:fix": "eslint . --fix",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"format:check": "prettier --check .",
|
||||||
|
"validate": "pnpm lint && pnpm check && pnpm build",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:e2e": "playwright test"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@astrojs/mdx": "5.0.0",
|
||||||
|
"@astrojs/react": "5.0.0",
|
||||||
|
"@astrojs/sitemap": "^3.7.1",
|
||||||
|
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||||
|
"@fontsource-variable/manrope": "^5.2.8",
|
||||||
|
"@fontsource-variable/outfit": "^5.2.8",
|
||||||
|
"@iconify-json/lucide": "^1.2.98",
|
||||||
|
"@iconify-json/simple-icons": "^1.2.74",
|
||||||
|
"@iconify/react": "^6.0.2",
|
||||||
|
"astro": "6.0.0",
|
||||||
|
"astro-icon": "^1.1.5",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.563.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"resend": "^6.9.3",
|
||||||
|
"schema-dts": "^1.1.2",
|
||||||
|
"tailwind-merge": "^2.6.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@astrojs/check": "0.9.7",
|
||||||
|
"@eslint/js": "^9.17.0",
|
||||||
|
"@pagefind/default-ui": "^1.3.0",
|
||||||
|
"@playwright/test": "^1.49.0",
|
||||||
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
"@types/node": "^22.10.0",
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"eslint": "^9.17.0",
|
||||||
|
"eslint-plugin-astro": "^1.3.0",
|
||||||
|
"globals": "^15.14.0",
|
||||||
|
"pagefind": "^1.3.0",
|
||||||
|
"prettier": "^3.4.0",
|
||||||
|
"prettier-plugin-astro": "^0.14.1",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.6.9",
|
||||||
|
"tailwindcss": "^4.0.0",
|
||||||
|
"typescript": "^5.7.0",
|
||||||
|
"typescript-eslint": "^8.18.0",
|
||||||
|
"vitest": "^3.2.0",
|
||||||
|
"zod": "^4.0.0"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"onlyBuiltDependencies": [
|
||||||
|
"esbuild",
|
||||||
|
"sharp"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.12.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<svg width="400" height="400" viewBox="0 0 400 400" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="400" height="400" rx="80" fill="#F94C10"/>
|
||||||
|
<text
|
||||||
|
x="200" y="200"
|
||||||
|
text-anchor="middle"
|
||||||
|
dominant-baseline="central"
|
||||||
|
fill="white"
|
||||||
|
font-family="system-ui, -apple-system, sans-serif"
|
||||||
|
font-weight="700"
|
||||||
|
font-size="260"
|
||||||
|
>A</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 376 B |
@@ -0,0 +1,35 @@
|
|||||||
|
<svg width="1200" height="630" viewBox="0 0 1200 630" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- Background -->
|
||||||
|
<rect width="1200" height="630" fill="#2563eb"/>
|
||||||
|
|
||||||
|
<!-- Lucide Rocket — scale(5), visual centre at (600, 240) -->
|
||||||
|
<g transform="translate(540, 180) scale(5)"
|
||||||
|
stroke="#dbeafe" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" fill="none" opacity="0.9">
|
||||||
|
<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/>
|
||||||
|
<path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/>
|
||||||
|
<path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/>
|
||||||
|
<path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Wordmark -->
|
||||||
|
<text x="600" y="410" font-size="96" text-anchor="middle" letter-spacing="-4"
|
||||||
|
fill="#eff6ff"
|
||||||
|
font-family="system-ui, -apple-system, 'Segoe UI', Helvetica, Arial, sans-serif"
|
||||||
|
font-weight="800">Astro Rocket</text>
|
||||||
|
|
||||||
|
<!-- Divider -->
|
||||||
|
<line x1="380" y1="436" x2="820" y2="436" stroke="#60a5fa" stroke-width="1" stroke-opacity="0.35"/>
|
||||||
|
|
||||||
|
<!-- Domain pill -->
|
||||||
|
<rect x="450" y="464" width="300" height="38" rx="19"
|
||||||
|
fill="#3b82f6" fill-opacity="0.25" stroke="#93c5fd" stroke-opacity="0.4" stroke-width="1"/>
|
||||||
|
<text x="600" y="488" font-size="13" text-anchor="middle" letter-spacing="1.5"
|
||||||
|
fill="#dbeafe"
|
||||||
|
font-family="system-ui, -apple-system, 'Segoe UI', Helvetica, Arial, sans-serif">hansmartens.dev</text>
|
||||||
|
|
||||||
|
<!-- Corner marks -->
|
||||||
|
<path d="M 36 60 L 36 36 L 60 36" stroke="#60a5fa" stroke-width="1.5" fill="none" stroke-opacity="0.45"/>
|
||||||
|
<path d="M 1140 36 L 1164 36 L 1164 60" stroke="#60a5fa" stroke-width="1.5" fill="none" stroke-opacity="0.45"/>
|
||||||
|
<path d="M 36 570 L 36 594 L 60 594" stroke="#60a5fa" stroke-width="1.5" fill="none" stroke-opacity="0.45"/>
|
||||||
|
<path d="M 1140 594 L 1164 594 L 1164 570" stroke="#60a5fa" stroke-width="1.5" fill="none" stroke-opacity="0.45"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,26 @@
|
|||||||
|
<svg width="880" height="260" viewBox="0 0 880 260" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- Background -->
|
||||||
|
<rect width="880" height="260" fill="#2563eb"/>
|
||||||
|
|
||||||
|
<!-- Lucide Rocket — scale(5), visual centre at (440, 100) -->
|
||||||
|
<g transform="translate(379, 41) scale(5)"
|
||||||
|
stroke="#dbeafe" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" fill="none" opacity="0.9">
|
||||||
|
<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/>
|
||||||
|
<path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/>
|
||||||
|
<path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/>
|
||||||
|
<path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Wordmark -->
|
||||||
|
<text x="440" y="209"
|
||||||
|
text-anchor="middle"
|
||||||
|
font-family="system-ui, -apple-system, 'Segoe UI', Helvetica, Arial, sans-serif"
|
||||||
|
font-weight="800" font-size="62" letter-spacing="-1.5"
|
||||||
|
fill="#eff6ff">Astro Rocket</text>
|
||||||
|
|
||||||
|
<!-- Corner marks -->
|
||||||
|
<path d="M 30 50 L 30 30 L 50 30" stroke="#60a5fa" stroke-width="1.5" fill="none" stroke-opacity="0.45"/>
|
||||||
|
<path d="M 830 30 L 850 30 L 850 50" stroke="#60a5fa" stroke-width="1.5" fill="none" stroke-opacity="0.45"/>
|
||||||
|
<path d="M 30 210 L 30 230 L 50 230" stroke="#60a5fa" stroke-width="1.5" fill="none" stroke-opacity="0.45"/>
|
||||||
|
<path d="M 830 230 L 850 230 L 850 210" stroke="#60a5fa" stroke-width="1.5" fill="none" stroke-opacity="0.45"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"navigationFallback": {
|
||||||
|
"rewrite": "/404.html",
|
||||||
|
"exclude": ["/assets/*", "/fonts/*", "/_astro/*", "*.ico", "*.png", "*.jpg", "*.svg", "*.webp", "*.webmanifest", "*.xml", "*.txt"]
|
||||||
|
},
|
||||||
|
"trailingSlash": "auto",
|
||||||
|
"mimeTypes": {
|
||||||
|
".json": "application/json"
|
||||||
|
},
|
||||||
|
"responseOverrides": {
|
||||||
|
"404": {
|
||||||
|
"rewrite": "/404.html"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// Schema mirroring src/pages/api/contact.ts
|
||||||
|
const contactSchema = z.object({
|
||||||
|
name: z.string().min(2, 'Name must be at least 2 characters').max(100),
|
||||||
|
email: z.email('Please enter a valid email address'),
|
||||||
|
subject: z.string().max(200).optional(),
|
||||||
|
message: z.string().min(10, 'Message must be at least 10 characters').max(5000),
|
||||||
|
honeypot: z.string().max(0),
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Contact form validation', () => {
|
||||||
|
const validData = {
|
||||||
|
name: 'Jane Doe',
|
||||||
|
email: 'jane@example.com',
|
||||||
|
subject: 'Hello',
|
||||||
|
message: 'This is a test message that is long enough.',
|
||||||
|
honeypot: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('accepts valid form data', () => {
|
||||||
|
const result = contactSchema.safeParse(validData);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts data without optional subject', () => {
|
||||||
|
const { subject: _s, ...data } = validData;
|
||||||
|
const result = contactSchema.safeParse(data);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects name shorter than 2 characters', () => {
|
||||||
|
const result = contactSchema.safeParse({ ...validData, name: 'J' });
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.error.issues[0].message).toBe('Name must be at least 2 characters');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid email address', () => {
|
||||||
|
const result = contactSchema.safeParse({ ...validData, email: 'not-an-email' });
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.error.issues[0].path[0]).toBe('email');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects message shorter than 10 characters', () => {
|
||||||
|
const result = contactSchema.safeParse({ ...validData, message: 'Too short' });
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.error.issues[0].message).toBe('Message must be at least 10 characters');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects filled honeypot field (bot detection)', () => {
|
||||||
|
const result = contactSchema.safeParse({ ...validData, honeypot: 'bot-value' });
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.error.issues[0].path[0]).toBe('honeypot');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects subject longer than 200 characters', () => {
|
||||||
|
const result = contactSchema.safeParse({ ...validData, subject: 'a'.repeat(201) });
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects message longer than 5000 characters', () => {
|
||||||
|
const result = contactSchema.safeParse({ ...validData, message: 'a'.repeat(5001) });
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// Schema mirroring src/pages/api/newsletter.ts
|
||||||
|
const newsletterSchema = z.object({
|
||||||
|
email: z.email('Please enter a valid email address'),
|
||||||
|
honeypot: z.string().max(0).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Newsletter form validation', () => {
|
||||||
|
it('accepts a valid email address', () => {
|
||||||
|
const result = newsletterSchema.safeParse({ email: 'user@example.com', honeypot: '' });
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an invalid email address', () => {
|
||||||
|
const result = newsletterSchema.safeParse({ email: 'not-an-email' });
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.error.issues[0].message).toBe('Please enter a valid email address');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an empty email field', () => {
|
||||||
|
const result = newsletterSchema.safeParse({ email: '' });
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects filled honeypot field (bot detection)', () => {
|
||||||
|
const result = newsletterSchema.safeParse({ email: 'user@example.com', honeypot: 'bot' });
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.error.issues[0].path[0]).toBe('honeypot');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts missing honeypot field (it is optional)', () => {
|
||||||
|
const result = newsletterSchema.safeParse({ email: 'user@example.com' });
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M32 16C32 24.8366 24.8366 32 16 32C7.16344 32 0 24.8366 0 16C0 7.16344 7.16344 0 16 0C24.8366 0 32 7.16344 32 16ZM4.5578 16C4.5578 22.3194 9.68065 27.4422 16 27.4422C22.3194 27.4422 27.4422 22.3194 27.4422 16C27.4422 9.68065 22.3194 4.5578 16 4.5578C9.68065 4.5578 4.5578 9.68065 4.5578 16Z" fill="#6200EA"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 421 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="208" height="71" viewBox="0 0 208 71" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M113 16C113 24.8366 105.837 32 97 32C88.1634 32 81 24.8366 81 16C81 7.16344 88.1634 0 97 0C105.837 0 113 7.16344 113 16ZM85.5578 16C85.5578 22.3194 90.6806 27.4422 97 27.4422C103.319 27.4422 108.442 22.3194 108.442 16C108.442 9.68065 103.319 4.5578 97 4.5578C90.6806 4.5578 85.5578 9.68065 85.5578 16Z" fill="#6200EA"/>
|
||||||
|
<path d="M14.5801 37.793L24.3008 63H20.6973L18.4648 57.1992H7.0918L4.85938 63H1.25586L10.9766 37.793H14.5801ZM8.46289 53.5957H17.0762L12.7871 42.4863L8.46289 53.5957ZM48.541 46.793C48.541 50.3086 47.041 52.3125 44.041 52.8047L48.7871 63H44.7969L40.1211 52.9277H32.2285V63H28.625V37.793H42.4238C46.502 37.793 48.541 39.8379 48.541 43.9277V46.793ZM32.2285 49.3242H42.2305C43.1914 49.3242 43.8828 49.1133 44.3047 48.6914C44.7266 48.2695 44.9375 47.5781 44.9375 46.6172V44.1035C44.9375 43.1426 44.7266 42.4512 44.3047 42.0293C43.8828 41.6074 43.1914 41.3965 42.2305 41.3965H32.2285V49.3242ZM67.5781 63L58.1738 44.5254V63H54.5703V37.793H58.5078L68.1055 56.7773L77.7207 37.793H81.6406V63H78.0547V44.5254L68.6504 63H67.5781ZM99.3066 37.793L109.027 63H105.424L103.191 57.1992H91.8184L89.5859 63H85.9824L95.7031 37.793H99.3066ZM93.1895 53.5957H101.803L97.5137 42.4863L93.1895 53.5957ZM133.268 46.793C133.268 50.3086 131.768 52.3125 128.768 52.8047L133.514 63H129.523L124.848 52.9277H116.955V63H113.352V37.793H127.15C131.229 37.793 133.268 39.8379 133.268 43.9277V46.793ZM116.955 49.3242H126.957C127.918 49.3242 128.609 49.1133 129.031 48.6914C129.453 48.2695 129.664 47.5781 129.664 46.6172V44.1035C129.664 43.1426 129.453 42.4512 129.031 42.0293C128.609 41.6074 127.918 41.3965 126.957 41.3965H116.955V49.3242ZM142.9 37.793V63H139.297V37.793H142.9ZM149.387 37.793H152.99V59.3965H167.387V37.793H170.99V59.3965C170.99 59.8887 170.896 60.3574 170.709 60.8027C170.521 61.2363 170.264 61.6172 169.936 61.9453C169.607 62.2734 169.221 62.5312 168.775 62.7188C168.342 62.9062 167.879 63 167.387 63H152.99C152.498 63 152.029 62.9062 151.584 62.7188C151.15 62.5312 150.77 62.2734 150.441 61.9453C150.113 61.6172 149.855 61.2363 149.668 60.8027C149.48 60.3574 149.387 59.8887 149.387 59.3965V37.793ZM190.484 63L181.08 44.5254V63H177.477V37.793H181.414L191.012 56.7773L200.627 37.793H204.547V63H200.961V44.5254L191.557 63H190.484Z" fill="black"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 4.7 MiB |
@@ -0,0 +1,24 @@
|
|||||||
|
<svg width="1200" height="630" viewBox="0 0 1200 630" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<style>
|
||||||
|
.bg { fill: var(--brand-500); }
|
||||||
|
.ico { stroke: var(--brand-100); stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; fill: none; }
|
||||||
|
.txt { fill: var(--brand-50); font-family: 'Outfit Variable', Outfit, system-ui, -apple-system, sans-serif; font-weight: 800; }
|
||||||
|
.ln { stroke: var(--brand-300); stroke-width: 1; stroke-opacity: 0.35; }
|
||||||
|
.pil { fill: var(--brand-400); fill-opacity: 0.25; stroke: var(--brand-200); stroke-opacity: 0.4; stroke-width: 1; }
|
||||||
|
.ptx { fill: var(--brand-100); font-family: 'Outfit Variable', Outfit, system-ui, -apple-system, sans-serif; }
|
||||||
|
.cor { stroke: var(--brand-300); stroke-width: 1.5; fill: none; stroke-opacity: 0.45; }
|
||||||
|
</style>
|
||||||
|
<rect width="1200" height="630" class="bg"/>
|
||||||
|
<!-- Lucide Zap -->
|
||||||
|
<g transform="translate(540, 180) scale(5)" class="ico" opacity="0.9">
|
||||||
|
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>
|
||||||
|
</g>
|
||||||
|
<text x="600" y="410" font-size="96" text-anchor="middle" letter-spacing="-2" class="txt">animations</text>
|
||||||
|
<line x1="380" y1="436" x2="820" y2="436" class="ln"/>
|
||||||
|
<rect x="450" y="464" width="300" height="38" rx="19" class="pil"/>
|
||||||
|
<text x="600" y="487" font-size="16" text-anchor="middle" class="ptx">hansmartens.dev</text>
|
||||||
|
<path d="M 36 60 L 36 36 L 60 36" class="cor"/>
|
||||||
|
<path d="M 1140 36 L 1164 36 L 1164 60" class="cor"/>
|
||||||
|
<path d="M 36 570 L 36 594 L 60 594" class="cor"/>
|
||||||
|
<path d="M 1140 594 L 1164 594 L 1164 570" class="cor"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,32 @@
|
|||||||
|
<svg width="1200" height="630" viewBox="0 0 1200 630" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<style>
|
||||||
|
.bg { fill: var(--brand-500); }
|
||||||
|
.ico { stroke: var(--brand-100); stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; fill: none; }
|
||||||
|
.txt { fill: var(--brand-50); font-family: 'Outfit Variable', Outfit, system-ui, -apple-system, sans-serif; font-weight: 800; }
|
||||||
|
.ln { stroke: var(--brand-300); stroke-width: 1; stroke-opacity: 0.35; }
|
||||||
|
.pil { fill: var(--brand-400); fill-opacity: 0.25; stroke: var(--brand-200); stroke-opacity: 0.4; stroke-width: 1; }
|
||||||
|
.ptx { fill: var(--brand-100); font-family: 'Outfit Variable', Outfit, system-ui, -apple-system, sans-serif; }
|
||||||
|
.cor { stroke: var(--brand-300); stroke-width: 1.5; fill: none; stroke-opacity: 0.45; }
|
||||||
|
</style>
|
||||||
|
<rect width="1200" height="630" class="bg"/>
|
||||||
|
<!-- Lucide Sliders -->
|
||||||
|
<g transform="translate(540, 180) scale(5)" class="ico" opacity="0.9">
|
||||||
|
<line x1="4" x2="4" y1="21" y2="14"/>
|
||||||
|
<line x1="4" x2="4" y1="10" y2="3"/>
|
||||||
|
<line x1="12" x2="12" y1="21" y2="12"/>
|
||||||
|
<line x1="12" x2="12" y1="8" y2="3"/>
|
||||||
|
<line x1="20" x2="20" y1="21" y2="16"/>
|
||||||
|
<line x1="20" x2="20" y1="12" y2="3"/>
|
||||||
|
<line x1="2" x2="6" y1="14" y2="14"/>
|
||||||
|
<line x1="10" x2="14" y1="8" y2="8"/>
|
||||||
|
<line x1="18" x2="22" y1="16" y2="16"/>
|
||||||
|
</g>
|
||||||
|
<text x="600" y="410" font-size="92" text-anchor="middle" letter-spacing="-3" class="txt">configuration</text>
|
||||||
|
<line x1="380" y1="436" x2="820" y2="436" class="ln"/>
|
||||||
|
<rect x="450" y="464" width="300" height="38" rx="19" class="pil"/>
|
||||||
|
<text x="600" y="487" font-size="16" text-anchor="middle" class="ptx">hansmartens.dev</text>
|
||||||
|
<path d="M 36 60 L 36 36 L 60 36" class="cor"/>
|
||||||
|
<path d="M 1140 36 L 1164 36 L 1164 60" class="cor"/>
|
||||||
|
<path d="M 36 570 L 36 594 L 60 594" class="cor"/>
|
||||||
|
<path d="M 1140 594 L 1164 594 L 1164 570" class="cor"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,26 @@
|
|||||||
|
<svg width="1200" height="630" viewBox="0 0 1200 630" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<style>
|
||||||
|
.bg { fill: var(--brand-500); }
|
||||||
|
.ico { stroke: var(--brand-100); stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; fill: none; }
|
||||||
|
.txt { fill: var(--brand-50); font-family: 'Outfit Variable', Outfit, system-ui, -apple-system, sans-serif; font-weight: 800; }
|
||||||
|
.ln { stroke: var(--brand-300); stroke-width: 1; stroke-opacity: 0.35; }
|
||||||
|
.pil { fill: var(--brand-400); fill-opacity: 0.25; stroke: var(--brand-200); stroke-opacity: 0.4; stroke-width: 1; }
|
||||||
|
.ptx { fill: var(--brand-100); font-family: 'Outfit Variable', Outfit, system-ui, -apple-system, sans-serif; }
|
||||||
|
.cor { stroke: var(--brand-300); stroke-width: 1.5; fill: none; stroke-opacity: 0.45; }
|
||||||
|
</style>
|
||||||
|
<rect width="1200" height="630" class="bg"/>
|
||||||
|
<!-- Lucide Sparkles -->
|
||||||
|
<g transform="translate(540, 180) scale(5)" class="ico" opacity="0.9">
|
||||||
|
<path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/>
|
||||||
|
<path d="M20 3v4"/><path d="M22 5h-4"/>
|
||||||
|
<path d="M4 17v2"/><path d="M5 18H3"/>
|
||||||
|
</g>
|
||||||
|
<text x="600" y="410" font-size="120" text-anchor="middle" letter-spacing="-4" class="txt">features</text>
|
||||||
|
<line x1="380" y1="436" x2="820" y2="436" class="ln"/>
|
||||||
|
<rect x="450" y="464" width="300" height="38" rx="19" class="pil"/>
|
||||||
|
<text x="600" y="487" font-size="16" text-anchor="middle" class="ptx">hansmartens.dev</text>
|
||||||
|
<path d="M 36 60 L 36 36 L 60 36" class="cor"/>
|
||||||
|
<path d="M 1140 36 L 1164 36 L 1164 60" class="cor"/>
|
||||||
|
<path d="M 36 570 L 36 594 L 60 594" class="cor"/>
|
||||||
|
<path d="M 1140 594 L 1164 594 L 1164 570" class="cor"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,27 @@
|
|||||||
|
<svg width="1200" height="630" viewBox="0 0 1200 630" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<style>
|
||||||
|
.bg { fill: var(--brand-500); }
|
||||||
|
.ico { stroke: var(--brand-100); stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; fill: none; }
|
||||||
|
.txt { fill: var(--brand-50); font-family: 'Outfit Variable', Outfit, system-ui, -apple-system, sans-serif; font-weight: 800; }
|
||||||
|
.ln { stroke: var(--brand-300); stroke-width: 1; stroke-opacity: 0.35; }
|
||||||
|
.pil { fill: var(--brand-400); fill-opacity: 0.25; stroke: var(--brand-200); stroke-opacity: 0.4; stroke-width: 1; }
|
||||||
|
.ptx { fill: var(--brand-100); font-family: 'Outfit Variable', Outfit, system-ui, -apple-system, sans-serif; }
|
||||||
|
.cor { stroke: var(--brand-300); stroke-width: 1.5; fill: none; stroke-opacity: 0.45; }
|
||||||
|
</style>
|
||||||
|
<rect width="1200" height="630" class="bg"/>
|
||||||
|
<!-- Lucide Rocket -->
|
||||||
|
<g transform="translate(540, 180) scale(5)" class="ico" opacity="0.9">
|
||||||
|
<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/>
|
||||||
|
<path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/>
|
||||||
|
<path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/>
|
||||||
|
<path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/>
|
||||||
|
</g>
|
||||||
|
<text x="600" y="410" font-size="108" text-anchor="middle" letter-spacing="-3" class="txt">get started</text>
|
||||||
|
<line x1="380" y1="436" x2="820" y2="436" class="ln"/>
|
||||||
|
<rect x="450" y="464" width="300" height="38" rx="19" class="pil"/>
|
||||||
|
<text x="600" y="487" font-size="16" text-anchor="middle" class="ptx">hansmartens.dev</text>
|
||||||
|
<path d="M 36 60 L 36 36 L 60 36" class="cor"/>
|
||||||
|
<path d="M 1140 36 L 1164 36 L 1164 60" class="cor"/>
|
||||||
|
<path d="M 36 570 L 36 594 L 60 594" class="cor"/>
|
||||||
|
<path d="M 1140 594 L 1164 594 L 1164 570" class="cor"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,24 @@
|
|||||||
|
<svg width="1200" height="630" viewBox="0 0 1200 630" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<style>
|
||||||
|
.bg { fill: var(--brand-500); }
|
||||||
|
.ico { stroke: var(--brand-100); stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; fill: none; }
|
||||||
|
.txt { fill: var(--brand-50); font-family: 'Outfit Variable', Outfit, system-ui, -apple-system, sans-serif; font-weight: 800; }
|
||||||
|
.ln { stroke: var(--brand-300); stroke-width: 1; stroke-opacity: 0.35; }
|
||||||
|
.pil { fill: var(--brand-400); fill-opacity: 0.25; stroke: var(--brand-200); stroke-opacity: 0.4; stroke-width: 1; }
|
||||||
|
.ptx { fill: var(--brand-100); font-family: 'Outfit Variable', Outfit, system-ui, -apple-system, sans-serif; }
|
||||||
|
.cor { stroke: var(--brand-300); stroke-width: 1.5; fill: none; stroke-opacity: 0.45; }
|
||||||
|
</style>
|
||||||
|
<rect width="1200" height="630" class="bg"/>
|
||||||
|
<!-- Lucide Zap -->
|
||||||
|
<g transform="translate(540, 180) scale(5)" class="ico" opacity="0.9">
|
||||||
|
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>
|
||||||
|
</g>
|
||||||
|
<text x="600" y="410" font-size="120" text-anchor="middle" letter-spacing="-4" class="txt">intro</text>
|
||||||
|
<line x1="380" y1="436" x2="820" y2="436" class="ln"/>
|
||||||
|
<rect x="450" y="464" width="300" height="38" rx="19" class="pil"/>
|
||||||
|
<text x="600" y="487" font-size="16" text-anchor="middle" class="ptx">hansmartens.dev</text>
|
||||||
|
<path d="M 36 60 L 36 36 L 60 36" class="cor"/>
|
||||||
|
<path d="M 1140 36 L 1164 36 L 1164 60" class="cor"/>
|
||||||
|
<path d="M 36 570 L 36 594 L 60 594" class="cor"/>
|
||||||
|
<path d="M 1140 594 L 1164 594 L 1164 570" class="cor"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,27 @@
|
|||||||
|
<svg width="1200" height="630" viewBox="0 0 1200 630" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<style>
|
||||||
|
.bg { fill: var(--brand-500); }
|
||||||
|
.ico { stroke: var(--brand-100); stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; fill: none; }
|
||||||
|
.txt { fill: var(--brand-50); font-family: 'Outfit Variable', Outfit, system-ui, -apple-system, sans-serif; font-weight: 800; }
|
||||||
|
.ln { stroke: var(--brand-300); stroke-width: 1; stroke-opacity: 0.35; }
|
||||||
|
.pil { fill: var(--brand-400); fill-opacity: 0.25; stroke: var(--brand-200); stroke-opacity: 0.4; stroke-width: 1; }
|
||||||
|
.ptx { fill: var(--brand-100); font-family: 'Outfit Variable', Outfit, system-ui, -apple-system, sans-serif; }
|
||||||
|
.cor { stroke: var(--brand-300); stroke-width: 1.5; fill: none; stroke-opacity: 0.45; }
|
||||||
|
</style>
|
||||||
|
<rect width="1200" height="630" class="bg"/>
|
||||||
|
<!-- Lucide Rocket -->
|
||||||
|
<g transform="translate(540, 180) scale(5)" class="ico" opacity="0.9">
|
||||||
|
<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/>
|
||||||
|
<path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/>
|
||||||
|
<path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/>
|
||||||
|
<path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/>
|
||||||
|
</g>
|
||||||
|
<text x="600" y="410" font-size="96" text-anchor="middle" letter-spacing="-4" class="txt">Astro Rocket</text>
|
||||||
|
<line x1="380" y1="436" x2="820" y2="436" class="ln"/>
|
||||||
|
<rect x="450" y="464" width="300" height="38" rx="19" class="pil"/>
|
||||||
|
<text x="600" y="487" font-size="16" text-anchor="middle" class="ptx">hansmartens.dev</text>
|
||||||
|
<path d="M 36 60 L 36 36 L 60 36" class="cor"/>
|
||||||
|
<path d="M 1140 36 L 1164 36 L 1164 60" class="cor"/>
|
||||||
|
<path d="M 36 570 L 36 594 L 60 594" class="cor"/>
|
||||||
|
<path d="M 1140 594 L 1164 594 L 1164 570" class="cor"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,30 @@
|
|||||||
|
<svg width="1200" height="630" viewBox="0 0 1200 630" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<style>
|
||||||
|
.bg { fill: var(--brand-500); }
|
||||||
|
.ico { stroke: var(--brand-100); stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; fill: none; }
|
||||||
|
.txt { fill: var(--brand-50); font-family: 'Outfit Variable', Outfit, system-ui, -apple-system, sans-serif; font-weight: 800; }
|
||||||
|
.ln { stroke: var(--brand-300); stroke-width: 1; stroke-opacity: 0.35; }
|
||||||
|
.pil { fill: var(--brand-400); fill-opacity: 0.25; stroke: var(--brand-200); stroke-opacity: 0.4; stroke-width: 1; }
|
||||||
|
.ptx { fill: var(--brand-100); font-family: 'Outfit Variable', Outfit, system-ui, -apple-system, sans-serif; }
|
||||||
|
.cor { stroke: var(--brand-300); stroke-width: 1.5; fill: none; stroke-opacity: 0.45; }
|
||||||
|
.num { fill: var(--brand-50); font-family: 'Outfit Variable', Outfit, system-ui, -apple-system, sans-serif; font-weight: 800; fill-opacity: 0.18; }
|
||||||
|
</style>
|
||||||
|
<rect width="1200" height="630" class="bg"/>
|
||||||
|
<!-- Large faint "57" in background -->
|
||||||
|
<text x="600" y="435" font-size="340" text-anchor="middle" letter-spacing="-8" class="num">57</text>
|
||||||
|
<!-- Lucide layout-dashboard icon: 4 panels of different sizes, scale=5, centered at (600,255) -->
|
||||||
|
<g transform="translate(540, 195) scale(5)" class="ico" opacity="0.9">
|
||||||
|
<rect width="7" height="9" x="3" y="3" rx="1"/>
|
||||||
|
<rect width="7" height="5" x="14" y="3" rx="1"/>
|
||||||
|
<rect width="7" height="9" x="14" y="12" rx="1"/>
|
||||||
|
<rect width="7" height="5" x="3" y="16" rx="1"/>
|
||||||
|
</g>
|
||||||
|
<text x="600" y="410" font-size="88" text-anchor="middle" letter-spacing="-2" class="txt">components</text>
|
||||||
|
<line x1="380" y1="436" x2="820" y2="436" class="ln"/>
|
||||||
|
<rect x="450" y="464" width="300" height="38" rx="19" class="pil"/>
|
||||||
|
<text x="600" y="487" font-size="16" text-anchor="middle" class="ptx">hansmartens.dev</text>
|
||||||
|
<path d="M 36 60 L 36 36 L 60 36" class="cor"/>
|
||||||
|
<path d="M 1140 36 L 1164 36 L 1164 60" class="cor"/>
|
||||||
|
<path d="M 36 570 L 36 594 L 60 594" class="cor"/>
|
||||||
|
<path d="M 1140 594 L 1164 594 L 1164 570" class="cor"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,31 @@
|
|||||||
|
<svg width="1200" height="630" viewBox="0 0 1200 630" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<style>
|
||||||
|
.bg { fill: var(--brand-500); }
|
||||||
|
.ico { stroke: var(--brand-100); stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; fill: none; }
|
||||||
|
.txt { fill: var(--brand-50); font-family: 'Outfit Variable', Outfit, system-ui, -apple-system, sans-serif; font-weight: 800; }
|
||||||
|
.ln { stroke: var(--brand-300); stroke-width: 1; stroke-opacity: 0.35; }
|
||||||
|
.pil { fill: var(--brand-400); fill-opacity: 0.25; stroke: var(--brand-200); stroke-opacity: 0.4; stroke-width: 1; }
|
||||||
|
.ptx { fill: var(--brand-100); font-family: 'Outfit Variable', Outfit, system-ui, -apple-system, sans-serif; }
|
||||||
|
.cor { stroke: var(--brand-300); stroke-width: 1.5; fill: none; stroke-opacity: 0.45; }
|
||||||
|
</style>
|
||||||
|
<rect width="1200" height="630" class="bg"/>
|
||||||
|
<!-- Lucide Sunset -->
|
||||||
|
<g transform="translate(540, 180) scale(5)" class="ico" opacity="0.9">
|
||||||
|
<path d="M12 10V2"/>
|
||||||
|
<path d="m4.93 10.93 1.41 1.41"/>
|
||||||
|
<path d="M2 18h2"/>
|
||||||
|
<path d="M20 18h2"/>
|
||||||
|
<path d="m19.07 10.93-1.41 1.41"/>
|
||||||
|
<path d="M22 22H2"/>
|
||||||
|
<path d="m16 6-4 4-4-4"/>
|
||||||
|
<path d="M16 18a4 4 0 0 0-8 0"/>
|
||||||
|
</g>
|
||||||
|
<text x="600" y="410" font-size="96" text-anchor="middle" letter-spacing="-2" class="txt">gradient</text>
|
||||||
|
<line x1="370" y1="436" x2="830" y2="436" class="ln"/>
|
||||||
|
<rect x="450" y="464" width="300" height="38" rx="19" class="pil"/>
|
||||||
|
<text x="600" y="487" font-size="16" text-anchor="middle" class="ptx">hansmartens.dev</text>
|
||||||
|
<path d="M 36 60 L 36 36 L 60 36" class="cor"/>
|
||||||
|
<path d="M 1140 36 L 1164 36 L 1164 60" class="cor"/>
|
||||||
|
<path d="M 36 570 L 36 594 L 60 594" class="cor"/>
|
||||||
|
<path d="M 1140 594 L 1164 594 L 1164 570" class="cor"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,24 @@
|
|||||||
|
<svg width="1200" height="630" viewBox="0 0 1200 630" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<style>
|
||||||
|
.bg { fill: var(--brand-500); }
|
||||||
|
.ico { stroke: var(--brand-100); stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; fill: none; }
|
||||||
|
.txt { fill: var(--brand-50); font-family: 'Outfit Variable', Outfit, system-ui, -apple-system, sans-serif; font-weight: 800; }
|
||||||
|
.ln { stroke: var(--brand-300); stroke-width: 1; stroke-opacity: 0.35; }
|
||||||
|
.pil { fill: var(--brand-400); fill-opacity: 0.25; stroke: var(--brand-200); stroke-opacity: 0.4; stroke-width: 1; }
|
||||||
|
.ptx { fill: var(--brand-100); font-family: 'Outfit Variable', Outfit, system-ui, -apple-system, sans-serif; }
|
||||||
|
.cor { stroke: var(--brand-300); stroke-width: 1.5; fill: none; stroke-opacity: 0.45; }
|
||||||
|
</style>
|
||||||
|
<rect width="1200" height="630" class="bg"/>
|
||||||
|
<!-- Lucide Moon -->
|
||||||
|
<g transform="translate(540, 180) scale(5)" class="ico" opacity="0.9">
|
||||||
|
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>
|
||||||
|
</g>
|
||||||
|
<text x="600" y="410" font-size="120" text-anchor="middle" letter-spacing="-4" class="txt">dark mode</text>
|
||||||
|
<line x1="380" y1="436" x2="820" y2="436" class="ln"/>
|
||||||
|
<rect x="450" y="464" width="300" height="38" rx="19" class="pil"/>
|
||||||
|
<text x="600" y="487" font-size="16" text-anchor="middle" class="ptx">hansmartens.dev</text>
|
||||||
|
<path d="M 36 60 L 36 36 L 60 36" class="cor"/>
|
||||||
|
<path d="M 1140 36 L 1164 36 L 1164 60" class="cor"/>
|
||||||
|
<path d="M 36 570 L 36 594 L 60 594" class="cor"/>
|
||||||
|
<path d="M 1140 594 L 1164 594 L 1164 570" class="cor"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,24 @@
|
|||||||
|
<svg width="1200" height="630" viewBox="0 0 1200 630" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<style>
|
||||||
|
.bg { fill: var(--brand-500); }
|
||||||
|
.ico { stroke: var(--brand-100); stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; fill: none; }
|
||||||
|
.txt { fill: var(--brand-50); font-family: 'Outfit Variable', Outfit, system-ui, -apple-system, sans-serif; font-weight: 800; }
|
||||||
|
.ln { stroke: var(--brand-300); stroke-width: 1; stroke-opacity: 0.35; }
|
||||||
|
.pil { fill: var(--brand-400); fill-opacity: 0.25; stroke: var(--brand-200); stroke-opacity: 0.4; stroke-width: 1; }
|
||||||
|
.ptx { fill: var(--brand-100); font-family: 'Outfit Variable', Outfit, system-ui, -apple-system, sans-serif; }
|
||||||
|
.cor { stroke: var(--brand-300); stroke-width: 1.5; fill: none; stroke-opacity: 0.45; }
|
||||||
|
</style>
|
||||||
|
<rect width="1200" height="630" class="bg"/>
|
||||||
|
<!-- Lucide Droplet -->
|
||||||
|
<g transform="translate(540, 180) scale(5)" class="ico" opacity="0.9">
|
||||||
|
<path d="M12 22a7 7 0 0 0 7-7c0-2-1-3.9-3-5.5s-3.5-4-4-6.5c-.5 2.5-2 4.9-4 6.5C6 11.1 5 13 5 15a7 7 0 0 0 7 7z"/>
|
||||||
|
</g>
|
||||||
|
<text x="600" y="410" font-size="100" text-anchor="middle" letter-spacing="-3" class="txt">color tokens</text>
|
||||||
|
<line x1="380" y1="436" x2="820" y2="436" class="ln"/>
|
||||||
|
<rect x="450" y="464" width="300" height="38" rx="19" class="pil"/>
|
||||||
|
<text x="600" y="487" font-size="16" text-anchor="middle" class="ptx">hansmartens.dev</text>
|
||||||
|
<path d="M 36 60 L 36 36 L 60 36" class="cor"/>
|
||||||
|
<path d="M 1140 36 L 1164 36 L 1164 60" class="cor"/>
|
||||||
|
<path d="M 36 570 L 36 594 L 60 594" class="cor"/>
|
||||||
|
<path d="M 1140 594 L 1164 594 L 1164 570" class="cor"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,25 @@
|
|||||||
|
<svg width="1200" height="630" viewBox="0 0 1200 630" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<style>
|
||||||
|
.bg { fill: var(--brand-500); }
|
||||||
|
.ico { stroke: var(--brand-100); stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; fill: none; }
|
||||||
|
.txt { fill: var(--brand-50); font-family: 'Outfit Variable', Outfit, system-ui, -apple-system, sans-serif; font-weight: 800; }
|
||||||
|
.ln { stroke: var(--brand-300); stroke-width: 1; stroke-opacity: 0.35; }
|
||||||
|
.pil { fill: var(--brand-400); fill-opacity: 0.25; stroke: var(--brand-200); stroke-opacity: 0.4; stroke-width: 1; }
|
||||||
|
.ptx { fill: var(--brand-100); font-family: 'Outfit Variable', Outfit, system-ui, -apple-system, sans-serif; }
|
||||||
|
.cor { stroke: var(--brand-300); stroke-width: 1.5; fill: none; stroke-opacity: 0.45; }
|
||||||
|
</style>
|
||||||
|
<rect width="1200" height="630" class="bg"/>
|
||||||
|
<!-- Lucide Terminal -->
|
||||||
|
<g transform="translate(540, 180) scale(5)" class="ico" opacity="0.9">
|
||||||
|
<polyline points="4 17 10 11 4 5"/>
|
||||||
|
<line x1="12" x2="20" y1="19" y2="19"/>
|
||||||
|
</g>
|
||||||
|
<text x="600" y="410" font-size="96" text-anchor="middle" letter-spacing="-2" class="txt">typing effect</text>
|
||||||
|
<line x1="380" y1="436" x2="820" y2="436" class="ln"/>
|
||||||
|
<rect x="450" y="464" width="300" height="38" rx="19" class="pil"/>
|
||||||
|
<text x="600" y="487" font-size="16" text-anchor="middle" class="ptx">hansmartens.dev</text>
|
||||||
|
<path d="M 36 60 L 36 36 L 60 36" class="cor"/>
|
||||||
|
<path d="M 1140 36 L 1164 36 L 1164 60" class="cor"/>
|
||||||
|
<path d="M 36 570 L 36 594 L 60 594" class="cor"/>
|
||||||
|
<path d="M 1140 594 L 1164 594 L 1164 570" class="cor"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,31 @@
|
|||||||
|
<svg width="1200" height="630" viewBox="0 0 1200 630" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<style>
|
||||||
|
.bg { fill: var(--brand-500); }
|
||||||
|
.ico { stroke: var(--brand-100); stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; fill: none; }
|
||||||
|
.txt { fill: var(--brand-50); font-family: 'Outfit Variable', Outfit, system-ui, -apple-system, sans-serif; font-weight: 800; }
|
||||||
|
.ln { stroke: var(--brand-300); stroke-width: 1; stroke-opacity: 0.35; }
|
||||||
|
.pil { fill: var(--brand-400); fill-opacity: 0.25; stroke: var(--brand-200); stroke-opacity: 0.4; stroke-width: 1; }
|
||||||
|
.ptx { fill: var(--brand-100); font-family: 'Outfit Variable', Outfit, system-ui, -apple-system, sans-serif; }
|
||||||
|
.cor { stroke: var(--brand-300); stroke-width: 1.5; fill: none; stroke-opacity: 0.45; }
|
||||||
|
.bar-track { fill: var(--brand-400); fill-opacity: 0.25; rx: 4; }
|
||||||
|
.bar-fill { fill: var(--brand-100); fill-opacity: 0.85; }
|
||||||
|
</style>
|
||||||
|
<rect width="1200" height="630" class="bg"/>
|
||||||
|
<!-- Progress bar illustration -->
|
||||||
|
<g transform="translate(360, 218)">
|
||||||
|
<!-- Track -->
|
||||||
|
<rect x="0" y="16" width="480" height="8" rx="4" class="bar-track"/>
|
||||||
|
<!-- Filled portion (~62%) -->
|
||||||
|
<rect x="0" y="16" width="298" height="8" rx="4" class="bar-fill"/>
|
||||||
|
<!-- Indicator dot -->
|
||||||
|
<circle cx="298" cy="20" r="7" fill="var(--brand-50)" fill-opacity="0.95"/>
|
||||||
|
</g>
|
||||||
|
<text x="600" y="410" font-size="96" text-anchor="middle" letter-spacing="-2" class="txt">progress</text>
|
||||||
|
<line x1="380" y1="436" x2="820" y2="436" class="ln"/>
|
||||||
|
<rect x="450" y="464" width="300" height="38" rx="19" class="pil"/>
|
||||||
|
<text x="600" y="487" font-size="16" text-anchor="middle" class="ptx">hansmartens.dev</text>
|
||||||
|
<path d="M 36 60 L 36 36 L 60 36" class="cor"/>
|
||||||
|
<path d="M 1140 36 L 1164 36 L 1164 60" class="cor"/>
|
||||||
|
<path d="M 36 570 L 36 594 L 60 594" class="cor"/>
|
||||||
|
<path d="M 1140 594 L 1164 594 L 1164 570" class="cor"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,25 @@
|
|||||||
|
<svg width="1200" height="630" viewBox="0 0 1200 630" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<style>
|
||||||
|
.bg { fill: var(--brand-500); }
|
||||||
|
.ico { stroke: var(--brand-100); stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; fill: none; }
|
||||||
|
.txt { fill: var(--brand-50); font-family: 'Outfit Variable', Outfit, system-ui, -apple-system, sans-serif; font-weight: 800; }
|
||||||
|
.ln { stroke: var(--brand-300); stroke-width: 1; stroke-opacity: 0.35; }
|
||||||
|
.pil { fill: var(--brand-400); fill-opacity: 0.25; stroke: var(--brand-200); stroke-opacity: 0.4; stroke-width: 1; }
|
||||||
|
.ptx { fill: var(--brand-100); font-family: 'Outfit Variable', Outfit, system-ui, -apple-system, sans-serif; }
|
||||||
|
.cor { stroke: var(--brand-300); stroke-width: 1.5; fill: none; stroke-opacity: 0.45; }
|
||||||
|
</style>
|
||||||
|
<rect width="1200" height="630" class="bg"/>
|
||||||
|
<!-- Lucide Search -->
|
||||||
|
<g transform="translate(540, 180) scale(5)" class="ico" opacity="0.9">
|
||||||
|
<circle cx="11" cy="11" r="8"/>
|
||||||
|
<path d="m21 21-4.3-4.3"/>
|
||||||
|
</g>
|
||||||
|
<text x="600" y="410" font-size="120" text-anchor="middle" letter-spacing="-4" class="txt">seo</text>
|
||||||
|
<line x1="380" y1="436" x2="820" y2="436" class="ln"/>
|
||||||
|
<rect x="450" y="464" width="300" height="38" rx="19" class="pil"/>
|
||||||
|
<text x="600" y="487" font-size="16" text-anchor="middle" class="ptx">hansmartens.dev</text>
|
||||||
|
<path d="M 36 60 L 36 36 L 60 36" class="cor"/>
|
||||||
|
<path d="M 1140 36 L 1164 36 L 1164 60" class="cor"/>
|
||||||
|
<path d="M 36 570 L 36 594 L 60 594" class="cor"/>
|
||||||
|
<path d="M 1140 594 L 1164 594 L 1164 570" class="cor"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="248" height="39" viewBox="0 0 248 39" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M32 20C32 28.8366 24.8366 36 16 36C7.16344 36 0 28.8366 0 20C0 11.1634 7.16344 4 16 4C24.8366 4 32 11.1634 32 20ZM4.5578 20C4.5578 26.3194 9.68065 31.4422 16 31.4422C22.3194 31.4422 27.4422 26.3194 27.4422 20C27.4422 13.6806 22.3194 8.5578 16 8.5578C9.68065 8.5578 4.5578 13.6806 4.5578 20Z" fill="#6200EA"/>
|
||||||
|
<path d="M54.5801 5.79297L64.3008 31H60.6973L58.4648 25.1992H47.0918L44.8594 31H41.2559L50.9766 5.79297H54.5801ZM48.4629 21.5957H57.0762L52.7871 10.4863L48.4629 21.5957ZM88.541 14.793C88.541 18.3086 87.041 20.3125 84.041 20.8047L88.7871 31H84.7969L80.1211 20.9277H72.2285V31H68.625V5.79297H82.4238C86.502 5.79297 88.541 7.83789 88.541 11.9277V14.793ZM72.2285 17.3242H82.2305C83.1914 17.3242 83.8828 17.1133 84.3047 16.6914C84.7266 16.2695 84.9375 15.5781 84.9375 14.6172V12.1035C84.9375 11.1426 84.7266 10.4512 84.3047 10.0293C83.8828 9.60742 83.1914 9.39648 82.2305 9.39648H72.2285V17.3242ZM107.578 31L98.1738 12.5254V31H94.5703V5.79297H98.5078L108.105 24.7773L117.721 5.79297H121.641V31H118.055V12.5254L108.65 31H107.578ZM139.307 5.79297L149.027 31H145.424L143.191 25.1992H131.818L129.586 31H125.982L135.703 5.79297H139.307ZM133.189 21.5957H141.803L137.514 10.4863L133.189 21.5957ZM173.268 14.793C173.268 18.3086 171.768 20.3125 168.768 20.8047L173.514 31H169.523L164.848 20.9277H156.955V31H153.352V5.79297H167.15C171.229 5.79297 173.268 7.83789 173.268 11.9277V14.793ZM156.955 17.3242H166.957C167.918 17.3242 168.609 17.1133 169.031 16.6914C169.453 16.2695 169.664 15.5781 169.664 14.6172V12.1035C169.664 11.1426 169.453 10.4512 169.031 10.0293C168.609 9.60742 167.918 9.39648 166.957 9.39648H156.955V17.3242ZM182.9 5.79297V31H179.297V5.79297H182.9ZM189.387 5.79297H192.99V27.3965H207.387V5.79297H210.99V27.3965C210.99 27.8887 210.896 28.3574 210.709 28.8027C210.521 29.2363 210.264 29.6172 209.936 29.9453C209.607 30.2734 209.221 30.5312 208.775 30.7188C208.342 30.9062 207.879 31 207.387 31H192.99C192.498 31 192.029 30.9062 191.584 30.7188C191.15 30.5312 190.77 30.2734 190.441 29.9453C190.113 29.6172 189.855 29.2363 189.668 28.8027C189.48 28.3574 189.387 27.8887 189.387 27.3965V5.79297ZM230.484 31L221.08 12.5254V31H217.477V5.79297H221.414L231.012 24.7773L240.627 5.79297H244.547V31H240.961V12.5254L231.557 31H230.484Z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,132 @@
|
|||||||
|
---
|
||||||
|
import { Image } from 'astro:assets';
|
||||||
|
import type { ImageMetadata } from 'astro';
|
||||||
|
import { formatDate } from '@/lib/utils';
|
||||||
|
import Logo from '@/components/ui/marketing/Logo/Logo.astro';
|
||||||
|
import BlogImageSVG from '@/components/blog/BlogImageSVG.astro';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
publishedAt: Date;
|
||||||
|
updatedAt?: Date;
|
||||||
|
author?: string;
|
||||||
|
tags?: string[];
|
||||||
|
image?: ImageMetadata;
|
||||||
|
imageAlt?: string;
|
||||||
|
svgSlug?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
publishedAt,
|
||||||
|
updatedAt,
|
||||||
|
author = 'Team',
|
||||||
|
tags = [],
|
||||||
|
image,
|
||||||
|
imageAlt,
|
||||||
|
svgSlug,
|
||||||
|
} = Astro.props;
|
||||||
|
|
||||||
|
// Estimate reading time
|
||||||
|
const wordsPerMinute = 200;
|
||||||
|
const estimatedWords = description.split(' ').length * 15;
|
||||||
|
const readingTime = Math.max(1, Math.ceil(estimatedWords / wordsPerMinute));
|
||||||
|
---
|
||||||
|
|
||||||
|
<header class="relative overflow-hidden pt-[var(--space-page-top-sm)] pb-[var(--space-section)]">
|
||||||
|
<div class="relative mx-auto max-w-4xl px-6 animate-hero-slide-up">
|
||||||
|
<!-- Tags -->
|
||||||
|
{tags.length > 0 && (
|
||||||
|
<div class="mb-[var(--space-heading-gap)] flex flex-wrap gap-2">
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<span class="inline-flex items-center rounded-full bg-brand-100 dark:bg-brand-900/30 px-3 py-1 text-xs font-semibold text-brand-700 dark:text-brand-300 ring-1 ring-inset ring-brand-200 dark:ring-brand-800">
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<!-- Title -->
|
||||||
|
<h1 class="font-display text-4xl font-bold tracking-tight text-foreground md:text-5xl lg:text-6xl mb-[var(--space-heading-gap)]">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<p class="text-xl text-foreground-muted leading-relaxed max-w-3xl mb-[var(--space-stack-lg)]">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Meta info -->
|
||||||
|
<div class="flex flex-wrap items-center gap-[var(--space-stack-lg)] text-sm text-foreground-muted">
|
||||||
|
<!-- Author -->
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<Logo size="sm" letter={author.charAt(0).toUpperCase()} />
|
||||||
|
<p class="font-semibold text-foreground">{author}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="h-8 w-px bg-border hidden md:block"></div>
|
||||||
|
|
||||||
|
<!-- Published date -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<svg class="h-5 w-5 text-foreground-subtle" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5" />
|
||||||
|
</svg>
|
||||||
|
<time datetime={publishedAt.toISOString()}>
|
||||||
|
{formatDate(publishedAt)}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{updatedAt && (
|
||||||
|
<>
|
||||||
|
<div class="h-8 w-px bg-border hidden md:block"></div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<svg class="h-5 w-5 text-foreground-subtle" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
|
||||||
|
</svg>
|
||||||
|
<time datetime={updatedAt.toISOString()}>
|
||||||
|
Updated {formatDate(updatedAt)}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div class="h-8 w-px bg-border hidden md:block"></div>
|
||||||
|
|
||||||
|
<!-- Reading time -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<svg class="h-5 w-5 text-foreground-subtle" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<span>{readingTime} min read</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(svgSlug || image) && (
|
||||||
|
<div class="relative mx-auto max-w-5xl px-6 mt-[var(--space-section)] animate-hero-slide-up [animation-delay:200ms]">
|
||||||
|
{svgSlug ? (
|
||||||
|
<div
|
||||||
|
class="relative overflow-hidden rounded-xl border border-border shadow-2xl
|
||||||
|
bg-gradient-to-br from-brand-100/50 to-brand-50/30 dark:from-brand-900/50 dark:to-brand-600/30"
|
||||||
|
style="color: var(--brand-500);"
|
||||||
|
>
|
||||||
|
<BlogImageSVG slug={svgSlug} title={imageAlt || title} />
|
||||||
|
</div>
|
||||||
|
) : image ? (
|
||||||
|
<div class="relative overflow-hidden rounded-xl border border-border shadow-2xl">
|
||||||
|
<Image
|
||||||
|
src={image}
|
||||||
|
alt={imageAlt || title}
|
||||||
|
layout="full-width"
|
||||||
|
widths={[640, 960, 1280, 1920]}
|
||||||
|
sizes="100vw"
|
||||||
|
class="aspect-video w-full object-cover"
|
||||||
|
loading="eager"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
---
|
||||||
|
import { Image } from 'astro:assets';
|
||||||
|
import type { ImageMetadata } from 'astro';
|
||||||
|
import { formatDate } from '@/lib/utils';
|
||||||
|
import Logo from '@/components/ui/marketing/Logo/Logo.astro';
|
||||||
|
import BlogImageSVG from '@/components/blog/BlogImageSVG.astro';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
href: string;
|
||||||
|
publishedAt: Date;
|
||||||
|
tags?: string[];
|
||||||
|
featured?: boolean;
|
||||||
|
author?: string;
|
||||||
|
image?: ImageMetadata;
|
||||||
|
svgSlug?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
href,
|
||||||
|
publishedAt,
|
||||||
|
tags = [],
|
||||||
|
author,
|
||||||
|
image,
|
||||||
|
svgSlug,
|
||||||
|
} = Astro.props;
|
||||||
|
|
||||||
|
// Estimate reading time (rough estimate based on average words)
|
||||||
|
const wordsPerMinute = 200;
|
||||||
|
const estimatedWords = description.split(' ').length * 10; // Rough estimate
|
||||||
|
const readingTime = Math.max(1, Math.ceil(estimatedWords / wordsPerMinute));
|
||||||
|
---
|
||||||
|
|
||||||
|
<article class="group rounded-lg border border-brand-500/30 bg-background p-6 ring-1 ring-brand-500/20 transition-all duration-200 ease-out hover:-translate-y-0.5 hover:border-brand-500 hover:shadow-md">
|
||||||
|
|
||||||
|
<a href={href} class="block">
|
||||||
|
<div
|
||||||
|
class="relative mb-4 overflow-hidden rounded-md
|
||||||
|
bg-background-secondary bg-gradient-to-br from-brand-100/65 to-transparent dark:from-brand-900/60 dark:to-brand-600/25"
|
||||||
|
style="color: var(--brand-500);"
|
||||||
|
>
|
||||||
|
{svgSlug ? (
|
||||||
|
<div class="transition-transform duration-300 group-hover:scale-105">
|
||||||
|
<BlogImageSVG slug={svgSlug} title={title} />
|
||||||
|
</div>
|
||||||
|
) : image ? (
|
||||||
|
<Image
|
||||||
|
src={image}
|
||||||
|
alt={title}
|
||||||
|
widths={[320, 640, 960]}
|
||||||
|
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 400px"
|
||||||
|
class="aspect-video w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div class="aspect-video w-full" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="font-display text-xl font-bold text-foreground transition-colors group-hover:text-brand-600 dark:group-hover:text-brand-400 mb-2">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p class="text-foreground-muted line-clamp-2 mb-4">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-3 text-sm text-foreground-subtle">
|
||||||
|
{author && (
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Logo size="sm" letter={author.charAt(0).toUpperCase()} />
|
||||||
|
<span class="font-medium">{author}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<time datetime={publishedAt.toISOString()} class="flex items-center gap-1">
|
||||||
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5" />
|
||||||
|
</svg>
|
||||||
|
{formatDate(publishedAt)}
|
||||||
|
</time>
|
||||||
|
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
{readingTime} min read
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tags.length > 0 && (
|
||||||
|
<div class="mt-4 flex flex-wrap gap-2">
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<span class="inline-flex items-center rounded-full bg-background-secondary px-2.5 py-0.5 text-xs font-medium text-foreground-muted ring-1 ring-inset ring-border transition-colors group-hover:bg-brand-50 group-hover:text-brand-700 group-hover:ring-brand-200 dark:group-hover:bg-brand-900/20 dark:group-hover:text-brand-400 dark:group-hover:ring-brand-800">
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
</article>
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
---
|
||||||
|
const svgs = import.meta.glob<string>('/src/assets/blog/*.svg', { as: 'raw', eager: true });
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { slug, title } = Astro.props;
|
||||||
|
const svgContent = svgs[`/src/assets/blog/${slug}.svg`] ?? '';
|
||||||
|
---
|
||||||
|
|
||||||
|
{svgContent && (
|
||||||
|
<div class="svg-host" role="img" aria-label={title} set:html={svgContent} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.svg-host :global(svg) {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.svg-host :global(svg) {
|
||||||
|
transform: scale(1.35);
|
||||||
|
transform-origin: center center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Light mode ─────────────────────────────────────────────────────────
|
||||||
|
Brand-500 background — the saturated mid-tone brand colour. Light
|
||||||
|
icons and text sit on top for vivid, colourful images in both modes.
|
||||||
|
── */
|
||||||
|
.svg-host :global(.bg) { fill: var(--brand-500); }
|
||||||
|
.svg-host :global(.ico) { stroke: var(--brand-50); }
|
||||||
|
.svg-host :global(.txt) { fill: var(--brand-50); }
|
||||||
|
.svg-host :global(.ln) { stroke: var(--brand-200); stroke-opacity: 0.5; }
|
||||||
|
.svg-host :global(.pil) { fill: var(--brand-300); fill-opacity: 0.35; stroke: var(--brand-100); stroke-opacity: 0.8; }
|
||||||
|
.svg-host :global(.ptx) { fill: var(--brand-50); }
|
||||||
|
.svg-host :global(.cor) { stroke: var(--brand-200); stroke-opacity: 0.7; }
|
||||||
|
.svg-host :global(.num) { fill: var(--brand-100); fill-opacity: 0.18; }
|
||||||
|
|
||||||
|
/* ── Dark mode ───────────────────────────────────────────────────────────
|
||||||
|
Deep background with all brand colors at full opacity — vivid, not faded.
|
||||||
|
── */
|
||||||
|
:global(html.dark) .svg-host :global(.bg) { fill: var(--brand-600); }
|
||||||
|
:global(html.dark) .svg-host :global(.ico) { stroke: var(--brand-200); }
|
||||||
|
:global(html.dark) .svg-host :global(.txt) { fill: var(--brand-50); }
|
||||||
|
:global(html.dark) .svg-host :global(.ln) { stroke: var(--brand-300); stroke-opacity: 0.5; }
|
||||||
|
:global(html.dark) .svg-host :global(.pil) { fill: var(--brand-600); fill-opacity: 0.4; stroke: var(--brand-300); stroke-opacity: 0.7; }
|
||||||
|
:global(html.dark) .svg-host :global(.ptx) { fill: var(--brand-100); }
|
||||||
|
:global(html.dark) .svg-host :global(.cor) { stroke: var(--brand-300); stroke-opacity: 0.6; }
|
||||||
|
:global(html.dark) .svg-host :global(.num) { fill: var(--brand-50); fill-opacity: 0.18; }
|
||||||
|
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
---
|
||||||
|
import BlogCard from './BlogCard.astro';
|
||||||
|
import { getCollection } from 'astro:content';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
currentSlug: string;
|
||||||
|
tags: string[];
|
||||||
|
locale?: string;
|
||||||
|
maxPosts?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { currentSlug, tags, locale = 'en', maxPosts = 3 } = Astro.props;
|
||||||
|
|
||||||
|
// Get all published posts in the same locale
|
||||||
|
const allPosts = await getCollection('blog', ({ data }) => {
|
||||||
|
return data.locale === locale && (import.meta.env.PROD ? data.draft !== true : true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter to find posts with matching tags
|
||||||
|
const relatedPosts = allPosts
|
||||||
|
.filter((post) => {
|
||||||
|
// Exclude current post
|
||||||
|
if (post.id === currentSlug || post.id.endsWith(`/${currentSlug}`)) return false;
|
||||||
|
|
||||||
|
// Must have at least one matching tag
|
||||||
|
if (!tags.length) return false;
|
||||||
|
return post.data.tags.some((tag) => tags.includes(tag));
|
||||||
|
})
|
||||||
|
// Sort by number of matching tags, then by date
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aMatches = a.data.tags.filter((tag) => tags.includes(tag)).length;
|
||||||
|
const bMatches = b.data.tags.filter((tag) => tags.includes(tag)).length;
|
||||||
|
|
||||||
|
if (bMatches !== aMatches) return bMatches - aMatches;
|
||||||
|
return b.data.publishedAt.valueOf() - a.data.publishedAt.valueOf();
|
||||||
|
})
|
||||||
|
.slice(0, maxPosts);
|
||||||
|
|
||||||
|
// Generate URLs for each post (remove locale prefix from id)
|
||||||
|
const getPostUrl = (postId: string) => {
|
||||||
|
const slug = postId.replace(`${locale}/`, '');
|
||||||
|
return `/blog/${slug}`;
|
||||||
|
};
|
||||||
|
---
|
||||||
|
|
||||||
|
{relatedPosts.length > 0 && (
|
||||||
|
<section class="border-t border-border py-[var(--space-section-sm)]">
|
||||||
|
<h2 class="font-display text-2xl font-bold text-foreground mb-[var(--space-stack-lg)]">
|
||||||
|
Related Posts
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="grid gap-[var(--space-stack-lg)] md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{relatedPosts.map((post) => (
|
||||||
|
<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>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
---
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, url } = Astro.props;
|
||||||
|
|
||||||
|
const encodedTitle = encodeURIComponent(title);
|
||||||
|
const encodedUrl = encodeURIComponent(url);
|
||||||
|
|
||||||
|
const shareLinks = {
|
||||||
|
twitter: `https://twitter.com/intent/tweet?text=${encodedTitle}&url=${encodedUrl}`,
|
||||||
|
linkedin: `https://www.linkedin.com/sharing/share-offsite/?url=${encodedUrl}`,
|
||||||
|
};
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<span class="text-sm font-medium text-foreground-muted">Share:</span>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<!-- Twitter/X -->
|
||||||
|
<a
|
||||||
|
href={shareLinks.twitter}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="inline-flex h-9 w-9 items-center justify-center rounded-full bg-brand-500 text-white border border-brand-500 transition-all hover:bg-brand-600 hover:border-brand-600"
|
||||||
|
aria-label="Share on Twitter"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- LinkedIn -->
|
||||||
|
<a
|
||||||
|
href={shareLinks.linkedin}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="inline-flex h-9 w-9 items-center justify-center rounded-full bg-brand-500 text-white border border-brand-500 transition-all hover:bg-brand-600 hover:border-brand-600"
|
||||||
|
aria-label="Share on LinkedIn"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Copy Link -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex h-9 w-9 items-center justify-center rounded-full bg-brand-500 text-white border border-brand-500 transition-all hover:bg-brand-600 hover:border-brand-600 copy-link-btn"
|
||||||
|
aria-label="Copy link"
|
||||||
|
data-url={url}
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4 copy-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
|
||||||
|
</svg>
|
||||||
|
<svg class="h-4 w-4 check-icon hidden" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function initShareButtons() {
|
||||||
|
document.querySelectorAll('.copy-link-btn').forEach((button) => {
|
||||||
|
button.addEventListener('click', async () => {
|
||||||
|
const url = button.getAttribute('data-url');
|
||||||
|
if (!url) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
|
||||||
|
const copyIcon = button.querySelector('.copy-icon');
|
||||||
|
const checkIcon = button.querySelector('.check-icon');
|
||||||
|
|
||||||
|
if (copyIcon && checkIcon) {
|
||||||
|
copyIcon.classList.add('hidden');
|
||||||
|
checkIcon.classList.remove('hidden');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
copyIcon.classList.remove('hidden');
|
||||||
|
checkIcon.classList.add('hidden');
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Clipboard API failed - user will need to copy manually
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
initShareButtons();
|
||||||
|
document.addEventListener('astro:page-load', initShareButtons);
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
---
|
||||||
|
import type { HTMLAttributes } from 'astro/types';
|
||||||
|
import { cn } from '@/lib/cn';
|
||||||
|
import { heroSectionVariants } from './hero.variants';
|
||||||
|
|
||||||
|
interface Props extends HTMLAttributes<'section'> {
|
||||||
|
/** Layout mode: centered single column or split two-column */
|
||||||
|
layout?: 'centered' | 'split';
|
||||||
|
/** Vertical padding size */
|
||||||
|
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||||
|
/** Show background grid pattern */
|
||||||
|
showGrid?: boolean;
|
||||||
|
/** Apply the dark-mode hero gradient (black → brand). Homepage only. */
|
||||||
|
gradient?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
layout = 'centered',
|
||||||
|
size = 'lg',
|
||||||
|
showGrid = false,
|
||||||
|
gradient = false,
|
||||||
|
class: className,
|
||||||
|
...attrs
|
||||||
|
} = Astro.props;
|
||||||
|
|
||||||
|
// Check which slots are provided
|
||||||
|
const hasBadgeSlot = Astro.slots.has('badge');
|
||||||
|
const hasTitleSlot = Astro.slots.has('title');
|
||||||
|
const hasDescriptionSlot = Astro.slots.has('description');
|
||||||
|
const hasActionsSlot = Astro.slots.has('actions');
|
||||||
|
const hasAsideSlot = Astro.slots.has('aside');
|
||||||
|
|
||||||
|
// Compute alignment based on layout
|
||||||
|
const alignment = layout === 'centered' ? 'text-center items-center' : 'text-left items-start';
|
||||||
|
|
||||||
|
// Compute classes
|
||||||
|
const sectionClasses = cn(heroSectionVariants({ size }), gradient && 'hero-dark-gradient', className);
|
||||||
|
|
||||||
|
const contentClasses = cn(
|
||||||
|
'z-10 flex flex-col',
|
||||||
|
alignment
|
||||||
|
);
|
||||||
|
|
||||||
|
const gridClasses = cn(
|
||||||
|
'mx-auto grid max-w-6xl grid-cols-1 items-center gap-[var(--space-section-gap)] px-6',
|
||||||
|
layout === 'split' && 'lg:grid-cols-2 lg:gap-[var(--space-section-gap)]'
|
||||||
|
);
|
||||||
|
---
|
||||||
|
|
||||||
|
<section class={sectionClasses} {...attrs}>
|
||||||
|
{showGrid && (
|
||||||
|
<div
|
||||||
|
class="bg-grid-pattern pointer-events-none absolute inset-x-0 -inset-y-[20%] opacity-30"
|
||||||
|
style="mask-image: radial-gradient(ellipse 50% 50% at 50% 40%, black 0%, transparent 70%);"
|
||||||
|
data-parallax="0.2"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div class={gridClasses}>
|
||||||
|
{/* Content Column */}
|
||||||
|
<div class={cn(contentClasses, 'order-1 lg:order-none animate-hero-slide-up')}>
|
||||||
|
{/* Badge Slot */}
|
||||||
|
{hasBadgeSlot && (
|
||||||
|
<div class="mb-[var(--space-heading-gap)]">
|
||||||
|
<slot name="badge" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Title Slot */}
|
||||||
|
{hasTitleSlot && (
|
||||||
|
<div class={cn(
|
||||||
|
'hero-title-slot mb-[var(--space-heading-gap)]',
|
||||||
|
'[&>h1]:font-display [&>h1]:text-5xl [&>h1]:leading-[1.1] [&>h1]:font-bold [&>h1]:tracking-tight [&>h1]:text-balance [&>h1]:text-foreground md:[&>h1]:text-6xl lg:[&>h1]:text-7xl',
|
||||||
|
'[&>h2]:font-display [&>h2]:text-4xl [&>h2]:leading-[1.1] [&>h2]:font-bold [&>h2]:tracking-tight [&>h2]:text-balance [&>h2]:text-foreground md:[&>h2]:text-5xl lg:[&>h2]:text-6xl'
|
||||||
|
)}>
|
||||||
|
<slot name="title" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Description Slot */}
|
||||||
|
{hasDescriptionSlot && (
|
||||||
|
<div class={cn(
|
||||||
|
'mb-[var(--space-stack-lg)] max-w-xl text-lg leading-relaxed text-foreground-muted',
|
||||||
|
'[&>p]:text-lg [&>p]:leading-relaxed [&>p]:text-foreground-muted',
|
||||||
|
layout === 'centered' && 'mx-auto'
|
||||||
|
)}>
|
||||||
|
<slot name="description" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions Slot */}
|
||||||
|
{hasActionsSlot && (
|
||||||
|
<div class={cn(
|
||||||
|
'flex w-full flex-col gap-4 sm:w-auto sm:flex-row',
|
||||||
|
layout === 'centered' && 'justify-center'
|
||||||
|
)}>
|
||||||
|
<slot name="actions" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Default slot for additional content (social proof, etc.) */}
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Aside Column (for split layout) */}
|
||||||
|
{layout === 'split' && hasAsideSlot && (
|
||||||
|
<div class="relative z-10 w-full order-2 lg:order-none">
|
||||||
|
<slot name="aside" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Parallax: move [data-parallax] layers at a fraction of scroll speed.
|
||||||
|
// Elements with -inset-y-[20%] have extra room to move without clipping.
|
||||||
|
// Skipped entirely when the user prefers reduced motion.
|
||||||
|
if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||||
|
const layers = document.querySelectorAll<HTMLElement>('[data-parallax]');
|
||||||
|
|
||||||
|
function applyParallax() {
|
||||||
|
layers.forEach((el) => {
|
||||||
|
const section = el.closest('section');
|
||||||
|
if (!section) return;
|
||||||
|
|
||||||
|
const rect = section.getBoundingClientRect();
|
||||||
|
// Skip sections fully outside the viewport
|
||||||
|
if (rect.bottom < 0 || rect.top > window.innerHeight) return;
|
||||||
|
|
||||||
|
const speed = parseFloat(el.dataset.parallax ?? '0.3');
|
||||||
|
// Offset relative to how far the section top is from the viewport top
|
||||||
|
const offset = -rect.top * speed;
|
||||||
|
el.style.transform = `translateY(${offset.toFixed(2)}px)`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('scroll', applyParallax, { passive: true });
|
||||||
|
applyParallax();
|
||||||
|
|
||||||
|
// Clean up the listener when navigating away to prevent accumulation
|
||||||
|
document.addEventListener('astro:before-swap', () => {
|
||||||
|
window.removeEventListener('scroll', applyParallax);
|
||||||
|
}, { once: true });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
|
||||||
|
export const heroSectionVariants = cva('relative overflow-hidden bg-background', {
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
sm: 'pt-[var(--space-page-top-sm)] pb-[var(--space-section-sm)]',
|
||||||
|
md: 'pt-[var(--space-page-top)] pb-[var(--space-section-md)]',
|
||||||
|
lg: 'pt-[calc(var(--space-page-top)_+_var(--space-8))] pb-[var(--space-section-lg)]',
|
||||||
|
xl: 'pt-[calc(var(--space-page-top)_+_var(--space-16))] pb-[var(--space-section-xl)]',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
size: 'lg',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type HeroSectionVariants = VariantProps<typeof heroSectionVariants>;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { default as Hero } from './Hero.astro';
|
||||||
|
export { heroSectionVariants } from './hero.variants';
|
||||||
|
export type { HeroSectionVariants } from './hero.variants';
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
---
|
||||||
|
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 { Image } from 'astro:assets';
|
||||||
|
import aboutPhoto from '@/assets/about_photo.jpg';
|
||||||
|
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">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- Team -->
|
||||||
|
<section class="bg-white dark:bg-gray-900 border-t border-border">
|
||||||
|
<div class="grid gap-16 py-8 px-4 mx-auto max-w-screen-xl lg:grid-cols-2 lg:py-16 lg:px-6">
|
||||||
|
<div class="text-foreground-muted sm:text-lg">
|
||||||
|
<h2 class="mb-4 text-4xl tracking-tight font-extrabold text-foreground dark:text-white">{t('about.team.title')}</h2>
|
||||||
|
<p class="mb-2 md:text-lg">{t('about.team.desc1')}</p>
|
||||||
|
<p class="font-light md:text-lg">{t('about.team.desc2')}</p>
|
||||||
|
</div>
|
||||||
|
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<div class="flex flex-col items-center pb-8 sm:flex-row">
|
||||||
|
<Image src={aboutPhoto} alt="Daniel Krähenbühl" class="mx-auto mb-4 w-36 h-36 rounded-full object-cover sm:ml-0 sm:mr-6" />
|
||||||
|
<div class="text-center sm:text-left">
|
||||||
|
<h3 class="text-xl font-bold tracking-tight text-foreground dark:text-white">Daniel Krähenbühl</h3>
|
||||||
|
<span class="text-foreground-muted dark:text-gray-400">{t('about.founder.role')}</span>
|
||||||
|
<p class="mt-3 mb-4 max-w-sm font-light text-foreground-muted dark:text-gray-400">{t('about.founder.bio')}</p>
|
||||||
|
<ul class="flex justify-center space-x-4 sm:justify-start">
|
||||||
|
<li>
|
||||||
|
<a href="https://www.linkedin.com/company/armarium-suite" target="_blank" rel="noopener noreferrer" aria-label="LinkedIn" class="text-foreground-muted hover:text-foreground dark:hover:text-white">
|
||||||
|
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.79-1.75-1.764s.784-1.764 1.75-1.764 1.75.79 1.75 1.764-.783 1.764-1.75 1.764zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z"/></svg>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- FAQ -->
|
||||||
|
<section class="bg-white dark:bg-gray-900 border-t border-border">
|
||||||
|
<div class="py-8 px-4 mx-auto max-w-screen-xl sm:py-16 lg:px-6">
|
||||||
|
<h2 class="mb-6 lg:mb-8 text-3xl lg:text-4xl tracking-tight font-extrabold text-center text-foreground dark:text-white">{t('about.faq.title')}</h2>
|
||||||
|
<div class="mx-auto max-w-screen-md divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{([1,2,3,4] as const).map((n) => (
|
||||||
|
<details class="group">
|
||||||
|
<summary class="flex justify-between items-center py-5 w-full font-medium text-left text-foreground cursor-pointer list-none">
|
||||||
|
<span>{t(`about.faq.q${n}` as any)}</span>
|
||||||
|
<svg class="w-6 h-6 shrink-0 transition-transform group-open:rotate-180" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>
|
||||||
|
</summary>
|
||||||
|
<div class="py-5 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<p class="text-foreground-muted dark:text-gray-400">{t(`about.faq.a${n}` as any)}</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
))}
|
||||||
|
</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="https://app.armarium.ch/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,94 @@
|
|||||||
|
---
|
||||||
|
import PageLayout from '@/layouts/PageLayout.astro';
|
||||||
|
import { Hero } from '@/components/hero';
|
||||||
|
import Button from '@/components/ui/form/Button/Button.astro';
|
||||||
|
import Icon from '@/components/ui/primitives/Icon/Icon.astro';
|
||||||
|
import BlogImageSVG from '@/components/blog/BlogImageSVG.astro';
|
||||||
|
import { Image } from 'astro:assets';
|
||||||
|
import { getCollection } from 'astro:content';
|
||||||
|
import { formatDate } 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 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 getPostUrl = (postId: string) => {
|
||||||
|
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">
|
||||||
|
<h1 slot="title">{t('blog.title')}</h1>
|
||||||
|
<p slot="description">{t('blog.desc')}</p>
|
||||||
|
</Hero>
|
||||||
|
|
||||||
|
<section class="bg-white dark:bg-gray-900 border-t border-border">
|
||||||
|
<div class="py-8 px-4 mx-auto max-w-screen-xl lg:py-16 lg:px-6">
|
||||||
|
<div class="grid gap-8 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{posts.map((post) => (
|
||||||
|
<article class="p-4 bg-white rounded-lg border border-gray-200 shadow-md dark:bg-gray-800 dark:border-gray-700">
|
||||||
|
<a href={getPostUrl(post.id)} class="block mb-5 rounded-lg overflow-hidden">
|
||||||
|
{post.data.svgSlug ? (
|
||||||
|
<BlogImageSVG slug={post.data.svgSlug} title={post.data.title} />
|
||||||
|
) : post.data.image ? (
|
||||||
|
<Image src={post.data.image} alt={post.data.imageAlt ?? post.data.title} class="w-full h-48 object-cover rounded-lg" />
|
||||||
|
) : (
|
||||||
|
<div class="w-full h-48 bg-gradient-to-br from-brand-500/20 to-brand-500/5 rounded-lg" />
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
{post.data.tags[0] && (
|
||||||
|
<span class="bg-brand-500/10 text-brand-500 text-xs font-semibold mr-2 px-2.5 py-0.5 rounded">
|
||||||
|
{post.data.tags[0]}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<h2 class="my-2 text-2xl font-bold tracking-tight text-foreground dark:text-white">
|
||||||
|
<a href={getPostUrl(post.id)} class="hover:text-brand-500 transition-colors">{post.data.title}</a>
|
||||||
|
</h2>
|
||||||
|
<p class="mb-4 font-light text-foreground-muted dark:text-gray-400">{post.data.description}</p>
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<div class="w-10 h-10 rounded-full bg-brand-500/15 flex items-center justify-center text-brand-500 font-bold text-sm shrink-0">
|
||||||
|
{post.data.author?.charAt(0) ?? 'A'}
|
||||||
|
</div>
|
||||||
|
<div class="font-medium text-foreground dark:text-white">
|
||||||
|
<div>{post.data.author ?? 'Team'}</div>
|
||||||
|
<div class="text-sm font-normal text-foreground-muted dark:text-gray-400">{formatDate(post.data.publishedAt)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</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('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="https://app.armarium.ch/register">
|
||||||
|
{t('cta.register')}
|
||||||
|
<Icon name="arrow-right" size="sm" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</PageLayout>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
import CTASection from '@/components/ui/marketing/CTA/CTA.astro';
|
||||||
|
import Button from '@/components/ui/form/Button/Button.astro';
|
||||||
|
import Icon from '@/components/ui/primitives/Icon/Icon.astro';
|
||||||
|
import Logo from '@/components/ui/marketing/Logo/Logo.astro';
|
||||||
|
import NpmCopyButton from '@/components/ui/marketing/NpmCopyButton/NpmCopyButton.astro';
|
||||||
|
---
|
||||||
|
|
||||||
|
<CTASection id="cta" variant="default" size="xl" maxWidth="lg">
|
||||||
|
<Logo slot="logo" size="2xl" class="mx-auto" />
|
||||||
|
|
||||||
|
<h2 slot="heading">
|
||||||
|
Stop configuring. <span class="text-brand-500">Start building.</span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p slot="description">
|
||||||
|
Join the developers building faster, better websites with Astro Rocket. Open source and free forever.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Fragment slot="actions">
|
||||||
|
<NpmCopyButton command="npm create astro@latest" />
|
||||||
|
<Button variant="outline" size="lg" href="https://github.com/hansmartens68/Astro-Rocket#readme" target="_blank">
|
||||||
|
<Icon name="book" size="md" />
|
||||||
|
View docs
|
||||||
|
</Button>
|
||||||
|
</Fragment>
|
||||||
|
</CTASection>
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
---
|
||||||
|
import PageLayout from '@/layouts/PageLayout.astro';
|
||||||
|
import { Hero } from '@/components/hero';
|
||||||
|
import siteConfig from '@/config/site.config';
|
||||||
|
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('contact.badge')} — Armarium`}
|
||||||
|
description={t('contact.desc')}
|
||||||
|
locale={locale}
|
||||||
|
>
|
||||||
|
<Hero layout="centered" size="sm">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<section class="bg-white dark:bg-gray-900 border-t border-border">
|
||||||
|
<div class="py-16 px-4 mx-auto max-w-screen-xl sm:py-24 lg:px-6">
|
||||||
|
<form action="#" class="grid grid-cols-1 gap-8 p-6 mx-auto mb-16 max-w-screen-md bg-white rounded-lg border border-gray-200 shadow-sm lg:mb-28 dark:bg-gray-800 dark:border-gray-700 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label for="first-name" class="block mb-2 text-sm font-medium text-foreground dark:text-gray-300">First Name</label>
|
||||||
|
<input type="text" id="first-name" class="block p-3 w-full text-sm text-foreground bg-gray-50 rounded-lg border border-gray-300 shadow-sm focus:ring-brand-500 focus:border-brand-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white" placeholder="Bonnie" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="last-name" class="block mb-2 text-sm font-medium text-foreground dark:text-gray-300">Last Name</label>
|
||||||
|
<input type="text" id="last-name" class="block p-3 w-full text-sm text-foreground bg-gray-50 rounded-lg border border-gray-300 shadow-sm focus:ring-brand-500 focus:border-brand-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white" placeholder="Green" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="email" class="block mb-2 text-sm font-medium text-foreground dark:text-gray-300">Your email</label>
|
||||||
|
<input type="email" id="email" class="shadow-sm bg-gray-50 border border-gray-300 text-foreground text-sm rounded-lg focus:ring-brand-500 focus:border-brand-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white" placeholder={`name@${siteConfig.url?.replace(/^https?:\/\//, '') ?? 'example.com'}`} required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="phone-number" class="block mb-2 text-sm font-medium text-foreground dark:text-gray-300">Phone Number</label>
|
||||||
|
<input type="tel" id="phone-number" class="block p-3 w-full text-sm text-foreground bg-gray-50 rounded-lg border border-gray-300 shadow-sm focus:ring-brand-500 focus:border-brand-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white" placeholder="+41 79 123 45 67">
|
||||||
|
</div>
|
||||||
|
<div class="sm:col-span-2">
|
||||||
|
<label for="message" class="block mb-2 text-sm font-medium text-foreground dark:text-gray-400">Your message</label>
|
||||||
|
<textarea id="message" rows="6" class="block p-2.5 w-full text-sm text-foreground bg-gray-50 rounded-lg shadow-sm border border-gray-300 focus:ring-brand-500 focus:border-brand-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white" placeholder="Leave a comment..."></textarea>
|
||||||
|
<p class="mt-4 text-sm text-foreground-muted">By submitting this form you agree to our <a href={t('footer.privacy.href')} class="text-brand-500 hover:underline">terms and conditions</a> and our <a href={t('footer.privacy.href')} class="text-brand-500 hover:underline">privacy policy</a>.</p>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="py-3 px-5 text-sm font-medium text-center text-white rounded-lg bg-brand-500 sm:w-fit hover:bg-brand-600 focus:ring-4 focus:outline-none focus:ring-brand-300 transition-colors">Send message</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="flex justify-center items-center mx-auto mb-4 w-10 h-10 bg-gray-100 rounded-lg dark:bg-gray-800 lg:h-16 lg:w-16">
|
||||||
|
<svg class="w-5 h-5 text-foreground-muted lg:w-8 lg:h-8" fill="currentColor" viewBox="0 0 20 20"><path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z"/><path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z"/></svg>
|
||||||
|
</div>
|
||||||
|
<p class="mb-2 text-xl font-bold text-foreground dark:text-white">Email us:</p>
|
||||||
|
<a href="mailto:info@armarium.ch" class="font-semibold text-brand-500 hover:underline">info@armarium.ch</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</PageLayout>
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
---
|
||||||
|
import Icon from '@/components/ui/primitives/Icon/Icon.astro';
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{ cmd: 'git clone https://github.com/hansmartens68/Astro-Rocket.git', desc: 'Clone the repository' },
|
||||||
|
{ cmd: 'cd Astro-Rocket && pnpm install', desc: 'Install dependencies' },
|
||||||
|
{ cmd: 'pnpm dev', desc: 'Start dev server on localhost:4321' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{ value: '3', label: 'steps to start' },
|
||||||
|
{ value: '57+', label: 'components included' },
|
||||||
|
{ value: '40+', label: 'pages & layouts' },
|
||||||
|
];
|
||||||
|
---
|
||||||
|
|
||||||
|
<section id="architecture" class="invert-section bg-background py-[var(--space-section-md)]">
|
||||||
|
<div class="mx-auto grid max-w-6xl grid-cols-1 items-center gap-[var(--space-section-gap)] px-6 lg:grid-cols-2 lg:gap-[var(--space-section-gap)]">
|
||||||
|
<!-- Left Column - Content -->
|
||||||
|
<div>
|
||||||
|
<!-- Badge -->
|
||||||
|
<div
|
||||||
|
class="text-brand-500 mb-4 flex items-center gap-2 text-sm font-bold tracking-wide uppercase"
|
||||||
|
>
|
||||||
|
<Icon name="terminal" size="sm" />
|
||||||
|
<span>Up and running fast</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="font-display mb-[var(--space-heading-gap)] text-3xl font-bold text-balance md:text-4xl">
|
||||||
|
Clone, install, <span class="text-brand-500">ship.</span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="text-foreground-secondary space-y-4 text-lg leading-relaxed">
|
||||||
|
<p>
|
||||||
|
Astro Rocket is a ready-to-go starter — no CLI wizard, no configuration maze. Clone the repo and you have a full production-grade site in minutes.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Swap out the content, adjust the design tokens, and deploy. Everything is already wired: routing, components, blog, i18n support, dark mode, and SEO.
|
||||||
|
</p>
|
||||||
|
<p class="text-foreground-muted">
|
||||||
|
Open source. No licence fees. No hidden dependencies.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats -->
|
||||||
|
<div class="border-border mt-[var(--space-stack-lg)] grid grid-cols-3 gap-[var(--space-stack-lg)] border-t pt-[var(--space-stack-lg)]">
|
||||||
|
{
|
||||||
|
stats.map((stat) => (
|
||||||
|
<div>
|
||||||
|
<div class="font-display text-foreground text-3xl font-bold">{stat.value}</div>
|
||||||
|
<div class="text-foreground-muted text-sm">{stat.label}</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Column - Terminal Card -->
|
||||||
|
<div class="relative">
|
||||||
|
<div
|
||||||
|
class="bg-card border-border overflow-hidden rounded-lg border shadow-xl"
|
||||||
|
>
|
||||||
|
<!-- Terminal Header -->
|
||||||
|
<div
|
||||||
|
class="bg-secondary border-border flex items-center gap-2 border-b px-4 py-3"
|
||||||
|
>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<span class="h-3 w-3 rounded-full" style="background-color: var(--error);"></span>
|
||||||
|
<span class="h-3 w-3 rounded-full" style="background-color: var(--warning);"></span>
|
||||||
|
<span class="h-3 w-3 rounded-full" style="background-color: var(--success);"></span>
|
||||||
|
</div>
|
||||||
|
<span class="text-foreground-muted ml-2 font-mono text-xs">terminal</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Terminal Body -->
|
||||||
|
<div class="p-5 font-mono text-sm leading-relaxed">
|
||||||
|
{
|
||||||
|
steps.map((step, i) => (
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="text-foreground-muted mb-1 text-xs">#{i + 1} {step.desc}</div>
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<span class="shrink-0 text-green-400 select-none">$</span>
|
||||||
|
<span class="text-cyan-400 break-all">{step.cmd}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Output -->
|
||||||
|
<div class="border-border mt-2 border-t pt-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-green-400">✓</span>
|
||||||
|
<span class="text-foreground">Ready at <span class="text-cyan-400">localhost:4321</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Decorative glow -->
|
||||||
|
<div
|
||||||
|
class="bg-brand-500/10 pointer-events-none absolute top-0 right-0 h-32 w-32 rounded-full blur-3xl"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,496 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Palette, Search, Zap, LayoutGrid, Globe, Copy, Check, Newspaper } from 'lucide-react';
|
||||||
|
import { VerticalTabs, type VerticalTab } from '@/components/ui/overlay/VerticalTabs';
|
||||||
|
|
||||||
|
interface TabContent {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabContent: Record<string, TabContent> = {
|
||||||
|
theming: {
|
||||||
|
title: 'Design Tokens & Dark Mode',
|
||||||
|
content:
|
||||||
|
"Complete design system using Tailwind v4's CSS-first configuration with built-in dark mode. Semantic color tokens, system preference detection, and localStorage persistence.",
|
||||||
|
},
|
||||||
|
seo: {
|
||||||
|
title: 'Automated SEO Handling',
|
||||||
|
content:
|
||||||
|
'Strictly typed metadata injection for every page with automatic OG image generation. Includes sitemap, robots.txt, and JSON-LD structured data.',
|
||||||
|
},
|
||||||
|
perf: {
|
||||||
|
title: 'Zero JS by Default',
|
||||||
|
content:
|
||||||
|
"Astro's island architecture ensures your pages ship 0kb of JavaScript unless explicitly interactive. Optimized for Core Web Vitals.",
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
title: 'Type-Safe Components',
|
||||||
|
content:
|
||||||
|
'TypeScript-first UI primitives with full prop validation and IDE autocompletion. Includes buttons, inputs, cards, modals, and more.',
|
||||||
|
},
|
||||||
|
i18n: {
|
||||||
|
title: 'i18n Ready',
|
||||||
|
content:
|
||||||
|
'Add multi-language support with the --i18n flag. Includes type-safe translations, automatic locale detection, and SEO-friendly URL structures.',
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
title: 'Content & Search',
|
||||||
|
content:
|
||||||
|
'Type-safe content collections with Zod schemas, MDX support, RSS feeds, and Pagefind integration for lightning-fast static search.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const codeExamples: Record<
|
||||||
|
string,
|
||||||
|
{ code: string; filename: string; lang: 'css' | 'astro' | 'typescript' | 'javascript' }
|
||||||
|
> = {
|
||||||
|
theming: {
|
||||||
|
lang: 'css',
|
||||||
|
code: `/* src/styles/themes/default.css — swap this file to re-theme */
|
||||||
|
:root {
|
||||||
|
/* Semantic Tokens - Light Mode */
|
||||||
|
--background: var(--gray-0);
|
||||||
|
--foreground: var(--gray-900);
|
||||||
|
--border: var(--gray-200);
|
||||||
|
--primary: var(--gray-900);
|
||||||
|
--primary-foreground: var(--gray-0);
|
||||||
|
--accent: var(--brand-500);
|
||||||
|
--card: var(--gray-0);
|
||||||
|
--ring: var(--gray-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark Mode */
|
||||||
|
.dark {
|
||||||
|
--background: var(--gray-950);
|
||||||
|
--foreground: var(--gray-50);
|
||||||
|
--border: var(--gray-800);
|
||||||
|
--primary: var(--gray-0);
|
||||||
|
--primary-foreground: var(--gray-900);
|
||||||
|
}`,
|
||||||
|
filename: 'src/styles/themes/default.css',
|
||||||
|
},
|
||||||
|
seo: {
|
||||||
|
lang: 'astro',
|
||||||
|
code: `---
|
||||||
|
// src/components/seo/SEO.astro
|
||||||
|
import siteConfig from '@/config/site.config';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
image?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, description, image } = Astro.props;
|
||||||
|
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
|
||||||
|
|
||||||
|
// Auto-generate OG image if none provided
|
||||||
|
const ogImage = image || \`/og/\${Astro.url.pathname}.png\`;
|
||||||
|
---
|
||||||
|
|
||||||
|
<title>{title}</title>
|
||||||
|
<meta name="description" content={description} />
|
||||||
|
<link rel="canonical" href={canonicalURL.toString()} />
|
||||||
|
<meta property="og:title" content={title} />
|
||||||
|
<meta property="og:image" content={ogImage} />`,
|
||||||
|
filename: 'src/components/seo/SEO.astro',
|
||||||
|
},
|
||||||
|
perf: {
|
||||||
|
lang: 'astro',
|
||||||
|
code: `---
|
||||||
|
// src/pages/index.astro
|
||||||
|
import LandingLayout from '@/layouts/LandingLayout.astro';
|
||||||
|
import { Hero } from '@/components/hero';
|
||||||
|
import { TerminalDemo } from '@/components/ui/marketing/TerminalDemo';
|
||||||
|
import FeatureTabs from '@/components/landing/FeatureTabs.tsx';
|
||||||
|
import TechStack from '@/components/landing/TechStack.astro';
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- Static Astro components - ships 0kb JS -->
|
||||||
|
<Hero layout="split" size="lg">
|
||||||
|
<!-- React component - hydrates immediately -->
|
||||||
|
<TerminalDemo slot="aside" client:load />
|
||||||
|
</Hero>
|
||||||
|
|
||||||
|
<!-- Static HTML, no JS -->
|
||||||
|
<TechStack />
|
||||||
|
|
||||||
|
<!-- React component - hydrates when scrolled into view -->
|
||||||
|
<FeatureTabs client:visible />`,
|
||||||
|
filename: 'src/pages/index.astro',
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
lang: 'typescript',
|
||||||
|
code: `// src/components/ui/form/Button/Button.tsx
|
||||||
|
import { type Ref } from 'react';
|
||||||
|
import { cn } from '@/lib/cn';
|
||||||
|
import { isExternalUrl } from '@/lib/utils';
|
||||||
|
import { buttonVariants, type ButtonVariants } from './button.variants';
|
||||||
|
|
||||||
|
interface BaseProps {
|
||||||
|
ref?: Ref<HTMLButtonElement | HTMLAnchorElement>;
|
||||||
|
variant?: ButtonVariants['variant'];
|
||||||
|
size?: ButtonVariants['size'];
|
||||||
|
loading?: boolean;
|
||||||
|
href?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Button({ ref, variant = 'primary', size = 'md', href, ...rest }: BaseProps) {
|
||||||
|
const classes = cn(buttonVariants({ variant, size }), rest.className);
|
||||||
|
const isExternal = href ? isExternalUrl(href) : false;
|
||||||
|
|
||||||
|
if (href) {
|
||||||
|
return <a ref={ref} href={href} className={classes} target={isExternal ? '_blank' : undefined} />;
|
||||||
|
}
|
||||||
|
return <button ref={ref} className={classes} {...rest} />;
|
||||||
|
}`,
|
||||||
|
filename: 'src/components/ui/form/Button/Button.tsx',
|
||||||
|
},
|
||||||
|
i18n: {
|
||||||
|
lang: 'typescript',
|
||||||
|
code: `// src/i18n/config.ts (with --i18n flag)
|
||||||
|
export const languages = {
|
||||||
|
en: 'English',
|
||||||
|
es: 'Español',
|
||||||
|
fr: 'Français',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const defaultLang = 'en';
|
||||||
|
|
||||||
|
// src/i18n/translations/en.ts
|
||||||
|
export default {
|
||||||
|
'nav.home': 'Home',
|
||||||
|
'nav.about': 'About',
|
||||||
|
'hero.title': 'Ship faster with Astro Rocket',
|
||||||
|
'hero.subtitle': 'The modern Astro starter',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Usage in components
|
||||||
|
import { t } from '@/i18n';
|
||||||
|
const title = t('hero.title'); // "Ship faster..."`,
|
||||||
|
filename: 'src/i18n/config.ts',
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
lang: 'typescript',
|
||||||
|
code: `// src/content.config.ts
|
||||||
|
import { defineCollection, z } from 'astro:content';
|
||||||
|
import { glob } from 'astro/loaders';
|
||||||
|
|
||||||
|
const blog = defineCollection({
|
||||||
|
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/blog' }),
|
||||||
|
schema: ({ image }) =>
|
||||||
|
z.object({
|
||||||
|
title: z.string().max(100),
|
||||||
|
description: z.string().max(200),
|
||||||
|
publishedAt: z.coerce.date(),
|
||||||
|
updatedAt: z.coerce.date().optional(),
|
||||||
|
author: z.string().default('Team'),
|
||||||
|
image: image().optional(),
|
||||||
|
tags: z.array(z.string()).default([]),
|
||||||
|
featured: z.boolean().default(false),
|
||||||
|
draft: z.boolean().default(false),
|
||||||
|
locale: z.enum(['en', 'es', 'fr']).default('en'),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const collections = { blog, pages, authors, faqs };
|
||||||
|
// + Pagefind indexes all content at build time`,
|
||||||
|
filename: 'src/content.config.ts',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Simple syntax highlighter
|
||||||
|
function highlightCode(code: string, lang: string): React.ReactNode[] {
|
||||||
|
const lines = code.split('\n');
|
||||||
|
|
||||||
|
return lines.map((line, lineIndex) => {
|
||||||
|
const tokens: React.ReactNode[] = [];
|
||||||
|
let remaining = line;
|
||||||
|
let keyIndex = 0;
|
||||||
|
|
||||||
|
const addToken = (text: string, className?: string) => {
|
||||||
|
if (text) {
|
||||||
|
tokens.push(
|
||||||
|
<span key={`${lineIndex}-${keyIndex++}`} className={className}>
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Process the line character by character with regex patterns
|
||||||
|
while (remaining.length > 0) {
|
||||||
|
let matched = false;
|
||||||
|
|
||||||
|
// Comments (// and /* */)
|
||||||
|
const commentMatch = remaining.match(/^(\/\/.*|\/\*[\s\S]*?\*\/)/);
|
||||||
|
if (commentMatch) {
|
||||||
|
addToken(commentMatch[0], 'text-foreground-muted italic');
|
||||||
|
remaining = remaining.slice(commentMatch[0].length);
|
||||||
|
matched = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTML comments
|
||||||
|
const htmlCommentMatch = remaining.match(/^(<!--[\s\S]*?-->)/);
|
||||||
|
if (htmlCommentMatch) {
|
||||||
|
addToken(htmlCommentMatch[0], 'text-foreground-muted italic');
|
||||||
|
remaining = remaining.slice(htmlCommentMatch[0].length);
|
||||||
|
matched = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strings (single, double, template)
|
||||||
|
const stringMatch = remaining.match(/^(['"`])(?:(?!\1)[^\\]|\\.)*\1/);
|
||||||
|
if (stringMatch) {
|
||||||
|
addToken(stringMatch[0], 'text-green-600 dark:text-green-400');
|
||||||
|
remaining = remaining.slice(stringMatch[0].length);
|
||||||
|
matched = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Astro frontmatter delimiters
|
||||||
|
if (remaining.startsWith('---')) {
|
||||||
|
addToken('---', 'text-purple-600 dark:text-purple-400 font-semibold');
|
||||||
|
remaining = remaining.slice(3);
|
||||||
|
matched = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTML/JSX tags
|
||||||
|
const tagMatch = remaining.match(/^(<\/?[\w-]+|>|\/>)/);
|
||||||
|
if (tagMatch) {
|
||||||
|
addToken(tagMatch[0], 'text-pink-600 dark:text-pink-400');
|
||||||
|
remaining = remaining.slice(tagMatch[0].length);
|
||||||
|
matched = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSS at-rules (@theme, @import, etc.)
|
||||||
|
const atRuleMatch = remaining.match(/^(@[\w-]+)/);
|
||||||
|
if (atRuleMatch) {
|
||||||
|
addToken(atRuleMatch[0], 'text-purple-600 dark:text-purple-400 font-semibold');
|
||||||
|
remaining = remaining.slice(atRuleMatch[0].length);
|
||||||
|
matched = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keywords
|
||||||
|
const keywordMatch = remaining.match(
|
||||||
|
/^(const|let|var|function|return|import|export|from|interface|type|class|extends|implements|new|async|await|if|else|for|while|switch|case|break|default|try|catch|finally|throw|typeof|instanceof|in|of|as|readonly|public|private|protected)\b/
|
||||||
|
);
|
||||||
|
if (keywordMatch) {
|
||||||
|
addToken(keywordMatch[0], 'text-purple-600 dark:text-purple-400 font-semibold');
|
||||||
|
remaining = remaining.slice(keywordMatch[0].length);
|
||||||
|
matched = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boolean/null
|
||||||
|
const boolMatch = remaining.match(/^(true|false|null|undefined)\b/);
|
||||||
|
if (boolMatch) {
|
||||||
|
addToken(boolMatch[0], 'text-orange-700 dark:text-orange-300');
|
||||||
|
remaining = remaining.slice(boolMatch[0].length);
|
||||||
|
matched = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Numbers
|
||||||
|
const numberMatch = remaining.match(/^(\d+\.?\d*)/);
|
||||||
|
if (numberMatch) {
|
||||||
|
addToken(numberMatch[0], 'text-orange-700 dark:text-orange-300');
|
||||||
|
remaining = remaining.slice(numberMatch[0].length);
|
||||||
|
matched = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSS properties (word followed by colon)
|
||||||
|
const cssPropMatch = remaining.match(/^([\w-]+)(:)/);
|
||||||
|
if (cssPropMatch && (lang === 'css' || line.includes('{'))) {
|
||||||
|
addToken(cssPropMatch[1], 'text-blue-600 dark:text-blue-400');
|
||||||
|
addToken(cssPropMatch[2], 'text-foreground-secondary');
|
||||||
|
remaining = remaining.slice(cssPropMatch[0].length);
|
||||||
|
matched = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSS functions (var, oklch, etc.)
|
||||||
|
const cssFuncMatch = remaining.match(
|
||||||
|
/^(var|oklch|rgb|rgba|hsl|hsla|calc|url|clamp|min|max)(\()/
|
||||||
|
);
|
||||||
|
if (cssFuncMatch) {
|
||||||
|
addToken(cssFuncMatch[1], 'text-amber-700 dark:text-amber-300');
|
||||||
|
addToken(cssFuncMatch[2], 'text-foreground-secondary');
|
||||||
|
remaining = remaining.slice(cssFuncMatch[0].length);
|
||||||
|
matched = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function calls
|
||||||
|
const funcMatch = remaining.match(/^([\w]+)(\()/);
|
||||||
|
if (funcMatch) {
|
||||||
|
addToken(funcMatch[1], 'text-amber-700 dark:text-amber-300');
|
||||||
|
addToken(funcMatch[2], 'text-foreground-secondary');
|
||||||
|
remaining = remaining.slice(funcMatch[0].length);
|
||||||
|
matched = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type annotations after colon
|
||||||
|
const typeMatch = remaining.match(/^(:\s*)([\w<>[\]|&]+)/);
|
||||||
|
if (typeMatch) {
|
||||||
|
addToken(typeMatch[1], 'text-foreground-secondary');
|
||||||
|
addToken(typeMatch[2], 'text-cyan-700 dark:text-cyan-300');
|
||||||
|
remaining = remaining.slice(typeMatch[0].length);
|
||||||
|
matched = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: single character
|
||||||
|
if (!matched) {
|
||||||
|
addToken(remaining[0], 'text-foreground-secondary');
|
||||||
|
remaining = remaining.slice(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokens.length > 0 ? tokens : [<span key={lineIndex}> </span>];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function CodeBlock({ code, filename, lang }: { code: string; filename: string; lang: string }) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const highlightedLines = highlightCode(code.trim(), lang);
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
await navigator.clipboard.writeText(code);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="group border-border bg-background-secondary relative w-full overflow-hidden rounded-md border font-mono text-xs shadow-sm">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="border-border bg-background flex items-center justify-between border-b px-4 py-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<div className="bg-border-strong h-2 w-2 rounded-full" />
|
||||||
|
<div className="bg-border-strong h-2 w-2 rounded-full" />
|
||||||
|
<div className="bg-border-strong h-2 w-2 rounded-full" />
|
||||||
|
</div>
|
||||||
|
<span className="text-foreground-muted font-sans text-[10px] font-medium">
|
||||||
|
{filename}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="text-foreground-muted hover:bg-secondary hover:text-foreground flex items-center gap-1.5 rounded px-2 py-0.5 text-[10px] font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<>
|
||||||
|
<Check className="h-3 w-3 text-green-600" strokeWidth={2} />
|
||||||
|
<span className="text-green-600">Copied</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Copy className="h-3 w-3" strokeWidth={2} />
|
||||||
|
<span>Copy</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Code Area */}
|
||||||
|
<div className="bg-background overflow-x-auto p-3">
|
||||||
|
<pre className="flex flex-col leading-5">
|
||||||
|
{highlightedLines.map((lineTokens, i) => (
|
||||||
|
<div key={i} className="table-row">
|
||||||
|
<span className="text-foreground-subtle table-cell w-6 pr-3 text-right text-[10px] select-none">
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
<span className="table-cell whitespace-pre">{lineTokens}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab definitions with icons for VerticalTabs
|
||||||
|
const tabs: VerticalTab[] = [
|
||||||
|
{ id: 'theming', label: 'Theming', description: 'Design tokens & dark mode', icon: Palette },
|
||||||
|
{ id: 'seo', label: 'SEO & Meta', description: 'OG images & structured data', icon: Search },
|
||||||
|
{ id: 'perf', label: 'Performance', description: 'Zero JS by default', icon: Zap },
|
||||||
|
{
|
||||||
|
id: 'components',
|
||||||
|
label: 'Components',
|
||||||
|
description: 'Type-safe UI primitives',
|
||||||
|
icon: LayoutGrid,
|
||||||
|
},
|
||||||
|
{ id: 'i18n', label: 'i18n Ready', description: 'Optional multi-language', icon: Globe },
|
||||||
|
{ id: 'content', label: 'Content', description: 'Blog, MDX & search', icon: Newspaper },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function FeatureTabs() {
|
||||||
|
const [activeTab, setActiveTab] = useState('theming');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="features" className="bg-background relative overflow-hidden py-[var(--space-section-md)]">
|
||||||
|
{/* Decorative logomark watermark */}
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute -top-8 right-8 hidden h-[28rem] w-[28rem] opacity-[0.04] grayscale md:block lg:top-10 lg:right-24 lg:h-[44rem] lg:w-[44rem] dark:opacity-[0.06]"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 90 101" fill="none" className="h-full w-full">
|
||||||
|
<path
|
||||||
|
d="M35.1288 23.8398L45.1667 49.4151L56.2482 23.8398H87.1082C86.5647 23.3764 85.9776 22.9637 85.3616 22.5944L48.6165 0.704798C46.377 -0.0699896 43.4273 -0.439281 41.2675 0.842377L3.36286 23.3692C3.13819 23.5067 2.92801 23.666 2.72508 23.8326H35.1288V23.8398Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M0.144951 28.8578C0.079723 29.2851 0.0434853 29.7123 0.0434853 30.1323L0 72.036C0 76.1778 1.95684 78.3936 5.26172 80.3631L39.4919 100.703L0.144951 28.8578Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M89.9203 28.7058L50.0588 101L86.6661 79.1539C88.7027 77.9374 90 75.0265 90 72.6442L89.9783 29.6037C89.9783 29.2923 89.9493 28.9954 89.913 28.6985L89.9203 28.7058Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative mx-auto max-w-6xl px-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-[var(--space-section-header)]">
|
||||||
|
<h2 className="font-display text-foreground text-3xl font-bold md:text-4xl">
|
||||||
|
Everything you need.
|
||||||
|
<br />
|
||||||
|
<span className="text-brand-500">Nothing you don't.</span>
|
||||||
|
</h2>
|
||||||
|
<p className="text-foreground-muted mt-4 max-w-2xl text-lg">
|
||||||
|
We stripped away the bloat and kept the primitives that actually speed up development
|
||||||
|
for agencies and freelancers.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Vertical Tabs */}
|
||||||
|
<VerticalTabs tabs={tabs} value={activeTab} onChange={setActiveTab} mobileVariant="sheet">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<div key={tab.id} data-tab-content={tab.id}>
|
||||||
|
<div className="mb-[var(--space-heading-gap)]">
|
||||||
|
<h3 className="text-foreground text-xl font-semibold">{tabContent[tab.id].title}</h3>
|
||||||
|
<p className="text-foreground-muted mt-2">{tabContent[tab.id].content}</p>
|
||||||
|
</div>
|
||||||
|
<CodeBlock
|
||||||
|
code={codeExamples[tab.id].code}
|
||||||
|
filename={codeExamples[tab.id].filename}
|
||||||
|
lang={codeExamples[tab.id].lang}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</VerticalTabs>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FeatureTabs;
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
---
|
||||||
|
import PageLayout from '@/layouts/PageLayout.astro';
|
||||||
|
import Button from '@/components/ui/form/Button/Button.astro';
|
||||||
|
import Icon from '@/components/ui/primitives/Icon/Icon.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">
|
||||||
|
<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="bg-white dark:bg-gray-900 border-t border-border">
|
||||||
|
<div class="py-8 px-4 mx-auto max-w-screen-xl sm:py-16 lg:px-6">
|
||||||
|
<div class="mx-auto max-w-screen-md text-center mb-8 lg:mb-16">
|
||||||
|
<h2 class="mb-4 text-4xl tracking-tight font-extrabold text-foreground dark:text-white">Secure platform, secure data</h2>
|
||||||
|
<p class="font-light text-foreground-muted dark:text-gray-400 sm:text-xl">Here at Flowbite we focus on markets where technology, innovation, and capital can unlock long-term value and drive economic growth.</p>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-8 md:grid md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 md:gap-8 xl:gap-8 md:space-y-0">
|
||||||
|
<div class="p-6 bg-white rounded shadow dark:bg-gray-800">
|
||||||
|
<div class="flex justify-center items-center mb-4 w-10 h-10 rounded bg-brand-500/15 lg:h-12 lg:w-12 dark:bg-brand-500/20">
|
||||||
|
<svg class="w-5 h-5 text-brand-500 lg:w-6 lg:h-6 dark:text-brand-400" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M3 3a1 1 0 000 2v8a2 2 0 002 2h2.586l-1.293 1.293a1 1 0 101.414 1.414L10 15.414l2.293 2.293a1 1 0 001.414-1.414L12.414 15H15a2 2 0 002-2V5a1 1 0 100-2H3zm11.707 4.707a1 1 0 00-1.414-1.414L10 9.586 8.707 8.293a1 1 0 00-1.414 0l-2 2a1 1 0 101.414 1.414L8 10.414l1.293 1.293a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path></svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="mb-2 text-xl font-bold dark:text-white">Marketing</h3>
|
||||||
|
<p class="font-light text-foreground-muted dark:text-gray-400">Plan it, create it, launch it. Collaborate seamlessly with all the organization and hit your marketing goals every month with our marketing plan.</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-6 bg-white rounded shadow dark:bg-gray-800">
|
||||||
|
<div class="flex justify-center items-center mb-4 w-10 h-10 rounded bg-brand-500/15 lg:h-12 lg:w-12 dark:bg-brand-500/20">
|
||||||
|
<svg class="w-5 h-5 text-brand-500 lg:w-6 lg:h-6 dark:text-brand-400" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10.394 2.08a1 1 0 00-.788 0l-7 3a1 1 0 000 1.84L5.25 8.051a.999.999 0 01.356-.257l4-1.714a1 1 0 11.788 1.838L7.667 9.088l1.94.831a1 1 0 00.787 0l7-3a1 1 0 000-1.838l-7-3zM3.31 9.397L5 10.12v4.102a8.969 8.969 0 00-1.05-.174 1 1 0 01-.89-.89 11.115 11.115 0 01.25-3.762zM9.3 16.573A9.026 9.026 0 007 14.935v-3.957l1.818.78a3 3 0 002.364 0l5.508-2.361a11.026 11.026 0 01.25 3.762 1 1 0 01-.89.89 8.968 8.968 0 00-5.35 2.524 1 1 0 01-1.4 0zM6 18a1 1 0 001-1v-2.065a8.935 8.935 0 00-2-.712V17a1 1 0 001 1z"></path></svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="mb-2 text-xl font-bold dark:text-white">Legal</h3>
|
||||||
|
<p class="font-light text-foreground-muted dark:text-gray-400">Protect your organization, devices and stay compliant with our structured workflows and custom permissions made for you.</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-6 bg-white rounded shadow dark:bg-gray-800">
|
||||||
|
<div class="flex justify-center items-center mb-4 w-10 h-10 rounded bg-brand-500/15 lg:h-12 lg:w-12 dark:bg-brand-500/20">
|
||||||
|
<svg class="w-5 h-5 text-brand-500 lg:w-6 lg:h-6 dark:text-brand-400" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M6 6V5a3 3 0 013-3h2a3 3 0 013 3v1h2a2 2 0 012 2v3.57A22.952 22.952 0 0110 13a22.95 22.95 0 01-8-1.43V8a2 2 0 012-2h2zm2-1a1 1 0 011-1h2a1 1 0 011 1v1H8V5zm1 5a1 1 0 011-1h.01a1 1 0 110 2H10a1 1 0 01-1-1z" clip-rule="evenodd"></path><path d="M2 13.692V16a2 2 0 002 2h12a2 2 0 002-2v-2.308A24.974 24.974 0 0110 15c-2.796 0-5.487-.46-8-1.308z"></path></svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="mb-2 text-xl font-bold dark:text-white">Business Automation</h3>
|
||||||
|
<p class="font-light text-foreground-muted dark:text-gray-400">Auto-assign tasks, send Slack messages, and much more. Now power up with hundreds of new templates to help you get started.</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-6 bg-white rounded shadow dark:bg-gray-800">
|
||||||
|
<div class="flex justify-center items-center mb-4 w-10 h-10 rounded bg-brand-500/15 lg:h-12 lg:w-12 dark:bg-brand-500/20">
|
||||||
|
<svg class="w-5 h-5 text-brand-500 lg:w-6 lg:h-6 dark:text-brand-400" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M8.433 7.418c.155-.103.346-.196.567-.267v1.698a2.305 2.305 0 01-.567-.267C8.07 8.34 8 8.114 8 8c0-.114.07-.34.433-.582zM11 12.849v-1.698c.22.071.412.164.567.267.364.243.433.468.433.582 0 .114-.07.34-.433.582a2.305 2.305 0 01-.567.267z"></path><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-13a1 1 0 10-2 0v.092a4.535 4.535 0 00-1.676.662C6.602 6.234 6 7.009 6 8c0 .99.602 1.765 1.324 2.246.48.32 1.054.545 1.676.662v1.941c-.391-.127-.68-.317-.843-.504a1 1 0 10-1.51 1.31c.562.649 1.413 1.076 2.353 1.253V15a1 1 0 102 0v-.092a4.535 4.535 0 001.676-.662C13.398 13.766 14 12.991 14 12c0-.99-.602-1.765-1.324-2.246A4.535 4.535 0 0011 9.092V7.151c.391.127.68.317.843.504a1 1 0 101.511-1.31c-.563-.649-1.413-1.076-2.354-1.253V5z" clip-rule="evenodd"></path></svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="mb-2 text-xl font-bold dark:text-white">Finance</h3>
|
||||||
|
<p class="font-light text-foreground-muted dark:text-gray-400">Audit-proof software built for critical financial operations like month-end close and quarterly budgeting.</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-6 bg-white rounded shadow dark:bg-gray-800">
|
||||||
|
<div class="flex justify-center items-center mb-4 w-10 h-10 rounded bg-brand-500/15 lg:h-12 lg:w-12 dark:bg-brand-500/20">
|
||||||
|
<svg class="w-5 h-5 text-brand-500 lg:w-6 lg:h-6 dark:text-brand-400" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M7 3a1 1 0 000 2h6a1 1 0 100-2H7zM4 7a1 1 0 011-1h10a1 1 0 110 2H5a1 1 0 01-1-1zM2 11a2 2 0 012-2h12a2 2 0 012 2v4a2 2 0 01-2 2H4a2 2 0 01-2-2v-4z"></path></svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="mb-2 text-xl font-bold dark:text-white">Enterprise Design</h3>
|
||||||
|
<p class="font-light text-foreground-muted dark:text-gray-400">Craft beautiful, delightful experiences for both marketing and product with real cross-company collaboration.</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-6 bg-white rounded shadow dark:bg-gray-800">
|
||||||
|
<div class="flex justify-center items-center mb-4 w-10 h-10 rounded bg-brand-500/15 lg:h-12 lg:w-12 dark:bg-brand-500/20">
|
||||||
|
<svg class="w-5 h-5 text-brand-500 lg:w-6 lg:h-6 dark:text-brand-400" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd"></path></svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="mb-2 text-xl font-bold dark:text-white">Operations</h3>
|
||||||
|
<p class="font-light text-foreground-muted dark:text-gray-400">Keep your company's lights on with customizable, iterative, and structured workflows built for all efficient teams and individual.</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-6 bg-white rounded shadow dark:bg-gray-800">
|
||||||
|
<div class="flex justify-center items-center mb-4 w-10 h-10 rounded bg-brand-500/15 lg:h-12 lg:w-12 dark:bg-brand-500/20">
|
||||||
|
<svg class="w-5 h-5 text-brand-500 lg:w-6 lg:h-6 dark:text-brand-400" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M7 3a1 1 0 000 2h6a1 1 0 100-2H7zM4 7a1 1 0 011-1h10a1 1 0 110 2H5a1 1 0 01-1-1zM2 11a2 2 0 012-2h12a2 2 0 012 2v4a2 2 0 01-2 2H4a2 2 0 01-2-2v-4z"></path></svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="mb-2 text-xl font-bold dark:text-white">Enterprise Design</h3>
|
||||||
|
<p class="font-light text-foreground-muted dark:text-gray-400">Craft beautiful, delightful experiences for both marketing and product with real cross-company collaboration.</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-6 bg-white rounded shadow dark:bg-gray-800">
|
||||||
|
<div class="flex justify-center items-center mb-4 w-10 h-10 rounded bg-brand-500/15 lg:h-12 lg:w-12 dark:bg-brand-500/20">
|
||||||
|
<svg class="w-5 h-5 text-brand-500 lg:w-6 lg:h-6 dark:text-brand-400" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd"></path></svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="mb-2 text-xl font-bold dark:text-white">Operations</h3>
|
||||||
|
<p class="font-light text-foreground-muted dark:text-gray-400">Keep your company's lights on with customizable, iterative, and structured workflows built for all efficient teams and individual.</p>
|
||||||
|
</div>
|
||||||
|
</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="https://app.armarium.ch/register">
|
||||||
|
{t('cta.register')}
|
||||||
|
<Icon name="arrow-right" size="sm" />
|
||||||
|
</Button>
|
||||||
|
<Button size="lg" variant="outline" href="https://app.armarium.ch/login">
|
||||||
|
{t('features.cta.login')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</PageLayout>
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
---
|
||||||
|
import Icon from '@/components/ui/primitives/Icon/Icon.astro';
|
||||||
|
import { Image } from 'astro:assets';
|
||||||
|
import headerImg from '@/assets/header_img.jpg';
|
||||||
|
import contentImg from '@/assets/content_image.jpg';
|
||||||
|
import armariumImg from '@/assets/armarium_image.jpg';
|
||||||
|
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 allPosts = await getCollection('blog', ({ data }) => !data.draft && data.locale === locale);
|
||||||
|
const posts = allPosts.sort((a, b) => b.data.publishedAt.getTime() - a.data.publishedAt.getTime()).slice(0, 7);
|
||||||
|
const [featured, ...restPosts] = posts;
|
||||||
|
|
||||||
|
type BlogEntry = { href: string; title: string; description: string };
|
||||||
|
|
||||||
|
function toEntry(p: (typeof restPosts)[number]): BlogEntry {
|
||||||
|
return { href: `/blog/${p.id}`, title: p.data.title, description: p.data.description };
|
||||||
|
}
|
||||||
|
|
||||||
|
const dummies: BlogEntry[] = [
|
||||||
|
{ href: '#', title: 'So behältst du den Überblick über deine Finanzen', description: 'Mit einfachen Tricks und der richtigen App kannst du dein Budget im Griff behalten — ohne Stress.' },
|
||||||
|
{ href: '#', title: '5 Spartipps für den Alltag', description: 'Kleine Änderungen im Alltag können langfristig einen grossen Unterschied machen.' },
|
||||||
|
{ href: '#', title: 'Warum ein Haushaltsbuch sinnvoll ist', description: 'Wer seine Ausgaben kennt, kann gezielt sparen und Sparziele schneller erreichen.' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function fillTo3(items: (typeof restPosts), offset = 0): BlogEntry[] {
|
||||||
|
const real = items.map(toEntry);
|
||||||
|
return [...real, ...dummies].slice(offset, offset + 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
const colA = fillTo3(restPosts.slice(0, 3));
|
||||||
|
const colB = fillTo3(restPosts.slice(3, 6), restPosts.slice(3, 6).length === 0 ? 0 : 0);
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- Hero -->
|
||||||
|
<section class="bg-white dark:bg-gray-900">
|
||||||
|
<div class="grid max-w-screen-xl px-4 py-16 mx-auto lg:gap-8 xl:gap-0 lg:py-28 lg:grid-cols-12">
|
||||||
|
<div class="mr-auto place-self-center lg:col-span-7">
|
||||||
|
<h1 class="max-w-2xl mb-4 text-4xl font-extrabold tracking-tight leading-none md:text-5xl xl:text-6xl dark:text-white">
|
||||||
|
Armarium Suite<br />
|
||||||
|
<span class="text-brand-500">Budget</span> & More
|
||||||
|
</h1>
|
||||||
|
<p class="max-w-2xl mb-6 font-light text-gray-500 lg:mb-8 md:text-lg lg:text-xl dark:text-gray-400">
|
||||||
|
{t('hero.description')}
|
||||||
|
</p>
|
||||||
|
<a href="https://app.armarium.ch/register" class="inline-flex items-center justify-center px-5 py-3 mr-3 text-base font-medium text-center text-white rounded-lg bg-brand-500 hover:bg-brand-600 focus:ring-4 focus:ring-brand-300 dark:focus:ring-brand-900">
|
||||||
|
{t('hero.register')}
|
||||||
|
<Icon name="arrow-right" size="sm" class="ml-2 -mr-1" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="hidden lg:mt-0 lg:col-span-5 lg:flex items-center">
|
||||||
|
<div class="w-full overflow-hidden rounded-2xl shadow-md">
|
||||||
|
<Image src={headerImg} alt="Armarium Suite" class="w-full h-full object-contain" widths={[480, 720]} sizes="(max-width: 1024px) 0px, 40vw" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Device tabs section -->
|
||||||
|
<section class="bg-white dark:bg-gray-900 antialiased border-t border-border">
|
||||||
|
<div class="max-w-screen-xl px-4 py-8 mx-auto sm:py-16 lg:py-24">
|
||||||
|
<div class="grid grid-cols-1 gap-8 lg:gap-16 lg:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<div class="space-y-4 sm:space-y-6 lg:space-y-8">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-3xl font-extrabold leading-tight text-gray-900 sm:text-4xl dark:text-white">
|
||||||
|
{t('f1.title' as any)} — {t('f2.title' as any)}
|
||||||
|
</h2>
|
||||||
|
<p class="mt-4 text-base font-normal text-gray-500 dark:text-gray-400 sm:text-xl">
|
||||||
|
{t('hero.description')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="pt-4 border-t border-gray-200 sm:pt-6 lg:pt-8 dark:border-gray-800">
|
||||||
|
<ul class="space-y-4">
|
||||||
|
{(['f1','f2','f3'] as const).map((key) => (
|
||||||
|
<li class="flex items-center gap-2.5">
|
||||||
|
<div class="inline-flex items-center justify-center w-5 h-5 rounded-full bg-brand-500/10 text-brand-500 shrink-0">
|
||||||
|
<svg aria-hidden="true" class="w-3.5 h-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span class="text-base font-medium text-gray-900 dark:text-white">{t(`${key}.title` as any)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href={t('nav.features.href')} class="inline-flex items-center text-base font-medium text-brand-500 hover:underline">
|
||||||
|
{t('nav.features')}
|
||||||
|
<svg aria-hidden="true" class="w-5 h-5 ml-1.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- App image -->
|
||||||
|
<div class="hidden lg:flex items-center">
|
||||||
|
<Image src={armariumImg} alt="Armarium Suite" class="w-3/4 mx-auto rounded-2xl shadow-md object-contain" widths={[480, 720]} sizes="30vw" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom split: image + feature list -->
|
||||||
|
<div class="grid grid-cols-1 gap-8 mt-8 lg:mt-20 lg:gap-16 lg:grid-cols-2">
|
||||||
|
<div class="hidden lg:block">
|
||||||
|
<Image src={contentImg} alt="Armarium Suite Features" class="w-3/4 mx-auto rounded-2xl shadow-md object-cover" widths={[640, 960]} sizes="40vw" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4 sm:space-y-6 lg:space-y-8">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-3xl font-extrabold leading-tight text-gray-900 sm:text-4xl dark:text-white">
|
||||||
|
{t('features.title')}
|
||||||
|
</h2>
|
||||||
|
<p class="mt-4 text-base font-normal text-gray-500 dark:text-gray-400 sm:text-xl">
|
||||||
|
{t('features.description')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="pt-4 border-t border-gray-200 sm:pt-6 lg:pt-8 dark:border-gray-800">
|
||||||
|
<ul class="space-y-4">
|
||||||
|
{(['f1','f2','f3','f4','f5','f6'] as const).map((key) => (
|
||||||
|
<li class="flex items-center gap-2.5">
|
||||||
|
<div class="inline-flex items-center justify-center w-5 h-5 rounded-full bg-brand-500/10 text-brand-500 shrink-0">
|
||||||
|
<svg aria-hidden="true" class="w-3.5 h-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span class="text-base font-medium text-gray-900 dark:text-white">{t(`${key}.title` as any)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<a href="https://app.armarium.ch/register"
|
||||||
|
class="text-white bg-brand-500 justify-center hover:bg-brand-600 inline-flex items-center focus:ring-4 focus:outline-none focus:ring-brand-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-brand-600 dark:hover:bg-brand-700 dark:focus:ring-brand-800">
|
||||||
|
{t('hero.register')}
|
||||||
|
<svg aria-hidden="true" class="w-5 h-5 ml-2 -mr-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<a href={t('nav.features.href')}
|
||||||
|
class="px-5 py-2.5 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-brand-600 focus:z-10 focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700">
|
||||||
|
{t('nav.features')}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Blog section -->
|
||||||
|
<section class="bg-white dark:bg-gray-900 border-t border-border">
|
||||||
|
<div class="py-8 px-6 mx-auto max-w-screen-xl sm:py-16 lg:px-12">
|
||||||
|
<div class="mx-auto max-w-screen-sm text-center">
|
||||||
|
<h2 class="mb-4 text-3xl lg:text-4xl tracking-tight font-extrabold text-gray-900 dark:text-white">
|
||||||
|
{t('nav.blog')}
|
||||||
|
</h2>
|
||||||
|
<p class="mb-8 lg:mb-16 font-light text-gray-500 dark:text-gray-400 sm:text-xl">
|
||||||
|
{t('features.description')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-8 mb-16 lg:grid-cols-3 [&>*]:min-w-0">
|
||||||
|
<!-- Featured post with image -->
|
||||||
|
{featured && (
|
||||||
|
<article class="min-w-0">
|
||||||
|
<a href={`/blog/${featured.id}`} class="block mb-5">
|
||||||
|
<Image src={headerImg} alt={featured.data.title} class="rounded-lg w-full h-48 object-cover" />
|
||||||
|
</a>
|
||||||
|
<h2 class="my-2 text-2xl font-bold tracking-tight text-gray-900 dark:text-white">
|
||||||
|
<a href={`/blog/${featured.id}`}>{featured.data.title}</a>
|
||||||
|
</h2>
|
||||||
|
<p class="mb-4 font-light text-gray-500 dark:text-gray-400">{featured.data.description}</p>
|
||||||
|
<a href={`/blog/${featured.id}`} class="inline-flex items-center font-medium text-brand-500 hover:underline">
|
||||||
|
{t('blog.readmore' as any) || 'Weiterlesen'}
|
||||||
|
<svg class="ml-2 w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>
|
||||||
|
</a>
|
||||||
|
</article>
|
||||||
|
)}
|
||||||
|
<!-- Column A -->
|
||||||
|
<div class="space-y-8 lg:pl-10 lg:border-l lg:border-gray-200 dark:lg:border-gray-700">
|
||||||
|
{colA.map((post) => (
|
||||||
|
<article>
|
||||||
|
<h2 class="mb-2 text-2xl font-bold tracking-tight text-gray-900 dark:text-white">
|
||||||
|
<a href={post.href}>{post.title}</a>
|
||||||
|
</h2>
|
||||||
|
<p class="mb-4 font-light text-gray-500 dark:text-gray-400">{post.description}</p>
|
||||||
|
<a href={post.href} class="inline-flex items-center font-medium text-brand-500 hover:underline">
|
||||||
|
Weiterlesen
|
||||||
|
<svg class="ml-2 w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>
|
||||||
|
</a>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<!-- Column B -->
|
||||||
|
<div class="space-y-8 lg:pl-10 lg:border-l lg:border-gray-200 dark:lg:border-gray-700">
|
||||||
|
{colB.map((post) => (
|
||||||
|
<article>
|
||||||
|
<h2 class="mb-2 text-2xl font-bold tracking-tight text-gray-900 dark:text-white">
|
||||||
|
<a href={post.href}>{post.title}</a>
|
||||||
|
</h2>
|
||||||
|
<p class="mb-4 font-light text-gray-500 dark:text-gray-400">{post.description}</p>
|
||||||
|
<a href={post.href} class="inline-flex items-center font-medium text-brand-500 hover:underline">
|
||||||
|
Weiterlesen
|
||||||
|
<svg class="ml-2 w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>
|
||||||
|
</a>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</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">
|
||||||
|
Kostenlos mitmachen und sofort loslegen.
|
||||||
|
</p>
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<a href="https://app.armarium.ch/register" class="inline-flex items-center justify-center px-5 py-3 text-base font-medium text-center text-white rounded-lg bg-brand-500 hover:bg-brand-600 focus:ring-4 focus:ring-brand-300 dark:focus:ring-brand-900">
|
||||||
|
{t('cta.register')}
|
||||||
|
<Icon name="arrow-right" size="sm" class="ml-2 -mr-1" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
---
|
||||||
|
/**
|
||||||
|
* LighthouseScores.astro
|
||||||
|
* A callout component displaying perfect Lighthouse scores
|
||||||
|
* Designed to match the authentic Lighthouse report aesthetic
|
||||||
|
*/
|
||||||
|
import Badge from '@/components/ui/data-display/Badge/Badge.astro';
|
||||||
|
|
||||||
|
interface Score {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scores: Score[] = [
|
||||||
|
{ label: 'Performance', value: 100 },
|
||||||
|
{ label: 'Accessibility', value: 100 },
|
||||||
|
{ label: 'Best Practices', value: 100 },
|
||||||
|
{ label: 'SEO', value: 100 },
|
||||||
|
];
|
||||||
|
---
|
||||||
|
|
||||||
|
<section class="bg-background-secondary border-border border-y py-[var(--space-section-md)]">
|
||||||
|
<div class="mx-auto max-w-6xl px-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-8 text-center">
|
||||||
|
<div class="mb-4 flex justify-center">
|
||||||
|
<Badge variant="success">Lighthouse Report</Badge>
|
||||||
|
</div>
|
||||||
|
<h3 class="font-display text-foreground text-2xl font-bold md:text-3xl">
|
||||||
|
Perfect scores. Out of the box.
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scores Grid -->
|
||||||
|
<div
|
||||||
|
class="lighthouse-scores mx-auto grid max-w-2xl grid-cols-4 gap-[var(--space-content-gap)]"
|
||||||
|
role="list"
|
||||||
|
aria-label="Lighthouse scores"
|
||||||
|
>
|
||||||
|
{
|
||||||
|
scores.map((score, index) => (
|
||||||
|
<div
|
||||||
|
class="lighthouse-score group flex flex-col items-center"
|
||||||
|
role="listitem"
|
||||||
|
style={`--delay: ${index * 80}ms;`}
|
||||||
|
>
|
||||||
|
{/* Circular gauge - authentic Lighthouse style */}
|
||||||
|
<div class="relative">
|
||||||
|
<svg
|
||||||
|
class="lighthouse-gauge h-16 w-16 sm:h-20 sm:w-20 md:h-24 md:w-24"
|
||||||
|
viewBox="0 0 120 120"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{/* Outer gray track */}
|
||||||
|
<circle
|
||||||
|
cx="60"
|
||||||
|
cy="60"
|
||||||
|
r="54"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="8"
|
||||||
|
class="text-gray-200 dark:text-gray-800"
|
||||||
|
/>
|
||||||
|
{/* Green progress arc */}
|
||||||
|
<circle
|
||||||
|
cx="60"
|
||||||
|
cy="60"
|
||||||
|
r="54"
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--success)"
|
||||||
|
stroke-width="8"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-dasharray="339.292"
|
||||||
|
stroke-dashoffset="0"
|
||||||
|
class="lighthouse-progress"
|
||||||
|
style="transform: rotate(-90deg); transform-origin: center;"
|
||||||
|
/>
|
||||||
|
{/* Score number - authentic Lighthouse sizing */}
|
||||||
|
<text
|
||||||
|
x="60"
|
||||||
|
y="60"
|
||||||
|
text-anchor="middle"
|
||||||
|
dominant-baseline="central"
|
||||||
|
class="lighthouse-number fill-current"
|
||||||
|
style="font-size: 26px; font-weight: 600; font-family: Roboto, system-ui, sans-serif;"
|
||||||
|
>
|
||||||
|
{score.value}
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Label */}
|
||||||
|
<span class="text-foreground-muted mt-2 text-center text-xs font-medium sm:text-sm">
|
||||||
|
{score.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer note -->
|
||||||
|
<p class="text-foreground-subtle mt-[var(--space-stack-lg)] text-center text-xs">
|
||||||
|
*Tested in production for landing page demo · Desktop & Mobile emulation ·
|
||||||
|
Results will vary.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Authentic Lighthouse green for the number */
|
||||||
|
.lighthouse-number {
|
||||||
|
fill: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Entrance animations */
|
||||||
|
.lighthouse-score {
|
||||||
|
animation: score-enter 0.5s ease-out both;
|
||||||
|
animation-delay: calc(0.2s + var(--delay, 0ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
.lighthouse-progress {
|
||||||
|
animation: progress-fill 1s ease-out both;
|
||||||
|
animation-delay: calc(0.3s + var(--delay, 0ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes score-enter {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes progress-fill {
|
||||||
|
from {
|
||||||
|
stroke-dashoffset: 339.292;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animation-paused {
|
||||||
|
animation-play-state: paused;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animation-running {
|
||||||
|
animation-play-state: running;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.lighthouse-score,
|
||||||
|
.lighthouse-progress {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lighthouse-progress {
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Intersection Observer for scroll-triggered animations
|
||||||
|
const section = document.querySelector('.lighthouse-scores');
|
||||||
|
if (section) {
|
||||||
|
const elements = section.querySelectorAll('.lighthouse-score, .lighthouse-progress');
|
||||||
|
elements.forEach((el) => {
|
||||||
|
el.classList.add('animation-paused');
|
||||||
|
});
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
elements.forEach((el) => {
|
||||||
|
el.classList.remove('animation-paused');
|
||||||
|
el.classList.add('animation-running');
|
||||||
|
});
|
||||||
|
observer.unobserve(entry.target);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ threshold: 0.3 }
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.observe(section);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
---
|
||||||
|
/**
|
||||||
|
* TechStack — MDX-driven stack showcase
|
||||||
|
*
|
||||||
|
* Content lives in src/content/stack/*.mdx — add, remove, or
|
||||||
|
* edit a tool there without touching this component.
|
||||||
|
*/
|
||||||
|
import { getCollection } from 'astro:content';
|
||||||
|
import Icon from '@/components/ui/primitives/Icon/Icon.astro';
|
||||||
|
import Card from '@/components/ui/data-display/Card/Card.astro';
|
||||||
|
import Badge from '@/components/ui/data-display/Badge/Badge.astro';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
badge?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, description, badge } = Astro.props;
|
||||||
|
|
||||||
|
const items = await getCollection('stack');
|
||||||
|
const stack = items.sort((a, b) => a.data.order - b.data.order);
|
||||||
|
---
|
||||||
|
|
||||||
|
<section class="border-y border-border bg-background-secondary py-[var(--space-section-md)]">
|
||||||
|
<div class="mx-auto max-w-6xl px-6 flex flex-col gap-8">
|
||||||
|
{(badge || title || description) && (
|
||||||
|
<div class="flex flex-col items-center gap-4 text-center" data-reveal>
|
||||||
|
{(badge || title) && (
|
||||||
|
<div class="flex flex-col items-center gap-6">
|
||||||
|
{badge && (
|
||||||
|
<Badge variant="brand" pill>{badge}</Badge>
|
||||||
|
)}
|
||||||
|
{title && (
|
||||||
|
<h2 class="font-display text-4xl font-bold text-foreground">{title}</h2>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{description && (
|
||||||
|
<p class="text-lg text-foreground-muted max-w-2xl mx-auto">{description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div class="grid grid-cols-2 gap-[var(--space-content-gap)] md:grid-cols-4" data-reveal data-reveal-delay="1">
|
||||||
|
{stack.map((item) => (
|
||||||
|
<div
|
||||||
|
style={`--tech-color: oklch(${item.data.colorOklch}); --tech-bg: oklch(${item.data.colorOklch} / 0.1);`}
|
||||||
|
>
|
||||||
|
<Card variant="elevated" padding="md" hover={true} href={item.data.url} target="_blank" rel="noopener noreferrer" class="h-full text-center">
|
||||||
|
<div class="flex flex-col items-center gap-[var(--space-stack-sm)]">
|
||||||
|
<div class="tech-icon-wrap">
|
||||||
|
<Icon name={item.data.icon} size="lg" />
|
||||||
|
</div>
|
||||||
|
<span class="font-display text-lg font-bold text-foreground">{item.data.name}</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* ─── Shared icon container ─────────────────────────────── */
|
||||||
|
.tech-icon-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 3.5rem;
|
||||||
|
height: 3.5rem;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
color: var(--color-brand-500);
|
||||||
|
background: color-mix(in srgb, var(--color-brand-500) 10%, transparent);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
---
|
||||||
|
/**
|
||||||
|
* Analytics Component
|
||||||
|
*
|
||||||
|
* Conditionally loads analytics scripts based on environment variables.
|
||||||
|
* Supports Google Analytics (GA4) and Google Tag Manager.
|
||||||
|
*
|
||||||
|
* When PUBLIC_CONSENT_ENABLED is true, integrates with Google Consent Mode v2:
|
||||||
|
* - consent_mode_v2: Scripts load normally but with denied defaults (cookieless pings)
|
||||||
|
* - strict: Scripts only load after explicit user consent
|
||||||
|
*
|
||||||
|
* Environment variables:
|
||||||
|
* - PUBLIC_GA_MEASUREMENT_ID: Google Analytics 4 Measurement ID (e.g., G-XXXXXXXXXX)
|
||||||
|
* - PUBLIC_GTM_ID: Google Tag Manager Container ID (e.g., GTM-XXXXXXX)
|
||||||
|
* - PUBLIC_CONSENT_ENABLED: Enable cookie consent integration (boolean)
|
||||||
|
*
|
||||||
|
* CSP: All is:inline scripts here are STATIC (no define:vars). Dynamic values are
|
||||||
|
* passed via <script type="application/json"> data elements. Pin each script's
|
||||||
|
* sha256 hash in astro.config.mjs → security.csp.scriptDirective.hashes.
|
||||||
|
*
|
||||||
|
* Required CSP directives for GA4/GTM:
|
||||||
|
*
|
||||||
|
* script-src:
|
||||||
|
* https://*.googletagmanager.com
|
||||||
|
* https://*.google-analytics.com
|
||||||
|
* https://analytics.google.com
|
||||||
|
*
|
||||||
|
* connect-src:
|
||||||
|
* https://*.google-analytics.com (covers region1, region2, etc.)
|
||||||
|
* https://analytics.google.com (separate domain, not a subdomain)
|
||||||
|
* https://*.googletagmanager.com
|
||||||
|
*
|
||||||
|
* IMPORTANT: GA4 sends data to regional endpoints like
|
||||||
|
* https://region1.google-analytics.com/g/s/collect — whitelisting only
|
||||||
|
* www.google-analytics.com will silently drop all tracking requests with
|
||||||
|
* no console errors. The wildcard *.google-analytics.com is required.
|
||||||
|
*/
|
||||||
|
import { PUBLIC_GA_MEASUREMENT_ID, PUBLIC_GTM_ID, PUBLIC_CONSENT_ENABLED } from 'astro:env/client';
|
||||||
|
import consentConfig from '@/config/consent.config';
|
||||||
|
|
||||||
|
const gaId = PUBLIC_GA_MEASUREMENT_ID;
|
||||||
|
const gtmId = PUBLIC_GTM_ID;
|
||||||
|
const consentEnabled = PUBLIC_CONSENT_ENABLED;
|
||||||
|
const consentMode = consentConfig.mode;
|
||||||
|
const storageKey = consentConfig.storageKey;
|
||||||
|
const configVersion = consentConfig.version;
|
||||||
|
|
||||||
|
// Build GCM default values from config categories
|
||||||
|
const gcmDefaults: Record<string, string> = {};
|
||||||
|
for (const [, cat] of Object.entries(consentConfig.categories)) {
|
||||||
|
for (const gcmType of cat.gcmTypes) {
|
||||||
|
gcmDefaults[gcmType] = cat.defaultEnabled ? 'granted' : 'denied';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build category default states from config (e.g. { necessary: true, analytics: false, ... })
|
||||||
|
const categoryDefaults: Record<string, boolean> = {};
|
||||||
|
for (const [key, cat] of Object.entries(consentConfig.categories)) {
|
||||||
|
categoryDefaults[key] = cat.required || cat.defaultEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build category → GCM type mapping for dynamic resolution
|
||||||
|
const categoryGcmMap: Record<string, string[]> = {};
|
||||||
|
for (const [key, cat] of Object.entries(consentConfig.categories)) {
|
||||||
|
categoryGcmMap[key] = cat.gcmTypes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive the category key that maps to analytics_storage (for strict-mode guards)
|
||||||
|
let analyticsCategoryKey = 'analytics';
|
||||||
|
for (const [key, cat] of Object.entries(consentConfig.categories)) {
|
||||||
|
if (cat.gcmTypes.includes('analytics_storage')) {
|
||||||
|
analyticsCategoryKey = key;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rendering flags — keeps template conditions simple and highlighter-friendly
|
||||||
|
const hasGtm = !!gtmId;
|
||||||
|
const hasGa = !!gaId && !gtmId;
|
||||||
|
const hasAnalytics = !!(gaId || gtmId);
|
||||||
|
const noConsent = !consentEnabled;
|
||||||
|
const isV2 = consentEnabled && consentMode === 'consent_mode_v2';
|
||||||
|
const isStrict = consentEnabled && consentMode === 'strict';
|
||||||
|
|
||||||
|
// JSON data for all analytics scripts (single data element avoids duplication)
|
||||||
|
const analyticsDataJson = JSON.stringify({
|
||||||
|
gtmId: gtmId || null,
|
||||||
|
gaId: gaId || null,
|
||||||
|
storageKey,
|
||||||
|
configVersion,
|
||||||
|
gcmDefaults,
|
||||||
|
categoryDefaults,
|
||||||
|
categoryGcmMap,
|
||||||
|
analyticsCategoryKey,
|
||||||
|
});
|
||||||
|
---
|
||||||
|
|
||||||
|
{/* Analytics data element — read by all inline scripts below */}
|
||||||
|
{hasAnalytics && (
|
||||||
|
<script type="application/json" id="analytics-data" set:html={analyticsDataJson} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── No consent: load GTM directly ── */}
|
||||||
|
{noConsent && hasGtm && (
|
||||||
|
<script is:inline>
|
||||||
|
(function(){
|
||||||
|
const d = JSON.parse(document.getElementById('analytics-data').textContent);
|
||||||
|
const i = d.gtmId;
|
||||||
|
window.dataLayer = window.dataLayer || [];
|
||||||
|
window.dataLayer.push({ 'gtm.start': new Date().getTime(), event: 'gtm.js' });
|
||||||
|
const f = document.getElementsByTagName('script')[0],
|
||||||
|
j = document.createElement('script');
|
||||||
|
j.async = true;
|
||||||
|
j.src = 'https://www.googletagmanager.com/gtm.js?id=' + i;
|
||||||
|
f.parentNode.insertBefore(j, f);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── No consent: load GA directly ── */}
|
||||||
|
{noConsent && hasGa && (
|
||||||
|
<>
|
||||||
|
<script is:inline async src={`https://www.googletagmanager.com/gtag/js?id=${gaId}`}></script>
|
||||||
|
<script is:inline>
|
||||||
|
(function(){
|
||||||
|
const d = JSON.parse(document.getElementById('analytics-data').textContent);
|
||||||
|
window.dataLayer = window.dataLayer || [];
|
||||||
|
function gtag(...args){window.dataLayer.push(args);}
|
||||||
|
gtag('js', new Date());
|
||||||
|
gtag('config', d.gaId);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Consent enabled: set consent defaults BEFORE loading scripts ── */}
|
||||||
|
{consentEnabled && hasAnalytics && (
|
||||||
|
<script is:inline>
|
||||||
|
(function(){
|
||||||
|
const d = JSON.parse(document.getElementById('analytics-data').textContent);
|
||||||
|
window.dataLayer = window.dataLayer || [];
|
||||||
|
function gtag(...args){window.dataLayer.push(args);}
|
||||||
|
|
||||||
|
let stored = null;
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(d.storageKey);
|
||||||
|
if (raw) {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (parsed && parsed.version === d.configVersion) {
|
||||||
|
stored = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* ignored */ }
|
||||||
|
|
||||||
|
const gcmValues = {};
|
||||||
|
const keys = Object.keys(d.gcmDefaults);
|
||||||
|
for (let k = 0; k < keys.length; k++) { gcmValues[keys[k]] = d.gcmDefaults[keys[k]]; }
|
||||||
|
let decided = false;
|
||||||
|
let categories = {};
|
||||||
|
const catKeys = Object.keys(d.categoryDefaults);
|
||||||
|
for (let c = 0; c < catKeys.length; c++) { categories[catKeys[c]] = d.categoryDefaults[catKeys[c]]; }
|
||||||
|
|
||||||
|
if (stored && stored.categories) {
|
||||||
|
decided = true;
|
||||||
|
categories = stored.categories;
|
||||||
|
|
||||||
|
const mapKeys = Object.keys(d.categoryGcmMap);
|
||||||
|
for (let m = 0; m < mapKeys.length; m++) {
|
||||||
|
const catKey = mapKeys[m];
|
||||||
|
const granted = !!categories[catKey];
|
||||||
|
const gcmTypes = d.categoryGcmMap[catKey];
|
||||||
|
for (let g = 0; g < gcmTypes.length; g++) {
|
||||||
|
gcmValues[gcmTypes[g]] = granted ? 'granted' : 'denied';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
gtag('consent', 'default', gcmValues);
|
||||||
|
window.__consentState = { decided: decided, categories: categories };
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── V2 mode: load GTM after consent defaults ── */}
|
||||||
|
{isV2 && hasGtm && (
|
||||||
|
<script is:inline>
|
||||||
|
(function(){
|
||||||
|
const d = JSON.parse(document.getElementById('analytics-data').textContent);
|
||||||
|
const i = d.gtmId;
|
||||||
|
window.dataLayer = window.dataLayer || [];
|
||||||
|
window.dataLayer.push({ 'gtm.start': new Date().getTime(), event: 'gtm.js' });
|
||||||
|
const f = document.getElementsByTagName('script')[0],
|
||||||
|
j = document.createElement('script');
|
||||||
|
j.async = true;
|
||||||
|
j.src = 'https://www.googletagmanager.com/gtm.js?id=' + i;
|
||||||
|
f.parentNode.insertBefore(j, f);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── V2 mode: load GA after consent defaults ── */}
|
||||||
|
{isV2 && hasGa && (
|
||||||
|
<>
|
||||||
|
<script is:inline async src={`https://www.googletagmanager.com/gtag/js?id=${gaId}`}></script>
|
||||||
|
<script is:inline>
|
||||||
|
(function(){
|
||||||
|
const d = JSON.parse(document.getElementById('analytics-data').textContent);
|
||||||
|
window.dataLayer = window.dataLayer || [];
|
||||||
|
function gtag(...args){window.dataLayer.push(args);}
|
||||||
|
gtag('js', new Date());
|
||||||
|
gtag('config', d.gaId);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Strict mode: meta tags for consent banner dynamic injection ── */}
|
||||||
|
{isStrict && hasGtm && (
|
||||||
|
<meta name="gtm-id" content={gtmId} />
|
||||||
|
)}
|
||||||
|
{isStrict && hasGa && (
|
||||||
|
<meta name="ga-id" content={gaId} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Strict mode: conditionally load GTM if consent already granted ── */}
|
||||||
|
{isStrict && hasGtm && (
|
||||||
|
<script is:inline>
|
||||||
|
(function(){
|
||||||
|
const d = JSON.parse(document.getElementById('analytics-data').textContent);
|
||||||
|
if (window.__consentState && window.__consentState.decided && window.__consentState.categories[d.analyticsCategoryKey]) {
|
||||||
|
const i = d.gtmId;
|
||||||
|
window.dataLayer = window.dataLayer || [];
|
||||||
|
window.dataLayer.push({ 'gtm.start': new Date().getTime(), event: 'gtm.js' });
|
||||||
|
const f = document.getElementsByTagName('script')[0],
|
||||||
|
j = document.createElement('script');
|
||||||
|
j.async = true;
|
||||||
|
j.src = 'https://www.googletagmanager.com/gtm.js?id=' + i;
|
||||||
|
f.parentNode.insertBefore(j, f);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Strict mode: conditionally load GA if consent already granted ── */}
|
||||||
|
{isStrict && hasGa && (
|
||||||
|
<script is:inline>
|
||||||
|
(function(){
|
||||||
|
const d = JSON.parse(document.getElementById('analytics-data').textContent);
|
||||||
|
if (window.__consentState && window.__consentState.decided && window.__consentState.categories[d.analyticsCategoryKey]) {
|
||||||
|
const s = document.createElement('script');
|
||||||
|
s.async = true;
|
||||||
|
s.src = 'https://www.googletagmanager.com/gtag/js?id=' + d.gaId;
|
||||||
|
document.head.appendChild(s);
|
||||||
|
window.dataLayer = window.dataLayer || [];
|
||||||
|
function gtag(...args){window.dataLayer.push(args);}
|
||||||
|
gtag('js', new Date());
|
||||||
|
gtag('config', d.gaId);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
)}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
---
|
||||||
|
import Footer from './Footer.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;
|
||||||
|
background?: 'default' | 'secondary' | 'invert';
|
||||||
|
}
|
||||||
|
|
||||||
|
const { locale = 'de', background = 'secondary' } = Astro.props;
|
||||||
|
const t = useTranslations(locale);
|
||||||
|
---
|
||||||
|
|
||||||
|
<Footer
|
||||||
|
layout="columns"
|
||||||
|
columns={2}
|
||||||
|
{background}
|
||||||
|
copyright={t('footer.copyright')}
|
||||||
|
showSocial={false}
|
||||||
|
>
|
||||||
|
<div slot="tagline" class="space-y-3">
|
||||||
|
<p class="text-sm max-w-xs text-foreground-muted">{t('footer.tagline')}</p>
|
||||||
|
<a
|
||||||
|
href={t('nav.contact.href')}
|
||||||
|
class="inline-flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium border border-border text-foreground-muted hover:text-foreground hover:border-border-strong transition-colors"
|
||||||
|
>
|
||||||
|
{t('nav.contact')}
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://www.linkedin.com/company/armarium-suite"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label="LinkedIn"
|
||||||
|
class="inline-flex items-center justify-center w-9 h-9 rounded-full border border-border text-foreground-muted hover:text-foreground hover:border-border-strong transition-colors"
|
||||||
|
>
|
||||||
|
<Icon name="linkedin" size="sm" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div slot="columns" class="grid grid-cols-2 gap-8">
|
||||||
|
<div class="space-y-[var(--space-stack-md)]">
|
||||||
|
<h3 class="font-semibold text-sm text-foreground">{t('footer.app')}</h3>
|
||||||
|
<ul class="space-y-2">
|
||||||
|
<li><a href={t('nav.blog.href')} class="text-sm transition-colors text-foreground-muted hover:text-foreground">{t('nav.blog')}</a></li>
|
||||||
|
<li><a href={t('nav.features.href')} class="text-sm transition-colors text-foreground-muted hover:text-foreground">{t('nav.features')}</a></li>
|
||||||
|
<li><a href={t('nav.about.href')} class="text-sm transition-colors text-foreground-muted hover:text-foreground">{t('nav.about')}</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-[var(--space-stack-md)]">
|
||||||
|
<h3 class="font-semibold text-sm text-foreground">{t('footer.legal')}</h3>
|
||||||
|
<ul class="space-y-2">
|
||||||
|
<li><a href={t('footer.privacy.href')} class="text-sm transition-colors text-foreground-muted hover:text-foreground">{t('footer.privacy')}</a></li>
|
||||||
|
<li><a href={t('footer.imprint.href')} class="text-sm transition-colors text-foreground-muted hover:text-foreground">{t('footer.imprint')}</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Footer>
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
---
|
||||||
|
/**
|
||||||
|
* Footer Component
|
||||||
|
* Flexible footer with variant-based configuration
|
||||||
|
*
|
||||||
|
* Layouts:
|
||||||
|
* - simple: Single row with logo, nav links, and social
|
||||||
|
* - columns: Multi-column layout with link groups
|
||||||
|
* - minimal: Just copyright
|
||||||
|
* - stacked: Vertically stacked logo, nav, copyright
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Dynamic navigation from nav.config.ts (default) or custom nav prop
|
||||||
|
* - Social links with icon support
|
||||||
|
* - Copyright with {year} placeholder
|
||||||
|
* - Legal links section
|
||||||
|
* - Full slot support for customization
|
||||||
|
*/
|
||||||
|
import type { HTMLAttributes } from 'astro/types';
|
||||||
|
import { cn } from '@/lib/cn';
|
||||||
|
import { getNavItems, type NavItem as NavConfigItem } from '@/config/nav.config';
|
||||||
|
import { footerVariants, footerColumnGridVariants } from './footer.variants';
|
||||||
|
import Logo from '@/components/ui/marketing/Logo/Logo.astro';
|
||||||
|
import Icon from '@/components/ui/primitives/Icon/Icon.astro';
|
||||||
|
import siteConfig from '@/config/site.config';
|
||||||
|
|
||||||
|
export interface NavItem {
|
||||||
|
label: string;
|
||||||
|
href: string;
|
||||||
|
external?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FooterLinkGroup {
|
||||||
|
title: string;
|
||||||
|
links: NavItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SocialLink {
|
||||||
|
platform: 'github' | 'twitter' | 'linkedin' | string;
|
||||||
|
href: string;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LegalLink {
|
||||||
|
label: string;
|
||||||
|
href: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props extends HTMLAttributes<'footer'> {
|
||||||
|
/** Layout style */
|
||||||
|
layout?: 'simple' | 'columns' | 'minimal' | 'stacked';
|
||||||
|
/** Number of columns (only for columns layout) */
|
||||||
|
columns?: 2 | 3 | 4;
|
||||||
|
/** Background variant */
|
||||||
|
background?: 'default' | 'secondary' | 'invert';
|
||||||
|
/** Override default navigation */
|
||||||
|
nav?: NavItem[];
|
||||||
|
/** Link groups for columns layout */
|
||||||
|
linkGroups?: FooterLinkGroup[];
|
||||||
|
/** Social media links */
|
||||||
|
socialLinks?: SocialLink[];
|
||||||
|
/** Show social links */
|
||||||
|
showSocial?: boolean;
|
||||||
|
/** Show copyright */
|
||||||
|
showCopyright?: boolean;
|
||||||
|
/** Custom copyright text (supports {year} and {siteName} placeholders) */
|
||||||
|
copyright?: string;
|
||||||
|
/** Legal links (Privacy, Terms, etc.) */
|
||||||
|
legalLinks?: LegalLink[];
|
||||||
|
/** Hide logo */
|
||||||
|
hideLogo?: boolean;
|
||||||
|
/** Tagline text under logo */
|
||||||
|
tagline?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
layout = 'simple',
|
||||||
|
columns = 3,
|
||||||
|
background = 'default',
|
||||||
|
nav,
|
||||||
|
linkGroups = [],
|
||||||
|
socialLinks = [],
|
||||||
|
showSocial = true,
|
||||||
|
showCopyright = true,
|
||||||
|
copyright = '© {year} {siteName}. Designed by <a href="https://hansmartens.dev" class="underline hover:text-foreground transition-colors" target="_blank" rel="noopener noreferrer">Hans Martens</a>.',
|
||||||
|
legalLinks = [],
|
||||||
|
hideLogo = false,
|
||||||
|
tagline,
|
||||||
|
class: className,
|
||||||
|
...attrs
|
||||||
|
} = Astro.props;
|
||||||
|
|
||||||
|
// Get navigation items
|
||||||
|
const defaultNav: NavItem[] = getNavItems().map((item: NavConfigItem) => ({
|
||||||
|
label: item.label,
|
||||||
|
href: item.href,
|
||||||
|
}));
|
||||||
|
const navItems: NavItem[] = nav || defaultNav;
|
||||||
|
|
||||||
|
// Default external links if none provided via nav
|
||||||
|
const allNavItems: NavItem[] = nav ? navItems : navItems;
|
||||||
|
|
||||||
|
// Process copyright text
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const processedCopyright = copyright
|
||||||
|
.replace('{year}', String(currentYear))
|
||||||
|
.replace('{siteName}', siteConfig.name);
|
||||||
|
|
||||||
|
// Check slots
|
||||||
|
const hasLogoSlot = Astro.slots.has('logo');
|
||||||
|
const hasTaglineSlot = Astro.slots.has('tagline');
|
||||||
|
const hasColumnsSlot = Astro.slots.has('columns');
|
||||||
|
const hasSocialSlot = Astro.slots.has('social');
|
||||||
|
const hasBottomSlot = Astro.slots.has('bottom');
|
||||||
|
|
||||||
|
// Compute footer classes
|
||||||
|
const footerClasses = cn(footerVariants({ background }), className);
|
||||||
|
|
||||||
|
// Social platform to icon mapping
|
||||||
|
const socialIcons: Record<string, string> = {
|
||||||
|
github: 'github',
|
||||||
|
twitter: 'x-twitter',
|
||||||
|
linkedin: 'linkedin',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get icon for social platform
|
||||||
|
function getSocialIcon(platform: string): string {
|
||||||
|
return socialIcons[platform] || platform;
|
||||||
|
}
|
||||||
|
---
|
||||||
|
|
||||||
|
<footer class={footerClasses} {...attrs}>
|
||||||
|
<div class="mx-auto max-w-6xl px-6">
|
||||||
|
{layout === 'simple' && (
|
||||||
|
<>
|
||||||
|
<div class="flex flex-col md:flex-row justify-between items-center gap-[var(--space-stack-lg)]">
|
||||||
|
{/* Logo & Tagline */}
|
||||||
|
{!hideLogo && (
|
||||||
|
<div class="flex flex-col items-center md:items-start gap-2">
|
||||||
|
{hasLogoSlot ? (
|
||||||
|
<slot name="logo" />
|
||||||
|
) : (
|
||||||
|
<a href="/" class="flex items-center">
|
||||||
|
<Logo variant="full" size="md" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{(hasTaglineSlot || tagline) && (
|
||||||
|
<p class="text-sm text-foreground-muted">
|
||||||
|
{hasTaglineSlot ? <slot name="tagline" /> : tagline}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Navigation Links */}
|
||||||
|
<nav class="flex flex-wrap justify-center gap-[var(--space-inline-lg)] md:gap-[var(--space-stack-lg)] text-sm font-medium text-foreground-muted">
|
||||||
|
{allNavItems.map((item) => (
|
||||||
|
<a
|
||||||
|
href={item.href}
|
||||||
|
class="transition-colors hover:text-foreground"
|
||||||
|
target={item.external || item.href.startsWith('http') ? '_blank' : undefined}
|
||||||
|
rel={item.external || item.href.startsWith('http') ? 'noopener noreferrer' : undefined}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Social Links */}
|
||||||
|
{showSocial && socialLinks.length > 0 && (
|
||||||
|
hasSocialSlot ? (
|
||||||
|
<slot name="social" />
|
||||||
|
) : (
|
||||||
|
<div class="flex items-center gap-[var(--space-stack-md)]">
|
||||||
|
{socialLinks.map((social) => (
|
||||||
|
<a
|
||||||
|
href={social.href}
|
||||||
|
class="transition-colors text-foreground-muted hover:text-foreground"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label={social.label || `Follow us on ${social.platform}`}
|
||||||
|
>
|
||||||
|
<Icon name={getSocialIcon(social.platform)} size="md" />
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{showCopyright && (
|
||||||
|
<div class="mt-[var(--space-stack-lg)] pt-[var(--space-stack-lg)] border-t border-border text-center">
|
||||||
|
<p class="text-sm text-foreground-muted" set:html={processedCopyright} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{layout === 'columns' && (
|
||||||
|
<>
|
||||||
|
<div class={footerColumnGridVariants({ columns })}>
|
||||||
|
{/* Logo Column */}
|
||||||
|
{!hideLogo && (
|
||||||
|
<div class="space-y-[var(--space-stack-md)]">
|
||||||
|
{hasLogoSlot ? (
|
||||||
|
<slot name="logo" />
|
||||||
|
) : (
|
||||||
|
<a href="/" class="flex items-center">
|
||||||
|
<Logo variant="full" size="md" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{(hasTaglineSlot || tagline) && (
|
||||||
|
<p class="text-sm max-w-xs text-foreground-muted">
|
||||||
|
{hasTaglineSlot ? <slot name="tagline" /> : tagline}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{/* Social Links in columns layout */}
|
||||||
|
{showSocial && socialLinks.length > 0 && (
|
||||||
|
<div class="flex items-center gap-[var(--space-stack-md)] pt-2">
|
||||||
|
{socialLinks.map((social) => (
|
||||||
|
<a
|
||||||
|
href={social.href}
|
||||||
|
class="transition-colors text-foreground-muted hover:text-foreground"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label={social.label || `Follow us on ${social.platform}`}
|
||||||
|
>
|
||||||
|
<Icon name={getSocialIcon(social.platform)} size="md" />
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Link Groups */}
|
||||||
|
{hasColumnsSlot ? (
|
||||||
|
<slot name="columns" />
|
||||||
|
) : (
|
||||||
|
linkGroups.map((group) => (
|
||||||
|
<div class="space-y-[var(--space-stack-md)]">
|
||||||
|
<h3 class="font-semibold text-sm text-foreground">
|
||||||
|
{group.title}
|
||||||
|
</h3>
|
||||||
|
<ul class="space-y-2">
|
||||||
|
{group.links.map((link) => (
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href={link.href}
|
||||||
|
class="text-sm transition-colors text-foreground-muted hover:text-foreground"
|
||||||
|
target={link.external || link.href.startsWith('http') ? '_blank' : undefined}
|
||||||
|
rel={link.external || link.href.startsWith('http') ? 'noopener noreferrer' : undefined}
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom section */}
|
||||||
|
{(showCopyright || legalLinks.length > 0) && (
|
||||||
|
<div class="mt-[var(--space-section-header)] pt-[var(--space-stack-lg)] border-t border-border flex flex-col md:flex-row justify-between items-center gap-[var(--space-stack-md)]">
|
||||||
|
{hasBottomSlot ? (
|
||||||
|
<slot name="bottom" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{showCopyright && (
|
||||||
|
<p class="text-sm text-foreground-muted" set:html={processedCopyright} />
|
||||||
|
)}
|
||||||
|
{legalLinks.length > 0 && (
|
||||||
|
<div class="flex items-center gap-[var(--space-inline-lg)]">
|
||||||
|
{legalLinks.map((link) => (
|
||||||
|
<a
|
||||||
|
href={link.href}
|
||||||
|
class="text-sm transition-colors text-foreground-muted hover:text-foreground"
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{layout === 'minimal' && (
|
||||||
|
<div class="text-center">
|
||||||
|
{showCopyright && (
|
||||||
|
<p class="text-sm text-foreground-muted" set:html={processedCopyright} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{layout === 'stacked' && (
|
||||||
|
<div class="flex flex-col items-center gap-[var(--space-stack-lg)] text-center">
|
||||||
|
{/* Logo */}
|
||||||
|
{!hideLogo && (
|
||||||
|
hasLogoSlot ? (
|
||||||
|
<slot name="logo" />
|
||||||
|
) : (
|
||||||
|
<a href="/" class="flex items-center">
|
||||||
|
<Logo variant="full" size="md" />
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tagline */}
|
||||||
|
{(hasTaglineSlot || tagline) && (
|
||||||
|
<p class="text-sm max-w-md text-foreground-muted">
|
||||||
|
{hasTaglineSlot ? <slot name="tagline" /> : tagline}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Navigation Links */}
|
||||||
|
<nav class="flex flex-wrap justify-center gap-[var(--space-inline-lg)] text-sm font-medium text-foreground-muted">
|
||||||
|
{allNavItems.map((item) => (
|
||||||
|
<a
|
||||||
|
href={item.href}
|
||||||
|
class="transition-colors hover:text-foreground"
|
||||||
|
target={item.external || item.href.startsWith('http') ? '_blank' : undefined}
|
||||||
|
rel={item.external || item.href.startsWith('http') ? 'noopener noreferrer' : undefined}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Social Links */}
|
||||||
|
{showSocial && socialLinks.length > 0 && (
|
||||||
|
<div class="flex items-center gap-[var(--space-stack-md)]">
|
||||||
|
{socialLinks.map((social) => (
|
||||||
|
<a
|
||||||
|
href={social.href}
|
||||||
|
class="transition-colors text-foreground-muted hover:text-foreground"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label={social.label || `Follow us on ${social.platform}`}
|
||||||
|
>
|
||||||
|
<Icon name={getSocialIcon(social.platform)} size="md" />
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Copyright & Legal */}
|
||||||
|
{(showCopyright || legalLinks.length > 0) && (
|
||||||
|
<div class="pt-[var(--space-stack-lg)] border-t border-border w-full flex flex-col items-center gap-[var(--space-stack-md)]">
|
||||||
|
{showCopyright && (
|
||||||
|
<p class="text-sm text-foreground-muted" set:html={processedCopyright} />
|
||||||
|
)}
|
||||||
|
{legalLinks.length > 0 && (
|
||||||
|
<div class="flex items-center gap-[var(--space-inline-lg)]">
|
||||||
|
{legalLinks.map((link) => (
|
||||||
|
<a
|
||||||
|
href={link.href}
|
||||||
|
class="text-sm transition-colors text-foreground-muted hover:text-foreground"
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Default slot for additional content */}
|
||||||
|
<slot />
|
||||||
|
</footer>
|
||||||
@@ -0,0 +1,771 @@
|
|||||||
|
---
|
||||||
|
/**
|
||||||
|
* Header Component
|
||||||
|
* Flexible navigation header with variant-based configuration
|
||||||
|
*
|
||||||
|
* Variants:
|
||||||
|
* - layout: 'default' | 'centered' | 'minimal'
|
||||||
|
* - position: 'fixed' | 'sticky' | 'static'
|
||||||
|
* - size: 'sm' | 'md' | 'lg'
|
||||||
|
* - variant: 'default' | 'solid' | 'transparent'
|
||||||
|
* - colorScheme: 'default' | 'invert' (use 'invert' for dark backgrounds)
|
||||||
|
* - shape: 'bar' | 'floating' (use 'floating' for capsule header)
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Dynamic navigation from nav.config.ts (default) or custom nav prop
|
||||||
|
* - Optional CTA button with customization
|
||||||
|
* - Mobile menu with Escape key support
|
||||||
|
* - Theme toggle
|
||||||
|
* - GitHub/action buttons
|
||||||
|
* - Full slot support for customization
|
||||||
|
* - Inverted color scheme for use on dark/image backgrounds
|
||||||
|
* - Floating capsule shape with scroll-reactive bg + color flip
|
||||||
|
*/
|
||||||
|
import type { HTMLAttributes } from 'astro/types';
|
||||||
|
import { cn } from '@/lib/cn';
|
||||||
|
import { getNavItems, type NavItem as NavConfigItem } from '@/config/nav.config';
|
||||||
|
import { headerVariants, headerInnerVariants } from './header.variants';
|
||||||
|
import Button from '@/components/ui/form/Button/Button.astro';
|
||||||
|
import Icon from '@/components/ui/primitives/Icon/Icon.astro';
|
||||||
|
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;
|
||||||
|
href: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HeaderAction {
|
||||||
|
icon: string;
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
iconOnly?: boolean;
|
||||||
|
target?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props extends HTMLAttributes<'header'> {
|
||||||
|
/** Layout style: default (logo left, nav right), centered (logo center), minimal (logo + cta only) */
|
||||||
|
layout?: 'default' | 'centered' | 'minimal';
|
||||||
|
/** Position behavior */
|
||||||
|
position?: 'fixed' | 'sticky' | 'static';
|
||||||
|
/** Header height */
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
/** Background variant */
|
||||||
|
variant?: 'default' | 'solid' | 'transparent';
|
||||||
|
/** Color scheme for text/icons - use 'invert' for dark backgrounds */
|
||||||
|
colorScheme?: 'default' | 'invert';
|
||||||
|
/** Shape: 'bar' (full-width, default) or 'floating' (centered capsule) */
|
||||||
|
shape?: 'bar' | 'floating';
|
||||||
|
/** Override default navigation (replaces getNavRoutes()) */
|
||||||
|
nav?: NavItem[];
|
||||||
|
/** Additional navigation items (e.g., #features for landing pages) */
|
||||||
|
extraNav?: NavItem[];
|
||||||
|
/** Show CTA button */
|
||||||
|
showCta?: boolean;
|
||||||
|
/** CTA button configuration */
|
||||||
|
cta?: { label?: string; href?: string; icon?: string };
|
||||||
|
/** Action buttons (GitHub, etc.) */
|
||||||
|
actions?: HeaderAction[];
|
||||||
|
/** Show theme toggle (default: true) */
|
||||||
|
showThemeToggle?: boolean;
|
||||||
|
/** Show colour-theme selector swatches */
|
||||||
|
showThemeSelector?: boolean;
|
||||||
|
/** Show mobile menu (default: true) */
|
||||||
|
showMobileMenu?: boolean;
|
||||||
|
/** Show active state for current page (default: true) */
|
||||||
|
showActiveState?: boolean;
|
||||||
|
/** Logo text override */
|
||||||
|
logoText?: string;
|
||||||
|
/** Hide logo entirely */
|
||||||
|
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 */
|
||||||
|
showScrollProgress?: boolean;
|
||||||
|
/** Position of the scroll progress bar: 'top' (above header) or 'bottom' (below header, default) */
|
||||||
|
scrollProgressPosition?: 'top' | 'bottom';
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
layout = 'default',
|
||||||
|
position = 'fixed',
|
||||||
|
size = 'lg',
|
||||||
|
variant = 'solid',
|
||||||
|
colorScheme = 'default',
|
||||||
|
shape = 'bar',
|
||||||
|
nav,
|
||||||
|
extraNav = [],
|
||||||
|
showCta = false,
|
||||||
|
cta = { label: 'Start a project', href: '/contact' },
|
||||||
|
actions = [],
|
||||||
|
showThemeToggle = true,
|
||||||
|
showThemeSelector = false,
|
||||||
|
showMobileMenu = true,
|
||||||
|
showSocialLinks = false,
|
||||||
|
showActiveState = true,
|
||||||
|
showScrollProgress = false,
|
||||||
|
scrollProgressPosition = 'bottom',
|
||||||
|
showLanguageSwitcher = false,
|
||||||
|
currentLocale = 'de',
|
||||||
|
logoText,
|
||||||
|
hideLogo = false,
|
||||||
|
class: className,
|
||||||
|
...attrs
|
||||||
|
} = Astro.props;
|
||||||
|
|
||||||
|
// Shape + color scheme helpers
|
||||||
|
const isFloating = shape === 'floating';
|
||||||
|
const isInvert = colorScheme === 'invert';
|
||||||
|
|
||||||
|
// Get navigation items
|
||||||
|
const defaultNav = getNavItems().map((item: NavConfigItem) => ({
|
||||||
|
label: item.label,
|
||||||
|
href: item.href,
|
||||||
|
}));
|
||||||
|
const navItems: NavItem[] = nav || [...extraNav, ...defaultNav];
|
||||||
|
|
||||||
|
// Current path for active state
|
||||||
|
const currentPath = Astro.url.pathname;
|
||||||
|
|
||||||
|
// Check if we're on the landing page
|
||||||
|
const isLandingPage = currentPath === '/';
|
||||||
|
|
||||||
|
// Process CTA href for landing page anchor links
|
||||||
|
const ctaHref = cta.href?.startsWith('#') && !isLandingPage ? `/${cta.href}` : cta.href;
|
||||||
|
|
||||||
|
// Check slots
|
||||||
|
const hasLogoSlot = Astro.slots.has('logo');
|
||||||
|
const hasNavSlot = Astro.slots.has('nav');
|
||||||
|
const hasActionsSlot = Astro.slots.has('actions');
|
||||||
|
const hasMobileMenuSlot = Astro.slots.has('mobile-menu');
|
||||||
|
|
||||||
|
// Compute header classes
|
||||||
|
const headerClasses = cn(
|
||||||
|
headerVariants({ position, variant, shape }),
|
||||||
|
isInvert && !isFloating && 'invert-section',
|
||||||
|
className
|
||||||
|
);
|
||||||
|
|
||||||
|
// Compute inner container classes
|
||||||
|
const innerClasses = headerInnerVariants({ size, shape });
|
||||||
|
|
||||||
|
// Check if a nav item is active
|
||||||
|
function isActive(href: string): boolean {
|
||||||
|
if (!showActiveState) return false;
|
||||||
|
if (href.startsWith('#')) return false;
|
||||||
|
return currentPath === href || currentPath.startsWith(href + '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map a social URL to its icon name + accessible label
|
||||||
|
function getSocialIconData(url: string): { icon: string; label: string } {
|
||||||
|
if (url.includes('github.com')) return { icon: 'github', label: 'GitHub' };
|
||||||
|
if (url.includes('instagram.com')) return { icon: 'instagram', label: 'Instagram' };
|
||||||
|
if (url.includes('x.com') || url.includes('twitter.com')) return { icon: 'x-twitter', label: 'X' };
|
||||||
|
if (url.includes('linkedin.com')) return { icon: 'linkedin', label: 'LinkedIn' };
|
||||||
|
if (url.includes('bsky.app')) return { icon: 'bluesky', label: 'Bluesky' };
|
||||||
|
return { icon: 'link', label: 'Social' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate unique ID for this header instance
|
||||||
|
const menuId = `mobile-menu-${Math.random().toString(36).slice(2, 9)}`;
|
||||||
|
const buttonId = `${menuId}-button`;
|
||||||
|
---
|
||||||
|
|
||||||
|
<header
|
||||||
|
class={headerClasses}
|
||||||
|
data-menu-id={menuId}
|
||||||
|
data-button-id={buttonId}
|
||||||
|
data-header-shape={shape}
|
||||||
|
data-header-variant={variant}
|
||||||
|
data-header-color-scheme={colorScheme}
|
||||||
|
{...attrs}
|
||||||
|
>
|
||||||
|
<div class={innerClasses}>
|
||||||
|
{/* Logo */}
|
||||||
|
{
|
||||||
|
!hideLogo &&
|
||||||
|
(hasLogoSlot ? (
|
||||||
|
<slot name="logo" />
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
{/* Desktop Navigation */}
|
||||||
|
{
|
||||||
|
layout !== 'minimal' &&
|
||||||
|
(hasNavSlot ? (
|
||||||
|
<nav class="hidden items-center gap-1 md:flex" aria-label="Main navigation">
|
||||||
|
<slot name="nav" />
|
||||||
|
</nav>
|
||||||
|
) : (
|
||||||
|
<nav class="hidden items-center gap-1 md:flex" aria-label="Main navigation">
|
||||||
|
{navItems.map(({ label, href }) => (
|
||||||
|
<a
|
||||||
|
href={href.startsWith('#') && !isLandingPage ? `/${href}` : href}
|
||||||
|
class={cn(
|
||||||
|
'nav-link relative rounded-md px-3 py-2 text-sm',
|
||||||
|
'transition-all duration-(--transition-fast)',
|
||||||
|
isFloating && 'hdr-invert-text',
|
||||||
|
isFloating
|
||||||
|
? (isActive(href)
|
||||||
|
? 'hdr-nav-active font-semibold'
|
||||||
|
: 'font-medium opacity-80 hover:opacity-100')
|
||||||
|
: (isActive(href)
|
||||||
|
? 'nav-link-active font-semibold text-foreground bg-secondary'
|
||||||
|
: 'nav-link-inactive font-medium text-foreground-muted hover:text-foreground hover:bg-secondary/70')
|
||||||
|
)}
|
||||||
|
aria-current={isActive(href) ? 'page' : undefined}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
{/* Actions Area */}
|
||||||
|
<div class="flex items-center gap-2 justify-self-end">
|
||||||
|
{
|
||||||
|
hasActionsSlot ? (
|
||||||
|
<slot name="actions" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{showThemeToggle && (
|
||||||
|
<ThemeToggle class={isFloating ? 'hdr-invert-text' : undefined} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showThemeSelector && (
|
||||||
|
<div class="hidden md:flex">
|
||||||
|
<ThemeSelectorDropdown class={isFloating ? 'hdr-invert-text' : undefined} />
|
||||||
|
</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) => {
|
||||||
|
const { icon, label } = getSocialIconData(url);
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label={label}
|
||||||
|
class={cn(
|
||||||
|
'rounded-md p-2 transition-colors duration-(--transition-fast)',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||||
|
isFloating
|
||||||
|
? 'hdr-invert-text'
|
||||||
|
: 'text-foreground-muted hover:text-foreground hover:bg-secondary/70'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon name={icon} size="md" />
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{actions.map((action) => (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
icon={action.iconOnly}
|
||||||
|
href={action.href}
|
||||||
|
target={action.target}
|
||||||
|
aria-label={action.label}
|
||||||
|
class={isFloating ? 'hdr-invert-text' : undefined}
|
||||||
|
>
|
||||||
|
<Icon name={action.icon} size="sm" />
|
||||||
|
{!action.iconOnly && action.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{showCta && (
|
||||||
|
<div class="hidden md:flex">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
href={ctaHref}
|
||||||
|
target={ctaHref?.startsWith('http') ? '_blank' : undefined}
|
||||||
|
class={cn('hdr-cta-brand', isFloating ? 'hdr-invert-cta' : undefined)}
|
||||||
|
>
|
||||||
|
{cta.icon && <Icon name={cta.icon} size="sm" />}
|
||||||
|
{cta.label}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
{/* Mobile Menu Toggle */}
|
||||||
|
{
|
||||||
|
showMobileMenu && layout !== 'minimal' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id={buttonId}
|
||||||
|
class={cn(
|
||||||
|
'inline-flex items-center justify-center rounded-md p-2 md:hidden',
|
||||||
|
'transition-colors',
|
||||||
|
'focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none',
|
||||||
|
isFloating
|
||||||
|
? 'hdr-invert-text'
|
||||||
|
: 'text-foreground-muted hover:text-foreground hover:bg-secondary'
|
||||||
|
)}
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-controls={menuId}
|
||||||
|
aria-label="Toggle menu"
|
||||||
|
>
|
||||||
|
<span class="menu-icon">
|
||||||
|
<Icon name="menu" size="md" />
|
||||||
|
</span>
|
||||||
|
<span class="close-icon hidden">
|
||||||
|
<Icon name="x" size="md" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scroll Progress Bar */}
|
||||||
|
{showScrollProgress && (
|
||||||
|
<div
|
||||||
|
id="scroll-progress-bar"
|
||||||
|
class={`absolute left-0 h-[2px] w-0 bg-brand-500 transition-none ${scrollProgressPosition === 'top' ? 'top-0' : 'bottom-0'}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mobile Menu */}
|
||||||
|
{
|
||||||
|
showMobileMenu &&
|
||||||
|
layout !== 'minimal' &&
|
||||||
|
(hasMobileMenuSlot ? (
|
||||||
|
<div
|
||||||
|
id={menuId}
|
||||||
|
class={cn(
|
||||||
|
'hidden origin-top scale-y-0 opacity-0 shadow-[0_8px_24px_-12px_rgba(0,0,0,0.12)] md:hidden',
|
||||||
|
isFloating
|
||||||
|
? 'rounded-b-2xl bg-background/95 backdrop-blur-xl'
|
||||||
|
: 'border-border bg-background border-t'
|
||||||
|
)}
|
||||||
|
role="navigation"
|
||||||
|
aria-label="Mobile navigation"
|
||||||
|
>
|
||||||
|
<slot name="mobile-menu" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
id={menuId}
|
||||||
|
class={cn(
|
||||||
|
'hidden origin-top scale-y-0 opacity-0 shadow-[0_8px_24px_-12px_rgba(0,0,0,0.12)] md:hidden',
|
||||||
|
isFloating
|
||||||
|
? 'rounded-b-2xl bg-background/95 backdrop-blur-xl'
|
||||||
|
: 'border-border bg-background border-t'
|
||||||
|
)}
|
||||||
|
role="navigation"
|
||||||
|
aria-label="Mobile navigation"
|
||||||
|
>
|
||||||
|
<div class={cn(
|
||||||
|
'space-y-1 py-4',
|
||||||
|
isFloating ? 'px-4' : 'mx-auto max-w-6xl px-6'
|
||||||
|
)}>
|
||||||
|
{navItems.map(({ label, href }) => (
|
||||||
|
<a
|
||||||
|
href={href.startsWith('#') && !isLandingPage ? `/${href}` : href}
|
||||||
|
class={cn(
|
||||||
|
'mobile-nav-link block rounded-md px-3 py-2 text-sm',
|
||||||
|
'transition-all duration-(--transition-fast)',
|
||||||
|
isActive(href)
|
||||||
|
? 'mobile-nav-link-active bg-secondary text-foreground font-semibold'
|
||||||
|
: 'mobile-nav-link-inactive text-foreground-muted hover:bg-secondary/70 hover:text-foreground font-medium'
|
||||||
|
)}
|
||||||
|
aria-current={isActive(href) ? 'page' : undefined}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{showCta && (
|
||||||
|
<div class="border-border mt-3 border-t pt-3">
|
||||||
|
<Button fullWidth href={ctaHref} target={ctaHref?.startsWith('http') ? '_blank' : undefined}>
|
||||||
|
{cta.label}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showThemeSelector && (
|
||||||
|
<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">Colour theme</span>
|
||||||
|
<ThemeSelector />
|
||||||
|
</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>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Mobile Menu Backdrop - positioned outside header to blur page content */}
|
||||||
|
{
|
||||||
|
showMobileMenu && layout !== 'minimal' && (
|
||||||
|
<div
|
||||||
|
id={`${menuId}-backdrop`}
|
||||||
|
class="pointer-events-none fixed inset-0 z-40 opacity-0 transition-opacity duration-200 md:hidden"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function initMobileMenu() {
|
||||||
|
const menuHeaders = document.querySelectorAll<HTMLElement>('header[data-menu-id]');
|
||||||
|
menuHeaders.forEach((header) => {
|
||||||
|
const menuId = header.dataset.menuId!;
|
||||||
|
const buttonId = header.dataset.buttonId!;
|
||||||
|
const isFloating = header.dataset.headerShape === 'floating';
|
||||||
|
|
||||||
|
const button = document.getElementById(buttonId);
|
||||||
|
const menu = document.getElementById(menuId);
|
||||||
|
const backdrop = document.getElementById(`${menuId}-backdrop`);
|
||||||
|
const menuIcon = button?.querySelector('.menu-icon');
|
||||||
|
const closeIcon = button?.querySelector('.close-icon');
|
||||||
|
|
||||||
|
if (!button || !menu || !menuIcon || !closeIcon) return;
|
||||||
|
if (button.dataset.menuInit) return;
|
||||||
|
button.dataset.menuInit = 'true';
|
||||||
|
|
||||||
|
let isOpen = false;
|
||||||
|
let isAnimating = false;
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
if (isOpen || isAnimating) return;
|
||||||
|
isAnimating = true;
|
||||||
|
isOpen = true;
|
||||||
|
|
||||||
|
button!.setAttribute('aria-expanded', 'true');
|
||||||
|
menuIcon!.classList.add('hidden');
|
||||||
|
closeIcon!.classList.remove('hidden');
|
||||||
|
|
||||||
|
if (isFloating) {
|
||||||
|
// Force scrolled state + flatten bottom corners
|
||||||
|
header.setAttribute('data-scrolled', '');
|
||||||
|
header.classList.remove('rounded-2xl');
|
||||||
|
header.classList.add('rounded-t-2xl');
|
||||||
|
} else {
|
||||||
|
header.classList.add('!bg-background');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fade out and blur the page content
|
||||||
|
const mainContent = document.querySelector('main');
|
||||||
|
const footer = document.querySelector('footer');
|
||||||
|
if (mainContent) mainContent.classList.add('mobile-menu-blur');
|
||||||
|
if (footer) footer.classList.add('mobile-menu-blur');
|
||||||
|
|
||||||
|
// Show menu and backdrop with animations
|
||||||
|
menu!.classList.remove('hidden', 'animate-menu-up', 'opacity-0', 'scale-y-0');
|
||||||
|
menu!.classList.add('animate-menu-down');
|
||||||
|
if (backdrop) {
|
||||||
|
backdrop.classList.remove('pointer-events-none', 'animate-backdrop-out');
|
||||||
|
backdrop.classList.add('animate-backdrop');
|
||||||
|
}
|
||||||
|
|
||||||
|
isAnimating = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
if (!isOpen || isAnimating) return;
|
||||||
|
isAnimating = true;
|
||||||
|
|
||||||
|
button!.setAttribute('aria-expanded', 'false');
|
||||||
|
menuIcon!.classList.remove('hidden');
|
||||||
|
closeIcon!.classList.add('hidden');
|
||||||
|
|
||||||
|
// Start closing animation
|
||||||
|
menu!.classList.remove('animate-menu-down');
|
||||||
|
menu!.classList.add('animate-menu-up');
|
||||||
|
if (backdrop) {
|
||||||
|
backdrop.classList.remove('animate-backdrop');
|
||||||
|
backdrop.classList.add('animate-backdrop-out');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore page content
|
||||||
|
const mainContent = document.querySelector('main');
|
||||||
|
const footer = document.querySelector('footer');
|
||||||
|
if (mainContent) mainContent.classList.remove('mobile-menu-blur');
|
||||||
|
if (footer) footer.classList.remove('mobile-menu-blur');
|
||||||
|
|
||||||
|
// Wait for animation to complete before hiding
|
||||||
|
setTimeout(() => {
|
||||||
|
menu!.classList.add('hidden', 'opacity-0', 'scale-y-0');
|
||||||
|
if (backdrop) {
|
||||||
|
backdrop.classList.add('pointer-events-none');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFloating) {
|
||||||
|
// Restore rounded corners
|
||||||
|
header.classList.remove('rounded-t-2xl');
|
||||||
|
header.classList.add('rounded-2xl');
|
||||||
|
// Only remove scrolled if actually at top
|
||||||
|
if (window.scrollY <= 60) {
|
||||||
|
header.removeAttribute('data-scrolled');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
header.classList.remove('!bg-background');
|
||||||
|
}
|
||||||
|
|
||||||
|
isOpen = false;
|
||||||
|
isAnimating = false;
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
if (isOpen) {
|
||||||
|
close();
|
||||||
|
} else {
|
||||||
|
open();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
button.addEventListener('click', toggle);
|
||||||
|
|
||||||
|
// Close on backdrop click
|
||||||
|
if (backdrop) {
|
||||||
|
backdrop.addEventListener('click', close);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close on Escape key
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape' && isOpen) {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close when clicking on mobile menu links
|
||||||
|
menu.querySelectorAll('a').forEach((link) => {
|
||||||
|
link.addEventListener('click', close);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
initMobileMenu();
|
||||||
|
document.addEventListener('astro:page-load', initMobileMenu);
|
||||||
|
document.addEventListener('astro:after-swap', initMobileMenu);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const SCROLL_THRESHOLD = 60;
|
||||||
|
const BAR_SCROLLED_CLASSES = ['bg-background/80', 'backdrop-blur-lg', 'border-b', 'border-border/50'];
|
||||||
|
|
||||||
|
function initScrollWatcher() {
|
||||||
|
const scrollHeaders = document.querySelectorAll<HTMLElement>('header[data-header-shape="floating"], header[data-header-shape="bar"]');
|
||||||
|
scrollHeaders.forEach((header) => {
|
||||||
|
if (header.dataset.scrollInit) return;
|
||||||
|
header.dataset.scrollInit = 'true';
|
||||||
|
|
||||||
|
const isBar = header.dataset.headerShape === 'bar';
|
||||||
|
const isTransparentBar = isBar && header.dataset.headerVariant === 'transparent';
|
||||||
|
let ticking = false;
|
||||||
|
|
||||||
|
function onScroll() {
|
||||||
|
if (ticking) return;
|
||||||
|
ticking = true;
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (window.scrollY > SCROLL_THRESHOLD) {
|
||||||
|
header.setAttribute('data-scrolled', '');
|
||||||
|
if (isTransparentBar) {
|
||||||
|
header.classList.add(...BAR_SCROLLED_CLASSES);
|
||||||
|
header.classList.remove('bg-transparent');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Don't remove if mobile menu is open
|
||||||
|
const menuId = header.dataset.menuId;
|
||||||
|
const menu = menuId ? document.getElementById(menuId) : null;
|
||||||
|
const menuOpen = menu && !menu.classList.contains('hidden');
|
||||||
|
if (!menuOpen) {
|
||||||
|
header.removeAttribute('data-scrolled');
|
||||||
|
if (isTransparentBar) {
|
||||||
|
header.classList.remove(...BAR_SCROLLED_CLASSES);
|
||||||
|
header.classList.add('bg-transparent');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ticking = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('scroll', onScroll, { passive: true });
|
||||||
|
|
||||||
|
// Set initial state
|
||||||
|
onScroll();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
initScrollWatcher();
|
||||||
|
document.addEventListener('astro:page-load', initScrollWatcher);
|
||||||
|
document.addEventListener('astro:after-swap', initScrollWatcher);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function initScrollProgress() {
|
||||||
|
const bar = document.getElementById('scroll-progress-bar');
|
||||||
|
if (!bar) return;
|
||||||
|
if (bar.dataset.progressInit) return;
|
||||||
|
bar.dataset.progressInit = 'true';
|
||||||
|
|
||||||
|
let ticking = false;
|
||||||
|
|
||||||
|
function update() {
|
||||||
|
if (!bar) return;
|
||||||
|
const scrollTop = window.scrollY;
|
||||||
|
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
|
||||||
|
const pct = docHeight > 0 ? (scrollTop / docHeight) * 100 : 0;
|
||||||
|
bar!.style.width = `${pct}%`;
|
||||||
|
ticking = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('scroll', () => {
|
||||||
|
if (!ticking) {
|
||||||
|
ticking = true;
|
||||||
|
requestAnimationFrame(update);
|
||||||
|
}
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
|
||||||
|
initScrollProgress();
|
||||||
|
document.addEventListener('astro:page-load', initScrollProgress);
|
||||||
|
document.addEventListener('astro:after-swap', initScrollProgress);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style is:global>
|
||||||
|
.mobile-menu-blur {
|
||||||
|
opacity: 0.3;
|
||||||
|
filter: blur(4px);
|
||||||
|
transition: opacity 200ms, filter 200ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Floating header: scroll state ===== */
|
||||||
|
[data-header-shape="floating"][data-scrolled] {
|
||||||
|
background: color-mix(in oklch, var(--color-background) 92%, transparent);
|
||||||
|
backdrop-filter: blur(24px);
|
||||||
|
border-color: var(--color-border);
|
||||||
|
box-shadow: 0 4px 20px -6px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Floating header: color flip (invert → normal on scroll) ===== */
|
||||||
|
|
||||||
|
/* Text elements: on-invert → foreground */
|
||||||
|
[data-header-shape="floating"][data-header-color-scheme="invert"] .hdr-invert-text {
|
||||||
|
color: var(--color-on-invert);
|
||||||
|
transition: color 300ms;
|
||||||
|
}
|
||||||
|
[data-header-shape="floating"][data-header-color-scheme="invert"][data-scrolled] .hdr-invert-text {
|
||||||
|
color: var(--color-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Logo text: on-invert → brand-500 */
|
||||||
|
[data-header-shape="floating"][data-header-color-scheme="invert"] .hdr-logo-text {
|
||||||
|
color: var(--color-on-invert);
|
||||||
|
transition: color 300ms;
|
||||||
|
}
|
||||||
|
[data-header-shape="floating"][data-header-color-scheme="invert"][data-scrolled] .hdr-logo-text {
|
||||||
|
color: var(--color-brand-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Non-invert floating: use normal colors */
|
||||||
|
[data-header-shape="floating"][data-header-color-scheme="default"] .hdr-logo-text {
|
||||||
|
color: var(--color-brand-500);
|
||||||
|
}
|
||||||
|
[data-header-shape="floating"][data-header-color-scheme="default"] .hdr-invert-text {
|
||||||
|
color: var(--color-foreground-muted);
|
||||||
|
transition: color 300ms;
|
||||||
|
}
|
||||||
|
[data-header-shape="floating"][data-header-color-scheme="default"] .hdr-invert-text:hover {
|
||||||
|
color: var(--color-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Floating nav link underline indicators ===== */
|
||||||
|
[data-header-shape="floating"] .nav-link::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: 2px;
|
||||||
|
left: 50%;
|
||||||
|
right: 50%;
|
||||||
|
height: 2px;
|
||||||
|
background: currentColor;
|
||||||
|
border-radius: 1px;
|
||||||
|
transition: left 200ms, right 200ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-header-shape="floating"] .nav-link:hover::after,
|
||||||
|
[data-header-shape="floating"] .nav-link.hdr-nav-active::after {
|
||||||
|
left: 12px;
|
||||||
|
right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== CTA: invert color flip ===== */
|
||||||
|
[data-header-shape="floating"][data-header-color-scheme="invert"] .hdr-invert-cta {
|
||||||
|
background: white;
|
||||||
|
color: #111;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.15);
|
||||||
|
transition: background 300ms, color 300ms, border-color 300ms;
|
||||||
|
}
|
||||||
|
[data-header-shape="floating"][data-header-color-scheme="invert"] .hdr-invert-cta:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
[data-header-shape="floating"][data-header-color-scheme="invert"][data-scrolled] .hdr-invert-cta {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-primary-foreground);
|
||||||
|
}
|
||||||
|
[data-header-shape="floating"][data-header-color-scheme="invert"][data-scrolled] .hdr-invert-cta:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Reduced motion ===== */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
[data-header-shape="floating"],
|
||||||
|
[data-header-shape="floating"] .hdr-invert-text,
|
||||||
|
[data-header-shape="floating"] .hdr-logo-text,
|
||||||
|
[data-header-shape="floating"] .hdr-invert-cta,
|
||||||
|
[data-header-shape="floating"] .nav-link::after {
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
---
|
||||||
|
/**
|
||||||
|
* ThemeSelector
|
||||||
|
* A row of five colour swatches that switches the active colour theme at
|
||||||
|
* runtime by writing `data-theme` on <html> and persisting to localStorage.
|
||||||
|
*
|
||||||
|
* Usage in Header: <ThemeSelector />
|
||||||
|
* Pass `class` to tint the label colour for floating / inverted headers.
|
||||||
|
*/
|
||||||
|
interface Props {
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { class: className } = Astro.props;
|
||||||
|
|
||||||
|
// All 13 themes in Tailwind color order
|
||||||
|
const themes = [
|
||||||
|
{ id: 'orange', name: 'Orange', color: 'oklch(62.5% 0.22 38)' },
|
||||||
|
{ id: 'amber', name: 'Amber', color: 'oklch(68% 0.19 75)' },
|
||||||
|
{ id: 'lime', name: 'Lime', color: 'oklch(64% 0.27 130)' },
|
||||||
|
{ id: 'emerald', name: 'Emerald', color: 'oklch(62.5% 0.22 160)' },
|
||||||
|
{ id: 'teal', name: 'Teal', color: 'oklch(62.5% 0.22 190)' },
|
||||||
|
{ id: 'cyan', name: 'Cyan', color: 'oklch(65% 0.22 200)' },
|
||||||
|
{ id: 'sky', name: 'Sky', color: 'oklch(67% 0.21 222)' },
|
||||||
|
{ id: 'blue', name: 'Blue', color: 'oklch(62.5% 0.22 255)' },
|
||||||
|
{ id: 'indigo', name: 'Indigo', color: 'oklch(60% 0.24 264)' },
|
||||||
|
{ id: 'violet', name: 'Violet', color: 'oklch(62.5% 0.26 277)' },
|
||||||
|
{ id: 'purple', name: 'Purple', color: 'oklch(62.5% 0.25 303)' },
|
||||||
|
{ id: 'magenta', name: 'Magenta', color: 'oklch(58% 0.28 330)' },
|
||||||
|
];
|
||||||
|
---
|
||||||
|
|
||||||
|
<div
|
||||||
|
class:list={['theme-selector flex flex-wrap items-center gap-1', className]}
|
||||||
|
role="group"
|
||||||
|
aria-label="Select colour theme"
|
||||||
|
>
|
||||||
|
{themes.map((theme) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="theme-swatch relative h-3.5 w-3.5 rounded-full transition-transform duration-150 focus-visible:outline-none"
|
||||||
|
data-theme-id={theme.id}
|
||||||
|
style={`background-color:${theme.color};--swatch:${theme.color}`}
|
||||||
|
title={theme.name}
|
||||||
|
aria-label={`${theme.name} theme`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Inactive: subtle border + dimmed so active pops without extra decoration */
|
||||||
|
.theme-swatch {
|
||||||
|
box-shadow: inset 0 0 0 1px oklch(0% 0 0 / 0.12);
|
||||||
|
opacity: 0.55;
|
||||||
|
transition:
|
||||||
|
opacity 150ms ease,
|
||||||
|
transform 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active: full opacity + slim underline pill in the swatch colour */
|
||||||
|
.theme-swatch[data-active] {
|
||||||
|
opacity: 1;
|
||||||
|
box-shadow: inset 0 0 0 1px oklch(0% 0 0 / 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-swatch[data-active]::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: -3px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 10px;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--swatch);
|
||||||
|
border-radius: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover: lift opacity on inactive */
|
||||||
|
.theme-swatch:not([data-active]):hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const STORAGE_KEY = 'color-theme';
|
||||||
|
const DEFAULT_THEME = 'blue';
|
||||||
|
|
||||||
|
function getActiveTheme(): string {
|
||||||
|
try {
|
||||||
|
return sessionStorage.getItem(STORAGE_KEY) || DEFAULT_THEME;
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_THEME;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTheme(id: string) {
|
||||||
|
document.documentElement.setAttribute('data-theme', id);
|
||||||
|
try { sessionStorage.setItem(STORAGE_KEY, id); } catch { /* private mode */ }
|
||||||
|
|
||||||
|
// Update every swatch on the page (handles multiple selector instances)
|
||||||
|
const swatches = document.querySelectorAll<HTMLButtonElement>('.theme-swatch');
|
||||||
|
swatches.forEach((btn) => {
|
||||||
|
if (btn.dataset.themeId === id) {
|
||||||
|
btn.setAttribute('data-active', '');
|
||||||
|
} else {
|
||||||
|
btn.removeAttribute('data-active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initThemeSelector() {
|
||||||
|
const active = getActiveTheme();
|
||||||
|
|
||||||
|
const activeSwatches = document.querySelectorAll<HTMLButtonElement>('.theme-swatch');
|
||||||
|
activeSwatches.forEach((btn) => {
|
||||||
|
// Mark current active
|
||||||
|
if (btn.dataset.themeId === active) {
|
||||||
|
btn.setAttribute('data-active', '');
|
||||||
|
} else {
|
||||||
|
btn.removeAttribute('data-active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard against double-binding across re-runs
|
||||||
|
if (btn.dataset.selectorInit) return;
|
||||||
|
btn.dataset.selectorInit = 'true';
|
||||||
|
|
||||||
|
btn.addEventListener('click', () => setTheme(btn.dataset.themeId!));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
initThemeSelector();
|
||||||
|
document.addEventListener('astro:page-load', initThemeSelector);
|
||||||
|
document.addEventListener('astro:after-swap', initThemeSelector);
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
---
|
||||||
|
/**
|
||||||
|
* ThemeSelectorDropdown
|
||||||
|
* A dropdown button for desktop that exposes the colour-theme swatches.
|
||||||
|
* The mobile menu still uses the flat ThemeSelector component.
|
||||||
|
*/
|
||||||
|
import { cn } from '@/lib/cn';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { class: className } = Astro.props;
|
||||||
|
|
||||||
|
// All 13 themes in Tailwind color order
|
||||||
|
const themes = [
|
||||||
|
{ id: 'orange', name: 'Orange', color: 'oklch(62.5% 0.22 38)' },
|
||||||
|
{ id: 'amber', name: 'Amber', color: 'oklch(68% 0.19 75)' },
|
||||||
|
{ id: 'lime', name: 'Lime', color: 'oklch(64% 0.27 130)' },
|
||||||
|
{ id: 'emerald', name: 'Emerald', color: 'oklch(62.5% 0.22 160)' },
|
||||||
|
{ id: 'teal', name: 'Teal', color: 'oklch(62.5% 0.22 190)' },
|
||||||
|
{ id: 'cyan', name: 'Cyan', color: 'oklch(65% 0.22 200)' },
|
||||||
|
{ id: 'sky', name: 'Sky', color: 'oklch(67% 0.21 222)' },
|
||||||
|
{ id: 'blue', name: 'Blue', color: 'oklch(62.5% 0.22 255)' },
|
||||||
|
{ id: 'indigo', name: 'Indigo', color: 'oklch(60% 0.24 264)' },
|
||||||
|
{ id: 'violet', name: 'Violet', color: 'oklch(62.5% 0.26 277)' },
|
||||||
|
{ id: 'purple', name: 'Purple', color: 'oklch(62.5% 0.25 303)' },
|
||||||
|
{ id: 'magenta', name: 'Magenta', color: 'oklch(58% 0.28 330)' },
|
||||||
|
];
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class={cn('relative theme-dropdown-wrapper', className)}>
|
||||||
|
<!-- Trigger button -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="theme-dropdown-trigger"
|
||||||
|
aria-haspopup="true"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-label="Select colour theme"
|
||||||
|
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'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<!-- Active theme dot -->
|
||||||
|
<span
|
||||||
|
class="h-3.5 w-3.5 rounded-full block flex-shrink-0"
|
||||||
|
style="background-color: var(--color-brand-500)"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<!-- Chevron — inline, rotates on open -->
|
||||||
|
<svg
|
||||||
|
class="theme-chevron h-3 w-3 flex-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="theme-dropdown-panel"
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Colour theme"
|
||||||
|
class={cn(
|
||||||
|
'absolute right-0 top-full mt-2 z-50',
|
||||||
|
'w-52 rounded-xl border border-border bg-background shadow-lg',
|
||||||
|
'p-2.5',
|
||||||
|
'hidden'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p class="text-xs font-medium text-foreground-muted mb-2.5 px-0.5">Colour theme</p>
|
||||||
|
<div class="grid grid-cols-4 gap-1.5">
|
||||||
|
{themes.map((theme) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="theme-swatch-dd group flex flex-col items-center gap-1 rounded-lg p-1 hover:bg-secondary transition-colors duration-(--transition-fast) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
data-theme-id={theme.id}
|
||||||
|
aria-label={`${theme.name} theme`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="h-4 w-4 rounded-full block"
|
||||||
|
style={`background-color:${theme.color}`}
|
||||||
|
/>
|
||||||
|
<span class="text-[9px] font-medium text-foreground-muted group-hover:text-foreground leading-none">
|
||||||
|
{theme.name}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.theme-swatch-dd[data-active] {
|
||||||
|
background-color: var(--color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-swatch-dd[data-active] span:first-child {
|
||||||
|
box-shadow: 0 0 0 2px var(--color-background), 0 0 0 4px var(--color-brand-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-swatch-dd[data-active] span:last-child {
|
||||||
|
color: var(--color-foreground);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const STORAGE_KEY = 'color-theme';
|
||||||
|
const DEFAULT_THEME = 'blue';
|
||||||
|
|
||||||
|
function getActiveTheme(): string {
|
||||||
|
try {
|
||||||
|
return sessionStorage.getItem(STORAGE_KEY) || DEFAULT_THEME;
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_THEME;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTheme(id: string) {
|
||||||
|
document.documentElement.setAttribute('data-theme', id);
|
||||||
|
try { sessionStorage.setItem(STORAGE_KEY, id); } catch { /* private mode */ }
|
||||||
|
|
||||||
|
// Update dropdown swatches
|
||||||
|
const ddSwatches = document.querySelectorAll<HTMLButtonElement>('.theme-swatch-dd');
|
||||||
|
ddSwatches.forEach((btn) => {
|
||||||
|
if (btn.dataset.themeId === id) {
|
||||||
|
btn.setAttribute('data-active', '');
|
||||||
|
} else {
|
||||||
|
btn.removeAttribute('data-active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also sync flat ThemeSelector swatches (mobile menu)
|
||||||
|
const flatSwatches = document.querySelectorAll<HTMLButtonElement>('.theme-swatch');
|
||||||
|
flatSwatches.forEach((btn) => {
|
||||||
|
if (btn.dataset.themeId === id) {
|
||||||
|
btn.setAttribute('data-active', '');
|
||||||
|
} else {
|
||||||
|
btn.removeAttribute('data-active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDropdown() {
|
||||||
|
const panel = document.getElementById('theme-dropdown-panel');
|
||||||
|
const trigger = document.getElementById('theme-dropdown-trigger');
|
||||||
|
panel?.classList.add('hidden');
|
||||||
|
trigger?.setAttribute('aria-expanded', 'false');
|
||||||
|
trigger?.querySelector('.theme-chevron')?.classList.remove('rotate-180');
|
||||||
|
}
|
||||||
|
|
||||||
|
function initThemeDropdown() {
|
||||||
|
const trigger = document.getElementById('theme-dropdown-trigger');
|
||||||
|
const panel = document.getElementById('theme-dropdown-panel');
|
||||||
|
if (!trigger || !panel) return;
|
||||||
|
|
||||||
|
// Guard against double-binding
|
||||||
|
if (trigger.dataset.dropdownInit) return;
|
||||||
|
trigger.dataset.dropdownInit = 'true';
|
||||||
|
|
||||||
|
const active = getActiveTheme();
|
||||||
|
|
||||||
|
// Mark active swatch
|
||||||
|
const activeDdSwatches = document.querySelectorAll<HTMLButtonElement>('.theme-swatch-dd');
|
||||||
|
activeDdSwatches.forEach((btn) => {
|
||||||
|
if (btn.dataset.themeId === active) {
|
||||||
|
btn.setAttribute('data-active', '');
|
||||||
|
} else {
|
||||||
|
btn.removeAttribute('data-active');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!btn.dataset.selectorInit) {
|
||||||
|
btn.dataset.selectorInit = 'true';
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
setTheme(btn.dataset.themeId!);
|
||||||
|
closeDropdown();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toggle dropdown
|
||||||
|
trigger.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const isOpen = panel.classList.contains('hidden') === false;
|
||||||
|
if (isOpen) {
|
||||||
|
closeDropdown();
|
||||||
|
} else {
|
||||||
|
panel.classList.remove('hidden');
|
||||||
|
trigger.setAttribute('aria-expanded', 'true');
|
||||||
|
trigger.querySelector('.theme-chevron')?.classList.add('rotate-180');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close on outside click
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
const wrapper = trigger.closest('.theme-dropdown-wrapper');
|
||||||
|
if (wrapper && !wrapper.contains(e.target as Node)) {
|
||||||
|
closeDropdown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close on Escape
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') closeDropdown();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
initThemeDropdown();
|
||||||
|
document.addEventListener('astro:page-load', initThemeDropdown);
|
||||||
|
document.addEventListener('astro:after-swap', initThemeDropdown);
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
---
|
||||||
|
import { cn } from '@/lib/cn';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { class: className } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="theme-toggle"
|
||||||
|
class={cn(
|
||||||
|
'inline-flex items-center justify-center rounded-md p-2',
|
||||||
|
'text-muted-foreground 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',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
aria-label="Toggle theme"
|
||||||
|
>
|
||||||
|
<!-- Sun icon (shown in dark mode) -->
|
||||||
|
<svg
|
||||||
|
class="h-5 w-5 hidden dark:block"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<!-- Moon icon (shown in light mode) -->
|
||||||
|
<svg
|
||||||
|
class="h-5 w-5 block dark:hidden"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function initThemeToggle() {
|
||||||
|
const toggle = document.getElementById('theme-toggle');
|
||||||
|
|
||||||
|
// Guard: skip if not found or already initialised on this element
|
||||||
|
if (!toggle || toggle.dataset.themeInit) return;
|
||||||
|
toggle.dataset.themeInit = 'true';
|
||||||
|
|
||||||
|
toggle.addEventListener('click', () => {
|
||||||
|
const isDark = document.documentElement.classList.contains('dark');
|
||||||
|
|
||||||
|
if (isDark) {
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
sessionStorage.setItem('theme', 'light');
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
sessionStorage.removeItem('theme');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run on initial load and after every view-transition swap
|
||||||
|
initThemeToggle();
|
||||||
|
document.addEventListener('astro:after-swap', initThemeToggle);
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
|
||||||
|
export const footerVariants = cva('py-[var(--space-stack-lg)]', {
|
||||||
|
variants: {
|
||||||
|
background: {
|
||||||
|
default: 'bg-background border-t border-border',
|
||||||
|
secondary: 'bg-surface-secondary border-t border-border',
|
||||||
|
invert: 'invert-section bg-background border-t border-border',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
background: 'default',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const footerColumnGridVariants = cva('grid grid-cols-1 gap-[var(--space-stack-lg)]', {
|
||||||
|
variants: {
|
||||||
|
columns: {
|
||||||
|
2: 'md:grid-cols-2',
|
||||||
|
3: 'md:grid-cols-3',
|
||||||
|
4: 'md:grid-cols-4',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
columns: 3,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type FooterVariants = VariantProps<typeof footerVariants>;
|
||||||
|
export type FooterColumnGridVariants = VariantProps<typeof footerColumnGridVariants>;
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
|
||||||
|
export const headerVariants = cva('z-50', {
|
||||||
|
variants: {
|
||||||
|
position: {
|
||||||
|
fixed: 'fixed top-0 left-0 right-0',
|
||||||
|
sticky: 'sticky top-0',
|
||||||
|
static: 'relative',
|
||||||
|
},
|
||||||
|
variant: {
|
||||||
|
default: 'bg-background/80 backdrop-blur-lg border-b border-border/50',
|
||||||
|
solid: 'bg-background border-b border-border-strong',
|
||||||
|
transparent: 'bg-transparent',
|
||||||
|
},
|
||||||
|
shape: {
|
||||||
|
bar: 'w-full transition-[background,border-color,box-shadow,backdrop-filter] duration-300',
|
||||||
|
floating: 'rounded-2xl transition-[background,border-color,box-shadow] duration-300',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
compoundVariants: [
|
||||||
|
// Floating + fixed: centered with gap
|
||||||
|
{ shape: 'floating', position: 'fixed', class: '!left-1/2 !right-auto -translate-x-1/2 w-[calc(100%-2rem)] max-w-6xl mt-4' },
|
||||||
|
// Floating + sticky: centered with gap
|
||||||
|
{ shape: 'floating', position: 'sticky', class: '!top-4 mx-auto max-w-6xl' },
|
||||||
|
// Floating + static: centered
|
||||||
|
{ shape: 'floating', position: 'static', class: 'mx-auto max-w-6xl' },
|
||||||
|
// Floating + transparent: glass effect
|
||||||
|
{ shape: 'floating', variant: 'transparent', class: 'bg-white/[0.06] backdrop-blur-xl border border-white/[0.08]' },
|
||||||
|
// Floating + default: semi-transparent with blur
|
||||||
|
{ shape: 'floating', variant: 'default', class: '!bg-background/80 backdrop-blur-xl !border border-border/50 !border-b-border/50' },
|
||||||
|
// Floating + solid: opaque
|
||||||
|
{ shape: 'floating', variant: 'solid', class: '!bg-background !border border-border !border-b-border' },
|
||||||
|
],
|
||||||
|
defaultVariants: {
|
||||||
|
position: 'sticky',
|
||||||
|
variant: 'default',
|
||||||
|
shape: 'bar',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const headerInnerVariants = cva(
|
||||||
|
'flex items-center justify-between md:grid md:grid-cols-[1fr_auto_1fr]',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
sm: 'h-12',
|
||||||
|
md: 'h-14',
|
||||||
|
lg: 'h-16',
|
||||||
|
},
|
||||||
|
shape: {
|
||||||
|
bar: 'mx-auto max-w-6xl px-6',
|
||||||
|
floating: 'px-4',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
size: 'md',
|
||||||
|
shape: 'bar',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export type HeaderVariants = VariantProps<typeof headerVariants>;
|
||||||
|
export type HeaderInnerVariants = VariantProps<typeof headerInnerVariants>;
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
---
|
||||||
|
import Input from '@/components/ui/form/Input/Input.astro';
|
||||||
|
import Textarea from '@/components/ui/form/Textarea/Textarea.astro';
|
||||||
|
import Button from '@/components/ui/form/Button/Button.astro';
|
||||||
|
import Icon from '@/components/ui/primitives/Icon/Icon.astro';
|
||||||
|
import { cn } from '@/lib/cn';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
action?: string;
|
||||||
|
successMessage?: string;
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
action = '/api/contact',
|
||||||
|
successMessage = 'Message sent successfully!',
|
||||||
|
class: className,
|
||||||
|
} = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<form
|
||||||
|
id="contact-form"
|
||||||
|
action={action}
|
||||||
|
method="POST"
|
||||||
|
class={cn('space-y-6', className)}
|
||||||
|
data-success-message={successMessage}
|
||||||
|
>
|
||||||
|
<!-- Name + Email side by side on sm+ -->
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<Input
|
||||||
|
label="Name"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
autocomplete="name"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
autocomplete="email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Subject"
|
||||||
|
name="subject"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
label="Message"
|
||||||
|
name="message"
|
||||||
|
required
|
||||||
|
rows={5}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Honeypot field for spam protection -->
|
||||||
|
<div class="hidden" aria-hidden="true">
|
||||||
|
<input type="text" name="honeypot" tabindex="-1" autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="form-message" class="hidden text-sm"></div>
|
||||||
|
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<Button type="submit" id="submit-button" class="gap-2">
|
||||||
|
Send message
|
||||||
|
<Icon name="arrow-right" size="sm" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function initContactForm() {
|
||||||
|
const form = document.getElementById('contact-form') as HTMLFormElement;
|
||||||
|
const button = document.getElementById('submit-button') as HTMLButtonElement;
|
||||||
|
const message = document.getElementById('form-message') as HTMLDivElement;
|
||||||
|
|
||||||
|
if (!form || !button || !message) return;
|
||||||
|
|
||||||
|
const successMessage = form.dataset.successMessage || 'Message sent successfully!';
|
||||||
|
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
button.disabled = true;
|
||||||
|
button.textContent = 'Sending…';
|
||||||
|
message.classList.add('hidden');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData(form);
|
||||||
|
const response = await fetch(form.action, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
message.textContent = successMessage;
|
||||||
|
message.className = 'text-sm text-success';
|
||||||
|
form.reset();
|
||||||
|
} else {
|
||||||
|
const errors = data.errors
|
||||||
|
? Object.values(data.errors).flat().join(', ')
|
||||||
|
: 'Something went wrong';
|
||||||
|
message.textContent = errors as string;
|
||||||
|
message.className = 'text-sm text-destructive';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
message.textContent = 'Failed to send message. Please try again.';
|
||||||
|
message.className = 'text-sm text-destructive';
|
||||||
|
} finally {
|
||||||
|
button.disabled = false;
|
||||||
|
button.textContent = 'Send message';
|
||||||
|
message.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
initContactForm();
|
||||||
|
document.addEventListener('astro:after-swap', initContactForm);
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
---
|
||||||
|
/**
|
||||||
|
* EmptyState Pattern
|
||||||
|
* Composition example: Icon + text + action for empty data states.
|
||||||
|
* Shows how to compose UI primitives into a reusable pattern.
|
||||||
|
*/
|
||||||
|
import { cn } from '@/lib/cn';
|
||||||
|
import Icon from '@/components/ui/primitives/Icon/Icon.astro';
|
||||||
|
import Button from '@/components/ui/form/Button/Button.astro';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Icon name to display */
|
||||||
|
icon?: string;
|
||||||
|
/** Title text */
|
||||||
|
title: string;
|
||||||
|
/** Description text */
|
||||||
|
description?: string;
|
||||||
|
/** Primary action button label */
|
||||||
|
actionLabel?: string;
|
||||||
|
/** Primary action button href */
|
||||||
|
actionHref?: string;
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
icon = 'inbox',
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
actionLabel,
|
||||||
|
actionHref,
|
||||||
|
class: className,
|
||||||
|
} = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class={cn('flex flex-col items-center justify-center text-center py-12 px-4', className)}>
|
||||||
|
<div class="w-12 h-12 rounded-xl bg-secondary flex items-center justify-center text-foreground-muted mb-4">
|
||||||
|
<Icon name={icon} size="lg" />
|
||||||
|
</div>
|
||||||
|
<h3 class="text-base font-semibold text-foreground mb-1">{title}</h3>
|
||||||
|
{description && (
|
||||||
|
<p class="text-sm text-foreground-muted max-w-sm mb-4">{description}</p>
|
||||||
|
)}
|
||||||
|
{actionLabel && (
|
||||||
|
<Button variant="secondary" size="sm" href={actionHref}>
|
||||||
|
{actionLabel}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
import { cn } from '@/lib/cn';
|
||||||
|
import { generateId } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
label?: string;
|
||||||
|
error?: string;
|
||||||
|
hint?: string;
|
||||||
|
required?: boolean;
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { label, error, hint, required = false, class: className } = Astro.props;
|
||||||
|
|
||||||
|
const fieldId = generateId('field');
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class={cn('space-y-1.5', className)}>
|
||||||
|
{
|
||||||
|
label && (
|
||||||
|
<label for={fieldId} class="text-sm font-medium leading-none">
|
||||||
|
{label}
|
||||||
|
{required && <span class="text-destructive ml-0.5">*</span>}
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
<slot name="input" id={fieldId} />
|
||||||
|
|
||||||
|
{
|
||||||
|
error && (
|
||||||
|
<p class="text-sm text-destructive" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
hint && !error && (
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
{hint}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
---
|
||||||
|
import Input from '@/components/ui/form/Input/Input.astro';
|
||||||
|
import Button from '@/components/ui/form/Button/Button.astro';
|
||||||
|
import { cn } from '@/lib/cn';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
action?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
buttonText?: string;
|
||||||
|
successMessage?: string;
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
action = '/api/newsletter',
|
||||||
|
placeholder = 'Enter your email',
|
||||||
|
buttonText = 'Subscribe',
|
||||||
|
successMessage = 'Thanks for subscribing!',
|
||||||
|
class: className,
|
||||||
|
} = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<form
|
||||||
|
class={cn('newsletter-form', className)}
|
||||||
|
action={action}
|
||||||
|
method="POST"
|
||||||
|
data-success-message={successMessage}
|
||||||
|
>
|
||||||
|
<div class="flex flex-col sm:flex-row gap-3">
|
||||||
|
<div class="flex-1">
|
||||||
|
<Input
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
placeholder={placeholder}
|
||||||
|
required
|
||||||
|
autocomplete="email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" class="newsletter-submit">
|
||||||
|
{buttonText}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="newsletter-message hidden mt-3 text-sm"></p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function initNewsletterForms() {
|
||||||
|
const forms = document.querySelectorAll<HTMLFormElement>('.newsletter-form');
|
||||||
|
|
||||||
|
forms.forEach((form) => {
|
||||||
|
const button = form.querySelector('.newsletter-submit') as HTMLButtonElement;
|
||||||
|
const message = form.querySelector('.newsletter-message') as HTMLParagraphElement;
|
||||||
|
|
||||||
|
if (!button || !message) return;
|
||||||
|
|
||||||
|
const successMessage = form.dataset.successMessage || 'Thanks for subscribing!';
|
||||||
|
const originalText = button.textContent || 'Subscribe';
|
||||||
|
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
button.disabled = true;
|
||||||
|
button.textContent = 'Subscribing...';
|
||||||
|
message.classList.add('hidden');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData(form);
|
||||||
|
const response = await fetch(form.action, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
message.textContent = successMessage;
|
||||||
|
message.className = 'newsletter-message mt-3 text-sm text-success';
|
||||||
|
form.reset();
|
||||||
|
} else {
|
||||||
|
message.textContent = data.error || 'Something went wrong';
|
||||||
|
message.className = 'newsletter-message mt-3 text-sm text-destructive';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
message.textContent = 'Subscription failed. Please try again.';
|
||||||
|
message.className = 'newsletter-message mt-3 text-sm text-destructive';
|
||||||
|
} finally {
|
||||||
|
button.disabled = false;
|
||||||
|
button.textContent = originalText;
|
||||||
|
message.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
initNewsletterForms();
|
||||||
|
document.addEventListener('astro:after-swap', initNewsletterForms);
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
---
|
||||||
|
/**
|
||||||
|
* PasswordInput Pattern
|
||||||
|
* Composition example: Input with show/hide password toggle.
|
||||||
|
* Demonstrates building interactive patterns from UI primitives.
|
||||||
|
*/
|
||||||
|
import type { HTMLAttributes } from 'astro/types';
|
||||||
|
import { cn } from '@/lib/cn';
|
||||||
|
import { generateId } from '@/lib/utils';
|
||||||
|
import { inputVariants, inputSizeConfig } from '@/components/ui/form/Input/input.variants';
|
||||||
|
import Icon from '@/components/ui/primitives/Icon/Icon.astro';
|
||||||
|
|
||||||
|
interface Props extends HTMLAttributes<'input'> {
|
||||||
|
label?: string;
|
||||||
|
error?: string;
|
||||||
|
hint?: string;
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
placeholder?: string;
|
||||||
|
class?: string;
|
||||||
|
id?: string;
|
||||||
|
autocomplete?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
label,
|
||||||
|
error,
|
||||||
|
hint,
|
||||||
|
size = 'md',
|
||||||
|
placeholder = 'Enter password',
|
||||||
|
autocomplete = 'current-password',
|
||||||
|
class: className,
|
||||||
|
id,
|
||||||
|
...rest
|
||||||
|
} = Astro.props;
|
||||||
|
|
||||||
|
const inputId = id || generateId('password');
|
||||||
|
const config = inputSizeConfig[size];
|
||||||
|
|
||||||
|
const inputStyles = cn(
|
||||||
|
inputVariants({ size }),
|
||||||
|
error && 'border-destructive focus-visible:ring-destructive',
|
||||||
|
config.baseLeftPadding,
|
||||||
|
config.trailingPadding
|
||||||
|
);
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class={cn('space-y-1.5', className)}>
|
||||||
|
{label && (
|
||||||
|
<label for={inputId} class="text-sm font-medium leading-none">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id={inputId}
|
||||||
|
class={inputStyles}
|
||||||
|
placeholder={placeholder}
|
||||||
|
autocomplete={autocomplete}
|
||||||
|
aria-invalid={error ? 'true' : undefined}
|
||||||
|
aria-describedby={error ? `${inputId}-error` : hint ? `${inputId}-hint` : undefined}
|
||||||
|
data-password-input
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={cn(
|
||||||
|
'absolute right-0 top-0 flex items-center justify-center h-full',
|
||||||
|
'text-foreground-muted hover:text-foreground transition-colors',
|
||||||
|
config.iconWrapper
|
||||||
|
)}
|
||||||
|
data-password-toggle
|
||||||
|
aria-label="Toggle password visibility"
|
||||||
|
>
|
||||||
|
<span data-icon-show><Icon name="eye" size="sm" /></span>
|
||||||
|
<span data-icon-hide class="hidden"><Icon name="eye-off" size="sm" /></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p id={`${inputId}-error`} class="text-sm text-destructive">{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hint && !error && (
|
||||||
|
<p id={`${inputId}-hint`} class="text-sm text-muted-foreground">{hint}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function initPasswordInputs() {
|
||||||
|
document.querySelectorAll('[data-password-toggle]').forEach((el) => {
|
||||||
|
const btn = el as HTMLElement;
|
||||||
|
if (btn.dataset.initialized) return;
|
||||||
|
btn.dataset.initialized = 'true';
|
||||||
|
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const input = btn.parentElement?.querySelector('[data-password-input]') as HTMLInputElement;
|
||||||
|
const showIcon = btn.querySelector('[data-icon-show]') as HTMLElement;
|
||||||
|
const hideIcon = btn.querySelector('[data-icon-hide]') as HTMLElement;
|
||||||
|
|
||||||
|
if (!input || !showIcon || !hideIcon) return;
|
||||||
|
|
||||||
|
const isPassword = input.type === 'password';
|
||||||
|
input.type = isPassword ? 'text' : 'password';
|
||||||
|
showIcon.classList.toggle('hidden', isPassword);
|
||||||
|
hideIcon.classList.toggle('hidden', !isPassword);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
initPasswordInputs();
|
||||||
|
document.addEventListener('astro:page-load', initPasswordInputs);
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
/**
|
||||||
|
* SearchInput Pattern
|
||||||
|
* Composition example: Input + Icon for a search field.
|
||||||
|
* Demonstrates building on UI primitives.
|
||||||
|
*/
|
||||||
|
import Input from '@/components/ui/form/Input/Input.astro';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
placeholder?: string;
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
placeholder = 'Search...',
|
||||||
|
size = 'md',
|
||||||
|
class: className,
|
||||||
|
} = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
placeholder={placeholder}
|
||||||
|
size={size}
|
||||||
|
leadingIcon="search"
|
||||||
|
class={className}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
---
|
||||||
|
/**
|
||||||
|
* StatCard Pattern
|
||||||
|
* Composition example: Card + typography for a metric display.
|
||||||
|
* Common in dashboards and landing pages.
|
||||||
|
*/
|
||||||
|
import { cn } from '@/lib/cn';
|
||||||
|
import Card from '@/components/ui/data-display/Card/Card.astro';
|
||||||
|
import Icon from '@/components/ui/primitives/Icon/Icon.astro';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
/** Optional trend indicator: 'up' | 'down' | 'neutral' */
|
||||||
|
trend?: 'up' | 'down' | 'neutral';
|
||||||
|
/** Trend description text (e.g., "+12% from last month") */
|
||||||
|
trendText?: string;
|
||||||
|
/** Icon name from the Icon component */
|
||||||
|
icon?: string;
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
trend,
|
||||||
|
trendText,
|
||||||
|
icon,
|
||||||
|
class: className,
|
||||||
|
} = Astro.props;
|
||||||
|
|
||||||
|
const trendColors = {
|
||||||
|
up: 'text-[var(--success)]',
|
||||||
|
down: 'text-[var(--error)]',
|
||||||
|
neutral: 'text-foreground-muted',
|
||||||
|
};
|
||||||
|
|
||||||
|
const trendIcons = {
|
||||||
|
up: 'trending-up',
|
||||||
|
down: 'trending-down',
|
||||||
|
neutral: 'minus',
|
||||||
|
};
|
||||||
|
---
|
||||||
|
|
||||||
|
<Card variant="default" padding="md" hover class={className}>
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-foreground-muted">{label}</p>
|
||||||
|
<p class="text-2xl font-bold text-foreground mt-1">{value}</p>
|
||||||
|
{trend && trendText && (
|
||||||
|
<div class={cn('flex items-center gap-1 mt-2 text-xs font-medium', trendColors[trend])}>
|
||||||
|
<Icon name={trendIcons[trend]} size="xs" />
|
||||||
|
<span>{trendText}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{icon && (
|
||||||
|
<div class="p-2.5 rounded-lg bg-secondary text-foreground-muted">
|
||||||
|
<Icon name={icon} size="md" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
---
|
||||||
|
import { Image } from 'astro:assets';
|
||||||
|
import type { ImageMetadata } from 'astro';
|
||||||
|
import Button from '@/components/ui/form/Button/Button.astro';
|
||||||
|
import Icon from '@/components/ui/primitives/Icon/Icon.astro';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
tags?: string[];
|
||||||
|
year?: number;
|
||||||
|
client?: string;
|
||||||
|
role?: string;
|
||||||
|
services?: string[];
|
||||||
|
url?: string;
|
||||||
|
repo?: string;
|
||||||
|
image?: ImageMetadata;
|
||||||
|
imageAlt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
tags = [],
|
||||||
|
year,
|
||||||
|
client,
|
||||||
|
role,
|
||||||
|
url,
|
||||||
|
repo,
|
||||||
|
image,
|
||||||
|
imageAlt,
|
||||||
|
} = Astro.props;
|
||||||
|
|
||||||
|
const hasMeta = year || client || role;
|
||||||
|
---
|
||||||
|
|
||||||
|
<header class="relative overflow-hidden pt-[var(--space-page-top-sm)] pb-[var(--space-section)]">
|
||||||
|
<div class="relative mx-auto max-w-4xl px-6 animate-hero-slide-up">
|
||||||
|
|
||||||
|
<!-- Tags -->
|
||||||
|
{tags.length > 0 && (
|
||||||
|
<div class="mb-[var(--space-heading-gap)] flex flex-wrap gap-2">
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<span class="inline-flex items-center rounded-full bg-brand-100 dark:bg-brand-900/30 px-3 py-1 text-xs font-semibold text-brand-700 dark:text-brand-300 ring-1 ring-inset ring-brand-200 dark:ring-brand-800">
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<!-- Title -->
|
||||||
|
<h1 class="font-display text-4xl font-bold tracking-tight text-foreground md:text-5xl lg:text-6xl mb-[var(--space-heading-gap)]">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<p class="text-xl text-foreground-muted leading-relaxed max-w-3xl mb-[var(--space-stack-lg)]">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Meta row -->
|
||||||
|
{hasMeta && (
|
||||||
|
<div class="flex flex-wrap items-center gap-[var(--space-stack-lg)] text-sm text-foreground-muted mb-[var(--space-stack-lg)]">
|
||||||
|
{year && (
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<svg class="h-5 w-5 text-foreground-subtle" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5" />
|
||||||
|
</svg>
|
||||||
|
<span>{year}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{client && (
|
||||||
|
<>
|
||||||
|
{year && <div class="h-8 w-px bg-border hidden md:block" aria-hidden="true"></div>}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<svg class="h-5 w-5 text-foreground-subtle" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
|
||||||
|
</svg>
|
||||||
|
<span>{client}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{role && (
|
||||||
|
<>
|
||||||
|
{(year || client) && <div class="h-8 w-px bg-border hidden md:block" aria-hidden="true"></div>}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<svg class="h-5 w-5 text-foreground-subtle" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 14.15v4.25c0 1.094-.787 2.036-1.872 2.18-2.087.277-4.216.42-6.378.42s-4.291-.143-6.378-.42c-1.085-.144-1.872-1.086-1.872-2.18v-4.25m16.5 0a2.18 2.18 0 00.75-1.661V8.706c0-1.081-.768-2.015-1.837-2.175a48.114 48.114 0 00-3.413-.387m4.5 8.006c-.194.165-.42.295-.673.38A23.978 23.978 0 0112 15.75c-2.648 0-5.195-.429-7.577-1.22a2.016 2.016 0 01-.673-.38m0 0A2.18 2.18 0 013 12.489V8.706c0-1.081.768-2.015 1.837-2.175a48.111 48.111 0 013.413-.387m7.5 0V5.25A2.25 2.25 0 0013.5 3h-3a2.25 2.25 0 00-2.25 2.25v.894m7.5 0a48.667 48.667 0 00-7.5 0M12 12.75h.008v.008H12v-.008z" />
|
||||||
|
</svg>
|
||||||
|
<span>{role}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<!-- Action buttons -->
|
||||||
|
{(url || repo) && (
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
{url && (
|
||||||
|
<Button href={url} target="_blank" rel="noopener noreferrer" size="md">
|
||||||
|
<Icon name="external-link" size="sm" />
|
||||||
|
View live site
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{repo && (
|
||||||
|
<Button href={repo} target="_blank" rel="noopener noreferrer" variant="outline" size="md">
|
||||||
|
<Icon name="github" size="sm" />
|
||||||
|
View source
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{image && (
|
||||||
|
<div class="relative mx-auto max-w-5xl px-6 mt-[var(--space-section)] animate-hero-slide-up [animation-delay:200ms]">
|
||||||
|
<div class="relative overflow-hidden rounded-xl border border-border shadow-2xl">
|
||||||
|
<Image
|
||||||
|
src={image}
|
||||||
|
alt={imageAlt || title}
|
||||||
|
widths={[640, 960, 1280, 1920]}
|
||||||
|
sizes="(max-width: 640px) 640px, (max-width: 960px) 960px, (max-width: 1280px) 1280px, 1920px"
|
||||||
|
class="aspect-video w-full object-cover"
|
||||||
|
loading="eager"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
---
|
||||||
|
import { cn } from '@/lib/cn';
|
||||||
|
import JsonLd from './JsonLd.astro';
|
||||||
|
import { createBreadcrumbSchema } from '@/lib/schema';
|
||||||
|
import siteConfig from '@/config/site.config';
|
||||||
|
|
||||||
|
interface BreadcrumbItem {
|
||||||
|
label: string;
|
||||||
|
href?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
items: BreadcrumbItem[];
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { items, class: className } = Astro.props;
|
||||||
|
|
||||||
|
// Build schema items with full URLs
|
||||||
|
const schemaItems = items.map((item) => ({
|
||||||
|
name: item.label,
|
||||||
|
url: item.href ? new URL(item.href, siteConfig.url).toString() : siteConfig.url,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const schema = createBreadcrumbSchema(schemaItems);
|
||||||
|
---
|
||||||
|
|
||||||
|
<JsonLd schema={schema} />
|
||||||
|
|
||||||
|
<nav aria-label="Breadcrumb" class={cn('text-sm', className)}>
|
||||||
|
<ol class="flex flex-wrap items-center gap-2">
|
||||||
|
{
|
||||||
|
items.map((item, index) => (
|
||||||
|
<li class="flex items-center gap-2">
|
||||||
|
{index > 0 && (
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4 text-muted-foreground"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{item.href && index !== items.length - 1 ? (
|
||||||
|
<a href={item.href} class="text-muted-foreground hover:text-foreground transition-colors">
|
||||||
|
{item.label}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span class="text-foreground font-medium" aria-current={index === items.length - 1 ? 'page' : undefined}>
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
import type { Thing, WithContext } from 'schema-dts';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
schema: WithContext<Thing> | WithContext<Thing>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { schema } = Astro.props;
|
||||||
|
|
||||||
|
const schemas = Array.isArray(schema) ? schema : [schema];
|
||||||
|
---
|
||||||
|
|
||||||
|
{
|
||||||
|
schemas.map((s) => (
|
||||||
|
<script is:inline type="application/ld+json" set:html={JSON.stringify(s, null, 0)} />
|
||||||
|
))
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
---
|
||||||
|
import siteConfig from '@/config/site.config';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
image?: string;
|
||||||
|
imageAlt?: string;
|
||||||
|
article?: {
|
||||||
|
publishedTime?: Date;
|
||||||
|
modifiedTime?: Date;
|
||||||
|
authors?: string[];
|
||||||
|
tags?: string[];
|
||||||
|
};
|
||||||
|
noindex?: boolean;
|
||||||
|
nofollow?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
title,
|
||||||
|
description = siteConfig.description,
|
||||||
|
image,
|
||||||
|
imageAlt,
|
||||||
|
article,
|
||||||
|
noindex = false,
|
||||||
|
nofollow = false,
|
||||||
|
} = Astro.props;
|
||||||
|
|
||||||
|
const pageTitle = title ? `${title} — ${siteConfig.name}` : siteConfig.name;
|
||||||
|
|
||||||
|
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
|
||||||
|
|
||||||
|
// Process image - fall back to the static default OG image
|
||||||
|
let ogImage: string;
|
||||||
|
if (image) {
|
||||||
|
ogImage = image.startsWith('http') ? image : new URL(image, Astro.site).toString();
|
||||||
|
} else {
|
||||||
|
ogImage = new URL(siteConfig.ogImage, Astro.site).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const robotsContent = [noindex ? 'noindex' : 'index', nofollow ? 'nofollow' : 'follow'].join(', ');
|
||||||
|
|
||||||
|
// Normalize BCP-47 locale (e.g. "en") to OG language_TERRITORY format (e.g. "en_US")
|
||||||
|
const localeMap: Record<string, string> = {
|
||||||
|
en: 'en_US', fr: 'fr_FR', de: 'de_DE', es: 'es_ES', it: 'it_IT',
|
||||||
|
pt: 'pt_BR', nl: 'nl_NL', ja: 'ja_JP', ko: 'ko_KR', zh: 'zh_CN',
|
||||||
|
ru: 'ru_RU', ar: 'ar_SA', hi: 'hi_IN', pl: 'pl_PL', sv: 'sv_SE',
|
||||||
|
};
|
||||||
|
const rawLocale = Astro.currentLocale || 'en_US';
|
||||||
|
const locale = rawLocale.includes('_') || rawLocale.includes('-')
|
||||||
|
? rawLocale.replace('-', '_')
|
||||||
|
: (localeMap[rawLocale] || `${rawLocale}_${rawLocale.toUpperCase()}`);
|
||||||
|
|
||||||
|
// Determine OG image MIME type from file extension
|
||||||
|
const extToMime: Record<string, string> = {
|
||||||
|
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
||||||
|
'.webp': 'image/webp', '.gif': 'image/gif', '.svg': 'image/svg+xml',
|
||||||
|
};
|
||||||
|
function getImageType(url: string): string | undefined {
|
||||||
|
const ext = url.match(/(\.\w+)(?:\?|$)/)?.[1]?.toLowerCase();
|
||||||
|
return ext ? extToMime[ext] : undefined;
|
||||||
|
}
|
||||||
|
const ogImageType = getImageType(ogImage);
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- Primary Meta Tags -->
|
||||||
|
<title>{pageTitle}</title>
|
||||||
|
<meta name="description" content={description} />
|
||||||
|
<link rel="canonical" href={canonicalURL.toString()} />
|
||||||
|
<meta name="robots" content={robotsContent} />
|
||||||
|
|
||||||
|
<!-- Open Graph -->
|
||||||
|
<meta property="og:title" content={pageTitle} />
|
||||||
|
<meta property="og:type" content={article ? 'article' : 'website'} />
|
||||||
|
<meta property="og:image" content={ogImage} />
|
||||||
|
<meta property="og:url" content={canonicalURL.toString()} />
|
||||||
|
<meta property="og:description" content={description} />
|
||||||
|
<meta property="og:site_name" content={siteConfig.name} />
|
||||||
|
<meta property="og:locale" content={locale} />
|
||||||
|
<meta property="og:image:alt" content={imageAlt || pageTitle} />
|
||||||
|
<meta property="og:image:width" content="1200" />
|
||||||
|
<meta property="og:image:height" content="630" />
|
||||||
|
{ogImageType && <meta property="og:image:type" content={ogImageType} />}
|
||||||
|
|
||||||
|
<!-- Article Metadata -->
|
||||||
|
{article?.publishedTime && <meta property="article:published_time" content={article.publishedTime.toISOString()} />}
|
||||||
|
{article?.modifiedTime && <meta property="article:modified_time" content={article.modifiedTime.toISOString()} />}
|
||||||
|
{article?.authors?.map((author) => <meta property="article:author" content={author} />)}
|
||||||
|
{article?.tags?.map((tag) => <meta property="article:tag" content={tag} />)}
|
||||||
|
|
||||||
|
<!-- Twitter Card -->
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content={pageTitle} />
|
||||||
|
<meta name="twitter:description" content={description} />
|
||||||
|
<meta name="twitter:image" content={ogImage} />
|
||||||
|
{siteConfig.twitter?.site && <meta name="twitter:site" content={siteConfig.twitter.site} />}
|
||||||
|
{siteConfig.twitter?.creator && <meta name="twitter:creator" content={siteConfig.twitter.creator} />}
|
||||||
|
|
||||||
|
<!-- Verification -->
|
||||||
|
{siteConfig.verification?.google && <meta name="google-site-verification" content={siteConfig.verification.google} />}
|
||||||
|
{siteConfig.verification?.bing && <meta name="msvalidate.01" content={siteConfig.verification.bing} />}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
---
|
||||||
|
interface Props {
|
||||||
|
/** Array of strings to cycle through */
|
||||||
|
words: string[];
|
||||||
|
/** Typing speed in ms per character */
|
||||||
|
typeSpeed?: number;
|
||||||
|
/** Deleting speed in ms per character */
|
||||||
|
deleteSpeed?: number;
|
||||||
|
/** Pause after fully typed, in ms */
|
||||||
|
pauseAfterType?: number;
|
||||||
|
/** Pause after fully deleted, in ms */
|
||||||
|
pauseAfterDelete?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
words,
|
||||||
|
typeSpeed = 120,
|
||||||
|
deleteSpeed = 70,
|
||||||
|
pauseAfterType = 1800,
|
||||||
|
pauseAfterDelete = 400,
|
||||||
|
} = Astro.props;
|
||||||
|
|
||||||
|
const id = `typing-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
---
|
||||||
|
|
||||||
|
<span id={id} class="typing-effect" aria-label={words.join(', ')}>
|
||||||
|
<span class="typing-text"></span><span class="typing-cursor" aria-hidden="true">|</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.typing-effect {
|
||||||
|
display: inline-block;
|
||||||
|
white-space: nowrap;
|
||||||
|
vertical-align: bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-cursor {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 1px;
|
||||||
|
animation: blink 0.75s step-end infinite;
|
||||||
|
color: var(--color-brand-500, currentColor);
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script define:vars={{ id, words, typeSpeed, deleteSpeed, pauseAfterType, pauseAfterDelete }}>
|
||||||
|
function startTyping() {
|
||||||
|
const root = document.getElementById(id);
|
||||||
|
if (!root) return;
|
||||||
|
const textEl = root.querySelector('.typing-text');
|
||||||
|
|
||||||
|
// Lock the element width to the widest word so the layout never shifts
|
||||||
|
const measurer = document.createElement('span');
|
||||||
|
measurer.setAttribute('aria-hidden', 'true');
|
||||||
|
measurer.style.cssText = 'visibility:hidden;position:absolute;white-space:nowrap;pointer-events:none;';
|
||||||
|
const cs = getComputedStyle(root);
|
||||||
|
measurer.style.font = cs.font;
|
||||||
|
measurer.style.letterSpacing = cs.letterSpacing;
|
||||||
|
document.body.appendChild(measurer);
|
||||||
|
|
||||||
|
let maxWidth = 0;
|
||||||
|
for (const word of words) {
|
||||||
|
measurer.textContent = word + '|'; // include cursor character in measurement
|
||||||
|
maxWidth = Math.max(maxWidth, measurer.offsetWidth);
|
||||||
|
}
|
||||||
|
document.body.removeChild(measurer);
|
||||||
|
root.style.minWidth = maxWidth + 'px';
|
||||||
|
|
||||||
|
let wordIndex = 0;
|
||||||
|
let charIndex = 0;
|
||||||
|
let isDeleting = false;
|
||||||
|
let timer;
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
const current = words[wordIndex];
|
||||||
|
|
||||||
|
if (isDeleting) {
|
||||||
|
charIndex--;
|
||||||
|
textEl.textContent = current.slice(0, charIndex);
|
||||||
|
|
||||||
|
if (charIndex === 0) {
|
||||||
|
isDeleting = false;
|
||||||
|
wordIndex = (wordIndex + 1) % words.length;
|
||||||
|
timer = setTimeout(tick, pauseAfterDelete);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
timer = setTimeout(tick, deleteSpeed);
|
||||||
|
} else {
|
||||||
|
charIndex++;
|
||||||
|
textEl.textContent = current.slice(0, charIndex);
|
||||||
|
|
||||||
|
if (charIndex === current.length) {
|
||||||
|
isDeleting = true;
|
||||||
|
timer = setTimeout(tick, pauseAfterType);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
timer = setTimeout(tick, typeSpeed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start after a short initial delay so the page paint settles
|
||||||
|
timer = setTimeout(tick, 600);
|
||||||
|
|
||||||
|
// Clean up pending timer when navigating away
|
||||||
|
document.addEventListener('astro:before-swap', () => clearTimeout(timer), { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run on initial load and on every client-side navigation back to this page
|
||||||
|
document.addEventListener('astro:page-load', startTyping);
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
---
|
||||||
|
import { cn } from '@/lib/cn';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
code: string;
|
||||||
|
filename?: string;
|
||||||
|
showLineNumbers?: boolean;
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
code,
|
||||||
|
filename,
|
||||||
|
showLineNumbers = true,
|
||||||
|
class: className,
|
||||||
|
} = Astro.props;
|
||||||
|
|
||||||
|
const lines = code.trim().split('\n');
|
||||||
|
const codeId = `code-${Math.random().toString(36).slice(2, 9)}`;
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class={cn(
|
||||||
|
"group relative w-full overflow-hidden rounded-md border border-border bg-background-secondary shadow-sm font-mono text-sm",
|
||||||
|
className
|
||||||
|
)}>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between border-b border-border bg-card px-4 py-2.5">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="flex gap-1.5">
|
||||||
|
<div class="h-2.5 w-2.5 rounded-full bg-gray-300 dark:bg-gray-600"></div>
|
||||||
|
<div class="h-2.5 w-2.5 rounded-full bg-gray-300 dark:bg-gray-600"></div>
|
||||||
|
<div class="h-2.5 w-2.5 rounded-full bg-gray-300 dark:bg-gray-600"></div>
|
||||||
|
</div>
|
||||||
|
{filename && (
|
||||||
|
<span class="text-xs font-medium text-foreground-muted font-sans">{filename}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="copy-button flex items-center gap-1.5 rounded px-2 py-0.5 text-xs font-medium text-foreground-muted transition-colors hover:bg-secondary hover:text-foreground focus:outline-none"
|
||||||
|
data-code-id={codeId}
|
||||||
|
aria-label="Copy code to clipboard"
|
||||||
|
>
|
||||||
|
<svg class="copy-icon h-3 w-3" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||||
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||||
|
</svg>
|
||||||
|
<svg class="check-icon hidden h-3 w-3 text-success" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polyline points="20 6 9 17 4 12"></polyline>
|
||||||
|
</svg>
|
||||||
|
<span class="copy-text">Copy</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Code Area -->
|
||||||
|
<div class="overflow-x-auto p-4 bg-card">
|
||||||
|
<pre id={codeId} class="flex flex-col leading-6">{lines.map((line, i) => (
|
||||||
|
<div class="table-row">
|
||||||
|
{showLineNumbers && (
|
||||||
|
<span class="table-cell select-none pr-4 text-right text-xs text-foreground-subtle w-8">
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span class="table-cell whitespace-pre text-foreground-secondary">{line || ' '}</span>
|
||||||
|
</div>
|
||||||
|
))}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function initCodeBlocks() {
|
||||||
|
const copyButtons = document.querySelectorAll<HTMLButtonElement>('.copy-button');
|
||||||
|
copyButtons.forEach((button) => {
|
||||||
|
button.addEventListener('click', async () => {
|
||||||
|
const codeId = button.dataset.codeId;
|
||||||
|
if (!codeId) return;
|
||||||
|
|
||||||
|
const codeEl = document.getElementById(codeId);
|
||||||
|
if (!codeEl) return;
|
||||||
|
|
||||||
|
const code = codeEl.textContent || '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(code);
|
||||||
|
|
||||||
|
const copyIcon = button.querySelector('.copy-icon');
|
||||||
|
const checkIcon = button.querySelector('.check-icon');
|
||||||
|
const copyText = button.querySelector('.copy-text');
|
||||||
|
|
||||||
|
if (copyIcon && checkIcon && copyText) {
|
||||||
|
copyIcon.classList.add('hidden');
|
||||||
|
checkIcon.classList.remove('hidden');
|
||||||
|
copyText.textContent = 'Copied';
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
copyIcon.classList.remove('hidden');
|
||||||
|
checkIcon.classList.add('hidden');
|
||||||
|
copyText.textContent = 'Copy';
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Clipboard API failed - user will need to copy manually
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize on page load
|
||||||
|
initCodeBlocks();
|
||||||
|
|
||||||
|
// Re-initialize on view transitions (Astro)
|
||||||
|
document.addEventListener('astro:page-load', initCodeBlocks);
|
||||||
|
</script>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './CodeBlock.astro';
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// Content Display Components
|
||||||
|
export { default as CodeBlock } from './CodeBlock';
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
---
|
||||||
|
/**
|
||||||
|
* Avatar Component
|
||||||
|
* Displays user avatars with fallback initials
|
||||||
|
*/
|
||||||
|
import type { HTMLAttributes } from 'astro/types';
|
||||||
|
import { cn } from '@/lib/cn';
|
||||||
|
import { avatarVariants } from './avatar.variants';
|
||||||
|
|
||||||
|
interface Props extends HTMLAttributes<'div'> {
|
||||||
|
src?: string;
|
||||||
|
alt?: string;
|
||||||
|
fallback?: string;
|
||||||
|
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||||
|
}
|
||||||
|
|
||||||
|
const { src, alt = '', fallback, size = 'md', class: className, ...attrs } = Astro.props;
|
||||||
|
|
||||||
|
// Generate initials from alt text or fallback
|
||||||
|
const initials = fallback || alt
|
||||||
|
.split(' ')
|
||||||
|
.map((word) => word[0])
|
||||||
|
.join('')
|
||||||
|
.slice(0, 2)
|
||||||
|
.toUpperCase();
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class={cn(avatarVariants({ size }), className)} {...attrs}>
|
||||||
|
{src ? (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
data-avatar-img
|
||||||
|
/>
|
||||||
|
<span class="hidden items-center justify-center w-full h-full" aria-hidden="true" data-avatar-fallback>
|
||||||
|
{initials || '?'}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span aria-hidden="true">{initials || '?'}</span>
|
||||||
|
)}
|
||||||
|
<span class="sr-only">{alt || 'User avatar'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function initAvatarFallbacks() {
|
||||||
|
const avatarImgs = document.querySelectorAll<HTMLImageElement>('[data-avatar-img]');
|
||||||
|
avatarImgs.forEach((img) => {
|
||||||
|
if (img.dataset.avatarInit) return;
|
||||||
|
img.dataset.avatarInit = 'true';
|
||||||
|
|
||||||
|
img.addEventListener('error', () => {
|
||||||
|
img.classList.add('hidden');
|
||||||
|
const fallback = img.nextElementSibling as HTMLElement | null;
|
||||||
|
if (fallback && fallback.hasAttribute('data-avatar-fallback')) {
|
||||||
|
fallback.classList.remove('hidden');
|
||||||
|
fallback.classList.add('flex');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
initAvatarFallbacks();
|
||||||
|
document.addEventListener('astro:page-load', initAvatarFallbacks);
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { type HTMLAttributes, type Ref, useState } from 'react';
|
||||||
|
import { cn } from '@/lib/cn';
|
||||||
|
import { avatarVariants, type AvatarVariants } from './avatar.variants';
|
||||||
|
|
||||||
|
interface AvatarProps extends Omit<HTMLAttributes<HTMLDivElement>, 'ref'> {
|
||||||
|
ref?: Ref<HTMLDivElement>;
|
||||||
|
src?: string;
|
||||||
|
alt?: string;
|
||||||
|
fallback?: string;
|
||||||
|
size?: AvatarVariants['size'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Avatar({ ref, src, alt = '', fallback, size = 'md', className, ...rest }: AvatarProps) {
|
||||||
|
const [imgError, setImgError] = useState(false);
|
||||||
|
|
||||||
|
const initials = fallback || alt
|
||||||
|
.split(' ')
|
||||||
|
.map((word) => word[0])
|
||||||
|
.join('')
|
||||||
|
.slice(0, 2)
|
||||||
|
.toUpperCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={cn(avatarVariants({ size }), className)} {...rest}>
|
||||||
|
{src && !imgError ? (
|
||||||
|
<>
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
onError={() => setImgError(true)}
|
||||||
|
/>
|
||||||
|
<span className="sr-only">{alt || 'User avatar'}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span aria-hidden="true">{initials || '?'}</span>
|
||||||
|
<span className="sr-only">{alt || 'User avatar'}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Avatar;
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
|
||||||
|
export const avatarVariants = cva(
|
||||||
|
[
|
||||||
|
'relative inline-flex items-center justify-center',
|
||||||
|
'rounded-full overflow-hidden',
|
||||||
|
'bg-secondary text-secondary-foreground font-medium',
|
||||||
|
'ring-2 ring-background',
|
||||||
|
],
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
xs: 'w-6 h-6 text-[10px]',
|
||||||
|
sm: 'w-8 h-8 text-xs',
|
||||||
|
md: 'w-10 h-10 text-sm',
|
||||||
|
lg: 'w-12 h-12 text-base',
|
||||||
|
xl: 'w-16 h-16 text-lg',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
size: 'md',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export type AvatarVariants = VariantProps<typeof avatarVariants>;
|
||||||