Initial release — Astro Rocket 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,73 @@
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Lint
|
||||
run: pnpm run lint
|
||||
|
||||
- name: Type check
|
||||
run: pnpm run check
|
||||
|
||||
- name: Build
|
||||
run: pnpm run build
|
||||
env:
|
||||
SITE_URL: ${{ secrets.SITE_URL }}
|
||||
|
||||
# Uncomment and configure for your deployment platform
|
||||
#
|
||||
# Vercel deployment:
|
||||
# - name: Deploy to Vercel
|
||||
# if: github.ref == 'refs/heads/main'
|
||||
# uses: amondnet/vercel-action@v25
|
||||
# with:
|
||||
# vercel-token: ${{ secrets.VERCEL_TOKEN }}
|
||||
# vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
|
||||
# vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||
# vercel-args: '--prod'
|
||||
#
|
||||
# Netlify deployment:
|
||||
# - name: Deploy to Netlify
|
||||
# if: github.ref == 'refs/heads/main'
|
||||
# uses: nwtgck/actions-netlify@v2
|
||||
# with:
|
||||
# publish-dir: './dist'
|
||||
# production-branch: main
|
||||
# github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
# env:
|
||||
# NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
# NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
||||
#
|
||||
# Cloudflare Pages deployment:
|
||||
# - name: Deploy to Cloudflare Pages
|
||||
# if: github.ref == 'refs/heads/main'
|
||||
# uses: cloudflare/wrangler-action@v3
|
||||
# with:
|
||||
# apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
# accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
# command: pages deploy dist --project-name=astro-rocket
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
# 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/).
|
||||
|
||||
---
|
||||
|
||||
## [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,707 @@
|
||||
<p align="center">
|
||||
<img src="public/readme-hero.svg" alt="Astro Rocket" width="880" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>Astro Rocket</strong> — A production-ready Astro 6 starter theme. Change the text, launch your site.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://astro.build"><img src="https://img.shields.io/badge/Astro-6.0-bc52ee?logo=astro&logoColor=white" alt="Astro" /></a>
|
||||
<a href="https://tailwindcss.com"><img src="https://img.shields.io/badge/Tailwind-4.0-38bdf8?logo=tailwindcss&logoColor=white" alt="Tailwind CSS" /></a>
|
||||
<a href="https://www.typescriptlang.org"><img src="https://img.shields.io/badge/TypeScript-5.7-3178c6?logo=typescript&logoColor=white" alt="TypeScript" /></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-22c55e" alt="License" /></a>
|
||||
<a href="https://github.com/hansmartens68/astro-rocket"><img src="https://img.shields.io/github/stars/hansmartens68/astro-rocket?style=flat&label=%E2%AD%90%20Star%20on%20GitHub&color=f59e0b" alt="Star on GitHub" /></a>
|
||||
<img src="https://visitor-badge.laobi.icu/badge?page_id=hansmartens68.astro-rocket" alt="Visitors" />
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Astro Rocket is a **launch-ready starter theme** for web designers, developers, bloggers, and anyone who needs a portfolio website. Every page is already built and styled — you change the text and content, and your site is ready to go live.
|
||||
|
||||
It ships with a full blog, a complete component library, a built-in SEO layer, dark mode, a contact form, and 12 colour themes you can switch with one click. It's built on Astro 6 and Tailwind CSS v4.
|
||||
|
||||
**[Live demo → astrorocket.dev](https://astrorocket.dev)** · **[Built by Hans Martens → hansmartens.dev](https://hansmartens.dev)**
|
||||
|
||||
> **Astro Rocket is a fork of [Velocity](https://github.com/southwellmedia/velocity) by [Southwell Media](https://southwellmedia.com).** Velocity is the foundation — a powerful Astro boilerplate with a comprehensive design system and component library. Full credit to the Southwell Media team for that work. Astro Rocket builds on it with a different goal: a complete, ready-to-launch website where you only change the text to make it your own.
|
||||
|
||||
---
|
||||
|
||||
## What changed from Velocity
|
||||
|
||||
The following changes were made to the free Velocity theme to create Astro Rocket:
|
||||
|
||||
| Change | Velocity | Astro Rocket |
|
||||
|--------|----------|--------------|
|
||||
| **Theme switching** | Edit a CSS import file and rebuild | 12 colour swatches in the header — click one and the logo badge, blog images, and every brand color update live on screen. No file edits, no rebuilds. Selector can be removed from the header once you've chosen a color. |
|
||||
| **Colour themes** | 1 default theme | 12 Tailwind-based themes — all 12 shown as swatches in the header selector (Orange, Amber, Lime, Emerald, Teal, Cyan, Sky, Blue, Indigo, Violet, Purple, Magenta) |
|
||||
| **Logo badge** | Requires a custom logo file | Auto-generated monogram badge — first letter of your site name on brand color, live-updates with active theme |
|
||||
| **Favicon** | Static file to replace manually | Auto-generated SVG favicon — first letter + brand color, pre-rendered at build time from `site.config.ts`, no design tools needed |
|
||||
| **Blog image gradients** | Plain image containers | Every blog cover and card uses a brand-color gradient background that updates live when the active theme changes |
|
||||
| **Icon system** | Basic SVG `Icon` component | Unified `Icon` component powered by Iconify — 350+ Lucide UI icons + 3000+ Simple Icons brand icons |
|
||||
| **Typing effect** | Not included | Hero section includes an animated typing effect |
|
||||
| **Dark mode storage** | `localStorage` | `sessionStorage` — resets to dark on every new tab/session (see [why](#dark-mode)) |
|
||||
| **Target audience** | Developers & agencies | Web designers, developers, bloggers, and portfolio sites |
|
||||
| **Ready to launch** | Boilerplate starting point | Fully styled pages — replace the text and your site is live |
|
||||
| **Maintained by** | Southwell Media | Hans Martens |
|
||||
|
||||
---
|
||||
|
||||
## Key Features
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| **Astro 6** | Latest version with Content Layer API, security features, and performance optimizations |
|
||||
| **Tailwind CSS v4** | CSS-first configuration with OKLCH color system and fluid typography |
|
||||
| **12 Colour Themes** | All 12 colour swatches are shown in the header dropdown — click one and the logo badge, blog image gradients, and every brand color update live instantly. No file edits, no rebuilds. The selector can be removed from the header once you've settled on a color. |
|
||||
| **Scroll Progress Bar** | A thin 2px brand-coloured bar on the header edge that fills as you scroll. Enabled on the homepage (above the floating header), blog index, and post pages (below the solid header). Controlled via `showScrollProgress` and `scrollProgressPosition` props on the Header component. |
|
||||
| **Design Tokens** | Three-tier token architecture (reference → semantic → component) |
|
||||
| **57 Components** | 31 UI, 7 patterns, 1 hero, 4 layout, 4 blog, 7 landing, 3 SEO — all accessible with TypeScript |
|
||||
| **Auto Logo & Favicon** | First letter of your site name on brand color — generated automatically from `site.config.ts`, no design tools needed |
|
||||
| **Icon System** | Unified `Icon` component (Astro + React) — 350+ [Lucide](https://lucide.dev) UI icons and 3000+ [Simple Icons](https://simpleicons.org) brand icons via Iconify |
|
||||
| **Typing Effect** | Animated typing effect in the hero section |
|
||||
| **Page Animations** | Smooth page transitions via Astro View Transitions, scroll-triggered counter and score animations, scroll-reactive header, card hover effects, and a full suite of UI micro-animations — all with reduced-motion support |
|
||||
| **SEO Toolkit** | Meta tags, JSON-LD structured data, sitemap, and robots.txt |
|
||||
| **Static OG Image** | A polished default Open Graph image serves as social preview for all pages — no build-time generation required |
|
||||
| **Dark Mode** | Dark-first design with `sessionStorage` persistence |
|
||||
| **Content Collections** | Type-safe blog, pages, authors, and FAQs with Zod validation |
|
||||
| **API Routes** | Contact form and newsletter endpoints with validation |
|
||||
| **React Islands** | Optional client-side interactivity where needed |
|
||||
|
||||
### Internationalization (i18n)
|
||||
|
||||
The base theme is i18n-ready with locale-aware content collection schemas. Full i18n support with language routing and a `LanguageSwitcher` component can be added via the **[create-velocity-astro](https://github.com/southwellmedia/create-velocity-astro)** CLI from Southwell Media.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Node.js 22.12.0+** (required for Astro 6)
|
||||
- **pnpm 9.x** (recommended) or npm/yarn
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/hansmartens68/astro-rocket.git my-project
|
||||
cd my-project
|
||||
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Copy environment variables
|
||||
cp .env.example .env
|
||||
|
||||
# Start development server
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Visit `http://localhost:4321` to see your site.
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
astro-rocket/
|
||||
├── public/ # Static assets (fonts, favicon)
|
||||
├── src/
|
||||
│ ├── assets/ # Images and icons (processed by Astro)
|
||||
│ ├── components/
|
||||
│ │ ├── ui/ # UI component library (31 components)
|
||||
│ │ │ ├── form/ # Button, Input, Textarea, Select, Checkbox, Radio, Switch
|
||||
│ │ │ ├── data-display/ # Card, Badge, Avatar, Table, Pagination, Progress, Skeleton
|
||||
│ │ │ ├── feedback/ # Alert, Toast, Tooltip
|
||||
│ │ │ ├── overlay/ # Dialog, Dropdown, Tabs, VerticalTabs, Accordion
|
||||
│ │ │ ├── layout/ # Separator
|
||||
│ │ │ ├── primitives/ # Icon
|
||||
│ │ │ ├── content/ # CodeBlock
|
||||
│ │ │ └── marketing/ # Logo, CTA, NpmCopyButton, SocialProof, TerminalDemo
|
||||
│ │ ├── patterns/ # Composed patterns (ContactForm, SearchInput, StatCard, etc.)
|
||||
│ │ ├── layout/ # Header, Footer, Navigation, ThemeToggle, ThemeSelector
|
||||
│ │ ├── seo/ # SEO, JsonLd, Breadcrumbs
|
||||
│ │ ├── blog/ # Blog-specific components
|
||||
│ │ └── landing/ # Landing page components
|
||||
│ ├── content/ # Content collections
|
||||
│ │ ├── blog/ # Blog posts (en/, es/, fr/)
|
||||
│ │ ├── projects/ # Portfolio project pages
|
||||
│ │ ├── authors/ # Author profiles
|
||||
│ │ └── faqs/ # FAQ entries
|
||||
│ ├── layouts/ # Page layouts
|
||||
│ ├── lib/ # Utilities (schema, cn)
|
||||
│ ├── pages/ # Routes and API endpoints
|
||||
│ │ ├── api/ # Contact, newsletter endpoints
|
||||
│ │ └── blog/ # Blog routes
|
||||
│ ├── styles/ # Global CSS and design tokens
|
||||
│ │ ├── tokens/ # colors.css, typography.css, spacing.css
|
||||
│ │ └── themes/ # 12 colour theme files
|
||||
│ └── config/ # Site and navigation configuration
|
||||
├── astro.config.mjs # Astro configuration
|
||||
├── package.json
|
||||
└── tsconfig.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `pnpm dev` | Start development server with hot reload |
|
||||
| `pnpm build` | Build for production |
|
||||
| `pnpm preview` | Preview production build locally |
|
||||
| `pnpm check` | Run Astro type checker |
|
||||
| `pnpm lint` | Run ESLint |
|
||||
| `pnpm lint:fix` | Fix ESLint issues |
|
||||
| `pnpm format` | Format code with Prettier |
|
||||
| `pnpm format:check` | Check code formatting |
|
||||
| `pnpm test` | Run Vitest tests |
|
||||
| `pnpm test:e2e` | Run Playwright E2E tests |
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Site Configuration
|
||||
|
||||
Edit `src/config/site.config.ts`:
|
||||
|
||||
```typescript
|
||||
const siteConfig = {
|
||||
name: 'Your Site Name',
|
||||
description: 'Your site description for SEO',
|
||||
url: 'https://yoursite.com',
|
||||
ogImage: '/og-default.svg',
|
||||
author: 'Your Name',
|
||||
email: 'hello@yoursite.com',
|
||||
twitter: {
|
||||
site: '@yourhandle',
|
||||
creator: '@yourhandle',
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Create a `.env` file from `.env.example`:
|
||||
|
||||
```bash
|
||||
# Required
|
||||
SITE_URL=https://yoursite.com
|
||||
|
||||
# Optional - Analytics
|
||||
PUBLIC_GA_MEASUREMENT_ID=G-XXXXXXXXXX
|
||||
PUBLIC_GTM_ID=GTM-XXXXXXX
|
||||
|
||||
# Optional - Verification
|
||||
GOOGLE_SITE_VERIFICATION=your-code
|
||||
BING_SITE_VERIFICATION=your-code
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Design System
|
||||
|
||||
Astro Rocket uses a three-tier design token system with OKLCH colors for perceptual uniformity:
|
||||
|
||||
1. **Primitives** (`src/styles/tokens/primitives.css`) — raw color scales (gray, brand, status)
|
||||
2. **Semantic tokens** (`src/styles/themes/*.css`) — purpose-based mappings (background, foreground, border, etc.)
|
||||
3. **Tailwind** (`src/styles/global.css`) — `@theme` directives that expose tokens as utility classes
|
||||
|
||||
### Switching Themes
|
||||
|
||||
Astro Rocket ships with 12 colour themes, all based on Tailwind's color palette. All 12 are shown as colour swatches in the header dropdown (`ThemeSelectorDropdown`) on desktop and in the mobile menu (`ThemeSelector`). Clicking a swatch applies the theme instantly — the logo badge, blog image gradients, and every brand color on the page update live. No file edits, no rebuilds. This is the key difference from Velocity, where switching theme requires editing a CSS import file and rebuilding.
|
||||
|
||||
The 12 themes in order: Orange, Amber, Lime, Emerald, Teal, Cyan, Sky, Blue (default), Indigo, Violet, Purple, and Magenta. The `themes` array in `src/components/layout/ThemeSelector.astro` controls which swatches are shown and in what order. You can also **remove the selector from the header entirely** once you've settled on a color — just remove `showThemeSelector` from the layout file.
|
||||
|
||||
The theme files live in `src/styles/themes/`:
|
||||
|
||||
```
|
||||
amber.css blue.css cyan.css emerald.css
|
||||
green.css indigo.css lime.css magenta.css
|
||||
orange.css purple.css sky.css teal.css
|
||||
violet.css
|
||||
```
|
||||
|
||||
### Customizing Brand Colors
|
||||
|
||||
Edit `src/styles/tokens/primitives.css` and update the `--brand-*` OKLCH values:
|
||||
|
||||
```css
|
||||
:root {
|
||||
--brand-50: oklch(97.5% 0.02 45); /* lightest tint */
|
||||
--brand-100: oklch(94.8% 0.04 45);
|
||||
--brand-200: oklch(87.5% 0.08 45);
|
||||
--brand-300: oklch(77.8% 0.14 45);
|
||||
--brand-400: oklch(68.5% 0.19 40);
|
||||
--brand-500: oklch(62.5% 0.22 38); /* primary brand color */
|
||||
--brand-600: oklch(53.2% 0.19 38);
|
||||
--brand-700: oklch(45.5% 0.16 38);
|
||||
--brand-800: oklch(37.2% 0.13 38);
|
||||
--brand-900: oklch(26.5% 0.09 38);
|
||||
}
|
||||
```
|
||||
|
||||
OKLCH values are `oklch(lightness chroma hue)`. To shift your brand to blue, change the hue from `38-45` to `~260`. Use [oklch.com](https://oklch.com) to pick colors visually.
|
||||
|
||||
### Creating a New Theme
|
||||
|
||||
1. Duplicate `src/styles/themes/default.css` as your starting point
|
||||
2. Implement all ~35 semantic tokens for both `:root` (light) and `.dark` (dark):
|
||||
|
||||
**Backgrounds**: `--background`, `--background-secondary`, `--background-tertiary`, `--background-elevated`
|
||||
|
||||
**Foregrounds**: `--foreground`, `--foreground-secondary`, `--foreground-muted`, `--foreground-subtle`
|
||||
|
||||
**Borders**: `--border`, `--border-strong`, `--border-subtle`
|
||||
|
||||
**Interactive**: `--primary`, `--primary-hover`, `--primary-foreground`, `--secondary`, `--secondary-hover`, `--secondary-foreground`, `--accent`, `--accent-hover`, `--accent-light`
|
||||
|
||||
**Surfaces**: `--muted`, `--muted-foreground`, `--card`, `--card-border`, `--input-bg`, `--input-border`, `--input-focus`, `--ring`
|
||||
|
||||
**Destructive**: `--destructive`, `--destructive-foreground`
|
||||
|
||||
**Gradients**: `--gradient-start`, `--gradient-end`
|
||||
|
||||
**Invert sections**: `--surface-invert`, `--surface-invert-secondary`, `--surface-invert-tertiary`, `--on-invert`, `--on-invert-secondary`, `--on-invert-muted`, `--border-invert`, `--border-invert-strong`
|
||||
|
||||
3. Update the import in `src/styles/tokens/colors.css` to point to your new theme file
|
||||
|
||||
### Dark Mode
|
||||
|
||||
Dark mode toggles via the `.dark` class on `<html>`. The default is **dark** — the design was built dark-first and it looks great for portfolios and creative sites.
|
||||
|
||||
FOUC is prevented by an inline script that reads `sessionStorage` before first paint. Use the included `ThemeToggle` component:
|
||||
|
||||
```astro
|
||||
---
|
||||
import ThemeToggle from '@/components/layout/ThemeToggle.astro';
|
||||
---
|
||||
|
||||
<ThemeToggle />
|
||||
```
|
||||
|
||||
To opt out of dark mode, remove the `.dark { ... }` block from your theme file.
|
||||
|
||||
> **Why `sessionStorage` instead of `localStorage`?** This is a deliberate choice. `sessionStorage` persists the user's preference during their visit but resets when the tab is closed — so every new visit starts with the intended dark design. For a portfolio or marketing site this is the right call. For a product users return to daily (a SaaS dashboard, editor, etc.), switch to `localStorage` so the preference survives across sessions. Read the full reasoning in [this blog post](https://hansmartens.dev/blog/dark-mode-sessionstorage).
|
||||
|
||||
### WCAG Contrast Requirements
|
||||
|
||||
Foreground tokens are documented with their contrast ratios inline. When customizing, maintain these minimums:
|
||||
|
||||
| Token | Minimum ratio | Standard |
|
||||
|-------|:---:|:---:|
|
||||
| `--foreground` | 7:1 | WCAG AAA |
|
||||
| `--foreground-secondary` | 7:1 | WCAG AAA |
|
||||
| `--foreground-muted` | 4.5:1 | WCAG AA |
|
||||
| `--foreground-subtle` | 4.5:1 | WCAG AA |
|
||||
| Status `-foreground` tokens | 4.5:1 | WCAG AA (on their `-light` bg) |
|
||||
|
||||
### Using Design Tokens
|
||||
|
||||
```astro
|
||||
<!-- Tailwind utilities (recommended) -->
|
||||
<div class="bg-background text-foreground">
|
||||
<h1 class="text-primary font-display">Hello</h1>
|
||||
</div>
|
||||
|
||||
<!-- CSS custom properties -->
|
||||
<style>
|
||||
.custom {
|
||||
background: var(--background-secondary);
|
||||
color: var(--foreground);
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Components
|
||||
|
||||
Astro Rocket includes 57 components across 7 categories. All UI components use [class-variance-authority (CVA)](https://cva.style) for type-safe variant management.
|
||||
|
||||
### UI Components (31)
|
||||
|
||||
#### Form (`ui/form/`)
|
||||
|
||||
| Component | Description |
|
||||
|-----------|-------------|
|
||||
| Button | Interactive button with primary, secondary, outline, ghost, destructive variants and loading state |
|
||||
| Input | Text input with label, hint, and error states |
|
||||
| Textarea | Multi-line text input |
|
||||
| Select | Dropdown selection |
|
||||
| Checkbox | Boolean toggle with indeterminate state |
|
||||
| Radio | Single selection from group |
|
||||
| Switch | Toggle switch input |
|
||||
|
||||
#### Data Display (`ui/data-display/`)
|
||||
|
||||
| Component | Description |
|
||||
|-----------|-------------|
|
||||
| Card | Content container with variant, padding, and hover options |
|
||||
| Badge | Status labels and tags with contextual variants |
|
||||
| Avatar | User images with fallback |
|
||||
| AvatarGroup | Grouped avatar display with overlap |
|
||||
| Table | Styled data table |
|
||||
| Pagination | Page navigation controls |
|
||||
| Progress | Progress bar indicator |
|
||||
| Skeleton | Loading placeholders |
|
||||
|
||||
#### Feedback (`ui/feedback/`)
|
||||
|
||||
| Component | Description |
|
||||
|-----------|-------------|
|
||||
| Alert | Contextual feedback messages (info, success, warning, error) |
|
||||
| Toast | Temporary notification messages |
|
||||
| Tooltip | Hover tooltips with positioning |
|
||||
|
||||
#### Overlay (`ui/overlay/`)
|
||||
|
||||
| Component | Description |
|
||||
|-----------|-------------|
|
||||
| Dialog | Modal overlay |
|
||||
| Dropdown | Menu with trigger |
|
||||
| Tabs | Horizontal tabbed content panels |
|
||||
| VerticalTabs | Vertical tab navigation |
|
||||
| Accordion | Collapsible content sections |
|
||||
|
||||
#### Layout (`ui/layout/`)
|
||||
|
||||
| Component | Description |
|
||||
|-----------|-------------|
|
||||
| Separator | Visual divider between sections |
|
||||
|
||||
#### Primitives (`ui/primitives/`)
|
||||
|
||||
| Component | Description |
|
||||
|-----------|-------------|
|
||||
| Icon | Unified icon component (Astro + React) powered by Iconify. Supports all [Lucide](https://lucide.dev) icons (`lucide:*`) and all [Simple Icons](https://simpleicons.org) brand icons (`simple-icons:*`). Includes shorthand names for common social and brand icons. Five size variants: `xs`, `sm`, `md`, `lg`, `xl`. |
|
||||
|
||||
#### Content (`ui/content/`)
|
||||
|
||||
| Component | Description |
|
||||
|-----------|-------------|
|
||||
| CodeBlock | Syntax-highlighted code display |
|
||||
|
||||
#### Marketing (`ui/marketing/`)
|
||||
|
||||
| Component | Description |
|
||||
|-----------|-------------|
|
||||
| Logo | Auto-generated monogram badge — renders the first letter of `siteConfig.name` on the active brand color. Five sizes: `sm`, `md`, `lg`, `xl`, `2xl`. No logo file required. |
|
||||
| CTA | Call-to-action sections with slot-based composition |
|
||||
| NpmCopyButton | NPM install command with copy-to-clipboard |
|
||||
| SocialProof | Testimonial and trust indicator cards |
|
||||
| TerminalDemo | Animated terminal demonstration (React) |
|
||||
|
||||
### Pattern Components (7)
|
||||
|
||||
| Component | Description |
|
||||
|-----------|-------------|
|
||||
| ContactForm | Complete contact form with validation |
|
||||
| NewsletterForm | Email subscription form |
|
||||
| FormField | Reusable form field wrapper |
|
||||
| SearchInput | Search input with icon |
|
||||
| PasswordInput | Password input with visibility toggle |
|
||||
| StatCard | Statistics display card |
|
||||
| EmptyState | Empty state placeholder with icon and action |
|
||||
|
||||
### Other Categories
|
||||
|
||||
| Category | Count | Components |
|
||||
|----------|-------|------------|
|
||||
| Hero | 1 | Hero section with centered/split layouts, grid pattern, and typing effect |
|
||||
| Layout | 6 | Header (with scroll progress bar), Footer, ThemeToggle, ThemeSelector, ThemeSelectorDropdown, Analytics |
|
||||
| Blog | 4 | ArticleHero, BlogCard, ShareButtons, RelatedPosts |
|
||||
| Landing | 5 | Credibility, LighthouseScores, TechStack, FeatureTabs, and more |
|
||||
| SEO | 3 | SEO, JsonLd, Breadcrumbs |
|
||||
|
||||
### Usage Example
|
||||
|
||||
```astro
|
||||
---
|
||||
import { Button, Input, Card } from '@/components/ui';
|
||||
---
|
||||
|
||||
<Card>
|
||||
<Input label="Email" type="email" name="email" required />
|
||||
<Button variant="primary">Submit</Button>
|
||||
</Card>
|
||||
```
|
||||
|
||||
### Icon Usage
|
||||
|
||||
```astro
|
||||
---
|
||||
import Icon from '@/components/ui/primitives/Icon/Icon.astro';
|
||||
---
|
||||
|
||||
<!-- Lucide UI icons — use any icon name from lucide.dev -->
|
||||
<Icon name="arrow-right" size="md" />
|
||||
<Icon name="mail" size="sm" />
|
||||
<Icon name="layers" size="lg" />
|
||||
|
||||
<!-- Simple Icons brand icons — shorthand names available -->
|
||||
<Icon name="github" size="md" />
|
||||
<Icon name="x-twitter" size="md" />
|
||||
<Icon name="brand-astro" size="md" />
|
||||
<Icon name="brand-tailwind" size="md" />
|
||||
|
||||
<!-- Or use the full Iconify name directly -->
|
||||
<Icon name="simple-icons:vercel" size="md" />
|
||||
<Icon name="lucide:rocket" size="xl" />
|
||||
```
|
||||
|
||||
All UI components are imported via barrel exports from `@/components/ui`. View all components at `/components` in development.
|
||||
|
||||
---
|
||||
|
||||
## Content Management
|
||||
|
||||
### Blog Posts
|
||||
|
||||
Create posts in `src/content/blog/[locale]/`:
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: "Your Post Title"
|
||||
description: "Brief description for SEO"
|
||||
publishedAt: 2026-01-30
|
||||
author: "Author Name"
|
||||
tags: ["astro", "tutorial"]
|
||||
locale: en
|
||||
---
|
||||
|
||||
Your content here...
|
||||
```
|
||||
|
||||
### Querying Content
|
||||
|
||||
```astro
|
||||
---
|
||||
import { getCollection } from 'astro:content';
|
||||
|
||||
const posts = await getCollection('blog', ({ data }) => {
|
||||
return import.meta.env.PROD ? !data.draft : true;
|
||||
});
|
||||
---
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SEO
|
||||
|
||||
### Automatic Features
|
||||
|
||||
- **Meta tags**: Title, description, canonical URL
|
||||
- **Open Graph**: Complete OG tags for social sharing
|
||||
- **Twitter Cards**: Large image cards
|
||||
- **JSON-LD**: WebSite, Organization, BlogPosting, Breadcrumb, FAQ schemas
|
||||
- **Sitemap**: Auto-generated at `/sitemap-index.xml`
|
||||
- **robots.txt**: Dynamic generation with sitemap reference
|
||||
- **OG Images**: A static default OG image serves all pages and blog posts
|
||||
|
||||
### Using the SEO Component
|
||||
|
||||
```astro
|
||||
---
|
||||
import SEO from '@/components/seo/SEO.astro';
|
||||
---
|
||||
|
||||
<head>
|
||||
<SEO
|
||||
title="Page Title"
|
||||
description="Page description"
|
||||
/>
|
||||
</head>
|
||||
```
|
||||
|
||||
### OG Image
|
||||
|
||||
A static default OG image (`public/og-default.svg`) serves as the social preview for all pages. The path is set via `ogImage` in `src/config/site.config.ts`. To use a custom image for a specific page, pass it as the `image` prop to the layout component.
|
||||
|
||||
---
|
||||
|
||||
## API Routes
|
||||
|
||||
### Contact Form
|
||||
|
||||
**POST** `/api/contact`
|
||||
|
||||
```typescript
|
||||
// Request (FormData)
|
||||
{
|
||||
name: string, // 2-100 chars
|
||||
email: string, // Valid email
|
||||
subject: string, // Required
|
||||
message: string, // 10-5000 chars
|
||||
honeypot: string // Must be empty (spam check)
|
||||
}
|
||||
|
||||
// Response
|
||||
{ success: true }
|
||||
// or
|
||||
{ success: false, errors: { field: ["message"] } }
|
||||
```
|
||||
|
||||
### Newsletter
|
||||
|
||||
**POST** `/api/newsletter`
|
||||
|
||||
```typescript
|
||||
// Request (FormData)
|
||||
{ email: string }
|
||||
|
||||
// Response
|
||||
{ success: true }
|
||||
// or
|
||||
{ success: false, error: "message" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deployment
|
||||
|
||||
Configuration files included for major platforms.
|
||||
|
||||
### Vercel (Recommended)
|
||||
|
||||
```bash
|
||||
vercel
|
||||
```
|
||||
|
||||
### Netlify
|
||||
|
||||
```bash
|
||||
netlify deploy --prod
|
||||
```
|
||||
|
||||
### Cloudflare Pages
|
||||
|
||||
```bash
|
||||
wrangler pages deploy dist
|
||||
```
|
||||
|
||||
### Static Export
|
||||
|
||||
Build outputs to `dist/` for any static host:
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Browser Support
|
||||
|
||||
- Chrome (last 2 versions)
|
||||
- Firefox (last 2 versions)
|
||||
- Safari (last 2 versions)
|
||||
- Edge (last 2 versions)
|
||||
|
||||
---
|
||||
|
||||
## Performance
|
||||
|
||||
Astro Rocket is optimized for Core Web Vitals:
|
||||
|
||||
- **Lighthouse Score**: 95+ across all categories
|
||||
- **Zero JavaScript** by default (islands architecture)
|
||||
- **Optimized fonts** with `font-display: swap`
|
||||
- **Image optimization** via Astro's built-in processing
|
||||
- **Prefetching** for instant page transitions
|
||||
|
||||
---
|
||||
|
||||
## Animations
|
||||
|
||||
Every page in Astro Rocket includes purposeful animations that make the site feel polished and alive. All animations respect the user's `prefers-reduced-motion` setting — they are disabled automatically for users who prefer less motion.
|
||||
|
||||
### Page transitions
|
||||
|
||||
Astro Rocket uses Astro's built-in `<ClientRouter />` (View Transitions API) to animate between pages. Instead of a full browser reload, content fades smoothly from one page to the next. This is enabled globally in `BaseLayout.astro` and requires no per-page configuration.
|
||||
|
||||
### Scroll-triggered animations
|
||||
|
||||
Two components use an `IntersectionObserver` to trigger animations when elements enter the viewport:
|
||||
|
||||
- **Counter animation** — the stats block on the homepage (Years Experience, Projects Delivered, etc.) counts up from zero when it scrolls into view. Each number animates with a cubic ease-out over 1.2 seconds.
|
||||
- **Lighthouse score bars** — the `LighthouseScores` landing component animates its score bars into place as the section becomes visible.
|
||||
|
||||
### Scroll-reactive header
|
||||
|
||||
The floating header changes its appearance as the user scrolls. When the page is at the top, the header is transparent with inverted text. Once the user scrolls past 60px, the header gains a solid background and the text flips to normal colors — all driven by CSS transitions via a `data-scrolled` attribute.
|
||||
|
||||
### Scroll progress bar
|
||||
|
||||
A thin 2px brand-coloured bar on the header edge that grows from left to right as the user scrolls, showing reading progress at a glance. Enable it with two props on the `<Header>` component:
|
||||
|
||||
| Prop | Type | Default | What it does |
|
||||
|------|------|:-------:|--------------|
|
||||
| `showScrollProgress` | `boolean` | `false` | Renders the progress bar |
|
||||
| `scrollProgressPosition` | `'top'` \| `'bottom'` | `'bottom'` | Edge of the header where the bar sits |
|
||||
|
||||
The bar is enabled by default on three page types: the **homepage** (above the floating header), the **blog index**, and **individual blog posts** (both below the solid bar header). Use `scrollProgressPosition="top"` on a floating capsule header and `'bottom'` on a solid bar header. The bar colour always matches `--color-brand-500` and updates instantly when the visitor switches themes.
|
||||
|
||||
### Card hover effects
|
||||
|
||||
Cards throughout the site lift slightly on hover (`-translate-y-1`) and gain a subtle shadow. This is a Tailwind utility applied consistently to all interactive cards.
|
||||
|
||||
### UI micro-animations
|
||||
|
||||
The full animation library is defined in `src/styles/global.css`. These classes are used by components throughout the site:
|
||||
|
||||
| Class | What it does |
|
||||
|-------|-------------|
|
||||
| `animate-fade-in` | Fades an element from transparent to visible (0.5s ease-out) |
|
||||
| `animate-slide-up` | Slides an element up from 12px below while fading in (0.5s ease-out) |
|
||||
| `animate-slide-down` | Slides an element down from 12px above while fading in (0.5s ease-out) |
|
||||
| `animate-dropdown-in` | Slides and scales a dropdown menu into view (0.2s spring) |
|
||||
| `animate-dropdown-out` | Collapses a dropdown menu out of view (0.15s) |
|
||||
| `animate-sheet-up` | Slides a bottom sheet up from off-screen (0.25s spring) |
|
||||
| `animate-menu-down` | Slides the mobile navigation drawer open (0.25s spring) |
|
||||
| `animate-tab-enter` | Crossfades tab panel content when switching tabs |
|
||||
| `animate-toast-in` | Slides a toast notification in from the right (350ms spring) |
|
||||
| `animate-tooltip-in` | Fades and scales a tooltip into view |
|
||||
| `animate-pulse` | Breathing pulse for skeleton loading states |
|
||||
| `animate-spin` | Continuous rotation for loading spinners |
|
||||
| `animate-shake` | Brief shake for error feedback (400ms) |
|
||||
|
||||
Animation delay utilities (`.delay-0` through `.delay-5`, in 50ms steps) let you stagger multiple elements into view.
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome!
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
||||
3. Commit your changes (`git commit -m 'Add amazing feature'`)
|
||||
4. Push to the branch (`git push origin feature/amazing-feature`)
|
||||
5. Open a Pull Request
|
||||
|
||||
Please ensure your code passes linting (`pnpm lint`) and type checking (`pnpm check`) before submitting.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT License — see [LICENSE](LICENSE) for details.
|
||||
|
||||
---
|
||||
|
||||
## Links
|
||||
|
||||
- [Astro Rocket on GitHub](https://github.com/hansmartens68/astro-rocket)
|
||||
- [Velocity — the original theme](https://github.com/southwellmedia/velocity) by [Southwell Media](https://southwellmedia.com)
|
||||
- [Astro Documentation](https://docs.astro.build)
|
||||
- [Tailwind CSS v4](https://tailwindcss.com/docs)
|
||||
|
||||
---
|
||||
|
||||
**Astro Rocket** is designed and maintained by [Hans Martens](https://hansmartens.dev).
|
||||
Built on [Velocity](https://github.com/southwellmedia/velocity) — the original theme by [Southwell Media](https://southwellmedia.com).
|
||||
@@ -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';
|
||||
import vercel from '@astrojs/vercel';
|
||||
import netlify from '@astrojs/netlify';
|
||||
|
||||
const isNetlify = process.env.DEPLOY_TARGET === 'netlify';
|
||||
|
||||
export default defineConfig({
|
||||
adapter: isNetlify ? netlify() : vercel(),
|
||||
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()],
|
||||
},
|
||||
|
||||
security: {
|
||||
checkOrigin: true,
|
||||
},
|
||||
|
||||
markdown: {
|
||||
shikiConfig: {
|
||||
theme: 'github-dark',
|
||||
wrap: true,
|
||||
},
|
||||
},
|
||||
|
||||
});
|
||||
@@ -0,0 +1,638 @@
|
||||
{
|
||||
"$schema": "./component-registry.schema.json",
|
||||
"version": "2.0.0",
|
||||
"categories": {
|
||||
"ui": {
|
||||
"name": "UI Components",
|
||||
"description": "Core building blocks - buttons, forms, cards, dialogs",
|
||||
"subcategories": {
|
||||
"form": {
|
||||
"name": "Form",
|
||||
"description": "Form input components - buttons, inputs, selects"
|
||||
},
|
||||
"data-display": {
|
||||
"name": "Data Display",
|
||||
"description": "Components for displaying data - cards, badges, tables"
|
||||
},
|
||||
"feedback": {
|
||||
"name": "Feedback",
|
||||
"description": "User feedback components - alerts, toasts, tooltips"
|
||||
},
|
||||
"overlay": {
|
||||
"name": "Overlay",
|
||||
"description": "Overlay components - dialogs, dropdowns, tabs"
|
||||
},
|
||||
"layout": {
|
||||
"name": "Layout",
|
||||
"description": "Layout components - separators, dividers"
|
||||
},
|
||||
"primitives": {
|
||||
"name": "Primitives",
|
||||
"description": "Fundamental components used across the system"
|
||||
},
|
||||
"content": {
|
||||
"name": "Content",
|
||||
"description": "Content display components"
|
||||
},
|
||||
"marketing": {
|
||||
"name": "Marketing",
|
||||
"description": "Marketing and landing page components"
|
||||
}
|
||||
}
|
||||
},
|
||||
"layout": {
|
||||
"name": "Layout Components",
|
||||
"description": "Page structure components - header, footer, navigation"
|
||||
},
|
||||
"patterns": {
|
||||
"name": "Patterns",
|
||||
"description": "Reusable form and UI patterns"
|
||||
},
|
||||
"hero": {
|
||||
"name": "Hero",
|
||||
"description": "Flexible hero section component"
|
||||
}
|
||||
},
|
||||
"utilities": {
|
||||
"cn": {
|
||||
"name": "cn utility",
|
||||
"description": "Tailwind CSS class merging utility",
|
||||
"files": ["src/lib/cn.ts"],
|
||||
"npm": ["clsx", "tailwind-merge"]
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"button": {
|
||||
"name": "Button",
|
||||
"category": "ui",
|
||||
"subcategory": "form",
|
||||
"files": [
|
||||
"src/components/ui/form/Button/Button.astro",
|
||||
"src/components/ui/form/Button/Button.tsx",
|
||||
"src/components/ui/form/Button/button.variants.ts",
|
||||
"src/components/ui/form/Button/index.ts"
|
||||
],
|
||||
"dependencies": {
|
||||
"utilities": ["cn"],
|
||||
"components": []
|
||||
},
|
||||
"premium": false
|
||||
},
|
||||
"input": {
|
||||
"name": "Input",
|
||||
"category": "ui",
|
||||
"subcategory": "form",
|
||||
"files": [
|
||||
"src/components/ui/form/Input/Input.astro",
|
||||
"src/components/ui/form/Input/Input.tsx",
|
||||
"src/components/ui/form/Input/input.variants.ts",
|
||||
"src/components/ui/form/Input/index.ts"
|
||||
],
|
||||
"dependencies": {
|
||||
"utilities": ["cn"],
|
||||
"components": []
|
||||
},
|
||||
"premium": false
|
||||
},
|
||||
"textarea": {
|
||||
"name": "Textarea",
|
||||
"category": "ui",
|
||||
"subcategory": "form",
|
||||
"files": [
|
||||
"src/components/ui/form/Textarea/Textarea.astro",
|
||||
"src/components/ui/form/Textarea/Textarea.tsx",
|
||||
"src/components/ui/form/Textarea/textarea.variants.ts",
|
||||
"src/components/ui/form/Textarea/index.ts"
|
||||
],
|
||||
"dependencies": {
|
||||
"utilities": ["cn"],
|
||||
"components": []
|
||||
},
|
||||
"premium": false
|
||||
},
|
||||
"select": {
|
||||
"name": "Select",
|
||||
"category": "ui",
|
||||
"subcategory": "form",
|
||||
"files": [
|
||||
"src/components/ui/form/Select/Select.astro",
|
||||
"src/components/ui/form/Select/Select.tsx",
|
||||
"src/components/ui/form/Select/select.variants.ts",
|
||||
"src/components/ui/form/Select/index.ts"
|
||||
],
|
||||
"dependencies": {
|
||||
"utilities": ["cn"],
|
||||
"components": []
|
||||
},
|
||||
"premium": false
|
||||
},
|
||||
"checkbox": {
|
||||
"name": "Checkbox",
|
||||
"category": "ui",
|
||||
"subcategory": "form",
|
||||
"files": [
|
||||
"src/components/ui/form/Checkbox/Checkbox.astro",
|
||||
"src/components/ui/form/Checkbox/Checkbox.tsx",
|
||||
"src/components/ui/form/Checkbox/checkbox.variants.ts",
|
||||
"src/components/ui/form/Checkbox/index.ts"
|
||||
],
|
||||
"dependencies": {
|
||||
"utilities": ["cn"],
|
||||
"components": []
|
||||
},
|
||||
"premium": false
|
||||
},
|
||||
"radio": {
|
||||
"name": "Radio",
|
||||
"category": "ui",
|
||||
"subcategory": "form",
|
||||
"files": [
|
||||
"src/components/ui/form/Radio/Radio.astro",
|
||||
"src/components/ui/form/Radio/Radio.tsx",
|
||||
"src/components/ui/form/Radio/radio.variants.ts",
|
||||
"src/components/ui/form/Radio/index.ts"
|
||||
],
|
||||
"dependencies": {
|
||||
"utilities": ["cn"],
|
||||
"components": []
|
||||
},
|
||||
"premium": false
|
||||
},
|
||||
"switch": {
|
||||
"name": "Switch",
|
||||
"category": "ui",
|
||||
"subcategory": "form",
|
||||
"files": [
|
||||
"src/components/ui/form/Switch/Switch.astro",
|
||||
"src/components/ui/form/Switch/Switch.tsx",
|
||||
"src/components/ui/form/Switch/switch.variants.ts",
|
||||
"src/components/ui/form/Switch/index.ts"
|
||||
],
|
||||
"dependencies": {
|
||||
"utilities": ["cn"],
|
||||
"components": []
|
||||
},
|
||||
"premium": false
|
||||
},
|
||||
"card": {
|
||||
"name": "Card",
|
||||
"category": "ui",
|
||||
"subcategory": "data-display",
|
||||
"files": [
|
||||
"src/components/ui/data-display/Card/Card.astro",
|
||||
"src/components/ui/data-display/Card/Card.tsx",
|
||||
"src/components/ui/data-display/Card/card.variants.ts",
|
||||
"src/components/ui/data-display/Card/index.ts"
|
||||
],
|
||||
"dependencies": {
|
||||
"utilities": ["cn"],
|
||||
"components": []
|
||||
},
|
||||
"premium": false
|
||||
},
|
||||
"badge": {
|
||||
"name": "Badge",
|
||||
"category": "ui",
|
||||
"subcategory": "data-display",
|
||||
"files": [
|
||||
"src/components/ui/data-display/Badge/Badge.astro",
|
||||
"src/components/ui/data-display/Badge/badge.variants.ts",
|
||||
"src/components/ui/data-display/Badge/index.ts"
|
||||
],
|
||||
"dependencies": {
|
||||
"utilities": ["cn"],
|
||||
"components": []
|
||||
},
|
||||
"premium": false
|
||||
},
|
||||
"avatar": {
|
||||
"name": "Avatar",
|
||||
"category": "ui",
|
||||
"subcategory": "data-display",
|
||||
"files": [
|
||||
"src/components/ui/data-display/Avatar/Avatar.astro",
|
||||
"src/components/ui/data-display/Avatar/avatar.variants.ts",
|
||||
"src/components/ui/data-display/Avatar/index.ts"
|
||||
],
|
||||
"dependencies": {
|
||||
"utilities": ["cn"],
|
||||
"components": []
|
||||
},
|
||||
"premium": false
|
||||
},
|
||||
"avatar-group": {
|
||||
"name": "AvatarGroup",
|
||||
"category": "ui",
|
||||
"subcategory": "data-display",
|
||||
"files": [
|
||||
"src/components/ui/data-display/AvatarGroup/AvatarGroup.astro",
|
||||
"src/components/ui/data-display/AvatarGroup/index.ts"
|
||||
],
|
||||
"dependencies": {
|
||||
"utilities": ["cn"],
|
||||
"components": ["avatar"]
|
||||
},
|
||||
"premium": false
|
||||
},
|
||||
"table": {
|
||||
"name": "Table",
|
||||
"category": "ui",
|
||||
"subcategory": "data-display",
|
||||
"files": [
|
||||
"src/components/ui/data-display/Table/Table.astro",
|
||||
"src/components/ui/data-display/Table/index.ts"
|
||||
],
|
||||
"dependencies": {
|
||||
"utilities": ["cn"],
|
||||
"components": []
|
||||
},
|
||||
"premium": false
|
||||
},
|
||||
"pagination": {
|
||||
"name": "Pagination",
|
||||
"category": "ui",
|
||||
"subcategory": "data-display",
|
||||
"files": [
|
||||
"src/components/ui/data-display/Pagination/Pagination.astro",
|
||||
"src/components/ui/data-display/Pagination/pagination.variants.ts",
|
||||
"src/components/ui/data-display/Pagination/index.ts"
|
||||
],
|
||||
"dependencies": {
|
||||
"utilities": ["cn"],
|
||||
"components": ["icon"]
|
||||
},
|
||||
"premium": false
|
||||
},
|
||||
"progress": {
|
||||
"name": "Progress",
|
||||
"category": "ui",
|
||||
"subcategory": "data-display",
|
||||
"files": [
|
||||
"src/components/ui/data-display/Progress/Progress.astro",
|
||||
"src/components/ui/data-display/Progress/progress.variants.ts",
|
||||
"src/components/ui/data-display/Progress/index.ts"
|
||||
],
|
||||
"dependencies": {
|
||||
"utilities": ["cn"],
|
||||
"components": []
|
||||
},
|
||||
"premium": false
|
||||
},
|
||||
"skeleton": {
|
||||
"name": "Skeleton",
|
||||
"category": "ui",
|
||||
"subcategory": "data-display",
|
||||
"files": [
|
||||
"src/components/ui/data-display/Skeleton/Skeleton.astro",
|
||||
"src/components/ui/data-display/Skeleton/skeleton.variants.ts",
|
||||
"src/components/ui/data-display/Skeleton/index.ts"
|
||||
],
|
||||
"dependencies": {
|
||||
"utilities": ["cn"],
|
||||
"components": []
|
||||
},
|
||||
"premium": false
|
||||
},
|
||||
"alert": {
|
||||
"name": "Alert",
|
||||
"category": "ui",
|
||||
"subcategory": "feedback",
|
||||
"files": [
|
||||
"src/components/ui/feedback/Alert/Alert.astro",
|
||||
"src/components/ui/feedback/Alert/alert.variants.ts",
|
||||
"src/components/ui/feedback/Alert/index.ts"
|
||||
],
|
||||
"dependencies": {
|
||||
"utilities": ["cn"],
|
||||
"components": []
|
||||
},
|
||||
"premium": false
|
||||
},
|
||||
"toast": {
|
||||
"name": "Toast",
|
||||
"category": "ui",
|
||||
"subcategory": "feedback",
|
||||
"files": [
|
||||
"src/components/ui/feedback/Toast/Toast.astro",
|
||||
"src/components/ui/feedback/Toast/Toast.tsx",
|
||||
"src/components/ui/feedback/Toast/ToastDemo.tsx",
|
||||
"src/components/ui/feedback/Toast/toast.variants.ts",
|
||||
"src/components/ui/feedback/Toast/index.ts"
|
||||
],
|
||||
"dependencies": {
|
||||
"utilities": ["cn"],
|
||||
"components": []
|
||||
},
|
||||
"premium": false
|
||||
},
|
||||
"tooltip": {
|
||||
"name": "Tooltip",
|
||||
"category": "ui",
|
||||
"subcategory": "feedback",
|
||||
"files": [
|
||||
"src/components/ui/feedback/Tooltip/Tooltip.astro",
|
||||
"src/components/ui/feedback/Tooltip/index.ts"
|
||||
],
|
||||
"dependencies": {
|
||||
"utilities": ["cn"],
|
||||
"components": []
|
||||
},
|
||||
"premium": false
|
||||
},
|
||||
"dialog": {
|
||||
"name": "Dialog",
|
||||
"category": "ui",
|
||||
"subcategory": "overlay",
|
||||
"files": [
|
||||
"src/components/ui/overlay/Dialog/Dialog.astro",
|
||||
"src/components/ui/overlay/Dialog/index.ts"
|
||||
],
|
||||
"dependencies": {
|
||||
"utilities": ["cn"],
|
||||
"components": []
|
||||
},
|
||||
"premium": false
|
||||
},
|
||||
"dropdown": {
|
||||
"name": "Dropdown",
|
||||
"category": "ui",
|
||||
"subcategory": "overlay",
|
||||
"files": [
|
||||
"src/components/ui/overlay/Dropdown/Dropdown.astro",
|
||||
"src/components/ui/overlay/Dropdown/index.ts"
|
||||
],
|
||||
"dependencies": {
|
||||
"utilities": ["cn"],
|
||||
"components": []
|
||||
},
|
||||
"premium": false
|
||||
},
|
||||
"tabs": {
|
||||
"name": "Tabs",
|
||||
"category": "ui",
|
||||
"subcategory": "overlay",
|
||||
"files": [
|
||||
"src/components/ui/overlay/Tabs/Tabs.astro",
|
||||
"src/components/ui/overlay/Tabs/index.ts"
|
||||
],
|
||||
"dependencies": {
|
||||
"utilities": ["cn"],
|
||||
"components": []
|
||||
},
|
||||
"premium": false
|
||||
},
|
||||
"vertical-tabs": {
|
||||
"name": "VerticalTabs",
|
||||
"category": "ui",
|
||||
"subcategory": "overlay",
|
||||
"files": [
|
||||
"src/components/ui/overlay/VerticalTabs/VerticalTabs.astro",
|
||||
"src/components/ui/overlay/VerticalTabs/VerticalTabs.tsx",
|
||||
"src/components/ui/overlay/VerticalTabs/index.ts"
|
||||
],
|
||||
"dependencies": {
|
||||
"utilities": ["cn"],
|
||||
"components": []
|
||||
},
|
||||
"premium": false
|
||||
},
|
||||
"accordion": {
|
||||
"name": "Accordion",
|
||||
"category": "ui",
|
||||
"subcategory": "overlay",
|
||||
"files": [
|
||||
"src/components/ui/overlay/Accordion/Accordion.astro",
|
||||
"src/components/ui/overlay/Accordion/accordion.variants.ts",
|
||||
"src/components/ui/overlay/Accordion/index.ts"
|
||||
],
|
||||
"dependencies": {
|
||||
"utilities": ["cn"],
|
||||
"components": ["icon"]
|
||||
},
|
||||
"premium": false
|
||||
},
|
||||
"separator": {
|
||||
"name": "Separator",
|
||||
"category": "ui",
|
||||
"subcategory": "layout",
|
||||
"files": [
|
||||
"src/components/ui/layout/Separator/Separator.astro",
|
||||
"src/components/ui/layout/Separator/separator.variants.ts",
|
||||
"src/components/ui/layout/Separator/index.ts"
|
||||
],
|
||||
"dependencies": {
|
||||
"utilities": ["cn"],
|
||||
"components": []
|
||||
},
|
||||
"premium": false
|
||||
},
|
||||
"icon": {
|
||||
"name": "Icon",
|
||||
"category": "ui",
|
||||
"subcategory": "primitives",
|
||||
"files": [
|
||||
"src/components/ui/primitives/Icon/Icon.astro",
|
||||
"src/components/ui/primitives/Icon/index.ts"
|
||||
],
|
||||
"dependencies": {
|
||||
"utilities": ["cn"],
|
||||
"components": []
|
||||
},
|
||||
"premium": false
|
||||
},
|
||||
"code-block": {
|
||||
"name": "CodeBlock",
|
||||
"category": "ui",
|
||||
"subcategory": "content",
|
||||
"files": [
|
||||
"src/components/ui/content/CodeBlock/CodeBlock.astro",
|
||||
"src/components/ui/content/CodeBlock/index.ts"
|
||||
],
|
||||
"dependencies": {
|
||||
"utilities": ["cn"],
|
||||
"components": []
|
||||
},
|
||||
"premium": false
|
||||
},
|
||||
"logo": {
|
||||
"name": "Logo",
|
||||
"category": "ui",
|
||||
"subcategory": "marketing",
|
||||
"files": [
|
||||
"src/components/ui/marketing/Logo/Logo.astro",
|
||||
"src/components/ui/marketing/Logo/index.ts"
|
||||
],
|
||||
"dependencies": {
|
||||
"utilities": ["cn"],
|
||||
"components": []
|
||||
},
|
||||
"premium": false
|
||||
},
|
||||
"cta": {
|
||||
"name": "CTA",
|
||||
"category": "ui",
|
||||
"subcategory": "marketing",
|
||||
"files": [
|
||||
"src/components/ui/marketing/CTA/CTA.astro",
|
||||
"src/components/ui/marketing/CTA/index.ts"
|
||||
],
|
||||
"dependencies": {
|
||||
"utilities": ["cn"],
|
||||
"components": ["logo"]
|
||||
},
|
||||
"premium": false
|
||||
},
|
||||
"npm-copy-button": {
|
||||
"name": "NpmCopyButton",
|
||||
"category": "ui",
|
||||
"subcategory": "marketing",
|
||||
"files": [
|
||||
"src/components/ui/marketing/NpmCopyButton/NpmCopyButton.astro",
|
||||
"src/components/ui/marketing/NpmCopyButton/index.ts"
|
||||
],
|
||||
"dependencies": {
|
||||
"utilities": ["cn"],
|
||||
"components": ["icon"]
|
||||
},
|
||||
"premium": false
|
||||
},
|
||||
"social-proof": {
|
||||
"name": "SocialProof",
|
||||
"category": "ui",
|
||||
"subcategory": "marketing",
|
||||
"files": [
|
||||
"src/components/ui/marketing/SocialProof/SocialProof.astro",
|
||||
"src/components/ui/marketing/SocialProof/index.ts"
|
||||
],
|
||||
"dependencies": {
|
||||
"utilities": ["cn"],
|
||||
"components": []
|
||||
},
|
||||
"premium": false
|
||||
},
|
||||
"terminal-demo": {
|
||||
"name": "TerminalDemo",
|
||||
"category": "ui",
|
||||
"subcategory": "marketing",
|
||||
"files": [
|
||||
"src/components/ui/marketing/TerminalDemo/TerminalDemo.tsx",
|
||||
"src/components/ui/marketing/TerminalDemo/index.ts"
|
||||
],
|
||||
"dependencies": {
|
||||
"utilities": ["cn"],
|
||||
"components": []
|
||||
},
|
||||
"premium": false
|
||||
},
|
||||
"contact-form": {
|
||||
"name": "ContactForm",
|
||||
"category": "patterns",
|
||||
"files": ["src/components/patterns/ContactForm.astro"],
|
||||
"dependencies": {
|
||||
"utilities": ["cn"],
|
||||
"components": ["input", "textarea", "button"]
|
||||
},
|
||||
"premium": false
|
||||
},
|
||||
"newsletter-form": {
|
||||
"name": "NewsletterForm",
|
||||
"category": "patterns",
|
||||
"files": ["src/components/patterns/NewsletterForm.astro"],
|
||||
"dependencies": {
|
||||
"utilities": ["cn"],
|
||||
"components": ["input", "button"]
|
||||
},
|
||||
"premium": false
|
||||
},
|
||||
"form-field": {
|
||||
"name": "FormField",
|
||||
"category": "patterns",
|
||||
"files": ["src/components/patterns/FormField.astro"],
|
||||
"dependencies": {
|
||||
"utilities": ["cn"],
|
||||
"components": []
|
||||
},
|
||||
"premium": false
|
||||
},
|
||||
"search-input": {
|
||||
"name": "SearchInput",
|
||||
"category": "patterns",
|
||||
"files": ["src/components/patterns/SearchInput.astro"],
|
||||
"dependencies": {
|
||||
"utilities": ["cn"],
|
||||
"components": ["input"]
|
||||
},
|
||||
"premium": false
|
||||
},
|
||||
"password-input": {
|
||||
"name": "PasswordInput",
|
||||
"category": "patterns",
|
||||
"files": ["src/components/patterns/PasswordInput.astro"],
|
||||
"dependencies": {
|
||||
"utilities": ["cn"],
|
||||
"components": ["input", "icon"]
|
||||
},
|
||||
"premium": false
|
||||
},
|
||||
"stat-card": {
|
||||
"name": "StatCard",
|
||||
"category": "patterns",
|
||||
"files": ["src/components/patterns/StatCard.astro"],
|
||||
"dependencies": {
|
||||
"utilities": ["cn"],
|
||||
"components": ["card", "icon"]
|
||||
},
|
||||
"premium": false
|
||||
},
|
||||
"empty-state": {
|
||||
"name": "EmptyState",
|
||||
"category": "patterns",
|
||||
"files": ["src/components/patterns/EmptyState.astro"],
|
||||
"dependencies": {
|
||||
"utilities": ["cn"],
|
||||
"components": ["icon", "button"]
|
||||
},
|
||||
"premium": false
|
||||
},
|
||||
"hero": {
|
||||
"name": "Hero",
|
||||
"category": "hero",
|
||||
"files": [
|
||||
"src/components/hero/Hero.astro",
|
||||
"src/components/hero/hero.variants.ts",
|
||||
"src/components/hero/index.ts"
|
||||
],
|
||||
"dependencies": {
|
||||
"utilities": ["cn"],
|
||||
"components": []
|
||||
},
|
||||
"premium": false
|
||||
},
|
||||
"header": {
|
||||
"name": "Header",
|
||||
"category": "layout",
|
||||
"files": [
|
||||
"src/components/layout/Header.astro",
|
||||
"src/components/layout/header.variants.ts"
|
||||
],
|
||||
"dependencies": {
|
||||
"utilities": ["cn"],
|
||||
"components": ["button", "icon", "logo"]
|
||||
},
|
||||
"premium": false
|
||||
},
|
||||
"footer": {
|
||||
"name": "Footer",
|
||||
"category": "layout",
|
||||
"files": [
|
||||
"src/components/layout/Footer.astro",
|
||||
"src/components/layout/footer.variants.ts"
|
||||
],
|
||||
"dependencies": {
|
||||
"utilities": ["cn"],
|
||||
"components": ["icon", "logo"]
|
||||
},
|
||||
"premium": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/hansmartens68/astro--rocket/component-registry.schema.json",
|
||||
"title": "Astro Rocket Component Registry",
|
||||
"description": "Schema for the Astro Rocket component registry",
|
||||
"type": "object",
|
||||
"required": ["version", "categories", "utilities", "components"],
|
||||
"properties": {
|
||||
"$schema": {
|
||||
"type": "string",
|
||||
"description": "JSON schema reference"
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d+\\.\\d+\\.\\d+$",
|
||||
"description": "Registry version (semver)"
|
||||
},
|
||||
"categories": {
|
||||
"type": "object",
|
||||
"description": "Component categories",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/$defs/category"
|
||||
}
|
||||
},
|
||||
"utilities": {
|
||||
"type": "object",
|
||||
"description": "Shared utility files",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/$defs/utility"
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"type": "object",
|
||||
"description": "Component definitions",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/$defs/component"
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"category": {
|
||||
"type": "object",
|
||||
"required": ["name", "description"],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Display name of the category"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Brief description of the category"
|
||||
}
|
||||
}
|
||||
},
|
||||
"utility": {
|
||||
"type": "object",
|
||||
"required": ["name", "description", "files", "npm"],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Display name of the utility"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Brief description of the utility"
|
||||
},
|
||||
"files": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "File paths for this utility"
|
||||
},
|
||||
"npm": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "NPM packages required by this utility"
|
||||
}
|
||||
}
|
||||
},
|
||||
"component": {
|
||||
"type": "object",
|
||||
"required": ["name", "category", "files", "dependencies", "premium"],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Display name of the component"
|
||||
},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"description": "Category ID this component belongs to"
|
||||
},
|
||||
"subcategory": {
|
||||
"type": "string",
|
||||
"description": "Subcategory within the main category (e.g., form, data-display, feedback)"
|
||||
},
|
||||
"files": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "File paths for this component"
|
||||
},
|
||||
"dependencies": {
|
||||
"type": "object",
|
||||
"required": ["utilities", "components"],
|
||||
"properties": {
|
||||
"utilities": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Utility IDs this component depends on"
|
||||
},
|
||||
"components": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Component IDs this component depends on"
|
||||
}
|
||||
}
|
||||
},
|
||||
"premium": {
|
||||
"type": "boolean",
|
||||
"description": "Whether this is a premium component"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'] }],
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,30 @@
|
||||
[build]
|
||||
command = "pnpm run build"
|
||||
publish = "dist"
|
||||
|
||||
[build.environment]
|
||||
NODE_VERSION = "22"
|
||||
|
||||
[[headers]]
|
||||
for = "/*"
|
||||
[headers.values]
|
||||
X-Frame-Options = "DENY"
|
||||
X-Content-Type-Options = "nosniff"
|
||||
Referrer-Policy = "strict-origin-when-cross-origin"
|
||||
Strict-Transport-Security = "max-age=31536000; includeSubDomains"
|
||||
|
||||
[[headers]]
|
||||
for = "/fonts/*"
|
||||
[headers.values]
|
||||
Cache-Control = "public, max-age=31536000, immutable"
|
||||
|
||||
[[headers]]
|
||||
for = "/_astro/*"
|
||||
[headers.values]
|
||||
Cache-Control = "public, max-age=31536000, immutable"
|
||||
|
||||
# Redirect rules
|
||||
[[redirects]]
|
||||
from = "/api/*"
|
||||
to = "/.netlify/functions/:splat"
|
||||
status = 200
|
||||
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"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/netlify": "^7.0.2",
|
||||
"@astrojs/react": "5.0.0",
|
||||
"@astrojs/sitemap": "^3.7.1",
|
||||
"@astrojs/vercel": "^10.0.0",
|
||||
"@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,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,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 |
@@ -0,0 +1,38 @@
|
||||
<svg width="880" height="260" viewBox="0 0 880 260" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<radialGradient id="glow" cx="440" cy="90" r="280" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#10b981" stop-opacity="0.18"/>
|
||||
<stop offset="100%" stop-color="#10b981" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Background -->
|
||||
<rect width="880" height="260" fill="#0d1117"/>
|
||||
<rect width="880" height="260" fill="url(#glow)"/>
|
||||
|
||||
<!-- Top accent stripe -->
|
||||
<rect width="880" height="4" fill="#10b981"/>
|
||||
|
||||
<!-- Corner marks -->
|
||||
<path d="M 30 50 L 30 30 L 50 30" stroke="#10b981" stroke-width="1.5" fill="none" stroke-opacity="0.5"/>
|
||||
<path d="M 830 30 L 850 30 L 850 50" stroke="#10b981" stroke-width="1.5" fill="none" stroke-opacity="0.5"/>
|
||||
<path d="M 30 210 L 30 230 L 50 230" stroke="#10b981" stroke-width="1.5" fill="none" stroke-opacity="0.5"/>
|
||||
<path d="M 830 230 L 850 230 L 850 210" stroke="#10b981" stroke-width="1.5" fill="none" stroke-opacity="0.5"/>
|
||||
|
||||
<!-- Lucide Rocket — scale(5), visual centre at (440, 90) -->
|
||||
<!-- Icon x≈2.5–22, y≈2–21 in 24×24; at scale 5: translate(379, 35) -->
|
||||
<g transform="translate(379, 35) scale(5)"
|
||||
stroke="#10b981" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" fill="none">
|
||||
<path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/>
|
||||
<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-.09"/>
|
||||
<path d="M9 12a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.4 22.4 0 0 1-4 2z"/>
|
||||
<path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 .05 5 .05"/>
|
||||
</g>
|
||||
|
||||
<!-- Wordmark — baseline y=218, icon bottom ≈ y=140, gap ≈ 33 px -->
|
||||
<text x="440" y="218"
|
||||
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="white">Astro <tspan fill="#10b981">Rocket</tspan></text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 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-800/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-800/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-800); }
|
||||
: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,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,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,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,392 @@
|
||||
---
|
||||
/**
|
||||
* 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 gap-2">
|
||||
<Logo size="md" />
|
||||
<span class="font-display text-base font-bold text-brand-500">
|
||||
{siteConfig.name}
|
||||
</span>
|
||||
</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 gap-2">
|
||||
<Logo size="md" />
|
||||
<span class="font-display text-base font-bold text-brand-500">
|
||||
{siteConfig.name}
|
||||
</span>
|
||||
</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">
|
||||
{processedCopyright}
|
||||
</p>
|
||||
)}
|
||||
{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">
|
||||
{processedCopyright}
|
||||
</p>
|
||||
)}
|
||||
</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 gap-2">
|
||||
<Logo size="md" />
|
||||
<span class="font-display text-xl font-bold text-brand-500">
|
||||
{siteConfig.name}
|
||||
</span>
|
||||
</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">
|
||||
{processedCopyright}
|
||||
</p>
|
||||
)}
|
||||
{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,753 @@
|
||||
---
|
||||
/**
|
||||
* 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 siteConfig from '@/config/site.config';
|
||||
|
||||
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;
|
||||
/** 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',
|
||||
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 gap-2">
|
||||
<Logo size={size === 'lg' ? 'lg' : 'md'} forceDark={isInvert} />
|
||||
<span
|
||||
class={cn(
|
||||
'font-display text-xl font-bold tracking-tight',
|
||||
isFloating ? 'hdr-logo-text' : (isInvert ? 'text-on-invert' : 'text-brand-500')
|
||||
)}
|
||||
>
|
||||
{logoText || siteConfig.name}
|
||||
</span>
|
||||
</a>
|
||||
))
|
||||
}
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
</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,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>;
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default } from './Avatar.astro';
|
||||
export { Avatar } from './Avatar';
|
||||
export { avatarVariants, type AvatarVariants } from './avatar.variants';
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
/**
|
||||
* AvatarGroup Component
|
||||
* Displays stacked avatars with an optional "+N" overflow indicator.
|
||||
*/
|
||||
import type { HTMLAttributes } from 'astro/types';
|
||||
import { cn } from '@/lib/cn';
|
||||
import Avatar from '../Avatar/Avatar.astro';
|
||||
|
||||
interface AvatarItem {
|
||||
src?: string;
|
||||
alt?: string;
|
||||
fallback?: string;
|
||||
}
|
||||
|
||||
interface Props extends HTMLAttributes<'div'> {
|
||||
avatars: AvatarItem[];
|
||||
/** Maximum number of avatars to show before "+N" */
|
||||
max?: number;
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
const {
|
||||
avatars,
|
||||
max = 4,
|
||||
size = 'md',
|
||||
class: className,
|
||||
...attrs
|
||||
} = Astro.props;
|
||||
|
||||
const visibleAvatars = avatars.slice(0, max);
|
||||
const overflowCount = Math.max(0, avatars.length - max);
|
||||
|
||||
const overflowSizes = {
|
||||
xs: 'w-6 h-6 text-[8px]',
|
||||
sm: 'w-8 h-8 text-[10px]',
|
||||
md: 'w-10 h-10 text-xs',
|
||||
lg: 'w-12 h-12 text-sm',
|
||||
};
|
||||
---
|
||||
|
||||
<div class={cn('flex -space-x-2', className)} {...attrs}>
|
||||
{visibleAvatars.map((avatar) => (
|
||||
<Avatar
|
||||
src={avatar.src}
|
||||
alt={avatar.alt || ''}
|
||||
fallback={avatar.fallback}
|
||||
size={size}
|
||||
class="ring-2 ring-background"
|
||||
/>
|
||||
))}
|
||||
{overflowCount > 0 && (
|
||||
<div
|
||||
class={cn(
|
||||
'relative inline-flex items-center justify-center',
|
||||
'rounded-full overflow-hidden',
|
||||
'bg-secondary text-foreground-muted font-semibold',
|
||||
'ring-2 ring-background',
|
||||
overflowSizes[size]
|
||||
)}
|
||||
aria-label={`${overflowCount} more`}
|
||||
>
|
||||
+{overflowCount}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -0,0 +1,61 @@
|
||||
import { type HTMLAttributes, type Ref } from 'react';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { Avatar } from '../Avatar/Avatar';
|
||||
import type { AvatarVariants } from '../Avatar/avatar.variants';
|
||||
|
||||
interface AvatarItem {
|
||||
src?: string;
|
||||
alt?: string;
|
||||
fallback?: string;
|
||||
}
|
||||
|
||||
interface AvatarGroupProps extends Omit<HTMLAttributes<HTMLDivElement>, 'ref'> {
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
avatars: AvatarItem[];
|
||||
max?: number;
|
||||
size?: NonNullable<AvatarVariants['size']>;
|
||||
}
|
||||
|
||||
const overflowSizes: Record<string, string> = {
|
||||
xs: 'w-6 h-6 text-[8px]',
|
||||
sm: 'w-8 h-8 text-[10px]',
|
||||
md: 'w-10 h-10 text-xs',
|
||||
lg: 'w-12 h-12 text-sm',
|
||||
xl: 'w-14 h-14 text-base',
|
||||
};
|
||||
|
||||
export function AvatarGroup({ ref, avatars, max = 4, size = 'md', className, ...rest }: AvatarGroupProps) {
|
||||
const visibleAvatars = avatars.slice(0, max);
|
||||
const overflowCount = Math.max(0, avatars.length - max);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn('flex -space-x-2', className)} {...rest}>
|
||||
{visibleAvatars.map((avatar, i) => (
|
||||
<Avatar
|
||||
key={i}
|
||||
src={avatar.src}
|
||||
alt={avatar.alt || ''}
|
||||
fallback={avatar.fallback}
|
||||
size={size}
|
||||
className="ring-2 ring-background"
|
||||
/>
|
||||
))}
|
||||
{overflowCount > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
'relative inline-flex items-center justify-center',
|
||||
'rounded-full overflow-hidden',
|
||||
'bg-secondary text-foreground-muted font-semibold',
|
||||
'ring-2 ring-background',
|
||||
overflowSizes[size]
|
||||
)}
|
||||
aria-label={`${overflowCount} more`}
|
||||
>
|
||||
+{overflowCount}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AvatarGroup;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from './AvatarGroup.astro';
|
||||
export { AvatarGroup } from './AvatarGroup';
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
/**
|
||||
* Badge Component
|
||||
* Displays a small status indicator or label with proper icon spacing
|
||||
*/
|
||||
import type { HTMLAttributes } from 'astro/types';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { badgeVariants } from './badge.variants';
|
||||
|
||||
interface Props extends HTMLAttributes<'span'> {
|
||||
variant?: 'default' | 'success' | 'warning' | 'error' | 'info' | 'brand';
|
||||
size?: 'sm' | 'md';
|
||||
/** Show a pulsing dot indicator */
|
||||
pulse?: boolean;
|
||||
/** Use pill styling (fully rounded with shadow) */
|
||||
pill?: boolean;
|
||||
}
|
||||
|
||||
const { variant = 'default', size = 'md', pulse = false, pill = false, class: className, ...attrs } = Astro.props;
|
||||
---
|
||||
|
||||
<span class={cn(badgeVariants({ variant, size, pill }), className)} {...attrs}>
|
||||
{pulse && (
|
||||
<span class="relative flex h-2 w-2 shrink-0" aria-hidden="true">
|
||||
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-brand-500 opacity-75" />
|
||||
<span class="relative inline-flex h-2 w-2 rounded-full bg-brand-500" />
|
||||
</span>
|
||||
)}
|
||||
<slot />
|
||||
</span>
|
||||
@@ -0,0 +1,25 @@
|
||||
import { type HTMLAttributes, type Ref, type ReactNode } from 'react';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { badgeVariants, type BadgeVariants } from './badge.variants';
|
||||
|
||||
interface BadgeProps extends Omit<HTMLAttributes<HTMLSpanElement>, 'ref'> {
|
||||
ref?: Ref<HTMLSpanElement>;
|
||||
variant?: BadgeVariants['variant'];
|
||||
size?: BadgeVariants['size'];
|
||||
pulse?: boolean;
|
||||
pill?: boolean;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function Badge({ ref, variant = 'default', size = 'md', pulse = false, pill = false, className, children, ...rest }: BadgeProps) {
|
||||
return (
|
||||
<span ref={ref} className={cn(badgeVariants({ variant, size, pill }), className)} {...rest}>
|
||||
{pulse && (
|
||||
<span className="flex h-2 w-2 shrink-0 animate-pulse rounded-full bg-brand-500" aria-hidden="true" />
|
||||
)}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default Badge;
|
||||
@@ -0,0 +1,44 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
export const badgeVariants = cva(
|
||||
[
|
||||
'inline-flex items-center font-medium border',
|
||||
'transition-colors',
|
||||
'[&>svg]:shrink-0 [&>svg]:h-3 [&>svg]:w-3',
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-secondary text-secondary-foreground border-border',
|
||||
success:
|
||||
'bg-[var(--success-light)] text-[var(--success-foreground)] border-[var(--success)]/20',
|
||||
warning:
|
||||
'bg-[var(--warning-light)] text-[var(--warning-foreground)] border-[var(--warning)]/20',
|
||||
error:
|
||||
'bg-[var(--error-light)] text-[var(--error-foreground)] border-[var(--error)]/20',
|
||||
info: 'bg-[var(--info-light)] text-[var(--info-foreground)] border-[var(--info)]/20',
|
||||
brand:
|
||||
'bg-brand-500/10 text-brand-600 border-brand-500/20 dark:text-brand-400',
|
||||
},
|
||||
size: {
|
||||
sm: 'text-[10px] px-2 py-0.5 gap-1',
|
||||
md: 'text-sm sm:text-xs px-2.5 py-1 gap-1.5',
|
||||
},
|
||||
pill: {
|
||||
true: 'rounded-full shadow-sm',
|
||||
false: 'rounded-md',
|
||||
},
|
||||
},
|
||||
compoundVariants: [
|
||||
{ pill: true, size: 'sm', class: 'px-2.5' },
|
||||
{ pill: true, size: 'md', class: 'px-3.5 sm:px-3' },
|
||||
],
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'md',
|
||||
pill: false,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export type BadgeVariants = VariantProps<typeof badgeVariants>;
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default } from './Badge.astro';
|
||||
export { Badge } from './Badge';
|
||||
export { badgeVariants, type BadgeVariants } from './badge.variants';
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
import type { HTMLAttributes } from 'astro/types';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { cardVariants } from './card.variants';
|
||||
|
||||
interface Props extends HTMLAttributes<'div'>, Pick<HTMLAttributes<'a'>, 'target' | 'rel'> {
|
||||
variant?: 'default' | 'solid' | 'outline' | 'ghost' | 'elevated';
|
||||
padding?: 'none' | 'sm' | 'md' | 'lg';
|
||||
hover?: boolean;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
variant = 'default',
|
||||
padding = 'md',
|
||||
hover = false,
|
||||
href,
|
||||
class: className,
|
||||
...attrs
|
||||
} = Astro.props;
|
||||
|
||||
const Element = href ? 'a' : 'div';
|
||||
|
||||
const cardStyles = cn(
|
||||
cardVariants({ variant, padding, hover }),
|
||||
href && 'block cursor-pointer',
|
||||
className
|
||||
);
|
||||
---
|
||||
|
||||
<Element class={cardStyles} href={href} {...attrs}>
|
||||
<slot />
|
||||
</Element>
|
||||
@@ -0,0 +1,156 @@
|
||||
import { type HTMLAttributes, type Ref, type ReactNode } from 'react';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { cardVariants, type CardVariants } from './card.variants';
|
||||
|
||||
type CardShadow = 'none' | 'sm' | 'md' | 'lg';
|
||||
|
||||
interface CardProps extends Omit<HTMLAttributes<HTMLDivElement>, 'ref'> {
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
padding?: CardVariants['padding'];
|
||||
shadow?: CardShadow;
|
||||
hover?: boolean;
|
||||
/** Visual style variant */
|
||||
variant?: CardVariants['variant'];
|
||||
/** Icon element to display in the card header */
|
||||
icon?: ReactNode;
|
||||
/** Card title */
|
||||
title?: string;
|
||||
/** Card subtitle/byline */
|
||||
subtitle?: string;
|
||||
/** Card description */
|
||||
description?: string;
|
||||
/** Whether to use the structured layout with icon/title/description */
|
||||
structured?: boolean;
|
||||
}
|
||||
|
||||
const shadows: Record<CardShadow, string> = {
|
||||
none: '',
|
||||
sm: 'shadow-sm',
|
||||
md: 'shadow-md',
|
||||
lg: 'shadow-lg',
|
||||
};
|
||||
|
||||
export function Card({
|
||||
ref,
|
||||
padding = 'md',
|
||||
shadow = 'none',
|
||||
hover = false,
|
||||
variant = 'default',
|
||||
icon,
|
||||
title,
|
||||
subtitle,
|
||||
description,
|
||||
structured = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CardProps) {
|
||||
const cardStyles = cn(
|
||||
cardVariants({ variant, padding, hover }),
|
||||
shadows[shadow],
|
||||
className
|
||||
);
|
||||
|
||||
// If using structured layout with icon/title/description
|
||||
if (structured || icon || title) {
|
||||
return (
|
||||
<div ref={ref} className={cardStyles} {...props}>
|
||||
<div className="flex items-start gap-4">
|
||||
{icon && (
|
||||
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-500/5 flex items-center justify-center text-brand-500 shrink-0">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
{(title || subtitle) && (
|
||||
<div className="flex-1 min-w-0">
|
||||
{title && (
|
||||
<h3 className="text-base font-semibold text-foreground">{title}</h3>
|
||||
)}
|
||||
{subtitle && (
|
||||
<p className="text-xs text-foreground-subtle mt-0.5 font-medium">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{description && (
|
||||
<div className="mt-4">
|
||||
<p className="text-sm text-foreground-muted leading-relaxed">{description}</p>
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cardStyles} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Card sub-components with refined spacing
|
||||
interface CardSubComponentProps extends Omit<HTMLAttributes<HTMLDivElement>, 'ref'> {
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
interface CardTitleProps extends Omit<HTMLAttributes<HTMLHeadingElement>, 'ref'> {
|
||||
ref?: Ref<HTMLHeadingElement>;
|
||||
}
|
||||
|
||||
interface CardTextProps extends Omit<HTMLAttributes<HTMLParagraphElement>, 'ref'> {
|
||||
ref?: Ref<HTMLParagraphElement>;
|
||||
}
|
||||
|
||||
export function CardHeader({ ref, className, ...props }: CardSubComponentProps) {
|
||||
return <div ref={ref} className={cn('flex flex-col gap-1', className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardTitle({ ref, className, ...props }: CardTitleProps) {
|
||||
return (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-base font-black leading-tight tracking-tight text-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardByline({ ref, className, ...props }: CardTextProps) {
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn('text-xs text-foreground-subtle mt-0.5 font-medium', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardDescription({ ref, className, ...props }: CardTextProps) {
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn('text-sm text-foreground-muted leading-relaxed mt-1.5', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardContent({ ref, className, ...props }: CardSubComponentProps) {
|
||||
return <div ref={ref} className={cn('mt-4', className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardFooter({ ref, className, ...props }: CardSubComponentProps) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex items-center mt-4 pt-4 border-t border-border', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default Card;
|
||||
@@ -0,0 +1,31 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
export const cardVariants = cva(
|
||||
['rounded-xl', 'transition-all duration-200 ease-out'],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-card border border-brand-500/30 hover:border-brand-500/70',
|
||||
solid: 'bg-secondary border border-transparent',
|
||||
outline: 'bg-transparent border-2 border-brand-500/30 hover:border-brand-500/70',
|
||||
ghost: 'bg-transparent border border-transparent',
|
||||
elevated: 'bg-card border border-brand-500/30 shadow-lg hover:border-brand-500/70',
|
||||
},
|
||||
padding: {
|
||||
none: '',
|
||||
sm: 'p-4',
|
||||
md: 'p-6',
|
||||
lg: 'p-8',
|
||||
},
|
||||
hover: {
|
||||
true: 'hover:border-brand-500 hover:shadow-md hover:-translate-y-0.5',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
padding: 'md',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export type CardVariants = VariantProps<typeof cardVariants>;
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default } from './Card.astro';
|
||||
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './Card';
|
||||
export { cardVariants, type CardVariants } from './card.variants';
|
||||
@@ -0,0 +1,378 @@
|
||||
---
|
||||
/**
|
||||
* GoogleMap — consent-aware Google Maps embed
|
||||
*
|
||||
* 3 states:
|
||||
* 1. No API key → setup prompt with instructions
|
||||
* 2. API key + consent required but not granted → placeholder with "Load Map" button
|
||||
* 3. API key + consent granted (or consent disabled) → iframe loads immediately
|
||||
*/
|
||||
import type { HTMLAttributes } from 'astro/types';
|
||||
import { PUBLIC_GOOGLE_MAPS_API_KEY, PUBLIC_CONSENT_ENABLED } from 'astro:env/client';
|
||||
import siteConfig from '@/config/site.config';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { googleMapVariants } from './googleMap.variants';
|
||||
|
||||
interface Props extends HTMLAttributes<'div'> {
|
||||
lat?: number;
|
||||
lng?: number;
|
||||
address?: string;
|
||||
zoom?: number;
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
||||
mode?: 'place' | 'view' | 'directions' | 'streetview' | 'search';
|
||||
mapType?: 'roadmap' | 'satellite';
|
||||
consentCategory?: string;
|
||||
ariaLabel?: string;
|
||||
placeholderTitle?: string;
|
||||
placeholderDescription?: string;
|
||||
externalLinkText?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
lat,
|
||||
lng,
|
||||
address,
|
||||
zoom = 15,
|
||||
size = 'md',
|
||||
mode = 'place',
|
||||
mapType = 'roadmap',
|
||||
consentCategory = 'marketing',
|
||||
ariaLabel = 'Google Maps',
|
||||
placeholderTitle = 'Map',
|
||||
placeholderDescription = 'Accept cookies to load the interactive map.',
|
||||
externalLinkText = 'View on Google Maps',
|
||||
class: className,
|
||||
...rest
|
||||
} = Astro.props;
|
||||
|
||||
const hasApiKey = !!PUBLIC_GOOGLE_MAPS_API_KEY;
|
||||
|
||||
// Build query — prefer lat/lng, fall back to address, then siteConfig.address
|
||||
let query = '';
|
||||
if (lat !== undefined && lng !== undefined) {
|
||||
query = `${lat},${lng}`;
|
||||
} else if (address) {
|
||||
query = address;
|
||||
} else if (siteConfig.address) {
|
||||
const a = siteConfig.address;
|
||||
query = [a.street, a.city, a.state, a.zip, a.country].filter(Boolean).join(', ');
|
||||
}
|
||||
|
||||
// Build iframe src (only when key exists)
|
||||
let iframeSrc = '';
|
||||
if (hasApiKey) {
|
||||
const params = new URLSearchParams({
|
||||
key: PUBLIC_GOOGLE_MAPS_API_KEY,
|
||||
q: query,
|
||||
zoom: String(zoom),
|
||||
maptype: mapType,
|
||||
});
|
||||
iframeSrc = `https://www.google.com/maps/embed/v1/${mode}?${params.toString()}`;
|
||||
}
|
||||
|
||||
// External link for placeholder
|
||||
const externalUrl = query
|
||||
? `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`
|
||||
: 'https://maps.google.com';
|
||||
|
||||
const consentEnabled = PUBLIC_CONSENT_ENABLED;
|
||||
|
||||
// Config for client script
|
||||
const mapConfig = JSON.stringify({
|
||||
consentCategory,
|
||||
consentEnabled,
|
||||
});
|
||||
|
||||
const id = `google-map-${Math.random().toString(36).slice(2, 9)}`;
|
||||
---
|
||||
|
||||
<div
|
||||
class={cn(googleMapVariants({ size }), className)}
|
||||
data-google-map={id}
|
||||
{...rest}
|
||||
>
|
||||
{!hasApiKey ? (
|
||||
/* No API key — setup prompt */
|
||||
<div class="google-map-setup">
|
||||
<div class="google-map-setup__inner">
|
||||
{/* Lucide map-pin-off */}
|
||||
<svg class="google-map-setup__icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M5.43 5.43A8.06 8.06 0 0 0 4 10c0 6 8 12 8 12a29.94 29.94 0 0 0 5-5" />
|
||||
<path d="M19.18 13.52A8.66 8.66 0 0 0 20 10a8 8 0 0 0-8-8 7.88 7.88 0 0 0-3.52.82" />
|
||||
<path d="M9.13 9.13a3 3 0 0 0 3.74 3.74" />
|
||||
<path d="M14.9 9.25a3 3 0 0 0-2.15-2.16" />
|
||||
<line x1="2" x2="22" y1="2" y2="22" />
|
||||
</svg>
|
||||
<p class="google-map-setup__title">Google Maps</p>
|
||||
<p class="google-map-setup__desc">
|
||||
Add <code>PUBLIC_GOOGLE_MAPS_API_KEY</code> to your <code>.env</code> file to enable the map.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Consent placeholder — shown when consent is required but not granted */}
|
||||
<div class="google-map-placeholder" data-map-placeholder={id}>
|
||||
<div class="google-map-placeholder__icon">
|
||||
{/* Lucide map-pin */}
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0" />
|
||||
<circle cx="12" cy="10" r="3" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{query && <p class="google-map-placeholder__address">{query}</p>}
|
||||
|
||||
<p class="google-map-placeholder__title">{placeholderTitle}</p>
|
||||
<p class="google-map-placeholder__desc">{placeholderDescription}</p>
|
||||
|
||||
<button class="google-map-placeholder__btn" data-map-load={id} type="button">
|
||||
Load Map
|
||||
</button>
|
||||
|
||||
<a
|
||||
href={externalUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="google-map-placeholder__link"
|
||||
>
|
||||
{/* Lucide external-link */}
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M15 3h6v6" />
|
||||
<path d="M10 14 21 3" />
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
||||
</svg>
|
||||
{externalLinkText}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Iframe — hidden until consent granted */}
|
||||
<iframe
|
||||
data-map-iframe={id}
|
||||
data-src={iframeSrc}
|
||||
hidden
|
||||
width="100%"
|
||||
height="100%"
|
||||
style="border:0;"
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer-when-downgrade"
|
||||
allow="fullscreen"
|
||||
aria-label={ariaLabel}
|
||||
></iframe>
|
||||
|
||||
<script type="application/json" data-google-map-config={id} set:html={mapConfig} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
interface MapWindow extends Window {
|
||||
__consentState?: { decided: boolean; categories: Record<string, boolean> };
|
||||
}
|
||||
|
||||
function initGoogleMaps() {
|
||||
const maps = document.querySelectorAll<HTMLElement>('[data-google-map]');
|
||||
|
||||
maps.forEach((container) => {
|
||||
const id = container.dataset.googleMap!;
|
||||
const configEl = container.querySelector<HTMLScriptElement>(`[data-google-map-config="${id}"]`);
|
||||
if (!configEl) return;
|
||||
|
||||
const config = JSON.parse(configEl.textContent!);
|
||||
const iframe = container.querySelector<HTMLIFrameElement>(`[data-map-iframe="${id}"]`);
|
||||
const placeholder = container.querySelector<HTMLElement>(`[data-map-placeholder="${id}"]`);
|
||||
const loadBtn = container.querySelector<HTMLButtonElement>(`[data-map-load="${id}"]`);
|
||||
|
||||
if (!iframe) return;
|
||||
|
||||
// Already loaded (idempotent)
|
||||
if (iframe.src && iframe.src !== 'about:blank') return;
|
||||
|
||||
const w = window as unknown as MapWindow;
|
||||
|
||||
function loadMap() {
|
||||
const src = iframe!.dataset.src;
|
||||
if (!src || (iframe!.src && iframe!.src !== 'about:blank')) return;
|
||||
iframe!.src = src;
|
||||
iframe!.removeAttribute('hidden');
|
||||
if (placeholder) placeholder.hidden = true;
|
||||
}
|
||||
|
||||
function hasConsent(): boolean {
|
||||
if (!config.consentEnabled) return true;
|
||||
if (!w.__consentState?.decided) return false;
|
||||
return !!w.__consentState.categories[config.consentCategory];
|
||||
}
|
||||
|
||||
// Check if consent is already granted
|
||||
if (hasConsent()) {
|
||||
loadMap();
|
||||
return;
|
||||
}
|
||||
|
||||
// Consent required — show placeholder
|
||||
if (config.consentEnabled) {
|
||||
if (placeholder) placeholder.hidden = false;
|
||||
|
||||
// "Load Map" button grants consent for this embed only
|
||||
if (loadBtn) {
|
||||
loadBtn.addEventListener('click', loadMap, { once: true });
|
||||
}
|
||||
|
||||
// Listen for consent-updated event
|
||||
window.addEventListener('consent-updated', function onConsent(e: Event) {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
if (detail?.categories?.[config.consentCategory]) {
|
||||
loadMap();
|
||||
window.removeEventListener('consent-updated', onConsent);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// No consent system — load immediately
|
||||
loadMap();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
initGoogleMaps();
|
||||
document.addEventListener('astro:page-load', initGoogleMaps);
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* ── No API key: setup prompt ── */
|
||||
.google-map-setup {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
background: repeating-linear-gradient(
|
||||
-45deg,
|
||||
transparent,
|
||||
transparent 8px,
|
||||
color-mix(in srgb, var(--foreground-muted) 4%, transparent) 8px,
|
||||
color-mix(in srgb, var(--foreground-muted) 4%, transparent) 9px
|
||||
);
|
||||
}
|
||||
|
||||
.google-map-setup__inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 1.5rem 2rem;
|
||||
background-color: var(--card);
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.google-map-setup__icon {
|
||||
color: var(--foreground-muted);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.google-map-setup__title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.google-map-setup__desc {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--foreground-muted);
|
||||
max-width: 22rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.google-map-setup__desc code {
|
||||
font-family: ui-monospace, 'SF Mono', 'Cascadia Code', monospace;
|
||||
font-size: 0.6875rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
background-color: var(--secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.25rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Consent placeholder ── */
|
||||
.google-map-placeholder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
background-color: var(--secondary);
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.google-map-placeholder__icon {
|
||||
color: var(--brand-500);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.google-map-placeholder__address {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--foreground);
|
||||
max-width: 24rem;
|
||||
}
|
||||
|
||||
.google-map-placeholder__title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.google-map-placeholder__desc {
|
||||
font-size: 0.875rem;
|
||||
color: var(--foreground-muted);
|
||||
max-width: 20rem;
|
||||
}
|
||||
|
||||
.google-map-placeholder__btn {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem 1.25rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: white;
|
||||
background-color: var(--brand-500);
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: opacity var(--transition-fast) ease;
|
||||
}
|
||||
|
||||
.google-map-placeholder__btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.google-map-placeholder__link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--foreground-muted);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-fast) ease;
|
||||
}
|
||||
|
||||
.google-map-placeholder__link:hover {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
/* ── Iframe ── */
|
||||
[data-map-iframe] {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,21 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
export const googleMapVariants = cva(
|
||||
'relative w-full overflow-hidden rounded-xl border border-border',
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
sm: 'h-[250px]',
|
||||
md: 'h-[400px]',
|
||||
lg: 'h-[500px]',
|
||||
xl: 'h-[600px]',
|
||||
full: 'h-[70vh]',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'md',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export type GoogleMapVariants = VariantProps<typeof googleMapVariants>;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from './GoogleMap.astro';
|
||||
export { googleMapVariants, type GoogleMapVariants } from './googleMap.variants';
|
||||
@@ -0,0 +1,135 @@
|
||||
---
|
||||
/**
|
||||
* Pagination Component
|
||||
* Page navigation with prev/next and numbered pages.
|
||||
*/
|
||||
import type { HTMLAttributes } from 'astro/types';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { paginationItemVariants } from './pagination.variants';
|
||||
import Icon from '../../primitives/Icon/Icon.astro';
|
||||
|
||||
interface Props extends HTMLAttributes<'nav'> {
|
||||
/** Current active page (1-indexed) */
|
||||
currentPage: number;
|
||||
/** Total number of pages */
|
||||
totalPages: number;
|
||||
/** Base URL for page links (page number appended) */
|
||||
baseUrl?: string;
|
||||
/** Maximum number of visible page buttons */
|
||||
maxVisible?: number;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
const {
|
||||
currentPage,
|
||||
totalPages,
|
||||
baseUrl = '?page=',
|
||||
maxVisible = 5,
|
||||
size = 'md',
|
||||
class: className,
|
||||
...attrs
|
||||
} = Astro.props;
|
||||
|
||||
/**
|
||||
* Build the array of page numbers and ellipsis markers to render.
|
||||
*
|
||||
* @param current - The 1-indexed active page.
|
||||
* @param total - Total number of pages.
|
||||
* @param max - Size of the central sliding window. When `total <= max`,
|
||||
* every page is returned directly. Otherwise a window of
|
||||
* `max` pages is centered around `current`, and first/last
|
||||
* pages plus `'...'` ellipsis markers are added outside the
|
||||
* window as needed — so the returned array can contain more
|
||||
* than `max` entries.
|
||||
* @returns An array of page numbers and `'...'` separators.
|
||||
*/
|
||||
function getPageRange(current: number, total: number, max: number): (number | '...')[] {
|
||||
if (total <= max) {
|
||||
return Array.from({ length: total }, (_, i) => i + 1);
|
||||
}
|
||||
|
||||
const pages: (number | '...')[] = [];
|
||||
const half = Math.floor(max / 2);
|
||||
let start = Math.max(1, current - half);
|
||||
let end = Math.min(total, start + max - 1);
|
||||
|
||||
if (end - start < max - 1) {
|
||||
start = Math.max(1, end - max + 1);
|
||||
}
|
||||
|
||||
if (start > 1) {
|
||||
pages.push(1);
|
||||
if (start > 2) pages.push('...');
|
||||
}
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
|
||||
if (end < total) {
|
||||
if (end < total - 1) pages.push('...');
|
||||
pages.push(total);
|
||||
}
|
||||
|
||||
return pages;
|
||||
}
|
||||
|
||||
const pages = getPageRange(currentPage, totalPages, maxVisible);
|
||||
const prevUrl = currentPage > 1 ? `${baseUrl}${currentPage - 1}` : undefined;
|
||||
const nextUrl = currentPage < totalPages ? `${baseUrl}${currentPage + 1}` : undefined;
|
||||
---
|
||||
|
||||
<nav class={cn('flex items-center gap-1', className)} aria-label="Pagination" {...attrs}>
|
||||
{/* Previous */}
|
||||
{prevUrl ? (
|
||||
<a
|
||||
href={prevUrl}
|
||||
class={cn(paginationItemVariants({ variant: 'default', size }))}
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<Icon name="chevron-left" size="sm" />
|
||||
</a>
|
||||
) : (
|
||||
<span class={cn(paginationItemVariants({ variant: 'disabled', size }))} aria-disabled="true">
|
||||
<Icon name="chevron-left" size="sm" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Page Numbers */}
|
||||
{pages.map((page) =>
|
||||
page === '...' ? (
|
||||
<span class={cn(paginationItemVariants({ variant: 'default', size }), 'cursor-default hover:bg-transparent')} role="separator" aria-label="More pages">
|
||||
<span aria-hidden="true">...</span>
|
||||
</span>
|
||||
) : page === currentPage ? (
|
||||
<span
|
||||
class={cn(paginationItemVariants({ variant: 'active', size }))}
|
||||
aria-current="page"
|
||||
>
|
||||
{page}
|
||||
</span>
|
||||
) : (
|
||||
<a
|
||||
href={`${baseUrl}${page}`}
|
||||
class={cn(paginationItemVariants({ variant: 'default', size }))}
|
||||
>
|
||||
{page}
|
||||
</a>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Next */}
|
||||
{nextUrl ? (
|
||||
<a
|
||||
href={nextUrl}
|
||||
class={cn(paginationItemVariants({ variant: 'default', size }))}
|
||||
aria-label="Next page"
|
||||
>
|
||||
<Icon name="chevron-right" size="sm" />
|
||||
</a>
|
||||
) : (
|
||||
<span class={cn(paginationItemVariants({ variant: 'disabled', size }))} aria-disabled="true">
|
||||
<Icon name="chevron-right" size="sm" />
|
||||
</span>
|
||||
)}
|
||||
</nav>
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Pagination Component (React)
|
||||
* Page navigation with prev/next and numbered pages.
|
||||
*/
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { paginationItemVariants } from './pagination.variants';
|
||||
|
||||
interface PaginationProps extends HTMLAttributes<HTMLElement> {
|
||||
/** Current active page (1-indexed) */
|
||||
currentPage: number;
|
||||
/** Total number of pages */
|
||||
totalPages: number;
|
||||
/** Callback when a page is selected */
|
||||
onPageChange: (page: number) => void;
|
||||
/** Maximum number of visible page buttons */
|
||||
maxVisible?: number;
|
||||
/** Button size variant */
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the array of page numbers and ellipsis markers to render.
|
||||
*
|
||||
* @param current - The 1-indexed active page.
|
||||
* @param total - Total number of pages.
|
||||
* @param max - Size of the central sliding window. When `total <= max`,
|
||||
* every page is returned directly. Otherwise a window of
|
||||
* `max` pages is centered around `current`, and first/last
|
||||
* pages plus `'...'` ellipsis markers are added outside the
|
||||
* window as needed — so the returned array can contain more
|
||||
* than `max` entries.
|
||||
* @returns An array of page numbers and `'...'` separators.
|
||||
*/
|
||||
function getPageRange(current: number, total: number, max: number): (number | '...')[] {
|
||||
if (total <= max) {
|
||||
return Array.from({ length: total }, (_, i) => i + 1);
|
||||
}
|
||||
|
||||
const pages: (number | '...')[] = [];
|
||||
const half = Math.floor(max / 2);
|
||||
let start = Math.max(1, current - half);
|
||||
const end = Math.min(total, start + max - 1);
|
||||
|
||||
if (end - start < max - 1) {
|
||||
start = Math.max(1, end - max + 1);
|
||||
}
|
||||
|
||||
if (start > 1) {
|
||||
pages.push(1);
|
||||
if (start > 2) pages.push('...');
|
||||
}
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
|
||||
if (end < total) {
|
||||
if (end < total - 1) pages.push('...');
|
||||
pages.push(total);
|
||||
}
|
||||
|
||||
return pages;
|
||||
}
|
||||
|
||||
export function Pagination({
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
maxVisible = 5,
|
||||
size = 'md',
|
||||
className,
|
||||
...attrs
|
||||
}: PaginationProps) {
|
||||
const pages = getPageRange(currentPage, totalPages, maxVisible);
|
||||
const hasPrev = currentPage > 1;
|
||||
const hasNext = currentPage < totalPages;
|
||||
|
||||
return (
|
||||
<nav className={cn('flex items-center gap-1', className)} aria-label="Pagination" {...attrs}>
|
||||
{/* Previous */}
|
||||
{hasPrev ? (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(paginationItemVariants({ variant: 'default', size }))}
|
||||
aria-label="Previous page"
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M15 18l-6-6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
className={cn(paginationItemVariants({ variant: 'disabled', size }))}
|
||||
aria-disabled="true"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M15 18l-6-6 6-6" />
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Page Numbers */}
|
||||
{pages.map((page, index) =>
|
||||
page === '...' ? (
|
||||
<span
|
||||
key={`ellipsis-${index}`}
|
||||
className={cn(
|
||||
paginationItemVariants({ variant: 'default', size }),
|
||||
'cursor-default hover:bg-transparent',
|
||||
)}
|
||||
role="separator"
|
||||
aria-label="More pages"
|
||||
>
|
||||
<span aria-hidden="true">...</span>
|
||||
</span>
|
||||
) : page === currentPage ? (
|
||||
<span
|
||||
key={page}
|
||||
className={cn(paginationItemVariants({ variant: 'active', size }))}
|
||||
aria-current="page"
|
||||
>
|
||||
{page}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
key={page}
|
||||
type="button"
|
||||
className={cn(paginationItemVariants({ variant: 'default', size }))}
|
||||
onClick={() => onPageChange(page)}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
|
||||
{/* Next */}
|
||||
{hasNext ? (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(paginationItemVariants({ variant: 'default', size }))}
|
||||
aria-label="Next page"
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M9 18l6-6-6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
className={cn(paginationItemVariants({ variant: 'disabled', size }))}
|
||||
aria-disabled="true"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M9 18l6-6-6-6" />
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export default Pagination;
|
||||