Initial release — Astro Rocket v1.0.0

This commit is contained in:
Claude
2026-04-06 07:31:47 +00:00
commit ddd0c22311
275 changed files with 38839 additions and 0 deletions
+74
View File
@@ -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);
});
});
+41
View File
@@ -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);
});
});
View File
@@ -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

+26
View File
@@ -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

+24
View File
@@ -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

+27
View File
@@ -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

+30
View File
@@ -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

+25
View File
@@ -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

+31
View File
@@ -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

+25
View File
@@ -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

+38
View File
@@ -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.522, y≈221 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

+132
View File
@@ -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>
+104
View File
@@ -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>
+56
View File
@@ -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>
+67
View File
@@ -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>
)}
+96
View File
@@ -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>
+147
View File
@@ -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>
+17
View File
@@ -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>;
+3
View File
@@ -0,0 +1,3 @@
export { default as Hero } from './Hero.astro';
export { heroSectionVariants } from './hero.variants';
export type { HeroSectionVariants } from './hero.variants';
+27
View File
@@ -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>
+106
View File
@@ -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>
+496
View File
@@ -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 &middot; Desktop & Mobile emulation &middot;
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>
+75
View File
@@ -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>
+261
View File
@@ -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>
)}
+392
View File
@@ -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>
+753
View File
@@ -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>
+135
View File
@@ -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>
+82
View File
@@ -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>
+30
View File
@@ -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>;
+63
View File
@@ -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>;
+124
View File
@@ -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>
+49
View File
@@ -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>
+45
View File
@@ -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>
+114
View File
@@ -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>
+28
View File
@@ -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}
/>
+63
View File
@@ -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>
+132
View File
@@ -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>
+59
View File
@@ -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>
+17
View File
@@ -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)} />
))
}
+101
View File
@@ -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} />}
+115
View File
@@ -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';
+2
View File
@@ -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;
@@ -0,0 +1,3 @@
export { default } from './Pagination.astro';
export { Pagination } from './Pagination';
export { paginationItemVariants, type PaginationVariants } from './pagination.variants';
@@ -0,0 +1,29 @@
import { cva, type VariantProps } from 'class-variance-authority';
export const paginationItemVariants = cva(
[
'inline-flex items-center justify-center rounded-md text-sm font-medium',
'transition-colors duration-150',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
],
{
variants: {
variant: {
default: 'hover:bg-secondary text-foreground-muted hover:text-foreground',
active: 'bg-foreground text-background',
disabled: 'text-foreground-subtle cursor-not-allowed opacity-50',
},
size: {
sm: 'h-8 w-8 text-xs',
md: 'h-9 w-9 text-sm',
lg: 'h-10 w-10 text-base',
},
},
defaultVariants: {
variant: 'default',
size: 'md',
},
}
);
export type PaginationVariants = VariantProps<typeof paginationItemVariants>;
@@ -0,0 +1,64 @@
---
/**
* Progress Component
* A bar indicator showing determinate or indeterminate progress.
*/
import type { HTMLAttributes } from 'astro/types';
import { cn } from '@/lib/cn';
import { progressTrackVariants, progressBarVariants } from './progress.variants';
interface Props extends HTMLAttributes<'div'> {
/** Progress value between 0 and 100. Omit for indeterminate mode. */
value?: number;
/** Maximum value (default 100) */
max?: number;
variant?: 'default' | 'brand' | 'success' | 'warning' | 'error';
size?: 'sm' | 'md' | 'lg';
/** Show percentage label */
showLabel?: boolean;
}
const {
value,
max = 100,
variant = 'default',
size = 'md',
showLabel = false,
class: className,
...attrs
} = Astro.props;
const isIndeterminate = value === undefined;
const percentage = isIndeterminate || max <= 0 ? 0 : Math.min(100, Math.max(0, (value / max) * 100));
---
<div class={cn('w-full', className)} {...attrs}>
{showLabel && !isIndeterminate && (
<div class="flex justify-between mb-1.5">
<slot />
<span class="text-xs font-medium text-foreground-muted">{Math.round(percentage)}%</span>
</div>
)}
<div
class={cn(progressTrackVariants({ size }))}
role="progressbar"
aria-valuenow={isIndeterminate ? undefined : Math.round(percentage)}
aria-valuemin={0}
aria-valuemax={max}
>
<div
class={cn(progressBarVariants({ variant, indeterminate: isIndeterminate }))}
style={!isIndeterminate ? `width: ${percentage}%` : undefined}
/>
</div>
</div>
<style>
@keyframes indeterminate {
0% { transform: translateX(-100%); }
100% { transform: translateX(400%); }
}
.animate-indeterminate {
animation: indeterminate 1.5s cubic-bezier(0.4, 0, 0.2, 1) infinite;
}
</style>
@@ -0,0 +1,43 @@
import { type HTMLAttributes, type Ref, type ReactNode } from 'react';
import { cn } from '@/lib/cn';
import { progressTrackVariants, progressBarVariants, type ProgressVariants } from './progress.variants';
interface ProgressProps extends Omit<HTMLAttributes<HTMLDivElement>, 'ref'> {
ref?: Ref<HTMLDivElement>;
value?: number;
max?: number;
variant?: ProgressVariants['variant'];
size?: ProgressVariants['size'];
showLabel?: boolean;
children?: ReactNode;
}
export function Progress({ ref, value, max = 100, variant = 'default', size = 'md', showLabel = false, className, children, ...rest }: ProgressProps) {
const isIndeterminate = value === undefined;
const percentage = isIndeterminate || max <= 0 ? 0 : Math.min(100, Math.max(0, (value / max) * 100));
return (
<div ref={ref} className={cn('w-full', className)} {...rest}>
{showLabel && !isIndeterminate && (
<div className="flex justify-between mb-1.5">
{children}
<span className="text-xs font-medium text-foreground-muted">{Math.round(percentage)}%</span>
</div>
)}
<div
className={cn(progressTrackVariants({ size }))}
role="progressbar"
aria-valuenow={isIndeterminate ? undefined : Math.round(percentage)}
aria-valuemin={0}
aria-valuemax={max}
>
<div
className={cn(progressBarVariants({ variant, indeterminate: isIndeterminate }))}
style={!isIndeterminate ? { width: `${percentage}%` } : undefined}
/>
</div>
</div>
);
}
export default Progress;
@@ -0,0 +1,3 @@
export { default } from './Progress.astro';
export { Progress } from './Progress';
export { progressTrackVariants, progressBarVariants, type ProgressVariants } from './progress.variants';
@@ -0,0 +1,41 @@
import { cva, type VariantProps } from 'class-variance-authority';
export const progressTrackVariants = cva(
'relative w-full overflow-hidden rounded-full bg-secondary',
{
variants: {
size: {
sm: 'h-1.5',
md: 'h-2.5',
lg: 'h-4',
},
},
defaultVariants: {
size: 'md',
},
}
);
export const progressBarVariants = cva(
'h-full rounded-full transition-all duration-500 ease-out',
{
variants: {
variant: {
default: 'bg-foreground',
brand: 'bg-brand-500',
success: 'bg-[var(--success)]',
warning: 'bg-[var(--warning)]',
error: 'bg-[var(--error)]',
},
indeterminate: {
true: 'animate-indeterminate w-1/3',
},
},
defaultVariants: {
variant: 'default',
},
}
);
export type ProgressVariants = VariantProps<typeof progressTrackVariants> &
VariantProps<typeof progressBarVariants>;
@@ -0,0 +1,53 @@
---
/**
* Skeleton Component
* Loading placeholder with pulse animation
*/
import type { HTMLAttributes } from 'astro/types';
import { cn } from '@/lib/cn';
import { skeletonVariants } from './skeleton.variants';
interface Props extends HTMLAttributes<'div'> {
variant?: 'default' | 'circular' | 'text';
width?: string;
height?: string;
animated?: boolean;
}
const {
variant = 'default',
width,
height,
animated = true,
class: className,
...attrs
} = Astro.props;
const style = [
width && `width: ${width}`,
height && `height: ${height}`,
].filter(Boolean).join('; ');
---
<div
class={cn(skeletonVariants({ variant, animated }), className)}
style={style || undefined}
aria-hidden="true"
role="presentation"
{...attrs}
/>
<style>
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.animate-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
</style>
@@ -0,0 +1,26 @@
import { type HTMLAttributes, type Ref } from 'react';
import { cn } from '@/lib/cn';
import { skeletonVariants, type SkeletonVariants } from './skeleton.variants';
interface SkeletonProps extends Omit<HTMLAttributes<HTMLDivElement>, 'ref'> {
ref?: Ref<HTMLDivElement>;
variant?: SkeletonVariants['variant'];
width?: string;
height?: string;
animated?: boolean;
}
export function Skeleton({ ref, variant = 'default', width, height, animated = true, className, style, ...rest }: SkeletonProps) {
return (
<div
ref={ref}
className={cn(skeletonVariants({ variant, animated }), className)}
style={{ ...style, width, height }}
aria-hidden="true"
role="presentation"
{...rest}
/>
);
}
export default Skeleton;
@@ -0,0 +1,3 @@
export { default } from './Skeleton.astro';
export { Skeleton } from './Skeleton';
export { skeletonVariants, type SkeletonVariants } from './skeleton.variants';
@@ -0,0 +1,20 @@
import { cva, type VariantProps } from 'class-variance-authority';
export const skeletonVariants = cva('bg-secondary', {
variants: {
variant: {
default: 'rounded-md',
circular: 'rounded-full',
text: 'rounded h-4',
},
animated: {
true: 'animate-pulse',
},
},
defaultVariants: {
variant: 'default',
animated: true,
},
});
export type SkeletonVariants = VariantProps<typeof skeletonVariants>;
@@ -0,0 +1,90 @@
---
/**
* Table Component
* A styled data table with hover rows and responsive design.
*/
import type { HTMLAttributes } from 'astro/types';
import { cn } from '@/lib/cn';
interface Column {
key: string;
label: string;
align?: 'left' | 'center' | 'right';
class?: string;
}
interface Props extends HTMLAttributes<'div'> {
columns: Column[];
rows: Record<string, unknown>[];
/** Enable row hover highlighting */
hoverable?: boolean;
/** Enable row striping */
striped?: boolean;
/** Make table compact */
compact?: boolean;
}
const {
columns,
rows,
hoverable = true,
striped = false,
compact = false,
class: className,
...attrs
} = Astro.props;
const alignClasses = {
left: 'text-left',
center: 'text-center',
right: 'text-right',
};
const cellPadding = compact ? 'px-3 py-2' : 'px-4 py-3';
---
<div class={cn('w-full overflow-auto rounded-lg border border-border', className)} {...attrs}>
<table class="w-full text-sm">
<thead>
<tr class="border-b border-border bg-secondary/50">
{columns.map((col) => (
<th
class={cn(
cellPadding,
'font-medium text-foreground-muted',
alignClasses[col.align || 'left'],
col.class
)}
>
{col.label}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, i) => (
<tr
class={cn(
'border-b border-border last:border-b-0',
'transition-colors',
hoverable && 'hover:bg-secondary/30',
striped && i % 2 === 1 && 'bg-secondary/20'
)}
>
{columns.map((col) => (
<td
class={cn(
cellPadding,
'text-foreground',
alignClasses[col.align || 'left'],
col.class
)}
>
{String(row[col.key] ?? '')}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
@@ -0,0 +1,98 @@
/**
* Table Component (React)
* A styled data table with hover rows and responsive design.
*/
import type { HTMLAttributes } from 'react';
import { cn } from '@/lib/cn';
interface Column {
key: string;
label: string;
align?: 'left' | 'center' | 'right';
class?: string;
}
interface TableProps extends HTMLAttributes<HTMLDivElement> {
columns: Column[];
rows: Record<string, unknown>[];
/** Enable row hover highlighting */
hoverable?: boolean;
/** Enable row striping */
striped?: boolean;
/** Make table compact */
compact?: boolean;
}
const alignClasses = {
left: 'text-left',
center: 'text-center',
right: 'text-right',
};
export function Table({
columns,
rows,
hoverable = true,
striped = false,
compact = false,
className,
...attrs
}: TableProps) {
const cellPadding = compact ? 'px-3 py-2' : 'px-4 py-3';
return (
<div
className={cn('overflow-auto rounded-lg border border-border', className)}
{...attrs}
>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-secondary/50">
{columns.map((col) => (
<th
key={col.key}
className={cn(
cellPadding,
'font-medium text-foreground-muted',
alignClasses[col.align || 'left'],
col.class,
)}
>
{col.label}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, i) => (
<tr
key={i}
className={cn(
'border-b border-border last:border-b-0',
'transition-colors',
hoverable && 'hover:bg-secondary/30',
striped && i % 2 === 1 && 'bg-secondary/20',
)}
>
{columns.map((col) => (
<td
key={col.key}
className={cn(
cellPadding,
'text-foreground',
alignClasses[col.align || 'left'],
col.class,
)}
>
{String(row[col.key] ?? '')}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
export default Table;
@@ -0,0 +1,2 @@
export { default } from './Table.astro';
export { Table } from './Table';
+10
View File
@@ -0,0 +1,10 @@
// Data Display Components
export * from './Card';
export * from './Badge';
export * from './Avatar';
export * from './AvatarGroup';
export * from './Table';
export * from './Pagination';
export * from './Progress';
export * from './Skeleton';
export * from './GoogleMap';
@@ -0,0 +1,89 @@
---
/**
* Alert Component
* Contextual feedback messages with refined design system integration
*/
import type { HTMLAttributes } from 'astro/types';
import { cn } from '@/lib/cn';
import Icon from '../../primitives/Icon/Icon.astro';
import { alertVariants, alertIconColors, alertAccentColors } from './alert.variants';
interface Props extends HTMLAttributes<'div'> {
variant?: 'info' | 'success' | 'warning' | 'error';
title?: string;
dismissible?: boolean;
}
const { variant = 'info', title, dismissible = false, class: className, ...attrs } = Astro.props;
const icons: Record<string, 'info' | 'check-circle' | 'alert-triangle' | 'x-circle'> = {
info: 'info',
success: 'check-circle',
warning: 'alert-triangle',
error: 'x-circle',
};
---
<div
class={cn(alertVariants({ variant }), className)}
role="alert"
data-alert
{...attrs}
>
{/* Accent bar */}
<div class={cn('absolute left-0 top-0 bottom-0 w-1', alertAccentColors[variant])} />
{/* Icon */}
<div class={cn('shrink-0 mt-0.5', alertIconColors[variant])}>
<Icon name={icons[variant]} class="w-5 h-5" strokeWidth={2} />
</div>
{/* Content */}
<div class="flex-1 min-w-0 pl-1">
{title && (
<h5 class="font-semibold text-sm mb-1 text-foreground">
{title}
</h5>
)}
<div class="text-sm leading-relaxed text-foreground-muted">
<slot />
</div>
</div>
{/* Dismiss button */}
{dismissible && (
<button
type="button"
class="shrink-0 p-1 -mr-1 -mt-1 rounded-md text-foreground-muted hover:text-foreground hover:bg-secondary transition-colors"
aria-label="Dismiss"
data-dismiss-alert
>
<Icon name="x" class="w-4 h-4" />
</button>
)}
</div>
{dismissible && (
<script>
function initAlerts() {
document.querySelectorAll('[data-dismiss-alert]').forEach((button) => {
button.addEventListener('click', () => {
const alert = button.closest('[data-alert]');
if (alert) {
alert.classList.add('opacity-0', 'scale-[0.98]');
setTimeout(() => alert.remove(), 150);
}
});
});
}
initAlerts();
document.addEventListener('astro:page-load', initAlerts);
</script>
)}
<style>
[data-alert] {
transition: opacity 150ms ease-out, transform 150ms ease-out;
}
</style>
@@ -0,0 +1,80 @@
import { type HTMLAttributes, type Ref, type ReactNode, useState } from 'react';
import { cn } from '@/lib/cn';
import { alertVariants, alertIconColors, alertAccentColors, type AlertVariants } from './alert.variants';
interface AlertProps extends Omit<HTMLAttributes<HTMLDivElement>, 'ref' | 'title'> {
ref?: Ref<HTMLDivElement>;
variant?: NonNullable<AlertVariants['variant']>;
title?: string;
dismissible?: boolean;
onDismiss?: () => void;
children?: ReactNode;
}
const iconPaths: Record<string, string> = {
info: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zM12 16v-4M12 8h.01',
success: 'M22 11.08V12a10 10 0 1 1-5.93-9.14M22 4L12 14.01l-3-3',
warning: 'M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0zM12 9v4M12 17h.01',
error: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zM15 9l-6 6M9 9l6 6',
};
export function Alert({ ref, variant = 'info', title, dismissible = false, onDismiss, className, children, ...rest }: AlertProps) {
const [dismissed, setDismissed] = useState(false);
const [hiding, setHiding] = useState(false);
if (dismissed) return null;
const handleDismiss = () => {
setHiding(true);
setTimeout(() => {
setDismissed(true);
onDismiss?.();
}, 150);
};
return (
<div
ref={ref}
className={cn(
alertVariants({ variant }),
hiding && 'opacity-0 scale-[0.98]',
className
)}
style={{ transition: 'opacity 150ms ease-out, transform 150ms ease-out' }}
role="alert"
{...rest}
>
<div className={cn('absolute left-0 top-0 bottom-0 w-1', alertAccentColors[variant])} />
<div className={cn('shrink-0 mt-0.5', alertIconColors[variant])}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" className="w-5 h-5" aria-hidden="true">
{iconPaths[variant].split('M').filter(Boolean).map((d, i) => (
<path key={i} d={`M${d}`} />
))}
</svg>
</div>
<div className="flex-1 min-w-0 pl-1">
{title && (
<h5 className="font-semibold text-sm mb-1 text-foreground">{title}</h5>
)}
<div className="text-sm leading-relaxed text-foreground-muted">{children}</div>
</div>
{dismissible && (
<button
type="button"
className="shrink-0 p-1 -mr-1 -mt-1 rounded-md text-foreground-muted hover:text-foreground hover:bg-secondary transition-colors"
aria-label="Dismiss"
onClick={handleDismiss}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" className="w-4 h-4" aria-hidden="true">
<path d="M18 6L6 18" /><path d="M6 6l12 12" />
</svg>
</button>
)}
</div>
);
}
export default Alert;
@@ -0,0 +1,34 @@
import { cva, type VariantProps } from 'class-variance-authority';
export const alertVariants = cva(
'relative flex gap-4 p-4 rounded-lg border overflow-hidden',
{
variants: {
variant: {
info: 'bg-background border-border',
success: 'bg-background border-[var(--success)]/30',
warning: 'bg-background border-[var(--warning)]/30',
error: 'bg-background border-[var(--error)]/30',
},
},
defaultVariants: {
variant: 'info',
},
}
);
export const alertIconColors = {
info: 'text-foreground-muted',
success: 'text-[var(--success)]',
warning: 'text-[var(--warning)]',
error: 'text-[var(--error)]',
} as const;
export const alertAccentColors = {
info: 'bg-foreground-muted',
success: 'bg-[var(--success)]',
warning: 'bg-[var(--warning)]',
error: 'bg-[var(--error)]',
} as const;
export type AlertVariants = VariantProps<typeof alertVariants>;
@@ -0,0 +1,3 @@
export { default } from './Alert.astro';
export { Alert } from './Alert';
export { alertVariants, alertIconColors, alertAccentColors, type AlertVariants } from './alert.variants';
@@ -0,0 +1,13 @@
---
/**
* Toast Astro Wrapper
* Renders the ToastProvider with client:load so Astro pages
* can include the toast system without React boilerplate.
* Use useToast() inside React children to trigger toasts.
*/
import { ToastProvider } from './Toast';
---
<ToastProvider client:load>
<slot />
</ToastProvider>
+128
View File
@@ -0,0 +1,128 @@
import { useState, useCallback, useEffect, createContext, useContext, type ReactNode } from 'react';
import { cn } from '@/lib/cn';
import { toastVariants, toastIconColors } from './toast.variants';
import { Icon } from '@/components/ui/primitives/Icon/Icon';
type ToastVariant = 'default' | 'success' | 'error' | 'warning' | 'info';
interface Toast {
id: string;
title?: string;
description?: string;
variant?: ToastVariant;
duration?: number;
}
interface ToastContextValue {
toast: (options: Omit<Toast, 'id'>) => void;
dismiss: (id: string) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
export function useToast() {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within a ToastProvider');
}
return context;
}
let toastCount = 0;
const icons: Record<ToastVariant, ReactNode> = {
default: null,
success: <Icon name="check-circle" size="md" />,
error: <Icon name="x-circle" size="md" />,
warning: <Icon name="alert-triangle" size="md" />,
info: <Icon name="info" size="md" />,
};
function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: (id: string) => void }) {
const [isExiting, setIsExiting] = useState(false);
const variant = toast.variant || 'default';
useEffect(() => {
const duration = toast.duration ?? 5000;
if (duration === Infinity) return;
const timer = setTimeout(() => {
setIsExiting(true);
setTimeout(() => onDismiss(toast.id), 300);
}, duration);
return () => clearTimeout(timer);
}, [toast.id, toast.duration, onDismiss]);
const handleDismiss = () => {
setIsExiting(true);
setTimeout(() => onDismiss(toast.id), 300);
};
return (
<div
className={cn(
toastVariants({ variant }),
'pointer-events-auto',
isExiting ? 'opacity-0 translate-x-full transition-all duration-300' : 'animate-toast-in'
)}
role="alert"
>
{icons[variant] && (
<div className={cn('mt-0.5', toastIconColors[variant])}>
{icons[variant]}
</div>
)}
<div className="flex-1 min-w-0">
{toast.title && (
<p className="text-sm font-semibold">{toast.title}</p>
)}
{toast.description && (
<p className="text-sm text-foreground-muted mt-0.5">{toast.description}</p>
)}
</div>
<button
type="button"
className="shrink-0 p-1 -mr-1 -mt-1 rounded-md text-foreground-muted hover:text-foreground transition-colors"
onClick={handleDismiss}
aria-label="Dismiss"
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
);
}
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const toast = useCallback((options: Omit<Toast, 'id'>) => {
const id = `toast-${++toastCount}`;
setToasts((prev) => [...prev, { ...options, id }]);
}, []);
const dismiss = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
return (
<ToastContext.Provider value={{ toast, dismiss }}>
{children}
{/* Toast container */}
<div
className="fixed bottom-4 right-4 z-[100] flex flex-col gap-2 max-w-[420px] w-full pointer-events-none"
aria-live="polite"
aria-label="Notifications"
>
{toasts.map((t) => (
<ToastItem key={t.id} toast={t} onDismiss={dismiss} />
))}
</div>
</ToastContext.Provider>
);
}
export default ToastProvider;
@@ -0,0 +1,82 @@
import { ToastProvider, useToast } from './Toast';
function ToastButtons() {
const { toast } = useToast();
return (
<div className="flex flex-wrap gap-3">
<button
type="button"
className="inline-flex items-center justify-center gap-2 rounded-md border border-border bg-background px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-secondary"
onClick={() =>
toast({
title: 'Default toast',
description: 'This is a default notification.',
})
}
>
Default
</button>
<button
type="button"
className="inline-flex items-center justify-center gap-2 rounded-md bg-green-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-green-700"
onClick={() =>
toast({
variant: 'success',
title: 'Success!',
description: 'Your changes have been saved.',
})
}
>
Success
</button>
<button
type="button"
className="inline-flex items-center justify-center gap-2 rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-red-700"
onClick={() =>
toast({
variant: 'error',
title: 'Error',
description: 'Something went wrong. Please try again.',
})
}
>
Error
</button>
<button
type="button"
className="inline-flex items-center justify-center gap-2 rounded-md bg-amber-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-amber-600"
onClick={() =>
toast({
variant: 'warning',
title: 'Warning',
description: 'Your session will expire in 5 minutes.',
})
}
>
Warning
</button>
<button
type="button"
className="inline-flex items-center justify-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700"
onClick={() =>
toast({
variant: 'info',
title: 'Info',
description: 'A new version is available for download.',
})
}
>
Info
</button>
</div>
);
}
export function ToastDemo() {
return (
<ToastProvider>
<ToastButtons />
</ToastProvider>
);
}
@@ -0,0 +1,5 @@
// Astro: import Toast from '@/components/ui/feedback/Toast/Toast.astro'
// React: import { ToastProvider, useToast } from '@/components/ui/feedback/Toast'
export { ToastProvider, useToast } from './Toast';
export { ToastDemo } from './ToastDemo';
export { toastVariants, toastIconColors, type ToastVariants } from './toast.variants';
@@ -0,0 +1,32 @@
import { cva, type VariantProps } from 'class-variance-authority';
export const toastVariants = cva(
[
'pointer-events-auto relative flex items-start gap-3 overflow-hidden rounded-lg border p-4 shadow-lg',
'transition-all duration-300 ease-out',
],
{
variants: {
variant: {
default: 'bg-card border-border text-foreground',
success: 'bg-card border-[var(--success)]/30 text-foreground',
error: 'bg-card border-[var(--error)]/30 text-foreground',
warning: 'bg-card border-[var(--warning)]/30 text-foreground',
info: 'bg-card border-[var(--info)]/30 text-foreground',
},
},
defaultVariants: {
variant: 'default',
},
}
);
export const toastIconColors = {
default: 'text-foreground-muted',
success: 'text-[var(--success)]',
error: 'text-[var(--error)]',
warning: 'text-[var(--warning)]',
info: 'text-[var(--info)]',
} as const;
export type ToastVariants = VariantProps<typeof toastVariants>;
@@ -0,0 +1,67 @@
---
/**
* Tooltip Component
* Shows additional information on hover with CSS Anchor Positioning (with fallback)
*/
import type { HTMLAttributes } from 'astro/types';
import { cn } from '@/lib/cn';
interface Props extends HTMLAttributes<'span'> {
content: string;
position?: 'top' | 'bottom' | 'left' | 'right';
delay?: number;
}
const { content, position = 'top', delay = 200, class: className, ...attrs } = Astro.props;
---
<span
class={cn('tooltip-wrapper', className)}
data-tooltip
data-tooltip-delay={delay}
data-tooltip-position={position}
{...attrs}
>
<slot />
<span
class="tooltip-bubble"
role="tooltip"
data-tooltip-content
>
{content}
</span>
</span>
<script>
function initTooltips() {
const tooltips = document.querySelectorAll<HTMLElement>('[data-tooltip]');
tooltips.forEach((wrapper) => {
if (wrapper.dataset.tooltipInit) return;
wrapper.dataset.tooltipInit = 'true';
const tooltip = wrapper.querySelector<HTMLElement>('[data-tooltip-content]');
const delay = parseInt(wrapper.getAttribute('data-tooltip-delay') || '200', 10);
let timeout: ReturnType<typeof setTimeout>;
function show() {
timeout = setTimeout(() => {
tooltip?.classList.add('visible');
}, delay);
}
function hide() {
clearTimeout(timeout);
tooltip?.classList.remove('visible');
}
wrapper.addEventListener('mouseenter', show);
wrapper.addEventListener('mouseleave', hide);
wrapper.addEventListener('focusin', show);
wrapper.addEventListener('focusout', hide);
});
}
initTooltips();
document.addEventListener('astro:page-load', initTooltips);
</script>
@@ -0,0 +1,69 @@
/**
* Tooltip Component (React)
* Shows additional information on hover with delay support.
* CSS for .tooltip-wrapper and .tooltip-bubble is defined in global.css.
*/
import { useState, useRef, useEffect, type ReactNode } from 'react';
import { cn } from '@/lib/cn';
interface TooltipProps {
content: string;
position?: 'top' | 'bottom' | 'left' | 'right';
delay?: number;
className?: string;
children: ReactNode;
}
export function Tooltip({
content,
position = 'top',
delay = 200,
className,
children,
}: TooltipProps) {
const [visible, setVisible] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
function show() {
timeoutRef.current = setTimeout(() => {
setVisible(true);
}, delay);
}
function hide() {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
setVisible(false);
}
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
return (
<span
className={cn('tooltip-wrapper', className)}
data-tooltip-position={position}
onMouseEnter={show}
onMouseLeave={hide}
onFocus={show}
onBlur={hide}
>
{children}
<span
className={cn('tooltip-bubble', visible && 'visible')}
role="tooltip"
>
{content}
</span>
</span>
);
}
export default Tooltip;
@@ -0,0 +1,2 @@
export { default } from './Tooltip.astro';
export { Tooltip } from './Tooltip';
+4
View File
@@ -0,0 +1,4 @@
// Feedback Components
export * from './Alert';
export * from './Toast';
export * from './Tooltip';

Some files were not shown because too many files have changed in this diff Show More