Initial release — Astro Rocket v1.0.0
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
---
|
||||
import { Image } from 'astro:assets';
|
||||
import type { ImageMetadata } from 'astro';
|
||||
import { formatDate } from '@/lib/utils';
|
||||
import Logo from '@/components/ui/marketing/Logo/Logo.astro';
|
||||
import BlogImageSVG from '@/components/blog/BlogImageSVG.astro';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
publishedAt: Date;
|
||||
updatedAt?: Date;
|
||||
author?: string;
|
||||
tags?: string[];
|
||||
image?: ImageMetadata;
|
||||
imageAlt?: string;
|
||||
svgSlug?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
publishedAt,
|
||||
updatedAt,
|
||||
author = 'Team',
|
||||
tags = [],
|
||||
image,
|
||||
imageAlt,
|
||||
svgSlug,
|
||||
} = Astro.props;
|
||||
|
||||
// Estimate reading time
|
||||
const wordsPerMinute = 200;
|
||||
const estimatedWords = description.split(' ').length * 15;
|
||||
const readingTime = Math.max(1, Math.ceil(estimatedWords / wordsPerMinute));
|
||||
---
|
||||
|
||||
<header class="relative overflow-hidden pt-[var(--space-page-top-sm)] pb-[var(--space-section)]">
|
||||
<div class="relative mx-auto max-w-4xl px-6 animate-hero-slide-up">
|
||||
<!-- Tags -->
|
||||
{tags.length > 0 && (
|
||||
<div class="mb-[var(--space-heading-gap)] flex flex-wrap gap-2">
|
||||
{tags.map((tag) => (
|
||||
<span class="inline-flex items-center rounded-full bg-brand-100 dark:bg-brand-900/30 px-3 py-1 text-xs font-semibold text-brand-700 dark:text-brand-300 ring-1 ring-inset ring-brand-200 dark:ring-brand-800">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<!-- Title -->
|
||||
<h1 class="font-display text-4xl font-bold tracking-tight text-foreground md:text-5xl lg:text-6xl mb-[var(--space-heading-gap)]">
|
||||
{title}
|
||||
</h1>
|
||||
|
||||
<!-- Description -->
|
||||
<p class="text-xl text-foreground-muted leading-relaxed max-w-3xl mb-[var(--space-stack-lg)]">
|
||||
{description}
|
||||
</p>
|
||||
|
||||
<!-- Meta info -->
|
||||
<div class="flex flex-wrap items-center gap-[var(--space-stack-lg)] text-sm text-foreground-muted">
|
||||
<!-- Author -->
|
||||
<div class="flex items-center gap-3">
|
||||
<Logo size="sm" letter={author.charAt(0).toUpperCase()} />
|
||||
<p class="font-semibold text-foreground">{author}</p>
|
||||
</div>
|
||||
|
||||
<div class="h-8 w-px bg-border hidden md:block"></div>
|
||||
|
||||
<!-- Published date -->
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="h-5 w-5 text-foreground-subtle" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5" />
|
||||
</svg>
|
||||
<time datetime={publishedAt.toISOString()}>
|
||||
{formatDate(publishedAt)}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
{updatedAt && (
|
||||
<>
|
||||
<div class="h-8 w-px bg-border hidden md:block"></div>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="h-5 w-5 text-foreground-subtle" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
|
||||
</svg>
|
||||
<time datetime={updatedAt.toISOString()}>
|
||||
Updated {formatDate(updatedAt)}
|
||||
</time>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div class="h-8 w-px bg-border hidden md:block"></div>
|
||||
|
||||
<!-- Reading time -->
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="h-5 w-5 text-foreground-subtle" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{readingTime} min read</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(svgSlug || image) && (
|
||||
<div class="relative mx-auto max-w-5xl px-6 mt-[var(--space-section)] animate-hero-slide-up [animation-delay:200ms]">
|
||||
{svgSlug ? (
|
||||
<div
|
||||
class="relative overflow-hidden rounded-xl border border-border shadow-2xl
|
||||
bg-gradient-to-br from-brand-100/50 to-brand-50/30 dark:from-brand-900/50 dark:to-brand-800/30"
|
||||
style="color: var(--brand-500);"
|
||||
>
|
||||
<BlogImageSVG slug={svgSlug} title={imageAlt || title} />
|
||||
</div>
|
||||
) : image ? (
|
||||
<div class="relative overflow-hidden rounded-xl border border-border shadow-2xl">
|
||||
<Image
|
||||
src={image}
|
||||
alt={imageAlt || title}
|
||||
layout="full-width"
|
||||
widths={[640, 960, 1280, 1920]}
|
||||
sizes="100vw"
|
||||
class="aspect-video w-full object-cover"
|
||||
loading="eager"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
@@ -0,0 +1,104 @@
|
||||
---
|
||||
import { Image } from 'astro:assets';
|
||||
import type { ImageMetadata } from 'astro';
|
||||
import { formatDate } from '@/lib/utils';
|
||||
import Logo from '@/components/ui/marketing/Logo/Logo.astro';
|
||||
import BlogImageSVG from '@/components/blog/BlogImageSVG.astro';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
href: string;
|
||||
publishedAt: Date;
|
||||
tags?: string[];
|
||||
featured?: boolean;
|
||||
author?: string;
|
||||
image?: ImageMetadata;
|
||||
svgSlug?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
href,
|
||||
publishedAt,
|
||||
tags = [],
|
||||
author,
|
||||
image,
|
||||
svgSlug,
|
||||
} = Astro.props;
|
||||
|
||||
// Estimate reading time (rough estimate based on average words)
|
||||
const wordsPerMinute = 200;
|
||||
const estimatedWords = description.split(' ').length * 10; // Rough estimate
|
||||
const readingTime = Math.max(1, Math.ceil(estimatedWords / wordsPerMinute));
|
||||
---
|
||||
|
||||
<article class="group rounded-lg border border-brand-500/30 bg-background p-6 ring-1 ring-brand-500/20 transition-all duration-200 ease-out hover:-translate-y-0.5 hover:border-brand-500 hover:shadow-md">
|
||||
|
||||
<a href={href} class="block">
|
||||
<div
|
||||
class="relative mb-4 overflow-hidden rounded-md
|
||||
bg-background-secondary bg-gradient-to-br from-brand-100/65 to-transparent dark:from-brand-900/60 dark:to-brand-800/25"
|
||||
style="color: var(--brand-500);"
|
||||
>
|
||||
{svgSlug ? (
|
||||
<div class="transition-transform duration-300 group-hover:scale-105">
|
||||
<BlogImageSVG slug={svgSlug} title={title} />
|
||||
</div>
|
||||
) : image ? (
|
||||
<Image
|
||||
src={image}
|
||||
alt={title}
|
||||
widths={[320, 640, 960]}
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 400px"
|
||||
class="aspect-video w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div class="aspect-video w-full" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h2 class="font-display text-xl font-bold text-foreground transition-colors group-hover:text-brand-600 dark:group-hover:text-brand-400 mb-2">
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
<p class="text-foreground-muted line-clamp-2 mb-4">
|
||||
{description}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3 text-sm text-foreground-subtle">
|
||||
{author && (
|
||||
<div class="flex items-center gap-2">
|
||||
<Logo size="sm" letter={author.charAt(0).toUpperCase()} />
|
||||
<span class="font-medium">{author}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<time datetime={publishedAt.toISOString()} class="flex items-center gap-1">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5" />
|
||||
</svg>
|
||||
{formatDate(publishedAt)}
|
||||
</time>
|
||||
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{readingTime} min read
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{tags.length > 0 && (
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
{tags.map((tag) => (
|
||||
<span class="inline-flex items-center rounded-full bg-background-secondary px-2.5 py-0.5 text-xs font-medium text-foreground-muted ring-1 ring-inset ring-border transition-colors group-hover:bg-brand-50 group-hover:text-brand-700 group-hover:ring-brand-200 dark:group-hover:bg-brand-900/20 dark:group-hover:text-brand-400 dark:group-hover:ring-brand-800">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</a>
|
||||
</article>
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
const svgs = import.meta.glob<string>('/src/assets/blog/*.svg', { as: 'raw', eager: true });
|
||||
|
||||
interface Props {
|
||||
slug: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const { slug, title } = Astro.props;
|
||||
const svgContent = svgs[`/src/assets/blog/${slug}.svg`] ?? '';
|
||||
---
|
||||
|
||||
{svgContent && (
|
||||
<div class="svg-host" role="img" aria-label={title} set:html={svgContent} />
|
||||
)}
|
||||
|
||||
<style>
|
||||
.svg-host :global(svg) {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.svg-host :global(svg) {
|
||||
transform: scale(1.35);
|
||||
transform-origin: center center;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Light mode ─────────────────────────────────────────────────────────
|
||||
Brand-500 background — the saturated mid-tone brand colour. Light
|
||||
icons and text sit on top for vivid, colourful images in both modes.
|
||||
── */
|
||||
.svg-host :global(.bg) { fill: var(--brand-500); }
|
||||
.svg-host :global(.ico) { stroke: var(--brand-50); }
|
||||
.svg-host :global(.txt) { fill: var(--brand-50); }
|
||||
.svg-host :global(.ln) { stroke: var(--brand-200); stroke-opacity: 0.5; }
|
||||
.svg-host :global(.pil) { fill: var(--brand-300); fill-opacity: 0.35; stroke: var(--brand-100); stroke-opacity: 0.8; }
|
||||
.svg-host :global(.ptx) { fill: var(--brand-50); }
|
||||
.svg-host :global(.cor) { stroke: var(--brand-200); stroke-opacity: 0.7; }
|
||||
.svg-host :global(.num) { fill: var(--brand-100); fill-opacity: 0.18; }
|
||||
|
||||
/* ── Dark mode ───────────────────────────────────────────────────────────
|
||||
Deep background with all brand colors at full opacity — vivid, not faded.
|
||||
── */
|
||||
:global(html.dark) .svg-host :global(.bg) { fill: var(--brand-800); }
|
||||
:global(html.dark) .svg-host :global(.ico) { stroke: var(--brand-200); }
|
||||
:global(html.dark) .svg-host :global(.txt) { fill: var(--brand-50); }
|
||||
:global(html.dark) .svg-host :global(.ln) { stroke: var(--brand-300); stroke-opacity: 0.5; }
|
||||
:global(html.dark) .svg-host :global(.pil) { fill: var(--brand-600); fill-opacity: 0.4; stroke: var(--brand-300); stroke-opacity: 0.7; }
|
||||
:global(html.dark) .svg-host :global(.ptx) { fill: var(--brand-100); }
|
||||
:global(html.dark) .svg-host :global(.cor) { stroke: var(--brand-300); stroke-opacity: 0.6; }
|
||||
:global(html.dark) .svg-host :global(.num) { fill: var(--brand-50); fill-opacity: 0.18; }
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
import BlogCard from './BlogCard.astro';
|
||||
import { getCollection } from 'astro:content';
|
||||
|
||||
interface Props {
|
||||
currentSlug: string;
|
||||
tags: string[];
|
||||
locale?: string;
|
||||
maxPosts?: number;
|
||||
}
|
||||
|
||||
const { currentSlug, tags, locale = 'en', maxPosts = 3 } = Astro.props;
|
||||
|
||||
// Get all published posts in the same locale
|
||||
const allPosts = await getCollection('blog', ({ data }) => {
|
||||
return data.locale === locale && (import.meta.env.PROD ? data.draft !== true : true);
|
||||
});
|
||||
|
||||
// Filter to find posts with matching tags
|
||||
const relatedPosts = allPosts
|
||||
.filter((post) => {
|
||||
// Exclude current post
|
||||
if (post.id === currentSlug || post.id.endsWith(`/${currentSlug}`)) return false;
|
||||
|
||||
// Must have at least one matching tag
|
||||
if (!tags.length) return false;
|
||||
return post.data.tags.some((tag) => tags.includes(tag));
|
||||
})
|
||||
// Sort by number of matching tags, then by date
|
||||
.sort((a, b) => {
|
||||
const aMatches = a.data.tags.filter((tag) => tags.includes(tag)).length;
|
||||
const bMatches = b.data.tags.filter((tag) => tags.includes(tag)).length;
|
||||
|
||||
if (bMatches !== aMatches) return bMatches - aMatches;
|
||||
return b.data.publishedAt.valueOf() - a.data.publishedAt.valueOf();
|
||||
})
|
||||
.slice(0, maxPosts);
|
||||
|
||||
// Generate URLs for each post (remove locale prefix from id)
|
||||
const getPostUrl = (postId: string) => {
|
||||
const slug = postId.replace(`${locale}/`, '');
|
||||
return `/blog/${slug}`;
|
||||
};
|
||||
---
|
||||
|
||||
{relatedPosts.length > 0 && (
|
||||
<section class="border-t border-border py-[var(--space-section-sm)]">
|
||||
<h2 class="font-display text-2xl font-bold text-foreground mb-[var(--space-stack-lg)]">
|
||||
Related Posts
|
||||
</h2>
|
||||
|
||||
<div class="grid gap-[var(--space-stack-lg)] md:grid-cols-2 lg:grid-cols-3">
|
||||
{relatedPosts.map((post) => (
|
||||
<BlogCard
|
||||
title={post.data.title}
|
||||
description={post.data.description}
|
||||
href={getPostUrl(post.id)}
|
||||
publishedAt={post.data.publishedAt}
|
||||
tags={post.data.tags}
|
||||
author={post.data.author}
|
||||
image={post.data.image}
|
||||
svgSlug={post.data.svgSlug}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
@@ -0,0 +1,96 @@
|
||||
---
|
||||
interface Props {
|
||||
title: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
const { title, url } = Astro.props;
|
||||
|
||||
const encodedTitle = encodeURIComponent(title);
|
||||
const encodedUrl = encodeURIComponent(url);
|
||||
|
||||
const shareLinks = {
|
||||
twitter: `https://twitter.com/intent/tweet?text=${encodedTitle}&url=${encodedUrl}`,
|
||||
linkedin: `https://www.linkedin.com/sharing/share-offsite/?url=${encodedUrl}`,
|
||||
};
|
||||
---
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-sm font-medium text-foreground-muted">Share:</span>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Twitter/X -->
|
||||
<a
|
||||
href={shareLinks.twitter}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex h-9 w-9 items-center justify-center rounded-full bg-brand-500 text-white border border-brand-500 transition-all hover:bg-brand-600 hover:border-brand-600"
|
||||
aria-label="Share on Twitter"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<!-- LinkedIn -->
|
||||
<a
|
||||
href={shareLinks.linkedin}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex h-9 w-9 items-center justify-center rounded-full bg-brand-500 text-white border border-brand-500 transition-all hover:bg-brand-600 hover:border-brand-600"
|
||||
aria-label="Share on LinkedIn"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<!-- Copy Link -->
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-9 w-9 items-center justify-center rounded-full bg-brand-500 text-white border border-brand-500 transition-all hover:bg-brand-600 hover:border-brand-600 copy-link-btn"
|
||||
aria-label="Copy link"
|
||||
data-url={url}
|
||||
>
|
||||
<svg class="h-4 w-4 copy-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
|
||||
</svg>
|
||||
<svg class="h-4 w-4 check-icon hidden" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function initShareButtons() {
|
||||
document.querySelectorAll('.copy-link-btn').forEach((button) => {
|
||||
button.addEventListener('click', async () => {
|
||||
const url = button.getAttribute('data-url');
|
||||
if (!url) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
|
||||
const copyIcon = button.querySelector('.copy-icon');
|
||||
const checkIcon = button.querySelector('.check-icon');
|
||||
|
||||
if (copyIcon && checkIcon) {
|
||||
copyIcon.classList.add('hidden');
|
||||
checkIcon.classList.remove('hidden');
|
||||
|
||||
setTimeout(() => {
|
||||
copyIcon.classList.remove('hidden');
|
||||
checkIcon.classList.add('hidden');
|
||||
}, 2000);
|
||||
}
|
||||
} catch {
|
||||
// Clipboard API failed - user will need to copy manually
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
initShareButtons();
|
||||
document.addEventListener('astro:page-load', initShareButtons);
|
||||
</script>
|
||||
@@ -0,0 +1,147 @@
|
||||
---
|
||||
import type { HTMLAttributes } from 'astro/types';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { heroSectionVariants } from './hero.variants';
|
||||
|
||||
interface Props extends HTMLAttributes<'section'> {
|
||||
/** Layout mode: centered single column or split two-column */
|
||||
layout?: 'centered' | 'split';
|
||||
/** Vertical padding size */
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
/** Show background grid pattern */
|
||||
showGrid?: boolean;
|
||||
/** Apply the dark-mode hero gradient (black → brand). Homepage only. */
|
||||
gradient?: boolean;
|
||||
}
|
||||
|
||||
const {
|
||||
layout = 'centered',
|
||||
size = 'lg',
|
||||
showGrid = false,
|
||||
gradient = false,
|
||||
class: className,
|
||||
...attrs
|
||||
} = Astro.props;
|
||||
|
||||
// Check which slots are provided
|
||||
const hasBadgeSlot = Astro.slots.has('badge');
|
||||
const hasTitleSlot = Astro.slots.has('title');
|
||||
const hasDescriptionSlot = Astro.slots.has('description');
|
||||
const hasActionsSlot = Astro.slots.has('actions');
|
||||
const hasAsideSlot = Astro.slots.has('aside');
|
||||
|
||||
// Compute alignment based on layout
|
||||
const alignment = layout === 'centered' ? 'text-center items-center' : 'text-left items-start';
|
||||
|
||||
// Compute classes
|
||||
const sectionClasses = cn(heroSectionVariants({ size }), gradient && 'hero-dark-gradient', className);
|
||||
|
||||
const contentClasses = cn(
|
||||
'z-10 flex flex-col',
|
||||
alignment
|
||||
);
|
||||
|
||||
const gridClasses = cn(
|
||||
'mx-auto grid max-w-6xl grid-cols-1 items-center gap-[var(--space-section-gap)] px-6',
|
||||
layout === 'split' && 'lg:grid-cols-2 lg:gap-[var(--space-section-gap)]'
|
||||
);
|
||||
---
|
||||
|
||||
<section class={sectionClasses} {...attrs}>
|
||||
{showGrid && (
|
||||
<div
|
||||
class="bg-grid-pattern pointer-events-none absolute inset-x-0 -inset-y-[20%] opacity-30"
|
||||
style="mask-image: radial-gradient(ellipse 50% 50% at 50% 40%, black 0%, transparent 70%);"
|
||||
data-parallax="0.2"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div class={gridClasses}>
|
||||
{/* Content Column */}
|
||||
<div class={cn(contentClasses, 'order-1 lg:order-none animate-hero-slide-up')}>
|
||||
{/* Badge Slot */}
|
||||
{hasBadgeSlot && (
|
||||
<div class="mb-[var(--space-heading-gap)]">
|
||||
<slot name="badge" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title Slot */}
|
||||
{hasTitleSlot && (
|
||||
<div class={cn(
|
||||
'hero-title-slot mb-[var(--space-heading-gap)]',
|
||||
'[&>h1]:font-display [&>h1]:text-5xl [&>h1]:leading-[1.1] [&>h1]:font-bold [&>h1]:tracking-tight [&>h1]:text-balance [&>h1]:text-foreground md:[&>h1]:text-6xl lg:[&>h1]:text-7xl',
|
||||
'[&>h2]:font-display [&>h2]:text-4xl [&>h2]:leading-[1.1] [&>h2]:font-bold [&>h2]:tracking-tight [&>h2]:text-balance [&>h2]:text-foreground md:[&>h2]:text-5xl lg:[&>h2]:text-6xl'
|
||||
)}>
|
||||
<slot name="title" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description Slot */}
|
||||
{hasDescriptionSlot && (
|
||||
<div class={cn(
|
||||
'mb-[var(--space-stack-lg)] max-w-xl text-lg leading-relaxed text-foreground-muted',
|
||||
'[&>p]:text-lg [&>p]:leading-relaxed [&>p]:text-foreground-muted',
|
||||
layout === 'centered' && 'mx-auto'
|
||||
)}>
|
||||
<slot name="description" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions Slot */}
|
||||
{hasActionsSlot && (
|
||||
<div class={cn(
|
||||
'flex w-full flex-col gap-4 sm:w-auto sm:flex-row',
|
||||
layout === 'centered' && 'justify-center'
|
||||
)}>
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Default slot for additional content (social proof, etc.) */}
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
{/* Aside Column (for split layout) */}
|
||||
{layout === 'split' && hasAsideSlot && (
|
||||
<div class="relative z-10 w-full order-2 lg:order-none">
|
||||
<slot name="aside" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<script>
|
||||
// Parallax: move [data-parallax] layers at a fraction of scroll speed.
|
||||
// Elements with -inset-y-[20%] have extra room to move without clipping.
|
||||
// Skipped entirely when the user prefers reduced motion.
|
||||
if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
const layers = document.querySelectorAll<HTMLElement>('[data-parallax]');
|
||||
|
||||
function applyParallax() {
|
||||
layers.forEach((el) => {
|
||||
const section = el.closest('section');
|
||||
if (!section) return;
|
||||
|
||||
const rect = section.getBoundingClientRect();
|
||||
// Skip sections fully outside the viewport
|
||||
if (rect.bottom < 0 || rect.top > window.innerHeight) return;
|
||||
|
||||
const speed = parseFloat(el.dataset.parallax ?? '0.3');
|
||||
// Offset relative to how far the section top is from the viewport top
|
||||
const offset = -rect.top * speed;
|
||||
el.style.transform = `translateY(${offset.toFixed(2)}px)`;
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', applyParallax, { passive: true });
|
||||
applyParallax();
|
||||
|
||||
// Clean up the listener when navigating away to prevent accumulation
|
||||
document.addEventListener('astro:before-swap', () => {
|
||||
window.removeEventListener('scroll', applyParallax);
|
||||
}, { once: true });
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,17 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
export const heroSectionVariants = cva('relative overflow-hidden bg-background', {
|
||||
variants: {
|
||||
size: {
|
||||
sm: 'pt-[var(--space-page-top-sm)] pb-[var(--space-section-sm)]',
|
||||
md: 'pt-[var(--space-page-top)] pb-[var(--space-section-md)]',
|
||||
lg: 'pt-[calc(var(--space-page-top)_+_var(--space-8))] pb-[var(--space-section-lg)]',
|
||||
xl: 'pt-[calc(var(--space-page-top)_+_var(--space-16))] pb-[var(--space-section-xl)]',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'lg',
|
||||
},
|
||||
});
|
||||
|
||||
export type HeroSectionVariants = VariantProps<typeof heroSectionVariants>;
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as Hero } from './Hero.astro';
|
||||
export { heroSectionVariants } from './hero.variants';
|
||||
export type { HeroSectionVariants } from './hero.variants';
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
import CTASection from '@/components/ui/marketing/CTA/CTA.astro';
|
||||
import Button from '@/components/ui/form/Button/Button.astro';
|
||||
import Icon from '@/components/ui/primitives/Icon/Icon.astro';
|
||||
import Logo from '@/components/ui/marketing/Logo/Logo.astro';
|
||||
import NpmCopyButton from '@/components/ui/marketing/NpmCopyButton/NpmCopyButton.astro';
|
||||
---
|
||||
|
||||
<CTASection id="cta" variant="default" size="xl" maxWidth="lg">
|
||||
<Logo slot="logo" size="2xl" class="mx-auto" />
|
||||
|
||||
<h2 slot="heading">
|
||||
Stop configuring. <span class="text-brand-500">Start building.</span>
|
||||
</h2>
|
||||
|
||||
<p slot="description">
|
||||
Join the developers building faster, better websites with Astro Rocket. Open source and free forever.
|
||||
</p>
|
||||
|
||||
<Fragment slot="actions">
|
||||
<NpmCopyButton command="npm create astro@latest" />
|
||||
<Button variant="outline" size="lg" href="https://github.com/hansmartens68/Astro-Rocket#readme" target="_blank">
|
||||
<Icon name="book" size="md" />
|
||||
View docs
|
||||
</Button>
|
||||
</Fragment>
|
||||
</CTASection>
|
||||
@@ -0,0 +1,106 @@
|
||||
---
|
||||
import Icon from '@/components/ui/primitives/Icon/Icon.astro';
|
||||
|
||||
const steps = [
|
||||
{ cmd: 'git clone https://github.com/hansmartens68/Astro-Rocket.git', desc: 'Clone the repository' },
|
||||
{ cmd: 'cd Astro-Rocket && pnpm install', desc: 'Install dependencies' },
|
||||
{ cmd: 'pnpm dev', desc: 'Start dev server on localhost:4321' },
|
||||
];
|
||||
|
||||
const stats = [
|
||||
{ value: '3', label: 'steps to start' },
|
||||
{ value: '57+', label: 'components included' },
|
||||
{ value: '40+', label: 'pages & layouts' },
|
||||
];
|
||||
---
|
||||
|
||||
<section id="architecture" class="invert-section bg-background py-[var(--space-section-md)]">
|
||||
<div class="mx-auto grid max-w-6xl grid-cols-1 items-center gap-[var(--space-section-gap)] px-6 lg:grid-cols-2 lg:gap-[var(--space-section-gap)]">
|
||||
<!-- Left Column - Content -->
|
||||
<div>
|
||||
<!-- Badge -->
|
||||
<div
|
||||
class="text-brand-500 mb-4 flex items-center gap-2 text-sm font-bold tracking-wide uppercase"
|
||||
>
|
||||
<Icon name="terminal" size="sm" />
|
||||
<span>Up and running fast</span>
|
||||
</div>
|
||||
|
||||
<h2 class="font-display mb-[var(--space-heading-gap)] text-3xl font-bold text-balance md:text-4xl">
|
||||
Clone, install, <span class="text-brand-500">ship.</span>
|
||||
</h2>
|
||||
|
||||
<div class="text-foreground-secondary space-y-4 text-lg leading-relaxed">
|
||||
<p>
|
||||
Astro Rocket is a ready-to-go starter — no CLI wizard, no configuration maze. Clone the repo and you have a full production-grade site in minutes.
|
||||
</p>
|
||||
<p>
|
||||
Swap out the content, adjust the design tokens, and deploy. Everything is already wired: routing, components, blog, i18n support, dark mode, and SEO.
|
||||
</p>
|
||||
<p class="text-foreground-muted">
|
||||
Open source. No licence fees. No hidden dependencies.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="border-border mt-[var(--space-stack-lg)] grid grid-cols-3 gap-[var(--space-stack-lg)] border-t pt-[var(--space-stack-lg)]">
|
||||
{
|
||||
stats.map((stat) => (
|
||||
<div>
|
||||
<div class="font-display text-foreground text-3xl font-bold">{stat.value}</div>
|
||||
<div class="text-foreground-muted text-sm">{stat.label}</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column - Terminal Card -->
|
||||
<div class="relative">
|
||||
<div
|
||||
class="bg-card border-border overflow-hidden rounded-lg border shadow-xl"
|
||||
>
|
||||
<!-- Terminal Header -->
|
||||
<div
|
||||
class="bg-secondary border-border flex items-center gap-2 border-b px-4 py-3"
|
||||
>
|
||||
<div class="flex gap-2">
|
||||
<span class="h-3 w-3 rounded-full" style="background-color: var(--error);"></span>
|
||||
<span class="h-3 w-3 rounded-full" style="background-color: var(--warning);"></span>
|
||||
<span class="h-3 w-3 rounded-full" style="background-color: var(--success);"></span>
|
||||
</div>
|
||||
<span class="text-foreground-muted ml-2 font-mono text-xs">terminal</span>
|
||||
</div>
|
||||
|
||||
<!-- Terminal Body -->
|
||||
<div class="p-5 font-mono text-sm leading-relaxed">
|
||||
{
|
||||
steps.map((step, i) => (
|
||||
<div class="mb-4">
|
||||
<div class="text-foreground-muted mb-1 text-xs">#{i + 1} {step.desc}</div>
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="shrink-0 text-green-400 select-none">$</span>
|
||||
<span class="text-cyan-400 break-all">{step.cmd}</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
|
||||
<!-- Output -->
|
||||
<div class="border-border mt-2 border-t pt-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-green-400">✓</span>
|
||||
<span class="text-foreground">Ready at <span class="text-cyan-400">localhost:4321</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Decorative glow -->
|
||||
<div
|
||||
class="bg-brand-500/10 pointer-events-none absolute top-0 right-0 h-32 w-32 rounded-full blur-3xl"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,496 @@
|
||||
import { useState } from 'react';
|
||||
import { Palette, Search, Zap, LayoutGrid, Globe, Copy, Check, Newspaper } from 'lucide-react';
|
||||
import { VerticalTabs, type VerticalTab } from '@/components/ui/overlay/VerticalTabs';
|
||||
|
||||
interface TabContent {
|
||||
title: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
const tabContent: Record<string, TabContent> = {
|
||||
theming: {
|
||||
title: 'Design Tokens & Dark Mode',
|
||||
content:
|
||||
"Complete design system using Tailwind v4's CSS-first configuration with built-in dark mode. Semantic color tokens, system preference detection, and localStorage persistence.",
|
||||
},
|
||||
seo: {
|
||||
title: 'Automated SEO Handling',
|
||||
content:
|
||||
'Strictly typed metadata injection for every page with automatic OG image generation. Includes sitemap, robots.txt, and JSON-LD structured data.',
|
||||
},
|
||||
perf: {
|
||||
title: 'Zero JS by Default',
|
||||
content:
|
||||
"Astro's island architecture ensures your pages ship 0kb of JavaScript unless explicitly interactive. Optimized for Core Web Vitals.",
|
||||
},
|
||||
components: {
|
||||
title: 'Type-Safe Components',
|
||||
content:
|
||||
'TypeScript-first UI primitives with full prop validation and IDE autocompletion. Includes buttons, inputs, cards, modals, and more.',
|
||||
},
|
||||
i18n: {
|
||||
title: 'i18n Ready',
|
||||
content:
|
||||
'Add multi-language support with the --i18n flag. Includes type-safe translations, automatic locale detection, and SEO-friendly URL structures.',
|
||||
},
|
||||
content: {
|
||||
title: 'Content & Search',
|
||||
content:
|
||||
'Type-safe content collections with Zod schemas, MDX support, RSS feeds, and Pagefind integration for lightning-fast static search.',
|
||||
},
|
||||
};
|
||||
|
||||
const codeExamples: Record<
|
||||
string,
|
||||
{ code: string; filename: string; lang: 'css' | 'astro' | 'typescript' | 'javascript' }
|
||||
> = {
|
||||
theming: {
|
||||
lang: 'css',
|
||||
code: `/* src/styles/themes/default.css — swap this file to re-theme */
|
||||
:root {
|
||||
/* Semantic Tokens - Light Mode */
|
||||
--background: var(--gray-0);
|
||||
--foreground: var(--gray-900);
|
||||
--border: var(--gray-200);
|
||||
--primary: var(--gray-900);
|
||||
--primary-foreground: var(--gray-0);
|
||||
--accent: var(--brand-500);
|
||||
--card: var(--gray-0);
|
||||
--ring: var(--gray-900);
|
||||
}
|
||||
|
||||
/* Dark Mode */
|
||||
.dark {
|
||||
--background: var(--gray-950);
|
||||
--foreground: var(--gray-50);
|
||||
--border: var(--gray-800);
|
||||
--primary: var(--gray-0);
|
||||
--primary-foreground: var(--gray-900);
|
||||
}`,
|
||||
filename: 'src/styles/themes/default.css',
|
||||
},
|
||||
seo: {
|
||||
lang: 'astro',
|
||||
code: `---
|
||||
// src/components/seo/SEO.astro
|
||||
import siteConfig from '@/config/site.config';
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
description?: string;
|
||||
image?: string;
|
||||
}
|
||||
|
||||
const { title, description, image } = Astro.props;
|
||||
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
|
||||
|
||||
// Auto-generate OG image if none provided
|
||||
const ogImage = image || \`/og/\${Astro.url.pathname}.png\`;
|
||||
---
|
||||
|
||||
<title>{title}</title>
|
||||
<meta name="description" content={description} />
|
||||
<link rel="canonical" href={canonicalURL.toString()} />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:image" content={ogImage} />`,
|
||||
filename: 'src/components/seo/SEO.astro',
|
||||
},
|
||||
perf: {
|
||||
lang: 'astro',
|
||||
code: `---
|
||||
// src/pages/index.astro
|
||||
import LandingLayout from '@/layouts/LandingLayout.astro';
|
||||
import { Hero } from '@/components/hero';
|
||||
import { TerminalDemo } from '@/components/ui/marketing/TerminalDemo';
|
||||
import FeatureTabs from '@/components/landing/FeatureTabs.tsx';
|
||||
import TechStack from '@/components/landing/TechStack.astro';
|
||||
---
|
||||
|
||||
<!-- Static Astro components - ships 0kb JS -->
|
||||
<Hero layout="split" size="lg">
|
||||
<!-- React component - hydrates immediately -->
|
||||
<TerminalDemo slot="aside" client:load />
|
||||
</Hero>
|
||||
|
||||
<!-- Static HTML, no JS -->
|
||||
<TechStack />
|
||||
|
||||
<!-- React component - hydrates when scrolled into view -->
|
||||
<FeatureTabs client:visible />`,
|
||||
filename: 'src/pages/index.astro',
|
||||
},
|
||||
components: {
|
||||
lang: 'typescript',
|
||||
code: `// src/components/ui/form/Button/Button.tsx
|
||||
import { type Ref } from 'react';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { isExternalUrl } from '@/lib/utils';
|
||||
import { buttonVariants, type ButtonVariants } from './button.variants';
|
||||
|
||||
interface BaseProps {
|
||||
ref?: Ref<HTMLButtonElement | HTMLAnchorElement>;
|
||||
variant?: ButtonVariants['variant'];
|
||||
size?: ButtonVariants['size'];
|
||||
loading?: boolean;
|
||||
href?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Button({ ref, variant = 'primary', size = 'md', href, ...rest }: BaseProps) {
|
||||
const classes = cn(buttonVariants({ variant, size }), rest.className);
|
||||
const isExternal = href ? isExternalUrl(href) : false;
|
||||
|
||||
if (href) {
|
||||
return <a ref={ref} href={href} className={classes} target={isExternal ? '_blank' : undefined} />;
|
||||
}
|
||||
return <button ref={ref} className={classes} {...rest} />;
|
||||
}`,
|
||||
filename: 'src/components/ui/form/Button/Button.tsx',
|
||||
},
|
||||
i18n: {
|
||||
lang: 'typescript',
|
||||
code: `// src/i18n/config.ts (with --i18n flag)
|
||||
export const languages = {
|
||||
en: 'English',
|
||||
es: 'Español',
|
||||
fr: 'Français',
|
||||
} as const;
|
||||
|
||||
export const defaultLang = 'en';
|
||||
|
||||
// src/i18n/translations/en.ts
|
||||
export default {
|
||||
'nav.home': 'Home',
|
||||
'nav.about': 'About',
|
||||
'hero.title': 'Ship faster with Astro Rocket',
|
||||
'hero.subtitle': 'The modern Astro starter',
|
||||
} as const;
|
||||
|
||||
// Usage in components
|
||||
import { t } from '@/i18n';
|
||||
const title = t('hero.title'); // "Ship faster..."`,
|
||||
filename: 'src/i18n/config.ts',
|
||||
},
|
||||
content: {
|
||||
lang: 'typescript',
|
||||
code: `// src/content.config.ts
|
||||
import { defineCollection, z } from 'astro:content';
|
||||
import { glob } from 'astro/loaders';
|
||||
|
||||
const blog = defineCollection({
|
||||
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/blog' }),
|
||||
schema: ({ image }) =>
|
||||
z.object({
|
||||
title: z.string().max(100),
|
||||
description: z.string().max(200),
|
||||
publishedAt: z.coerce.date(),
|
||||
updatedAt: z.coerce.date().optional(),
|
||||
author: z.string().default('Team'),
|
||||
image: image().optional(),
|
||||
tags: z.array(z.string()).default([]),
|
||||
featured: z.boolean().default(false),
|
||||
draft: z.boolean().default(false),
|
||||
locale: z.enum(['en', 'es', 'fr']).default('en'),
|
||||
}),
|
||||
});
|
||||
|
||||
export const collections = { blog, pages, authors, faqs };
|
||||
// + Pagefind indexes all content at build time`,
|
||||
filename: 'src/content.config.ts',
|
||||
},
|
||||
};
|
||||
|
||||
// Simple syntax highlighter
|
||||
function highlightCode(code: string, lang: string): React.ReactNode[] {
|
||||
const lines = code.split('\n');
|
||||
|
||||
return lines.map((line, lineIndex) => {
|
||||
const tokens: React.ReactNode[] = [];
|
||||
let remaining = line;
|
||||
let keyIndex = 0;
|
||||
|
||||
const addToken = (text: string, className?: string) => {
|
||||
if (text) {
|
||||
tokens.push(
|
||||
<span key={`${lineIndex}-${keyIndex++}`} className={className}>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Process the line character by character with regex patterns
|
||||
while (remaining.length > 0) {
|
||||
let matched = false;
|
||||
|
||||
// Comments (// and /* */)
|
||||
const commentMatch = remaining.match(/^(\/\/.*|\/\*[\s\S]*?\*\/)/);
|
||||
if (commentMatch) {
|
||||
addToken(commentMatch[0], 'text-foreground-muted italic');
|
||||
remaining = remaining.slice(commentMatch[0].length);
|
||||
matched = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// HTML comments
|
||||
const htmlCommentMatch = remaining.match(/^(<!--[\s\S]*?-->)/);
|
||||
if (htmlCommentMatch) {
|
||||
addToken(htmlCommentMatch[0], 'text-foreground-muted italic');
|
||||
remaining = remaining.slice(htmlCommentMatch[0].length);
|
||||
matched = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Strings (single, double, template)
|
||||
const stringMatch = remaining.match(/^(['"`])(?:(?!\1)[^\\]|\\.)*\1/);
|
||||
if (stringMatch) {
|
||||
addToken(stringMatch[0], 'text-green-600 dark:text-green-400');
|
||||
remaining = remaining.slice(stringMatch[0].length);
|
||||
matched = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Astro frontmatter delimiters
|
||||
if (remaining.startsWith('---')) {
|
||||
addToken('---', 'text-purple-600 dark:text-purple-400 font-semibold');
|
||||
remaining = remaining.slice(3);
|
||||
matched = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// HTML/JSX tags
|
||||
const tagMatch = remaining.match(/^(<\/?[\w-]+|>|\/>)/);
|
||||
if (tagMatch) {
|
||||
addToken(tagMatch[0], 'text-pink-600 dark:text-pink-400');
|
||||
remaining = remaining.slice(tagMatch[0].length);
|
||||
matched = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// CSS at-rules (@theme, @import, etc.)
|
||||
const atRuleMatch = remaining.match(/^(@[\w-]+)/);
|
||||
if (atRuleMatch) {
|
||||
addToken(atRuleMatch[0], 'text-purple-600 dark:text-purple-400 font-semibold');
|
||||
remaining = remaining.slice(atRuleMatch[0].length);
|
||||
matched = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Keywords
|
||||
const keywordMatch = remaining.match(
|
||||
/^(const|let|var|function|return|import|export|from|interface|type|class|extends|implements|new|async|await|if|else|for|while|switch|case|break|default|try|catch|finally|throw|typeof|instanceof|in|of|as|readonly|public|private|protected)\b/
|
||||
);
|
||||
if (keywordMatch) {
|
||||
addToken(keywordMatch[0], 'text-purple-600 dark:text-purple-400 font-semibold');
|
||||
remaining = remaining.slice(keywordMatch[0].length);
|
||||
matched = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Boolean/null
|
||||
const boolMatch = remaining.match(/^(true|false|null|undefined)\b/);
|
||||
if (boolMatch) {
|
||||
addToken(boolMatch[0], 'text-orange-700 dark:text-orange-300');
|
||||
remaining = remaining.slice(boolMatch[0].length);
|
||||
matched = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Numbers
|
||||
const numberMatch = remaining.match(/^(\d+\.?\d*)/);
|
||||
if (numberMatch) {
|
||||
addToken(numberMatch[0], 'text-orange-700 dark:text-orange-300');
|
||||
remaining = remaining.slice(numberMatch[0].length);
|
||||
matched = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// CSS properties (word followed by colon)
|
||||
const cssPropMatch = remaining.match(/^([\w-]+)(:)/);
|
||||
if (cssPropMatch && (lang === 'css' || line.includes('{'))) {
|
||||
addToken(cssPropMatch[1], 'text-blue-600 dark:text-blue-400');
|
||||
addToken(cssPropMatch[2], 'text-foreground-secondary');
|
||||
remaining = remaining.slice(cssPropMatch[0].length);
|
||||
matched = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// CSS functions (var, oklch, etc.)
|
||||
const cssFuncMatch = remaining.match(
|
||||
/^(var|oklch|rgb|rgba|hsl|hsla|calc|url|clamp|min|max)(\()/
|
||||
);
|
||||
if (cssFuncMatch) {
|
||||
addToken(cssFuncMatch[1], 'text-amber-700 dark:text-amber-300');
|
||||
addToken(cssFuncMatch[2], 'text-foreground-secondary');
|
||||
remaining = remaining.slice(cssFuncMatch[0].length);
|
||||
matched = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Function calls
|
||||
const funcMatch = remaining.match(/^([\w]+)(\()/);
|
||||
if (funcMatch) {
|
||||
addToken(funcMatch[1], 'text-amber-700 dark:text-amber-300');
|
||||
addToken(funcMatch[2], 'text-foreground-secondary');
|
||||
remaining = remaining.slice(funcMatch[0].length);
|
||||
matched = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Type annotations after colon
|
||||
const typeMatch = remaining.match(/^(:\s*)([\w<>[\]|&]+)/);
|
||||
if (typeMatch) {
|
||||
addToken(typeMatch[1], 'text-foreground-secondary');
|
||||
addToken(typeMatch[2], 'text-cyan-700 dark:text-cyan-300');
|
||||
remaining = remaining.slice(typeMatch[0].length);
|
||||
matched = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Default: single character
|
||||
if (!matched) {
|
||||
addToken(remaining[0], 'text-foreground-secondary');
|
||||
remaining = remaining.slice(1);
|
||||
}
|
||||
}
|
||||
|
||||
return tokens.length > 0 ? tokens : [<span key={lineIndex}> </span>];
|
||||
});
|
||||
}
|
||||
|
||||
function CodeBlock({ code, filename, lang }: { code: string; filename: string; lang: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const highlightedLines = highlightCode(code.trim(), lang);
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="group border-border bg-background-secondary relative w-full overflow-hidden rounded-md border font-mono text-xs shadow-sm">
|
||||
{/* Header */}
|
||||
<div className="border-border bg-background flex items-center justify-between border-b px-4 py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex gap-1.5">
|
||||
<div className="bg-border-strong h-2 w-2 rounded-full" />
|
||||
<div className="bg-border-strong h-2 w-2 rounded-full" />
|
||||
<div className="bg-border-strong h-2 w-2 rounded-full" />
|
||||
</div>
|
||||
<span className="text-foreground-muted font-sans text-[10px] font-medium">
|
||||
{filename}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="text-foreground-muted hover:bg-secondary hover:text-foreground flex items-center gap-1.5 rounded px-2 py-0.5 text-[10px] font-medium transition-colors"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-3 w-3 text-green-600" strokeWidth={2} />
|
||||
<span className="text-green-600">Copied</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-3 w-3" strokeWidth={2} />
|
||||
<span>Copy</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Code Area */}
|
||||
<div className="bg-background overflow-x-auto p-3">
|
||||
<pre className="flex flex-col leading-5">
|
||||
{highlightedLines.map((lineTokens, i) => (
|
||||
<div key={i} className="table-row">
|
||||
<span className="text-foreground-subtle table-cell w-6 pr-3 text-right text-[10px] select-none">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="table-cell whitespace-pre">{lineTokens}</span>
|
||||
</div>
|
||||
))}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Tab definitions with icons for VerticalTabs
|
||||
const tabs: VerticalTab[] = [
|
||||
{ id: 'theming', label: 'Theming', description: 'Design tokens & dark mode', icon: Palette },
|
||||
{ id: 'seo', label: 'SEO & Meta', description: 'OG images & structured data', icon: Search },
|
||||
{ id: 'perf', label: 'Performance', description: 'Zero JS by default', icon: Zap },
|
||||
{
|
||||
id: 'components',
|
||||
label: 'Components',
|
||||
description: 'Type-safe UI primitives',
|
||||
icon: LayoutGrid,
|
||||
},
|
||||
{ id: 'i18n', label: 'i18n Ready', description: 'Optional multi-language', icon: Globe },
|
||||
{ id: 'content', label: 'Content', description: 'Blog, MDX & search', icon: Newspaper },
|
||||
];
|
||||
|
||||
export function FeatureTabs() {
|
||||
const [activeTab, setActiveTab] = useState('theming');
|
||||
|
||||
return (
|
||||
<section id="features" className="bg-background relative overflow-hidden py-[var(--space-section-md)]">
|
||||
{/* Decorative logomark watermark */}
|
||||
<div
|
||||
className="pointer-events-none absolute -top-8 right-8 hidden h-[28rem] w-[28rem] opacity-[0.04] grayscale md:block lg:top-10 lg:right-24 lg:h-[44rem] lg:w-[44rem] dark:opacity-[0.06]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg viewBox="0 0 90 101" fill="none" className="h-full w-full">
|
||||
<path
|
||||
d="M35.1288 23.8398L45.1667 49.4151L56.2482 23.8398H87.1082C86.5647 23.3764 85.9776 22.9637 85.3616 22.5944L48.6165 0.704798C46.377 -0.0699896 43.4273 -0.439281 41.2675 0.842377L3.36286 23.3692C3.13819 23.5067 2.92801 23.666 2.72508 23.8326H35.1288V23.8398Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M0.144951 28.8578C0.079723 29.2851 0.0434853 29.7123 0.0434853 30.1323L0 72.036C0 76.1778 1.95684 78.3936 5.26172 80.3631L39.4919 100.703L0.144951 28.8578Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M89.9203 28.7058L50.0588 101L86.6661 79.1539C88.7027 77.9374 90 75.0265 90 72.6442L89.9783 29.6037C89.9783 29.2923 89.9493 28.9954 89.913 28.6985L89.9203 28.7058Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="relative mx-auto max-w-6xl px-6">
|
||||
{/* Header */}
|
||||
<div className="mb-[var(--space-section-header)]">
|
||||
<h2 className="font-display text-foreground text-3xl font-bold md:text-4xl">
|
||||
Everything you need.
|
||||
<br />
|
||||
<span className="text-brand-500">Nothing you don't.</span>
|
||||
</h2>
|
||||
<p className="text-foreground-muted mt-4 max-w-2xl text-lg">
|
||||
We stripped away the bloat and kept the primitives that actually speed up development
|
||||
for agencies and freelancers.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Vertical Tabs */}
|
||||
<VerticalTabs tabs={tabs} value={activeTab} onChange={setActiveTab} mobileVariant="sheet">
|
||||
{tabs.map((tab) => (
|
||||
<div key={tab.id} data-tab-content={tab.id}>
|
||||
<div className="mb-[var(--space-heading-gap)]">
|
||||
<h3 className="text-foreground text-xl font-semibold">{tabContent[tab.id].title}</h3>
|
||||
<p className="text-foreground-muted mt-2">{tabContent[tab.id].content}</p>
|
||||
</div>
|
||||
<CodeBlock
|
||||
code={codeExamples[tab.id].code}
|
||||
filename={codeExamples[tab.id].filename}
|
||||
lang={codeExamples[tab.id].lang}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</VerticalTabs>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default FeatureTabs;
|
||||
@@ -0,0 +1,192 @@
|
||||
---
|
||||
/**
|
||||
* LighthouseScores.astro
|
||||
* A callout component displaying perfect Lighthouse scores
|
||||
* Designed to match the authentic Lighthouse report aesthetic
|
||||
*/
|
||||
import Badge from '@/components/ui/data-display/Badge/Badge.astro';
|
||||
|
||||
interface Score {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
const scores: Score[] = [
|
||||
{ label: 'Performance', value: 100 },
|
||||
{ label: 'Accessibility', value: 100 },
|
||||
{ label: 'Best Practices', value: 100 },
|
||||
{ label: 'SEO', value: 100 },
|
||||
];
|
||||
---
|
||||
|
||||
<section class="bg-background-secondary border-border border-y py-[var(--space-section-md)]">
|
||||
<div class="mx-auto max-w-6xl px-6">
|
||||
<!-- Header -->
|
||||
<div class="mb-8 text-center">
|
||||
<div class="mb-4 flex justify-center">
|
||||
<Badge variant="success">Lighthouse Report</Badge>
|
||||
</div>
|
||||
<h3 class="font-display text-foreground text-2xl font-bold md:text-3xl">
|
||||
Perfect scores. Out of the box.
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<!-- Scores Grid -->
|
||||
<div
|
||||
class="lighthouse-scores mx-auto grid max-w-2xl grid-cols-4 gap-[var(--space-content-gap)]"
|
||||
role="list"
|
||||
aria-label="Lighthouse scores"
|
||||
>
|
||||
{
|
||||
scores.map((score, index) => (
|
||||
<div
|
||||
class="lighthouse-score group flex flex-col items-center"
|
||||
role="listitem"
|
||||
style={`--delay: ${index * 80}ms;`}
|
||||
>
|
||||
{/* Circular gauge - authentic Lighthouse style */}
|
||||
<div class="relative">
|
||||
<svg
|
||||
class="lighthouse-gauge h-16 w-16 sm:h-20 sm:w-20 md:h-24 md:w-24"
|
||||
viewBox="0 0 120 120"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* Outer gray track */}
|
||||
<circle
|
||||
cx="60"
|
||||
cy="60"
|
||||
r="54"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="8"
|
||||
class="text-gray-200 dark:text-gray-800"
|
||||
/>
|
||||
{/* Green progress arc */}
|
||||
<circle
|
||||
cx="60"
|
||||
cy="60"
|
||||
r="54"
|
||||
fill="none"
|
||||
stroke="var(--success)"
|
||||
stroke-width="8"
|
||||
stroke-linecap="round"
|
||||
stroke-dasharray="339.292"
|
||||
stroke-dashoffset="0"
|
||||
class="lighthouse-progress"
|
||||
style="transform: rotate(-90deg); transform-origin: center;"
|
||||
/>
|
||||
{/* Score number - authentic Lighthouse sizing */}
|
||||
<text
|
||||
x="60"
|
||||
y="60"
|
||||
text-anchor="middle"
|
||||
dominant-baseline="central"
|
||||
class="lighthouse-number fill-current"
|
||||
style="font-size: 26px; font-weight: 600; font-family: Roboto, system-ui, sans-serif;"
|
||||
>
|
||||
{score.value}
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Label */}
|
||||
<span class="text-foreground-muted mt-2 text-center text-xs font-medium sm:text-sm">
|
||||
{score.label}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Footer note -->
|
||||
<p class="text-foreground-subtle mt-[var(--space-stack-lg)] text-center text-xs">
|
||||
*Tested in production for landing page demo · Desktop & Mobile emulation ·
|
||||
Results will vary.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
/* Authentic Lighthouse green for the number */
|
||||
.lighthouse-number {
|
||||
fill: var(--success);
|
||||
}
|
||||
|
||||
/* Entrance animations */
|
||||
.lighthouse-score {
|
||||
animation: score-enter 0.5s ease-out both;
|
||||
animation-delay: calc(0.2s + var(--delay, 0ms));
|
||||
}
|
||||
|
||||
.lighthouse-progress {
|
||||
animation: progress-fill 1s ease-out both;
|
||||
animation-delay: calc(0.3s + var(--delay, 0ms));
|
||||
}
|
||||
|
||||
@keyframes score-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes progress-fill {
|
||||
from {
|
||||
stroke-dashoffset: 339.292;
|
||||
}
|
||||
to {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.animation-paused {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
.animation-running {
|
||||
animation-play-state: running;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.lighthouse-score,
|
||||
.lighthouse-progress {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.lighthouse-progress {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Intersection Observer for scroll-triggered animations
|
||||
const section = document.querySelector('.lighthouse-scores');
|
||||
if (section) {
|
||||
const elements = section.querySelectorAll('.lighthouse-score, .lighthouse-progress');
|
||||
elements.forEach((el) => {
|
||||
el.classList.add('animation-paused');
|
||||
});
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
elements.forEach((el) => {
|
||||
el.classList.remove('animation-paused');
|
||||
el.classList.add('animation-running');
|
||||
});
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
},
|
||||
{ threshold: 0.3 }
|
||||
);
|
||||
|
||||
observer.observe(section);
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
/**
|
||||
* TechStack — MDX-driven stack showcase
|
||||
*
|
||||
* Content lives in src/content/stack/*.mdx — add, remove, or
|
||||
* edit a tool there without touching this component.
|
||||
*/
|
||||
import { getCollection } from 'astro:content';
|
||||
import Icon from '@/components/ui/primitives/Icon/Icon.astro';
|
||||
import Card from '@/components/ui/data-display/Card/Card.astro';
|
||||
import Badge from '@/components/ui/data-display/Badge/Badge.astro';
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
description?: string;
|
||||
badge?: string;
|
||||
}
|
||||
|
||||
const { title, description, badge } = Astro.props;
|
||||
|
||||
const items = await getCollection('stack');
|
||||
const stack = items.sort((a, b) => a.data.order - b.data.order);
|
||||
---
|
||||
|
||||
<section class="border-y border-border bg-background-secondary py-[var(--space-section-md)]">
|
||||
<div class="mx-auto max-w-6xl px-6 flex flex-col gap-8">
|
||||
{(badge || title || description) && (
|
||||
<div class="flex flex-col items-center gap-4 text-center" data-reveal>
|
||||
{(badge || title) && (
|
||||
<div class="flex flex-col items-center gap-6">
|
||||
{badge && (
|
||||
<Badge variant="brand" pill>{badge}</Badge>
|
||||
)}
|
||||
{title && (
|
||||
<h2 class="font-display text-4xl font-bold text-foreground">{title}</h2>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{description && (
|
||||
<p class="text-lg text-foreground-muted max-w-2xl mx-auto">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div class="grid grid-cols-2 gap-[var(--space-content-gap)] md:grid-cols-4" data-reveal data-reveal-delay="1">
|
||||
{stack.map((item) => (
|
||||
<div
|
||||
style={`--tech-color: oklch(${item.data.colorOklch}); --tech-bg: oklch(${item.data.colorOklch} / 0.1);`}
|
||||
>
|
||||
<Card variant="elevated" padding="md" hover={true} href={item.data.url} target="_blank" rel="noopener noreferrer" class="h-full text-center">
|
||||
<div class="flex flex-col items-center gap-[var(--space-stack-sm)]">
|
||||
<div class="tech-icon-wrap">
|
||||
<Icon name={item.data.icon} size="lg" />
|
||||
</div>
|
||||
<span class="font-display text-lg font-bold text-foreground">{item.data.name}</span>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
/* ─── Shared icon container ─────────────────────────────── */
|
||||
.tech-icon-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
border-radius: var(--radius-full);
|
||||
color: var(--color-brand-500);
|
||||
background: color-mix(in srgb, var(--color-brand-500) 10%, transparent);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,261 @@
|
||||
---
|
||||
/**
|
||||
* Analytics Component
|
||||
*
|
||||
* Conditionally loads analytics scripts based on environment variables.
|
||||
* Supports Google Analytics (GA4) and Google Tag Manager.
|
||||
*
|
||||
* When PUBLIC_CONSENT_ENABLED is true, integrates with Google Consent Mode v2:
|
||||
* - consent_mode_v2: Scripts load normally but with denied defaults (cookieless pings)
|
||||
* - strict: Scripts only load after explicit user consent
|
||||
*
|
||||
* Environment variables:
|
||||
* - PUBLIC_GA_MEASUREMENT_ID: Google Analytics 4 Measurement ID (e.g., G-XXXXXXXXXX)
|
||||
* - PUBLIC_GTM_ID: Google Tag Manager Container ID (e.g., GTM-XXXXXXX)
|
||||
* - PUBLIC_CONSENT_ENABLED: Enable cookie consent integration (boolean)
|
||||
*
|
||||
* CSP: All is:inline scripts here are STATIC (no define:vars). Dynamic values are
|
||||
* passed via <script type="application/json"> data elements. Pin each script's
|
||||
* sha256 hash in astro.config.mjs → security.csp.scriptDirective.hashes.
|
||||
*
|
||||
* Required CSP directives for GA4/GTM:
|
||||
*
|
||||
* script-src:
|
||||
* https://*.googletagmanager.com
|
||||
* https://*.google-analytics.com
|
||||
* https://analytics.google.com
|
||||
*
|
||||
* connect-src:
|
||||
* https://*.google-analytics.com (covers region1, region2, etc.)
|
||||
* https://analytics.google.com (separate domain, not a subdomain)
|
||||
* https://*.googletagmanager.com
|
||||
*
|
||||
* IMPORTANT: GA4 sends data to regional endpoints like
|
||||
* https://region1.google-analytics.com/g/s/collect — whitelisting only
|
||||
* www.google-analytics.com will silently drop all tracking requests with
|
||||
* no console errors. The wildcard *.google-analytics.com is required.
|
||||
*/
|
||||
import { PUBLIC_GA_MEASUREMENT_ID, PUBLIC_GTM_ID, PUBLIC_CONSENT_ENABLED } from 'astro:env/client';
|
||||
import consentConfig from '@/config/consent.config';
|
||||
|
||||
const gaId = PUBLIC_GA_MEASUREMENT_ID;
|
||||
const gtmId = PUBLIC_GTM_ID;
|
||||
const consentEnabled = PUBLIC_CONSENT_ENABLED;
|
||||
const consentMode = consentConfig.mode;
|
||||
const storageKey = consentConfig.storageKey;
|
||||
const configVersion = consentConfig.version;
|
||||
|
||||
// Build GCM default values from config categories
|
||||
const gcmDefaults: Record<string, string> = {};
|
||||
for (const [, cat] of Object.entries(consentConfig.categories)) {
|
||||
for (const gcmType of cat.gcmTypes) {
|
||||
gcmDefaults[gcmType] = cat.defaultEnabled ? 'granted' : 'denied';
|
||||
}
|
||||
}
|
||||
|
||||
// Build category default states from config (e.g. { necessary: true, analytics: false, ... })
|
||||
const categoryDefaults: Record<string, boolean> = {};
|
||||
for (const [key, cat] of Object.entries(consentConfig.categories)) {
|
||||
categoryDefaults[key] = cat.required || cat.defaultEnabled;
|
||||
}
|
||||
|
||||
// Build category → GCM type mapping for dynamic resolution
|
||||
const categoryGcmMap: Record<string, string[]> = {};
|
||||
for (const [key, cat] of Object.entries(consentConfig.categories)) {
|
||||
categoryGcmMap[key] = cat.gcmTypes;
|
||||
}
|
||||
|
||||
// Derive the category key that maps to analytics_storage (for strict-mode guards)
|
||||
let analyticsCategoryKey = 'analytics';
|
||||
for (const [key, cat] of Object.entries(consentConfig.categories)) {
|
||||
if (cat.gcmTypes.includes('analytics_storage')) {
|
||||
analyticsCategoryKey = key;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Rendering flags — keeps template conditions simple and highlighter-friendly
|
||||
const hasGtm = !!gtmId;
|
||||
const hasGa = !!gaId && !gtmId;
|
||||
const hasAnalytics = !!(gaId || gtmId);
|
||||
const noConsent = !consentEnabled;
|
||||
const isV2 = consentEnabled && consentMode === 'consent_mode_v2';
|
||||
const isStrict = consentEnabled && consentMode === 'strict';
|
||||
|
||||
// JSON data for all analytics scripts (single data element avoids duplication)
|
||||
const analyticsDataJson = JSON.stringify({
|
||||
gtmId: gtmId || null,
|
||||
gaId: gaId || null,
|
||||
storageKey,
|
||||
configVersion,
|
||||
gcmDefaults,
|
||||
categoryDefaults,
|
||||
categoryGcmMap,
|
||||
analyticsCategoryKey,
|
||||
});
|
||||
---
|
||||
|
||||
{/* Analytics data element — read by all inline scripts below */}
|
||||
{hasAnalytics && (
|
||||
<script type="application/json" id="analytics-data" set:html={analyticsDataJson} />
|
||||
)}
|
||||
|
||||
{/* ── No consent: load GTM directly ── */}
|
||||
{noConsent && hasGtm && (
|
||||
<script is:inline>
|
||||
(function(){
|
||||
const d = JSON.parse(document.getElementById('analytics-data').textContent);
|
||||
const i = d.gtmId;
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
window.dataLayer.push({ 'gtm.start': new Date().getTime(), event: 'gtm.js' });
|
||||
const f = document.getElementsByTagName('script')[0],
|
||||
j = document.createElement('script');
|
||||
j.async = true;
|
||||
j.src = 'https://www.googletagmanager.com/gtm.js?id=' + i;
|
||||
f.parentNode.insertBefore(j, f);
|
||||
})();
|
||||
</script>
|
||||
)}
|
||||
|
||||
{/* ── No consent: load GA directly ── */}
|
||||
{noConsent && hasGa && (
|
||||
<>
|
||||
<script is:inline async src={`https://www.googletagmanager.com/gtag/js?id=${gaId}`}></script>
|
||||
<script is:inline>
|
||||
(function(){
|
||||
const d = JSON.parse(document.getElementById('analytics-data').textContent);
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(...args){window.dataLayer.push(args);}
|
||||
gtag('js', new Date());
|
||||
gtag('config', d.gaId);
|
||||
})();
|
||||
</script>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Consent enabled: set consent defaults BEFORE loading scripts ── */}
|
||||
{consentEnabled && hasAnalytics && (
|
||||
<script is:inline>
|
||||
(function(){
|
||||
const d = JSON.parse(document.getElementById('analytics-data').textContent);
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(...args){window.dataLayer.push(args);}
|
||||
|
||||
let stored = null;
|
||||
try {
|
||||
const raw = localStorage.getItem(d.storageKey);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && parsed.version === d.configVersion) {
|
||||
stored = parsed;
|
||||
}
|
||||
}
|
||||
} catch { /* ignored */ }
|
||||
|
||||
const gcmValues = {};
|
||||
const keys = Object.keys(d.gcmDefaults);
|
||||
for (let k = 0; k < keys.length; k++) { gcmValues[keys[k]] = d.gcmDefaults[keys[k]]; }
|
||||
let decided = false;
|
||||
let categories = {};
|
||||
const catKeys = Object.keys(d.categoryDefaults);
|
||||
for (let c = 0; c < catKeys.length; c++) { categories[catKeys[c]] = d.categoryDefaults[catKeys[c]]; }
|
||||
|
||||
if (stored && stored.categories) {
|
||||
decided = true;
|
||||
categories = stored.categories;
|
||||
|
||||
const mapKeys = Object.keys(d.categoryGcmMap);
|
||||
for (let m = 0; m < mapKeys.length; m++) {
|
||||
const catKey = mapKeys[m];
|
||||
const granted = !!categories[catKey];
|
||||
const gcmTypes = d.categoryGcmMap[catKey];
|
||||
for (let g = 0; g < gcmTypes.length; g++) {
|
||||
gcmValues[gcmTypes[g]] = granted ? 'granted' : 'denied';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
gtag('consent', 'default', gcmValues);
|
||||
window.__consentState = { decided: decided, categories: categories };
|
||||
})();
|
||||
</script>
|
||||
)}
|
||||
|
||||
{/* ── V2 mode: load GTM after consent defaults ── */}
|
||||
{isV2 && hasGtm && (
|
||||
<script is:inline>
|
||||
(function(){
|
||||
const d = JSON.parse(document.getElementById('analytics-data').textContent);
|
||||
const i = d.gtmId;
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
window.dataLayer.push({ 'gtm.start': new Date().getTime(), event: 'gtm.js' });
|
||||
const f = document.getElementsByTagName('script')[0],
|
||||
j = document.createElement('script');
|
||||
j.async = true;
|
||||
j.src = 'https://www.googletagmanager.com/gtm.js?id=' + i;
|
||||
f.parentNode.insertBefore(j, f);
|
||||
})();
|
||||
</script>
|
||||
)}
|
||||
|
||||
{/* ── V2 mode: load GA after consent defaults ── */}
|
||||
{isV2 && hasGa && (
|
||||
<>
|
||||
<script is:inline async src={`https://www.googletagmanager.com/gtag/js?id=${gaId}`}></script>
|
||||
<script is:inline>
|
||||
(function(){
|
||||
const d = JSON.parse(document.getElementById('analytics-data').textContent);
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(...args){window.dataLayer.push(args);}
|
||||
gtag('js', new Date());
|
||||
gtag('config', d.gaId);
|
||||
})();
|
||||
</script>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Strict mode: meta tags for consent banner dynamic injection ── */}
|
||||
{isStrict && hasGtm && (
|
||||
<meta name="gtm-id" content={gtmId} />
|
||||
)}
|
||||
{isStrict && hasGa && (
|
||||
<meta name="ga-id" content={gaId} />
|
||||
)}
|
||||
|
||||
{/* ── Strict mode: conditionally load GTM if consent already granted ── */}
|
||||
{isStrict && hasGtm && (
|
||||
<script is:inline>
|
||||
(function(){
|
||||
const d = JSON.parse(document.getElementById('analytics-data').textContent);
|
||||
if (window.__consentState && window.__consentState.decided && window.__consentState.categories[d.analyticsCategoryKey]) {
|
||||
const i = d.gtmId;
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
window.dataLayer.push({ 'gtm.start': new Date().getTime(), event: 'gtm.js' });
|
||||
const f = document.getElementsByTagName('script')[0],
|
||||
j = document.createElement('script');
|
||||
j.async = true;
|
||||
j.src = 'https://www.googletagmanager.com/gtm.js?id=' + i;
|
||||
f.parentNode.insertBefore(j, f);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
)}
|
||||
|
||||
{/* ── Strict mode: conditionally load GA if consent already granted ── */}
|
||||
{isStrict && hasGa && (
|
||||
<script is:inline>
|
||||
(function(){
|
||||
const d = JSON.parse(document.getElementById('analytics-data').textContent);
|
||||
if (window.__consentState && window.__consentState.decided && window.__consentState.categories[d.analyticsCategoryKey]) {
|
||||
const s = document.createElement('script');
|
||||
s.async = true;
|
||||
s.src = 'https://www.googletagmanager.com/gtag/js?id=' + d.gaId;
|
||||
document.head.appendChild(s);
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(...args){window.dataLayer.push(args);}
|
||||
gtag('js', new Date());
|
||||
gtag('config', d.gaId);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
)}
|
||||
@@ -0,0 +1,392 @@
|
||||
---
|
||||
/**
|
||||
* Footer Component
|
||||
* Flexible footer with variant-based configuration
|
||||
*
|
||||
* Layouts:
|
||||
* - simple: Single row with logo, nav links, and social
|
||||
* - columns: Multi-column layout with link groups
|
||||
* - minimal: Just copyright
|
||||
* - stacked: Vertically stacked logo, nav, copyright
|
||||
*
|
||||
* Features:
|
||||
* - Dynamic navigation from nav.config.ts (default) or custom nav prop
|
||||
* - Social links with icon support
|
||||
* - Copyright with {year} placeholder
|
||||
* - Legal links section
|
||||
* - Full slot support for customization
|
||||
*/
|
||||
import type { HTMLAttributes } from 'astro/types';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { getNavItems, type NavItem as NavConfigItem } from '@/config/nav.config';
|
||||
import { footerVariants, footerColumnGridVariants } from './footer.variants';
|
||||
import Logo from '@/components/ui/marketing/Logo/Logo.astro';
|
||||
import Icon from '@/components/ui/primitives/Icon/Icon.astro';
|
||||
import siteConfig from '@/config/site.config';
|
||||
|
||||
export interface NavItem {
|
||||
label: string;
|
||||
href: string;
|
||||
external?: boolean;
|
||||
}
|
||||
|
||||
export interface FooterLinkGroup {
|
||||
title: string;
|
||||
links: NavItem[];
|
||||
}
|
||||
|
||||
export interface SocialLink {
|
||||
platform: 'github' | 'twitter' | 'linkedin' | string;
|
||||
href: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface LegalLink {
|
||||
label: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
interface Props extends HTMLAttributes<'footer'> {
|
||||
/** Layout style */
|
||||
layout?: 'simple' | 'columns' | 'minimal' | 'stacked';
|
||||
/** Number of columns (only for columns layout) */
|
||||
columns?: 2 | 3 | 4;
|
||||
/** Background variant */
|
||||
background?: 'default' | 'secondary' | 'invert';
|
||||
/** Override default navigation */
|
||||
nav?: NavItem[];
|
||||
/** Link groups for columns layout */
|
||||
linkGroups?: FooterLinkGroup[];
|
||||
/** Social media links */
|
||||
socialLinks?: SocialLink[];
|
||||
/** Show social links */
|
||||
showSocial?: boolean;
|
||||
/** Show copyright */
|
||||
showCopyright?: boolean;
|
||||
/** Custom copyright text (supports {year} and {siteName} placeholders) */
|
||||
copyright?: string;
|
||||
/** Legal links (Privacy, Terms, etc.) */
|
||||
legalLinks?: LegalLink[];
|
||||
/** Hide logo */
|
||||
hideLogo?: boolean;
|
||||
/** Tagline text under logo */
|
||||
tagline?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
layout = 'simple',
|
||||
columns = 3,
|
||||
background = 'default',
|
||||
nav,
|
||||
linkGroups = [],
|
||||
socialLinks = [],
|
||||
showSocial = true,
|
||||
showCopyright = true,
|
||||
copyright = '© {year} {siteName}. Designed by <a href="https://hansmartens.dev" class="underline hover:text-foreground transition-colors" target="_blank" rel="noopener noreferrer">Hans Martens</a>.',
|
||||
legalLinks = [],
|
||||
hideLogo = false,
|
||||
tagline,
|
||||
class: className,
|
||||
...attrs
|
||||
} = Astro.props;
|
||||
|
||||
// Get navigation items
|
||||
const defaultNav: NavItem[] = getNavItems().map((item: NavConfigItem) => ({
|
||||
label: item.label,
|
||||
href: item.href,
|
||||
}));
|
||||
const navItems: NavItem[] = nav || defaultNav;
|
||||
|
||||
// Default external links if none provided via nav
|
||||
const allNavItems: NavItem[] = nav ? navItems : navItems;
|
||||
|
||||
// Process copyright text
|
||||
const currentYear = new Date().getFullYear();
|
||||
const processedCopyright = copyright
|
||||
.replace('{year}', String(currentYear))
|
||||
.replace('{siteName}', siteConfig.name);
|
||||
|
||||
// Check slots
|
||||
const hasLogoSlot = Astro.slots.has('logo');
|
||||
const hasTaglineSlot = Astro.slots.has('tagline');
|
||||
const hasColumnsSlot = Astro.slots.has('columns');
|
||||
const hasSocialSlot = Astro.slots.has('social');
|
||||
const hasBottomSlot = Astro.slots.has('bottom');
|
||||
|
||||
// Compute footer classes
|
||||
const footerClasses = cn(footerVariants({ background }), className);
|
||||
|
||||
// Social platform to icon mapping
|
||||
const socialIcons: Record<string, string> = {
|
||||
github: 'github',
|
||||
twitter: 'x-twitter',
|
||||
linkedin: 'linkedin',
|
||||
};
|
||||
|
||||
// Get icon for social platform
|
||||
function getSocialIcon(platform: string): string {
|
||||
return socialIcons[platform] || platform;
|
||||
}
|
||||
---
|
||||
|
||||
<footer class={footerClasses} {...attrs}>
|
||||
<div class="mx-auto max-w-6xl px-6">
|
||||
{layout === 'simple' && (
|
||||
<>
|
||||
<div class="flex flex-col md:flex-row justify-between items-center gap-[var(--space-stack-lg)]">
|
||||
{/* Logo & Tagline */}
|
||||
{!hideLogo && (
|
||||
<div class="flex flex-col items-center md:items-start gap-2">
|
||||
{hasLogoSlot ? (
|
||||
<slot name="logo" />
|
||||
) : (
|
||||
<a href="/" class="flex items-center gap-2">
|
||||
<Logo size="md" />
|
||||
<span class="font-display text-base font-bold text-brand-500">
|
||||
{siteConfig.name}
|
||||
</span>
|
||||
</a>
|
||||
)}
|
||||
{(hasTaglineSlot || tagline) && (
|
||||
<p class="text-sm text-foreground-muted">
|
||||
{hasTaglineSlot ? <slot name="tagline" /> : tagline}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation Links */}
|
||||
<nav class="flex flex-wrap justify-center gap-[var(--space-inline-lg)] md:gap-[var(--space-stack-lg)] text-sm font-medium text-foreground-muted">
|
||||
{allNavItems.map((item) => (
|
||||
<a
|
||||
href={item.href}
|
||||
class="transition-colors hover:text-foreground"
|
||||
target={item.external || item.href.startsWith('http') ? '_blank' : undefined}
|
||||
rel={item.external || item.href.startsWith('http') ? 'noopener noreferrer' : undefined}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Social Links */}
|
||||
{showSocial && socialLinks.length > 0 && (
|
||||
hasSocialSlot ? (
|
||||
<slot name="social" />
|
||||
) : (
|
||||
<div class="flex items-center gap-[var(--space-stack-md)]">
|
||||
{socialLinks.map((social) => (
|
||||
<a
|
||||
href={social.href}
|
||||
class="transition-colors text-foreground-muted hover:text-foreground"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={social.label || `Follow us on ${social.platform}`}
|
||||
>
|
||||
<Icon name={getSocialIcon(social.platform)} size="md" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{showCopyright && (
|
||||
<div class="mt-[var(--space-stack-lg)] pt-[var(--space-stack-lg)] border-t border-border text-center">
|
||||
<p class="text-sm text-foreground-muted" set:html={processedCopyright} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{layout === 'columns' && (
|
||||
<>
|
||||
<div class={footerColumnGridVariants({ columns })}>
|
||||
{/* Logo Column */}
|
||||
{!hideLogo && (
|
||||
<div class="space-y-[var(--space-stack-md)]">
|
||||
{hasLogoSlot ? (
|
||||
<slot name="logo" />
|
||||
) : (
|
||||
<a href="/" class="flex items-center gap-2">
|
||||
<Logo size="md" />
|
||||
<span class="font-display text-base font-bold text-brand-500">
|
||||
{siteConfig.name}
|
||||
</span>
|
||||
</a>
|
||||
)}
|
||||
{(hasTaglineSlot || tagline) && (
|
||||
<p class="text-sm max-w-xs text-foreground-muted">
|
||||
{hasTaglineSlot ? <slot name="tagline" /> : tagline}
|
||||
</p>
|
||||
)}
|
||||
{/* Social Links in columns layout */}
|
||||
{showSocial && socialLinks.length > 0 && (
|
||||
<div class="flex items-center gap-[var(--space-stack-md)] pt-2">
|
||||
{socialLinks.map((social) => (
|
||||
<a
|
||||
href={social.href}
|
||||
class="transition-colors text-foreground-muted hover:text-foreground"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={social.label || `Follow us on ${social.platform}`}
|
||||
>
|
||||
<Icon name={getSocialIcon(social.platform)} size="md" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Link Groups */}
|
||||
{hasColumnsSlot ? (
|
||||
<slot name="columns" />
|
||||
) : (
|
||||
linkGroups.map((group) => (
|
||||
<div class="space-y-[var(--space-stack-md)]">
|
||||
<h3 class="font-semibold text-sm text-foreground">
|
||||
{group.title}
|
||||
</h3>
|
||||
<ul class="space-y-2">
|
||||
{group.links.map((link) => (
|
||||
<li>
|
||||
<a
|
||||
href={link.href}
|
||||
class="text-sm transition-colors text-foreground-muted hover:text-foreground"
|
||||
target={link.external || link.href.startsWith('http') ? '_blank' : undefined}
|
||||
rel={link.external || link.href.startsWith('http') ? 'noopener noreferrer' : undefined}
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom section */}
|
||||
{(showCopyright || legalLinks.length > 0) && (
|
||||
<div class="mt-[var(--space-section-header)] pt-[var(--space-stack-lg)] border-t border-border flex flex-col md:flex-row justify-between items-center gap-[var(--space-stack-md)]">
|
||||
{hasBottomSlot ? (
|
||||
<slot name="bottom" />
|
||||
) : (
|
||||
<>
|
||||
{showCopyright && (
|
||||
<p class="text-sm text-foreground-muted">
|
||||
{processedCopyright}
|
||||
</p>
|
||||
)}
|
||||
{legalLinks.length > 0 && (
|
||||
<div class="flex items-center gap-[var(--space-inline-lg)]">
|
||||
{legalLinks.map((link) => (
|
||||
<a
|
||||
href={link.href}
|
||||
class="text-sm transition-colors text-foreground-muted hover:text-foreground"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{layout === 'minimal' && (
|
||||
<div class="text-center">
|
||||
{showCopyright && (
|
||||
<p class="text-sm text-foreground-muted">
|
||||
{processedCopyright}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{layout === 'stacked' && (
|
||||
<div class="flex flex-col items-center gap-[var(--space-stack-lg)] text-center">
|
||||
{/* Logo */}
|
||||
{!hideLogo && (
|
||||
hasLogoSlot ? (
|
||||
<slot name="logo" />
|
||||
) : (
|
||||
<a href="/" class="flex items-center gap-2">
|
||||
<Logo size="md" />
|
||||
<span class="font-display text-xl font-bold text-brand-500">
|
||||
{siteConfig.name}
|
||||
</span>
|
||||
</a>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Tagline */}
|
||||
{(hasTaglineSlot || tagline) && (
|
||||
<p class="text-sm max-w-md text-foreground-muted">
|
||||
{hasTaglineSlot ? <slot name="tagline" /> : tagline}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Navigation Links */}
|
||||
<nav class="flex flex-wrap justify-center gap-[var(--space-inline-lg)] text-sm font-medium text-foreground-muted">
|
||||
{allNavItems.map((item) => (
|
||||
<a
|
||||
href={item.href}
|
||||
class="transition-colors hover:text-foreground"
|
||||
target={item.external || item.href.startsWith('http') ? '_blank' : undefined}
|
||||
rel={item.external || item.href.startsWith('http') ? 'noopener noreferrer' : undefined}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Social Links */}
|
||||
{showSocial && socialLinks.length > 0 && (
|
||||
<div class="flex items-center gap-[var(--space-stack-md)]">
|
||||
{socialLinks.map((social) => (
|
||||
<a
|
||||
href={social.href}
|
||||
class="transition-colors text-foreground-muted hover:text-foreground"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={social.label || `Follow us on ${social.platform}`}
|
||||
>
|
||||
<Icon name={getSocialIcon(social.platform)} size="md" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Copyright & Legal */}
|
||||
{(showCopyright || legalLinks.length > 0) && (
|
||||
<div class="pt-[var(--space-stack-lg)] border-t border-border w-full flex flex-col items-center gap-[var(--space-stack-md)]">
|
||||
{showCopyright && (
|
||||
<p class="text-sm text-foreground-muted">
|
||||
{processedCopyright}
|
||||
</p>
|
||||
)}
|
||||
{legalLinks.length > 0 && (
|
||||
<div class="flex items-center gap-[var(--space-inline-lg)]">
|
||||
{legalLinks.map((link) => (
|
||||
<a
|
||||
href={link.href}
|
||||
class="text-sm transition-colors text-foreground-muted hover:text-foreground"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Default slot for additional content */}
|
||||
<slot />
|
||||
</footer>
|
||||
@@ -0,0 +1,753 @@
|
||||
---
|
||||
/**
|
||||
* Header Component
|
||||
* Flexible navigation header with variant-based configuration
|
||||
*
|
||||
* Variants:
|
||||
* - layout: 'default' | 'centered' | 'minimal'
|
||||
* - position: 'fixed' | 'sticky' | 'static'
|
||||
* - size: 'sm' | 'md' | 'lg'
|
||||
* - variant: 'default' | 'solid' | 'transparent'
|
||||
* - colorScheme: 'default' | 'invert' (use 'invert' for dark backgrounds)
|
||||
* - shape: 'bar' | 'floating' (use 'floating' for capsule header)
|
||||
*
|
||||
* Features:
|
||||
* - Dynamic navigation from nav.config.ts (default) or custom nav prop
|
||||
* - Optional CTA button with customization
|
||||
* - Mobile menu with Escape key support
|
||||
* - Theme toggle
|
||||
* - GitHub/action buttons
|
||||
* - Full slot support for customization
|
||||
* - Inverted color scheme for use on dark/image backgrounds
|
||||
* - Floating capsule shape with scroll-reactive bg + color flip
|
||||
*/
|
||||
import type { HTMLAttributes } from 'astro/types';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { getNavItems, type NavItem as NavConfigItem } from '@/config/nav.config';
|
||||
import { headerVariants, headerInnerVariants } from './header.variants';
|
||||
import Button from '@/components/ui/form/Button/Button.astro';
|
||||
import Icon from '@/components/ui/primitives/Icon/Icon.astro';
|
||||
import Logo from '@/components/ui/marketing/Logo/Logo.astro';
|
||||
import ThemeToggle from '@/components/layout/ThemeToggle.astro';
|
||||
import ThemeSelector from '@/components/layout/ThemeSelector.astro';
|
||||
import ThemeSelectorDropdown from '@/components/layout/ThemeSelectorDropdown.astro';
|
||||
import siteConfig from '@/config/site.config';
|
||||
|
||||
export interface NavItem {
|
||||
label: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export interface HeaderAction {
|
||||
icon: string;
|
||||
href: string;
|
||||
label: string;
|
||||
iconOnly?: boolean;
|
||||
target?: string;
|
||||
}
|
||||
|
||||
interface Props extends HTMLAttributes<'header'> {
|
||||
/** Layout style: default (logo left, nav right), centered (logo center), minimal (logo + cta only) */
|
||||
layout?: 'default' | 'centered' | 'minimal';
|
||||
/** Position behavior */
|
||||
position?: 'fixed' | 'sticky' | 'static';
|
||||
/** Header height */
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
/** Background variant */
|
||||
variant?: 'default' | 'solid' | 'transparent';
|
||||
/** Color scheme for text/icons - use 'invert' for dark backgrounds */
|
||||
colorScheme?: 'default' | 'invert';
|
||||
/** Shape: 'bar' (full-width, default) or 'floating' (centered capsule) */
|
||||
shape?: 'bar' | 'floating';
|
||||
/** Override default navigation (replaces getNavRoutes()) */
|
||||
nav?: NavItem[];
|
||||
/** Additional navigation items (e.g., #features for landing pages) */
|
||||
extraNav?: NavItem[];
|
||||
/** Show CTA button */
|
||||
showCta?: boolean;
|
||||
/** CTA button configuration */
|
||||
cta?: { label?: string; href?: string; icon?: string };
|
||||
/** Action buttons (GitHub, etc.) */
|
||||
actions?: HeaderAction[];
|
||||
/** Show theme toggle (default: true) */
|
||||
showThemeToggle?: boolean;
|
||||
/** Show colour-theme selector swatches */
|
||||
showThemeSelector?: boolean;
|
||||
/** Show mobile menu (default: true) */
|
||||
showMobileMenu?: boolean;
|
||||
/** Show active state for current page (default: true) */
|
||||
showActiveState?: boolean;
|
||||
/** Logo text override */
|
||||
logoText?: string;
|
||||
/** Hide logo entirely */
|
||||
hideLogo?: boolean;
|
||||
/** Show language switcher */
|
||||
showLanguageSwitcher?: boolean;
|
||||
/** Show social icon links (desktop/tablet only, reads from siteConfig.socialLinks) */
|
||||
showSocialLinks?: boolean;
|
||||
/** Show scroll progress bar at the bottom of the header */
|
||||
showScrollProgress?: boolean;
|
||||
/** Position of the scroll progress bar: 'top' (above header) or 'bottom' (below header, default) */
|
||||
scrollProgressPosition?: 'top' | 'bottom';
|
||||
}
|
||||
|
||||
const {
|
||||
layout = 'default',
|
||||
position = 'fixed',
|
||||
size = 'lg',
|
||||
variant = 'solid',
|
||||
colorScheme = 'default',
|
||||
shape = 'bar',
|
||||
nav,
|
||||
extraNav = [],
|
||||
showCta = false,
|
||||
cta = { label: 'Start a project', href: '/contact' },
|
||||
actions = [],
|
||||
showThemeToggle = true,
|
||||
showThemeSelector = false,
|
||||
showMobileMenu = true,
|
||||
showSocialLinks = false,
|
||||
showActiveState = true,
|
||||
showScrollProgress = false,
|
||||
scrollProgressPosition = 'bottom',
|
||||
logoText,
|
||||
hideLogo = false,
|
||||
class: className,
|
||||
...attrs
|
||||
} = Astro.props;
|
||||
|
||||
// Shape + color scheme helpers
|
||||
const isFloating = shape === 'floating';
|
||||
const isInvert = colorScheme === 'invert';
|
||||
|
||||
// Get navigation items
|
||||
const defaultNav = getNavItems().map((item: NavConfigItem) => ({
|
||||
label: item.label,
|
||||
href: item.href,
|
||||
}));
|
||||
const navItems: NavItem[] = nav || [...extraNav, ...defaultNav];
|
||||
|
||||
// Current path for active state
|
||||
const currentPath = Astro.url.pathname;
|
||||
|
||||
// Check if we're on the landing page
|
||||
const isLandingPage = currentPath === '/';
|
||||
|
||||
// Process CTA href for landing page anchor links
|
||||
const ctaHref = cta.href?.startsWith('#') && !isLandingPage ? `/${cta.href}` : cta.href;
|
||||
|
||||
// Check slots
|
||||
const hasLogoSlot = Astro.slots.has('logo');
|
||||
const hasNavSlot = Astro.slots.has('nav');
|
||||
const hasActionsSlot = Astro.slots.has('actions');
|
||||
const hasMobileMenuSlot = Astro.slots.has('mobile-menu');
|
||||
|
||||
// Compute header classes
|
||||
const headerClasses = cn(
|
||||
headerVariants({ position, variant, shape }),
|
||||
isInvert && !isFloating && 'invert-section',
|
||||
className
|
||||
);
|
||||
|
||||
// Compute inner container classes
|
||||
const innerClasses = headerInnerVariants({ size, shape });
|
||||
|
||||
// Check if a nav item is active
|
||||
function isActive(href: string): boolean {
|
||||
if (!showActiveState) return false;
|
||||
if (href.startsWith('#')) return false;
|
||||
return currentPath === href || currentPath.startsWith(href + '/');
|
||||
}
|
||||
|
||||
// Map a social URL to its icon name + accessible label
|
||||
function getSocialIconData(url: string): { icon: string; label: string } {
|
||||
if (url.includes('github.com')) return { icon: 'github', label: 'GitHub' };
|
||||
if (url.includes('instagram.com')) return { icon: 'instagram', label: 'Instagram' };
|
||||
if (url.includes('x.com') || url.includes('twitter.com')) return { icon: 'x-twitter', label: 'X' };
|
||||
if (url.includes('linkedin.com')) return { icon: 'linkedin', label: 'LinkedIn' };
|
||||
if (url.includes('bsky.app')) return { icon: 'bluesky', label: 'Bluesky' };
|
||||
return { icon: 'link', label: 'Social' };
|
||||
}
|
||||
|
||||
// Generate unique ID for this header instance
|
||||
const menuId = `mobile-menu-${Math.random().toString(36).slice(2, 9)}`;
|
||||
const buttonId = `${menuId}-button`;
|
||||
---
|
||||
|
||||
<header
|
||||
class={headerClasses}
|
||||
data-menu-id={menuId}
|
||||
data-button-id={buttonId}
|
||||
data-header-shape={shape}
|
||||
data-header-variant={variant}
|
||||
data-header-color-scheme={colorScheme}
|
||||
{...attrs}
|
||||
>
|
||||
<div class={innerClasses}>
|
||||
{/* Logo */}
|
||||
{
|
||||
!hideLogo &&
|
||||
(hasLogoSlot ? (
|
||||
<slot name="logo" />
|
||||
) : (
|
||||
<a href="/" class="flex items-center gap-2">
|
||||
<Logo size={size === 'lg' ? 'lg' : 'md'} forceDark={isInvert} />
|
||||
<span
|
||||
class={cn(
|
||||
'font-display text-xl font-bold tracking-tight',
|
||||
isFloating ? 'hdr-logo-text' : (isInvert ? 'text-on-invert' : 'text-brand-500')
|
||||
)}
|
||||
>
|
||||
{logoText || siteConfig.name}
|
||||
</span>
|
||||
</a>
|
||||
))
|
||||
}
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
{
|
||||
layout !== 'minimal' &&
|
||||
(hasNavSlot ? (
|
||||
<nav class="hidden items-center gap-1 md:flex" aria-label="Main navigation">
|
||||
<slot name="nav" />
|
||||
</nav>
|
||||
) : (
|
||||
<nav class="hidden items-center gap-1 md:flex" aria-label="Main navigation">
|
||||
{navItems.map(({ label, href }) => (
|
||||
<a
|
||||
href={href.startsWith('#') && !isLandingPage ? `/${href}` : href}
|
||||
class={cn(
|
||||
'nav-link relative rounded-md px-3 py-2 text-sm',
|
||||
'transition-all duration-(--transition-fast)',
|
||||
isFloating && 'hdr-invert-text',
|
||||
isFloating
|
||||
? (isActive(href)
|
||||
? 'hdr-nav-active font-semibold'
|
||||
: 'font-medium opacity-80 hover:opacity-100')
|
||||
: (isActive(href)
|
||||
? 'nav-link-active font-semibold text-foreground bg-secondary'
|
||||
: 'nav-link-inactive font-medium text-foreground-muted hover:text-foreground hover:bg-secondary/70')
|
||||
)}
|
||||
aria-current={isActive(href) ? 'page' : undefined}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
))
|
||||
}
|
||||
|
||||
{/* Actions Area */}
|
||||
<div class="flex items-center gap-2 justify-self-end">
|
||||
{
|
||||
hasActionsSlot ? (
|
||||
<slot name="actions" />
|
||||
) : (
|
||||
<>
|
||||
{showThemeToggle && (
|
||||
<ThemeToggle class={isFloating ? 'hdr-invert-text' : undefined} />
|
||||
)}
|
||||
|
||||
{showThemeSelector && (
|
||||
<div class="hidden md:flex">
|
||||
<ThemeSelectorDropdown class={isFloating ? 'hdr-invert-text' : undefined} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showSocialLinks && siteConfig.socialLinks.length > 0 && (
|
||||
<div class="hidden md:flex items-center gap-0.5">
|
||||
{siteConfig.socialLinks.map((url) => {
|
||||
const { icon, label } = getSocialIconData(url);
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={label}
|
||||
class={cn(
|
||||
'rounded-md p-2 transition-colors duration-(--transition-fast)',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
isFloating
|
||||
? 'hdr-invert-text'
|
||||
: 'text-foreground-muted hover:text-foreground hover:bg-secondary/70'
|
||||
)}
|
||||
>
|
||||
<Icon name={icon} size="md" />
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actions.map((action) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon={action.iconOnly}
|
||||
href={action.href}
|
||||
target={action.target}
|
||||
aria-label={action.label}
|
||||
class={isFloating ? 'hdr-invert-text' : undefined}
|
||||
>
|
||||
<Icon name={action.icon} size="sm" />
|
||||
{!action.iconOnly && action.label}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
{showCta && (
|
||||
<div class="hidden md:flex">
|
||||
<Button
|
||||
size="sm"
|
||||
href={ctaHref}
|
||||
target={ctaHref?.startsWith('http') ? '_blank' : undefined}
|
||||
class={cn('hdr-cta-brand', isFloating ? 'hdr-invert-cta' : undefined)}
|
||||
>
|
||||
{cta.icon && <Icon name={cta.icon} size="sm" />}
|
||||
{cta.label}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
{/* Mobile Menu Toggle */}
|
||||
{
|
||||
showMobileMenu && layout !== 'minimal' && (
|
||||
<button
|
||||
type="button"
|
||||
id={buttonId}
|
||||
class={cn(
|
||||
'inline-flex items-center justify-center rounded-md p-2 md:hidden',
|
||||
'transition-colors',
|
||||
'focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none',
|
||||
isFloating
|
||||
? 'hdr-invert-text'
|
||||
: 'text-foreground-muted hover:text-foreground hover:bg-secondary'
|
||||
)}
|
||||
aria-expanded="false"
|
||||
aria-controls={menuId}
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
<span class="menu-icon">
|
||||
<Icon name="menu" size="md" />
|
||||
</span>
|
||||
<span class="close-icon hidden">
|
||||
<Icon name="x" size="md" />
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scroll Progress Bar */}
|
||||
{showScrollProgress && (
|
||||
<div
|
||||
id="scroll-progress-bar"
|
||||
class={`absolute left-0 h-[2px] w-0 bg-brand-500 transition-none ${scrollProgressPosition === 'top' ? 'top-0' : 'bottom-0'}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mobile Menu */}
|
||||
{
|
||||
showMobileMenu &&
|
||||
layout !== 'minimal' &&
|
||||
(hasMobileMenuSlot ? (
|
||||
<div
|
||||
id={menuId}
|
||||
class={cn(
|
||||
'hidden origin-top scale-y-0 opacity-0 shadow-[0_8px_24px_-12px_rgba(0,0,0,0.12)] md:hidden',
|
||||
isFloating
|
||||
? 'rounded-b-2xl bg-background/95 backdrop-blur-xl'
|
||||
: 'border-border bg-background border-t'
|
||||
)}
|
||||
role="navigation"
|
||||
aria-label="Mobile navigation"
|
||||
>
|
||||
<slot name="mobile-menu" />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
id={menuId}
|
||||
class={cn(
|
||||
'hidden origin-top scale-y-0 opacity-0 shadow-[0_8px_24px_-12px_rgba(0,0,0,0.12)] md:hidden',
|
||||
isFloating
|
||||
? 'rounded-b-2xl bg-background/95 backdrop-blur-xl'
|
||||
: 'border-border bg-background border-t'
|
||||
)}
|
||||
role="navigation"
|
||||
aria-label="Mobile navigation"
|
||||
>
|
||||
<div class={cn(
|
||||
'space-y-1 py-4',
|
||||
isFloating ? 'px-4' : 'mx-auto max-w-6xl px-6'
|
||||
)}>
|
||||
{navItems.map(({ label, href }) => (
|
||||
<a
|
||||
href={href.startsWith('#') && !isLandingPage ? `/${href}` : href}
|
||||
class={cn(
|
||||
'mobile-nav-link block rounded-md px-3 py-2 text-sm',
|
||||
'transition-all duration-(--transition-fast)',
|
||||
isActive(href)
|
||||
? 'mobile-nav-link-active bg-secondary text-foreground font-semibold'
|
||||
: 'mobile-nav-link-inactive text-foreground-muted hover:bg-secondary/70 hover:text-foreground font-medium'
|
||||
)}
|
||||
aria-current={isActive(href) ? 'page' : undefined}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
))}
|
||||
|
||||
{showCta && (
|
||||
<div class="border-border mt-3 border-t pt-3">
|
||||
<Button fullWidth href={ctaHref} target={ctaHref?.startsWith('http') ? '_blank' : undefined}>
|
||||
{cta.label}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showThemeSelector && (
|
||||
<div class="border-border mt-3 border-t pt-3">
|
||||
<div class="flex items-center justify-between px-1">
|
||||
<span class="text-sm text-foreground-muted">Colour theme</span>
|
||||
<ThemeSelector />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</header>
|
||||
|
||||
{/* Mobile Menu Backdrop - positioned outside header to blur page content */}
|
||||
{
|
||||
showMobileMenu && layout !== 'minimal' && (
|
||||
<div
|
||||
id={`${menuId}-backdrop`}
|
||||
class="pointer-events-none fixed inset-0 z-40 opacity-0 transition-opacity duration-200 md:hidden"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
<script>
|
||||
function initMobileMenu() {
|
||||
const menuHeaders = document.querySelectorAll<HTMLElement>('header[data-menu-id]');
|
||||
menuHeaders.forEach((header) => {
|
||||
const menuId = header.dataset.menuId!;
|
||||
const buttonId = header.dataset.buttonId!;
|
||||
const isFloating = header.dataset.headerShape === 'floating';
|
||||
|
||||
const button = document.getElementById(buttonId);
|
||||
const menu = document.getElementById(menuId);
|
||||
const backdrop = document.getElementById(`${menuId}-backdrop`);
|
||||
const menuIcon = button?.querySelector('.menu-icon');
|
||||
const closeIcon = button?.querySelector('.close-icon');
|
||||
|
||||
if (!button || !menu || !menuIcon || !closeIcon) return;
|
||||
if (button.dataset.menuInit) return;
|
||||
button.dataset.menuInit = 'true';
|
||||
|
||||
let isOpen = false;
|
||||
let isAnimating = false;
|
||||
|
||||
function open() {
|
||||
if (isOpen || isAnimating) return;
|
||||
isAnimating = true;
|
||||
isOpen = true;
|
||||
|
||||
button!.setAttribute('aria-expanded', 'true');
|
||||
menuIcon!.classList.add('hidden');
|
||||
closeIcon!.classList.remove('hidden');
|
||||
|
||||
if (isFloating) {
|
||||
// Force scrolled state + flatten bottom corners
|
||||
header.setAttribute('data-scrolled', '');
|
||||
header.classList.remove('rounded-2xl');
|
||||
header.classList.add('rounded-t-2xl');
|
||||
} else {
|
||||
header.classList.add('!bg-background');
|
||||
}
|
||||
|
||||
// Fade out and blur the page content
|
||||
const mainContent = document.querySelector('main');
|
||||
const footer = document.querySelector('footer');
|
||||
if (mainContent) mainContent.classList.add('mobile-menu-blur');
|
||||
if (footer) footer.classList.add('mobile-menu-blur');
|
||||
|
||||
// Show menu and backdrop with animations
|
||||
menu!.classList.remove('hidden', 'animate-menu-up', 'opacity-0', 'scale-y-0');
|
||||
menu!.classList.add('animate-menu-down');
|
||||
if (backdrop) {
|
||||
backdrop.classList.remove('pointer-events-none', 'animate-backdrop-out');
|
||||
backdrop.classList.add('animate-backdrop');
|
||||
}
|
||||
|
||||
isAnimating = false;
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (!isOpen || isAnimating) return;
|
||||
isAnimating = true;
|
||||
|
||||
button!.setAttribute('aria-expanded', 'false');
|
||||
menuIcon!.classList.remove('hidden');
|
||||
closeIcon!.classList.add('hidden');
|
||||
|
||||
// Start closing animation
|
||||
menu!.classList.remove('animate-menu-down');
|
||||
menu!.classList.add('animate-menu-up');
|
||||
if (backdrop) {
|
||||
backdrop.classList.remove('animate-backdrop');
|
||||
backdrop.classList.add('animate-backdrop-out');
|
||||
}
|
||||
|
||||
// Restore page content
|
||||
const mainContent = document.querySelector('main');
|
||||
const footer = document.querySelector('footer');
|
||||
if (mainContent) mainContent.classList.remove('mobile-menu-blur');
|
||||
if (footer) footer.classList.remove('mobile-menu-blur');
|
||||
|
||||
// Wait for animation to complete before hiding
|
||||
setTimeout(() => {
|
||||
menu!.classList.add('hidden', 'opacity-0', 'scale-y-0');
|
||||
if (backdrop) {
|
||||
backdrop.classList.add('pointer-events-none');
|
||||
}
|
||||
|
||||
if (isFloating) {
|
||||
// Restore rounded corners
|
||||
header.classList.remove('rounded-t-2xl');
|
||||
header.classList.add('rounded-2xl');
|
||||
// Only remove scrolled if actually at top
|
||||
if (window.scrollY <= 60) {
|
||||
header.removeAttribute('data-scrolled');
|
||||
}
|
||||
} else {
|
||||
header.classList.remove('!bg-background');
|
||||
}
|
||||
|
||||
isOpen = false;
|
||||
isAnimating = false;
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (isOpen) {
|
||||
close();
|
||||
} else {
|
||||
open();
|
||||
}
|
||||
}
|
||||
|
||||
button.addEventListener('click', toggle);
|
||||
|
||||
// Close on backdrop click
|
||||
if (backdrop) {
|
||||
backdrop.addEventListener('click', close);
|
||||
}
|
||||
|
||||
// Close on Escape key
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
close();
|
||||
}
|
||||
});
|
||||
|
||||
// Close when clicking on mobile menu links
|
||||
menu.querySelectorAll('a').forEach((link) => {
|
||||
link.addEventListener('click', close);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
initMobileMenu();
|
||||
document.addEventListener('astro:page-load', initMobileMenu);
|
||||
document.addEventListener('astro:after-swap', initMobileMenu);
|
||||
</script>
|
||||
|
||||
<script>
|
||||
const SCROLL_THRESHOLD = 60;
|
||||
const BAR_SCROLLED_CLASSES = ['bg-background/80', 'backdrop-blur-lg', 'border-b', 'border-border/50'];
|
||||
|
||||
function initScrollWatcher() {
|
||||
const scrollHeaders = document.querySelectorAll<HTMLElement>('header[data-header-shape="floating"], header[data-header-shape="bar"]');
|
||||
scrollHeaders.forEach((header) => {
|
||||
if (header.dataset.scrollInit) return;
|
||||
header.dataset.scrollInit = 'true';
|
||||
|
||||
const isBar = header.dataset.headerShape === 'bar';
|
||||
const isTransparentBar = isBar && header.dataset.headerVariant === 'transparent';
|
||||
let ticking = false;
|
||||
|
||||
function onScroll() {
|
||||
if (ticking) return;
|
||||
ticking = true;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (window.scrollY > SCROLL_THRESHOLD) {
|
||||
header.setAttribute('data-scrolled', '');
|
||||
if (isTransparentBar) {
|
||||
header.classList.add(...BAR_SCROLLED_CLASSES);
|
||||
header.classList.remove('bg-transparent');
|
||||
}
|
||||
} else {
|
||||
// Don't remove if mobile menu is open
|
||||
const menuId = header.dataset.menuId;
|
||||
const menu = menuId ? document.getElementById(menuId) : null;
|
||||
const menuOpen = menu && !menu.classList.contains('hidden');
|
||||
if (!menuOpen) {
|
||||
header.removeAttribute('data-scrolled');
|
||||
if (isTransparentBar) {
|
||||
header.classList.remove(...BAR_SCROLLED_CLASSES);
|
||||
header.classList.add('bg-transparent');
|
||||
}
|
||||
}
|
||||
}
|
||||
ticking = false;
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
|
||||
// Set initial state
|
||||
onScroll();
|
||||
});
|
||||
}
|
||||
|
||||
initScrollWatcher();
|
||||
document.addEventListener('astro:page-load', initScrollWatcher);
|
||||
document.addEventListener('astro:after-swap', initScrollWatcher);
|
||||
</script>
|
||||
|
||||
<script>
|
||||
function initScrollProgress() {
|
||||
const bar = document.getElementById('scroll-progress-bar');
|
||||
if (!bar) return;
|
||||
if (bar.dataset.progressInit) return;
|
||||
bar.dataset.progressInit = 'true';
|
||||
|
||||
let ticking = false;
|
||||
|
||||
function update() {
|
||||
if (!bar) return;
|
||||
const scrollTop = window.scrollY;
|
||||
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
|
||||
const pct = docHeight > 0 ? (scrollTop / docHeight) * 100 : 0;
|
||||
bar!.style.width = `${pct}%`;
|
||||
ticking = false;
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', () => {
|
||||
if (!ticking) {
|
||||
ticking = true;
|
||||
requestAnimationFrame(update);
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
update();
|
||||
}
|
||||
|
||||
initScrollProgress();
|
||||
document.addEventListener('astro:page-load', initScrollProgress);
|
||||
document.addEventListener('astro:after-swap', initScrollProgress);
|
||||
</script>
|
||||
|
||||
<style is:global>
|
||||
.mobile-menu-blur {
|
||||
opacity: 0.3;
|
||||
filter: blur(4px);
|
||||
transition: opacity 200ms, filter 200ms;
|
||||
}
|
||||
|
||||
/* ===== Floating header: scroll state ===== */
|
||||
[data-header-shape="floating"][data-scrolled] {
|
||||
background: color-mix(in oklch, var(--color-background) 92%, transparent);
|
||||
backdrop-filter: blur(24px);
|
||||
border-color: var(--color-border);
|
||||
box-shadow: 0 4px 20px -6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* ===== Floating header: color flip (invert → normal on scroll) ===== */
|
||||
|
||||
/* Text elements: on-invert → foreground */
|
||||
[data-header-shape="floating"][data-header-color-scheme="invert"] .hdr-invert-text {
|
||||
color: var(--color-on-invert);
|
||||
transition: color 300ms;
|
||||
}
|
||||
[data-header-shape="floating"][data-header-color-scheme="invert"][data-scrolled] .hdr-invert-text {
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
|
||||
/* Logo text: on-invert → brand-500 */
|
||||
[data-header-shape="floating"][data-header-color-scheme="invert"] .hdr-logo-text {
|
||||
color: var(--color-on-invert);
|
||||
transition: color 300ms;
|
||||
}
|
||||
[data-header-shape="floating"][data-header-color-scheme="invert"][data-scrolled] .hdr-logo-text {
|
||||
color: var(--color-brand-500);
|
||||
}
|
||||
|
||||
/* Non-invert floating: use normal colors */
|
||||
[data-header-shape="floating"][data-header-color-scheme="default"] .hdr-logo-text {
|
||||
color: var(--color-brand-500);
|
||||
}
|
||||
[data-header-shape="floating"][data-header-color-scheme="default"] .hdr-invert-text {
|
||||
color: var(--color-foreground-muted);
|
||||
transition: color 300ms;
|
||||
}
|
||||
[data-header-shape="floating"][data-header-color-scheme="default"] .hdr-invert-text:hover {
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
|
||||
/* ===== Floating nav link underline indicators ===== */
|
||||
[data-header-shape="floating"] .nav-link::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
left: 50%;
|
||||
right: 50%;
|
||||
height: 2px;
|
||||
background: currentColor;
|
||||
border-radius: 1px;
|
||||
transition: left 200ms, right 200ms;
|
||||
}
|
||||
|
||||
[data-header-shape="floating"] .nav-link:hover::after,
|
||||
[data-header-shape="floating"] .nav-link.hdr-nav-active::after {
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
}
|
||||
|
||||
/* ===== CTA: invert color flip ===== */
|
||||
[data-header-shape="floating"][data-header-color-scheme="invert"] .hdr-invert-cta {
|
||||
background: white;
|
||||
color: #111;
|
||||
border: 1px solid rgba(0, 0, 0, 0.15);
|
||||
transition: background 300ms, color 300ms, border-color 300ms;
|
||||
}
|
||||
[data-header-shape="floating"][data-header-color-scheme="invert"] .hdr-invert-cta:hover {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
[data-header-shape="floating"][data-header-color-scheme="invert"][data-scrolled] .hdr-invert-cta {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-foreground);
|
||||
}
|
||||
[data-header-shape="floating"][data-header-color-scheme="invert"][data-scrolled] .hdr-invert-cta:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* ===== Reduced motion ===== */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-header-shape="floating"],
|
||||
[data-header-shape="floating"] .hdr-invert-text,
|
||||
[data-header-shape="floating"] .hdr-logo-text,
|
||||
[data-header-shape="floating"] .hdr-invert-cta,
|
||||
[data-header-shape="floating"] .nav-link::after {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,135 @@
|
||||
---
|
||||
/**
|
||||
* ThemeSelector
|
||||
* A row of five colour swatches that switches the active colour theme at
|
||||
* runtime by writing `data-theme` on <html> and persisting to localStorage.
|
||||
*
|
||||
* Usage in Header: <ThemeSelector />
|
||||
* Pass `class` to tint the label colour for floating / inverted headers.
|
||||
*/
|
||||
interface Props {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const { class: className } = Astro.props;
|
||||
|
||||
// All 13 themes in Tailwind color order
|
||||
const themes = [
|
||||
{ id: 'orange', name: 'Orange', color: 'oklch(62.5% 0.22 38)' },
|
||||
{ id: 'amber', name: 'Amber', color: 'oklch(68% 0.19 75)' },
|
||||
{ id: 'lime', name: 'Lime', color: 'oklch(64% 0.27 130)' },
|
||||
{ id: 'emerald', name: 'Emerald', color: 'oklch(62.5% 0.22 160)' },
|
||||
{ id: 'teal', name: 'Teal', color: 'oklch(62.5% 0.22 190)' },
|
||||
{ id: 'cyan', name: 'Cyan', color: 'oklch(65% 0.22 200)' },
|
||||
{ id: 'sky', name: 'Sky', color: 'oklch(67% 0.21 222)' },
|
||||
{ id: 'blue', name: 'Blue', color: 'oklch(62.5% 0.22 255)' },
|
||||
{ id: 'indigo', name: 'Indigo', color: 'oklch(60% 0.24 264)' },
|
||||
{ id: 'violet', name: 'Violet', color: 'oklch(62.5% 0.26 277)' },
|
||||
{ id: 'purple', name: 'Purple', color: 'oklch(62.5% 0.25 303)' },
|
||||
{ id: 'magenta', name: 'Magenta', color: 'oklch(58% 0.28 330)' },
|
||||
];
|
||||
---
|
||||
|
||||
<div
|
||||
class:list={['theme-selector flex flex-wrap items-center gap-1', className]}
|
||||
role="group"
|
||||
aria-label="Select colour theme"
|
||||
>
|
||||
{themes.map((theme) => (
|
||||
<button
|
||||
type="button"
|
||||
class="theme-swatch relative h-3.5 w-3.5 rounded-full transition-transform duration-150 focus-visible:outline-none"
|
||||
data-theme-id={theme.id}
|
||||
style={`background-color:${theme.color};--swatch:${theme.color}`}
|
||||
title={theme.name}
|
||||
aria-label={`${theme.name} theme`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Inactive: subtle border + dimmed so active pops without extra decoration */
|
||||
.theme-swatch {
|
||||
box-shadow: inset 0 0 0 1px oklch(0% 0 0 / 0.12);
|
||||
opacity: 0.55;
|
||||
transition:
|
||||
opacity 150ms ease,
|
||||
transform 150ms ease;
|
||||
}
|
||||
|
||||
/* Active: full opacity + slim underline pill in the swatch colour */
|
||||
.theme-swatch[data-active] {
|
||||
opacity: 1;
|
||||
box-shadow: inset 0 0 0 1px oklch(0% 0 0 / 0.12);
|
||||
}
|
||||
|
||||
.theme-swatch[data-active]::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -3px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 10px;
|
||||
height: 2px;
|
||||
background: var(--swatch);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
/* Hover: lift opacity on inactive */
|
||||
.theme-swatch:not([data-active]):hover {
|
||||
opacity: 0.85;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const STORAGE_KEY = 'color-theme';
|
||||
const DEFAULT_THEME = 'blue';
|
||||
|
||||
function getActiveTheme(): string {
|
||||
try {
|
||||
return sessionStorage.getItem(STORAGE_KEY) || DEFAULT_THEME;
|
||||
} catch {
|
||||
return DEFAULT_THEME;
|
||||
}
|
||||
}
|
||||
|
||||
function setTheme(id: string) {
|
||||
document.documentElement.setAttribute('data-theme', id);
|
||||
try { sessionStorage.setItem(STORAGE_KEY, id); } catch { /* private mode */ }
|
||||
|
||||
// Update every swatch on the page (handles multiple selector instances)
|
||||
const swatches = document.querySelectorAll<HTMLButtonElement>('.theme-swatch');
|
||||
swatches.forEach((btn) => {
|
||||
if (btn.dataset.themeId === id) {
|
||||
btn.setAttribute('data-active', '');
|
||||
} else {
|
||||
btn.removeAttribute('data-active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initThemeSelector() {
|
||||
const active = getActiveTheme();
|
||||
|
||||
const activeSwatches = document.querySelectorAll<HTMLButtonElement>('.theme-swatch');
|
||||
activeSwatches.forEach((btn) => {
|
||||
// Mark current active
|
||||
if (btn.dataset.themeId === active) {
|
||||
btn.setAttribute('data-active', '');
|
||||
} else {
|
||||
btn.removeAttribute('data-active');
|
||||
}
|
||||
|
||||
// Guard against double-binding across re-runs
|
||||
if (btn.dataset.selectorInit) return;
|
||||
btn.dataset.selectorInit = 'true';
|
||||
|
||||
btn.addEventListener('click', () => setTheme(btn.dataset.themeId!));
|
||||
});
|
||||
}
|
||||
|
||||
initThemeSelector();
|
||||
document.addEventListener('astro:page-load', initThemeSelector);
|
||||
document.addEventListener('astro:after-swap', initThemeSelector);
|
||||
</script>
|
||||
@@ -0,0 +1,222 @@
|
||||
---
|
||||
/**
|
||||
* ThemeSelectorDropdown
|
||||
* A dropdown button for desktop that exposes the colour-theme swatches.
|
||||
* The mobile menu still uses the flat ThemeSelector component.
|
||||
*/
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const { class: className } = Astro.props;
|
||||
|
||||
// All 13 themes in Tailwind color order
|
||||
const themes = [
|
||||
{ id: 'orange', name: 'Orange', color: 'oklch(62.5% 0.22 38)' },
|
||||
{ id: 'amber', name: 'Amber', color: 'oklch(68% 0.19 75)' },
|
||||
{ id: 'lime', name: 'Lime', color: 'oklch(64% 0.27 130)' },
|
||||
{ id: 'emerald', name: 'Emerald', color: 'oklch(62.5% 0.22 160)' },
|
||||
{ id: 'teal', name: 'Teal', color: 'oklch(62.5% 0.22 190)' },
|
||||
{ id: 'cyan', name: 'Cyan', color: 'oklch(65% 0.22 200)' },
|
||||
{ id: 'sky', name: 'Sky', color: 'oklch(67% 0.21 222)' },
|
||||
{ id: 'blue', name: 'Blue', color: 'oklch(62.5% 0.22 255)' },
|
||||
{ id: 'indigo', name: 'Indigo', color: 'oklch(60% 0.24 264)' },
|
||||
{ id: 'violet', name: 'Violet', color: 'oklch(62.5% 0.26 277)' },
|
||||
{ id: 'purple', name: 'Purple', color: 'oklch(62.5% 0.25 303)' },
|
||||
{ id: 'magenta', name: 'Magenta', color: 'oklch(58% 0.28 330)' },
|
||||
];
|
||||
---
|
||||
|
||||
<div class={cn('relative theme-dropdown-wrapper', className)}>
|
||||
<!-- Trigger button -->
|
||||
<button
|
||||
type="button"
|
||||
id="theme-dropdown-trigger"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
aria-label="Select colour theme"
|
||||
class={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-full border border-border-strong px-2.5 py-1.5',
|
||||
'text-foreground-muted hover:text-foreground hover:bg-secondary',
|
||||
'transition-colors duration-(--transition-fast)',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2'
|
||||
)}
|
||||
>
|
||||
<!-- Active theme dot -->
|
||||
<span
|
||||
class="h-3.5 w-3.5 rounded-full block flex-shrink-0"
|
||||
style="background-color: var(--color-brand-500)"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<!-- Chevron — inline, rotates on open -->
|
||||
<svg
|
||||
class="theme-chevron h-3 w-3 flex-shrink-0 transition-transform duration-200"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="m6 9 6 6 6-6"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Dropdown panel -->
|
||||
<div
|
||||
id="theme-dropdown-panel"
|
||||
role="dialog"
|
||||
aria-label="Colour theme"
|
||||
class={cn(
|
||||
'absolute right-0 top-full mt-2 z-50',
|
||||
'w-52 rounded-xl border border-border bg-background shadow-lg',
|
||||
'p-2.5',
|
||||
'hidden'
|
||||
)}
|
||||
>
|
||||
<p class="text-xs font-medium text-foreground-muted mb-2.5 px-0.5">Colour theme</p>
|
||||
<div class="grid grid-cols-4 gap-1.5">
|
||||
{themes.map((theme) => (
|
||||
<button
|
||||
type="button"
|
||||
class="theme-swatch-dd group flex flex-col items-center gap-1 rounded-lg p-1 hover:bg-secondary transition-colors duration-(--transition-fast) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
data-theme-id={theme.id}
|
||||
aria-label={`${theme.name} theme`}
|
||||
>
|
||||
<span
|
||||
class="h-4 w-4 rounded-full block"
|
||||
style={`background-color:${theme.color}`}
|
||||
/>
|
||||
<span class="text-[9px] font-medium text-foreground-muted group-hover:text-foreground leading-none">
|
||||
{theme.name}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.theme-swatch-dd[data-active] {
|
||||
background-color: var(--color-secondary);
|
||||
}
|
||||
|
||||
.theme-swatch-dd[data-active] span:first-child {
|
||||
box-shadow: 0 0 0 2px var(--color-background), 0 0 0 4px var(--color-brand-500);
|
||||
}
|
||||
|
||||
.theme-swatch-dd[data-active] span:last-child {
|
||||
color: var(--color-foreground);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const STORAGE_KEY = 'color-theme';
|
||||
const DEFAULT_THEME = 'blue';
|
||||
|
||||
function getActiveTheme(): string {
|
||||
try {
|
||||
return sessionStorage.getItem(STORAGE_KEY) || DEFAULT_THEME;
|
||||
} catch {
|
||||
return DEFAULT_THEME;
|
||||
}
|
||||
}
|
||||
|
||||
function setTheme(id: string) {
|
||||
document.documentElement.setAttribute('data-theme', id);
|
||||
try { sessionStorage.setItem(STORAGE_KEY, id); } catch { /* private mode */ }
|
||||
|
||||
// Update dropdown swatches
|
||||
const ddSwatches = document.querySelectorAll<HTMLButtonElement>('.theme-swatch-dd');
|
||||
ddSwatches.forEach((btn) => {
|
||||
if (btn.dataset.themeId === id) {
|
||||
btn.setAttribute('data-active', '');
|
||||
} else {
|
||||
btn.removeAttribute('data-active');
|
||||
}
|
||||
});
|
||||
|
||||
// Also sync flat ThemeSelector swatches (mobile menu)
|
||||
const flatSwatches = document.querySelectorAll<HTMLButtonElement>('.theme-swatch');
|
||||
flatSwatches.forEach((btn) => {
|
||||
if (btn.dataset.themeId === id) {
|
||||
btn.setAttribute('data-active', '');
|
||||
} else {
|
||||
btn.removeAttribute('data-active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function closeDropdown() {
|
||||
const panel = document.getElementById('theme-dropdown-panel');
|
||||
const trigger = document.getElementById('theme-dropdown-trigger');
|
||||
panel?.classList.add('hidden');
|
||||
trigger?.setAttribute('aria-expanded', 'false');
|
||||
trigger?.querySelector('.theme-chevron')?.classList.remove('rotate-180');
|
||||
}
|
||||
|
||||
function initThemeDropdown() {
|
||||
const trigger = document.getElementById('theme-dropdown-trigger');
|
||||
const panel = document.getElementById('theme-dropdown-panel');
|
||||
if (!trigger || !panel) return;
|
||||
|
||||
// Guard against double-binding
|
||||
if (trigger.dataset.dropdownInit) return;
|
||||
trigger.dataset.dropdownInit = 'true';
|
||||
|
||||
const active = getActiveTheme();
|
||||
|
||||
// Mark active swatch
|
||||
const activeDdSwatches = document.querySelectorAll<HTMLButtonElement>('.theme-swatch-dd');
|
||||
activeDdSwatches.forEach((btn) => {
|
||||
if (btn.dataset.themeId === active) {
|
||||
btn.setAttribute('data-active', '');
|
||||
} else {
|
||||
btn.removeAttribute('data-active');
|
||||
}
|
||||
|
||||
if (!btn.dataset.selectorInit) {
|
||||
btn.dataset.selectorInit = 'true';
|
||||
btn.addEventListener('click', () => {
|
||||
setTheme(btn.dataset.themeId!);
|
||||
closeDropdown();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle dropdown
|
||||
trigger.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const isOpen = panel.classList.contains('hidden') === false;
|
||||
if (isOpen) {
|
||||
closeDropdown();
|
||||
} else {
|
||||
panel.classList.remove('hidden');
|
||||
trigger.setAttribute('aria-expanded', 'true');
|
||||
trigger.querySelector('.theme-chevron')?.classList.add('rotate-180');
|
||||
}
|
||||
});
|
||||
|
||||
// Close on outside click
|
||||
document.addEventListener('click', (e) => {
|
||||
const wrapper = trigger.closest('.theme-dropdown-wrapper');
|
||||
if (wrapper && !wrapper.contains(e.target as Node)) {
|
||||
closeDropdown();
|
||||
}
|
||||
});
|
||||
|
||||
// Close on Escape
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') closeDropdown();
|
||||
});
|
||||
}
|
||||
|
||||
initThemeDropdown();
|
||||
document.addEventListener('astro:page-load', initThemeDropdown);
|
||||
document.addEventListener('astro:after-swap', initThemeDropdown);
|
||||
</script>
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const { class: className } = Astro.props;
|
||||
---
|
||||
|
||||
<button
|
||||
type="button"
|
||||
id="theme-toggle"
|
||||
class={cn(
|
||||
'inline-flex items-center justify-center rounded-md p-2',
|
||||
'text-muted-foreground hover:text-foreground hover:bg-secondary',
|
||||
'transition-colors duration-(--transition-fast)',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
className
|
||||
)}
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
<!-- Sun icon (shown in dark mode) -->
|
||||
<svg
|
||||
class="h-5 w-5 hidden dark:block"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<!-- Moon icon (shown in light mode) -->
|
||||
<svg
|
||||
class="h-5 w-5 block dark:hidden"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<script>
|
||||
function initThemeToggle() {
|
||||
const toggle = document.getElementById('theme-toggle');
|
||||
|
||||
// Guard: skip if not found or already initialised on this element
|
||||
if (!toggle || toggle.dataset.themeInit) return;
|
||||
toggle.dataset.themeInit = 'true';
|
||||
|
||||
toggle.addEventListener('click', () => {
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
|
||||
if (isDark) {
|
||||
document.documentElement.classList.remove('dark');
|
||||
sessionStorage.setItem('theme', 'light');
|
||||
} else {
|
||||
document.documentElement.classList.add('dark');
|
||||
sessionStorage.removeItem('theme');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Run on initial load and after every view-transition swap
|
||||
initThemeToggle();
|
||||
document.addEventListener('astro:after-swap', initThemeToggle);
|
||||
</script>
|
||||
@@ -0,0 +1,30 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
export const footerVariants = cva('py-[var(--space-stack-lg)]', {
|
||||
variants: {
|
||||
background: {
|
||||
default: 'bg-background border-t border-border',
|
||||
secondary: 'bg-surface-secondary border-t border-border',
|
||||
invert: 'invert-section bg-background border-t border-border',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
background: 'default',
|
||||
},
|
||||
});
|
||||
|
||||
export const footerColumnGridVariants = cva('grid grid-cols-1 gap-[var(--space-stack-lg)]', {
|
||||
variants: {
|
||||
columns: {
|
||||
2: 'md:grid-cols-2',
|
||||
3: 'md:grid-cols-3',
|
||||
4: 'md:grid-cols-4',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
columns: 3,
|
||||
},
|
||||
});
|
||||
|
||||
export type FooterVariants = VariantProps<typeof footerVariants>;
|
||||
export type FooterColumnGridVariants = VariantProps<typeof footerColumnGridVariants>;
|
||||
@@ -0,0 +1,63 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
export const headerVariants = cva('z-50', {
|
||||
variants: {
|
||||
position: {
|
||||
fixed: 'fixed top-0 left-0 right-0',
|
||||
sticky: 'sticky top-0',
|
||||
static: 'relative',
|
||||
},
|
||||
variant: {
|
||||
default: 'bg-background/80 backdrop-blur-lg border-b border-border/50',
|
||||
solid: 'bg-background border-b border-border-strong',
|
||||
transparent: 'bg-transparent',
|
||||
},
|
||||
shape: {
|
||||
bar: 'w-full transition-[background,border-color,box-shadow,backdrop-filter] duration-300',
|
||||
floating: 'rounded-2xl transition-[background,border-color,box-shadow] duration-300',
|
||||
},
|
||||
},
|
||||
compoundVariants: [
|
||||
// Floating + fixed: centered with gap
|
||||
{ shape: 'floating', position: 'fixed', class: '!left-1/2 !right-auto -translate-x-1/2 w-[calc(100%-2rem)] max-w-6xl mt-4' },
|
||||
// Floating + sticky: centered with gap
|
||||
{ shape: 'floating', position: 'sticky', class: '!top-4 mx-auto max-w-6xl' },
|
||||
// Floating + static: centered
|
||||
{ shape: 'floating', position: 'static', class: 'mx-auto max-w-6xl' },
|
||||
// Floating + transparent: glass effect
|
||||
{ shape: 'floating', variant: 'transparent', class: 'bg-white/[0.06] backdrop-blur-xl border border-white/[0.08]' },
|
||||
// Floating + default: semi-transparent with blur
|
||||
{ shape: 'floating', variant: 'default', class: '!bg-background/80 backdrop-blur-xl !border border-border/50 !border-b-border/50' },
|
||||
// Floating + solid: opaque
|
||||
{ shape: 'floating', variant: 'solid', class: '!bg-background !border border-border !border-b-border' },
|
||||
],
|
||||
defaultVariants: {
|
||||
position: 'sticky',
|
||||
variant: 'default',
|
||||
shape: 'bar',
|
||||
},
|
||||
});
|
||||
|
||||
export const headerInnerVariants = cva(
|
||||
'flex items-center justify-between md:grid md:grid-cols-[1fr_auto_1fr]',
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
sm: 'h-12',
|
||||
md: 'h-14',
|
||||
lg: 'h-16',
|
||||
},
|
||||
shape: {
|
||||
bar: 'mx-auto max-w-6xl px-6',
|
||||
floating: 'px-4',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'md',
|
||||
shape: 'bar',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export type HeaderVariants = VariantProps<typeof headerVariants>;
|
||||
export type HeaderInnerVariants = VariantProps<typeof headerInnerVariants>;
|
||||
@@ -0,0 +1,124 @@
|
||||
---
|
||||
import Input from '@/components/ui/form/Input/Input.astro';
|
||||
import Textarea from '@/components/ui/form/Textarea/Textarea.astro';
|
||||
import Button from '@/components/ui/form/Button/Button.astro';
|
||||
import Icon from '@/components/ui/primitives/Icon/Icon.astro';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
interface Props {
|
||||
action?: string;
|
||||
successMessage?: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
action = '/api/contact',
|
||||
successMessage = 'Message sent successfully!',
|
||||
class: className,
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<form
|
||||
id="contact-form"
|
||||
action={action}
|
||||
method="POST"
|
||||
class={cn('space-y-6', className)}
|
||||
data-success-message={successMessage}
|
||||
>
|
||||
<!-- Name + Email side by side on sm+ -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Name"
|
||||
name="name"
|
||||
type="text"
|
||||
required
|
||||
autocomplete="name"
|
||||
/>
|
||||
<Input
|
||||
label="Email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
autocomplete="email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Subject"
|
||||
name="subject"
|
||||
type="text"
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label="Message"
|
||||
name="message"
|
||||
required
|
||||
rows={5}
|
||||
/>
|
||||
|
||||
<!-- Honeypot field for spam protection -->
|
||||
<div class="hidden" aria-hidden="true">
|
||||
<input type="text" name="honeypot" tabindex="-1" autocomplete="off" />
|
||||
</div>
|
||||
|
||||
<div id="form-message" class="hidden text-sm"></div>
|
||||
|
||||
<div class="flex justify-center">
|
||||
<Button type="submit" id="submit-button" class="gap-2">
|
||||
Send message
|
||||
<Icon name="arrow-right" size="sm" />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
function initContactForm() {
|
||||
const form = document.getElementById('contact-form') as HTMLFormElement;
|
||||
const button = document.getElementById('submit-button') as HTMLButtonElement;
|
||||
const message = document.getElementById('form-message') as HTMLDivElement;
|
||||
|
||||
if (!form || !button || !message) return;
|
||||
|
||||
const successMessage = form.dataset.successMessage || 'Message sent successfully!';
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
button.disabled = true;
|
||||
button.textContent = 'Sending…';
|
||||
message.classList.add('hidden');
|
||||
|
||||
try {
|
||||
const formData = new FormData(form);
|
||||
const response = await fetch(form.action, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
message.textContent = successMessage;
|
||||
message.className = 'text-sm text-success';
|
||||
form.reset();
|
||||
} else {
|
||||
const errors = data.errors
|
||||
? Object.values(data.errors).flat().join(', ')
|
||||
: 'Something went wrong';
|
||||
message.textContent = errors as string;
|
||||
message.className = 'text-sm text-destructive';
|
||||
}
|
||||
} catch {
|
||||
message.textContent = 'Failed to send message. Please try again.';
|
||||
message.className = 'text-sm text-destructive';
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.textContent = 'Send message';
|
||||
message.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
initContactForm();
|
||||
document.addEventListener('astro:after-swap', initContactForm);
|
||||
</script>
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
/**
|
||||
* EmptyState Pattern
|
||||
* Composition example: Icon + text + action for empty data states.
|
||||
* Shows how to compose UI primitives into a reusable pattern.
|
||||
*/
|
||||
import { cn } from '@/lib/cn';
|
||||
import Icon from '@/components/ui/primitives/Icon/Icon.astro';
|
||||
import Button from '@/components/ui/form/Button/Button.astro';
|
||||
|
||||
interface Props {
|
||||
/** Icon name to display */
|
||||
icon?: string;
|
||||
/** Title text */
|
||||
title: string;
|
||||
/** Description text */
|
||||
description?: string;
|
||||
/** Primary action button label */
|
||||
actionLabel?: string;
|
||||
/** Primary action button href */
|
||||
actionHref?: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
icon = 'inbox',
|
||||
title,
|
||||
description,
|
||||
actionLabel,
|
||||
actionHref,
|
||||
class: className,
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<div class={cn('flex flex-col items-center justify-center text-center py-12 px-4', className)}>
|
||||
<div class="w-12 h-12 rounded-xl bg-secondary flex items-center justify-center text-foreground-muted mb-4">
|
||||
<Icon name={icon} size="lg" />
|
||||
</div>
|
||||
<h3 class="text-base font-semibold text-foreground mb-1">{title}</h3>
|
||||
{description && (
|
||||
<p class="text-sm text-foreground-muted max-w-sm mb-4">{description}</p>
|
||||
)}
|
||||
{actionLabel && (
|
||||
<Button variant="secondary" size="sm" href={actionHref}>
|
||||
{actionLabel}
|
||||
</Button>
|
||||
)}
|
||||
<slot />
|
||||
</div>
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
import { cn } from '@/lib/cn';
|
||||
import { generateId } from '@/lib/utils';
|
||||
|
||||
interface Props {
|
||||
label?: string;
|
||||
error?: string;
|
||||
hint?: string;
|
||||
required?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const { label, error, hint, required = false, class: className } = Astro.props;
|
||||
|
||||
const fieldId = generateId('field');
|
||||
---
|
||||
|
||||
<div class={cn('space-y-1.5', className)}>
|
||||
{
|
||||
label && (
|
||||
<label for={fieldId} class="text-sm font-medium leading-none">
|
||||
{label}
|
||||
{required && <span class="text-destructive ml-0.5">*</span>}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
<slot name="input" id={fieldId} />
|
||||
|
||||
{
|
||||
error && (
|
||||
<p class="text-sm text-destructive" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
hint && !error && (
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{hint}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,98 @@
|
||||
---
|
||||
import Input from '@/components/ui/form/Input/Input.astro';
|
||||
import Button from '@/components/ui/form/Button/Button.astro';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
interface Props {
|
||||
action?: string;
|
||||
placeholder?: string;
|
||||
buttonText?: string;
|
||||
successMessage?: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
action = '/api/newsletter',
|
||||
placeholder = 'Enter your email',
|
||||
buttonText = 'Subscribe',
|
||||
successMessage = 'Thanks for subscribing!',
|
||||
class: className,
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<form
|
||||
class={cn('newsletter-form', className)}
|
||||
action={action}
|
||||
method="POST"
|
||||
data-success-message={successMessage}
|
||||
>
|
||||
<div class="flex flex-col sm:flex-row gap-3">
|
||||
<div class="flex-1">
|
||||
<Input
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder={placeholder}
|
||||
required
|
||||
autocomplete="email"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" class="newsletter-submit">
|
||||
{buttonText}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p class="newsletter-message hidden mt-3 text-sm"></p>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
function initNewsletterForms() {
|
||||
const forms = document.querySelectorAll<HTMLFormElement>('.newsletter-form');
|
||||
|
||||
forms.forEach((form) => {
|
||||
const button = form.querySelector('.newsletter-submit') as HTMLButtonElement;
|
||||
const message = form.querySelector('.newsletter-message') as HTMLParagraphElement;
|
||||
|
||||
if (!button || !message) return;
|
||||
|
||||
const successMessage = form.dataset.successMessage || 'Thanks for subscribing!';
|
||||
const originalText = button.textContent || 'Subscribe';
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
button.disabled = true;
|
||||
button.textContent = 'Subscribing...';
|
||||
message.classList.add('hidden');
|
||||
|
||||
try {
|
||||
const formData = new FormData(form);
|
||||
const response = await fetch(form.action, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
message.textContent = successMessage;
|
||||
message.className = 'newsletter-message mt-3 text-sm text-success';
|
||||
form.reset();
|
||||
} else {
|
||||
message.textContent = data.error || 'Something went wrong';
|
||||
message.className = 'newsletter-message mt-3 text-sm text-destructive';
|
||||
}
|
||||
} catch {
|
||||
message.textContent = 'Subscription failed. Please try again.';
|
||||
message.className = 'newsletter-message mt-3 text-sm text-destructive';
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.textContent = originalText;
|
||||
message.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
initNewsletterForms();
|
||||
document.addEventListener('astro:after-swap', initNewsletterForms);
|
||||
</script>
|
||||
@@ -0,0 +1,114 @@
|
||||
---
|
||||
/**
|
||||
* PasswordInput Pattern
|
||||
* Composition example: Input with show/hide password toggle.
|
||||
* Demonstrates building interactive patterns from UI primitives.
|
||||
*/
|
||||
import type { HTMLAttributes } from 'astro/types';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { generateId } from '@/lib/utils';
|
||||
import { inputVariants, inputSizeConfig } from '@/components/ui/form/Input/input.variants';
|
||||
import Icon from '@/components/ui/primitives/Icon/Icon.astro';
|
||||
|
||||
interface Props extends HTMLAttributes<'input'> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
hint?: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
placeholder?: string;
|
||||
class?: string;
|
||||
id?: string;
|
||||
autocomplete?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
label,
|
||||
error,
|
||||
hint,
|
||||
size = 'md',
|
||||
placeholder = 'Enter password',
|
||||
autocomplete = 'current-password',
|
||||
class: className,
|
||||
id,
|
||||
...rest
|
||||
} = Astro.props;
|
||||
|
||||
const inputId = id || generateId('password');
|
||||
const config = inputSizeConfig[size];
|
||||
|
||||
const inputStyles = cn(
|
||||
inputVariants({ size }),
|
||||
error && 'border-destructive focus-visible:ring-destructive',
|
||||
config.baseLeftPadding,
|
||||
config.trailingPadding
|
||||
);
|
||||
---
|
||||
|
||||
<div class={cn('space-y-1.5', className)}>
|
||||
{label && (
|
||||
<label for={inputId} class="text-sm font-medium leading-none">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div class="relative">
|
||||
<input
|
||||
type="password"
|
||||
id={inputId}
|
||||
class={inputStyles}
|
||||
placeholder={placeholder}
|
||||
autocomplete={autocomplete}
|
||||
aria-invalid={error ? 'true' : undefined}
|
||||
aria-describedby={error ? `${inputId}-error` : hint ? `${inputId}-hint` : undefined}
|
||||
data-password-input
|
||||
{...rest}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class={cn(
|
||||
'absolute right-0 top-0 flex items-center justify-center h-full',
|
||||
'text-foreground-muted hover:text-foreground transition-colors',
|
||||
config.iconWrapper
|
||||
)}
|
||||
data-password-toggle
|
||||
aria-label="Toggle password visibility"
|
||||
>
|
||||
<span data-icon-show><Icon name="eye" size="sm" /></span>
|
||||
<span data-icon-hide class="hidden"><Icon name="eye-off" size="sm" /></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p id={`${inputId}-error`} class="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
|
||||
{hint && !error && (
|
||||
<p id={`${inputId}-hint`} class="text-sm text-muted-foreground">{hint}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function initPasswordInputs() {
|
||||
document.querySelectorAll('[data-password-toggle]').forEach((el) => {
|
||||
const btn = el as HTMLElement;
|
||||
if (btn.dataset.initialized) return;
|
||||
btn.dataset.initialized = 'true';
|
||||
|
||||
btn.addEventListener('click', () => {
|
||||
const input = btn.parentElement?.querySelector('[data-password-input]') as HTMLInputElement;
|
||||
const showIcon = btn.querySelector('[data-icon-show]') as HTMLElement;
|
||||
const hideIcon = btn.querySelector('[data-icon-hide]') as HTMLElement;
|
||||
|
||||
if (!input || !showIcon || !hideIcon) return;
|
||||
|
||||
const isPassword = input.type === 'password';
|
||||
input.type = isPassword ? 'text' : 'password';
|
||||
showIcon.classList.toggle('hidden', isPassword);
|
||||
hideIcon.classList.toggle('hidden', !isPassword);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
initPasswordInputs();
|
||||
document.addEventListener('astro:page-load', initPasswordInputs);
|
||||
</script>
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
/**
|
||||
* SearchInput Pattern
|
||||
* Composition example: Input + Icon for a search field.
|
||||
* Demonstrates building on UI primitives.
|
||||
*/
|
||||
import Input from '@/components/ui/form/Input/Input.astro';
|
||||
|
||||
interface Props {
|
||||
placeholder?: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
placeholder = 'Search...',
|
||||
size = 'md',
|
||||
class: className,
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={placeholder}
|
||||
size={size}
|
||||
leadingIcon="search"
|
||||
class={className}
|
||||
/>
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
/**
|
||||
* StatCard Pattern
|
||||
* Composition example: Card + typography for a metric display.
|
||||
* Common in dashboards and landing pages.
|
||||
*/
|
||||
import { cn } from '@/lib/cn';
|
||||
import Card from '@/components/ui/data-display/Card/Card.astro';
|
||||
import Icon from '@/components/ui/primitives/Icon/Icon.astro';
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
value: string;
|
||||
/** Optional trend indicator: 'up' | 'down' | 'neutral' */
|
||||
trend?: 'up' | 'down' | 'neutral';
|
||||
/** Trend description text (e.g., "+12% from last month") */
|
||||
trendText?: string;
|
||||
/** Icon name from the Icon component */
|
||||
icon?: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
label,
|
||||
value,
|
||||
trend,
|
||||
trendText,
|
||||
icon,
|
||||
class: className,
|
||||
} = Astro.props;
|
||||
|
||||
const trendColors = {
|
||||
up: 'text-[var(--success)]',
|
||||
down: 'text-[var(--error)]',
|
||||
neutral: 'text-foreground-muted',
|
||||
};
|
||||
|
||||
const trendIcons = {
|
||||
up: 'trending-up',
|
||||
down: 'trending-down',
|
||||
neutral: 'minus',
|
||||
};
|
||||
---
|
||||
|
||||
<Card variant="default" padding="md" hover class={className}>
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-foreground-muted">{label}</p>
|
||||
<p class="text-2xl font-bold text-foreground mt-1">{value}</p>
|
||||
{trend && trendText && (
|
||||
<div class={cn('flex items-center gap-1 mt-2 text-xs font-medium', trendColors[trend])}>
|
||||
<Icon name={trendIcons[trend]} size="xs" />
|
||||
<span>{trendText}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{icon && (
|
||||
<div class="p-2.5 rounded-lg bg-secondary text-foreground-muted">
|
||||
<Icon name={icon} size="md" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
@@ -0,0 +1,132 @@
|
||||
---
|
||||
import { Image } from 'astro:assets';
|
||||
import type { ImageMetadata } from 'astro';
|
||||
import Button from '@/components/ui/form/Button/Button.astro';
|
||||
import Icon from '@/components/ui/primitives/Icon/Icon.astro';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
tags?: string[];
|
||||
year?: number;
|
||||
client?: string;
|
||||
role?: string;
|
||||
services?: string[];
|
||||
url?: string;
|
||||
repo?: string;
|
||||
image?: ImageMetadata;
|
||||
imageAlt?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
tags = [],
|
||||
year,
|
||||
client,
|
||||
role,
|
||||
url,
|
||||
repo,
|
||||
image,
|
||||
imageAlt,
|
||||
} = Astro.props;
|
||||
|
||||
const hasMeta = year || client || role;
|
||||
---
|
||||
|
||||
<header class="relative overflow-hidden pt-[var(--space-page-top-sm)] pb-[var(--space-section)]">
|
||||
<div class="relative mx-auto max-w-4xl px-6 animate-hero-slide-up">
|
||||
|
||||
<!-- Tags -->
|
||||
{tags.length > 0 && (
|
||||
<div class="mb-[var(--space-heading-gap)] flex flex-wrap gap-2">
|
||||
{tags.map((tag) => (
|
||||
<span class="inline-flex items-center rounded-full bg-brand-100 dark:bg-brand-900/30 px-3 py-1 text-xs font-semibold text-brand-700 dark:text-brand-300 ring-1 ring-inset ring-brand-200 dark:ring-brand-800">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<!-- Title -->
|
||||
<h1 class="font-display text-4xl font-bold tracking-tight text-foreground md:text-5xl lg:text-6xl mb-[var(--space-heading-gap)]">
|
||||
{title}
|
||||
</h1>
|
||||
|
||||
<!-- Description -->
|
||||
<p class="text-xl text-foreground-muted leading-relaxed max-w-3xl mb-[var(--space-stack-lg)]">
|
||||
{description}
|
||||
</p>
|
||||
|
||||
<!-- Meta row -->
|
||||
{hasMeta && (
|
||||
<div class="flex flex-wrap items-center gap-[var(--space-stack-lg)] text-sm text-foreground-muted mb-[var(--space-stack-lg)]">
|
||||
{year && (
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="h-5 w-5 text-foreground-subtle" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5" />
|
||||
</svg>
|
||||
<span>{year}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{client && (
|
||||
<>
|
||||
{year && <div class="h-8 w-px bg-border hidden md:block" aria-hidden="true"></div>}
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="h-5 w-5 text-foreground-subtle" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
|
||||
</svg>
|
||||
<span>{client}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{role && (
|
||||
<>
|
||||
{(year || client) && <div class="h-8 w-px bg-border hidden md:block" aria-hidden="true"></div>}
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="h-5 w-5 text-foreground-subtle" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 14.15v4.25c0 1.094-.787 2.036-1.872 2.18-2.087.277-4.216.42-6.378.42s-4.291-.143-6.378-.42c-1.085-.144-1.872-1.086-1.872-2.18v-4.25m16.5 0a2.18 2.18 0 00.75-1.661V8.706c0-1.081-.768-2.015-1.837-2.175a48.114 48.114 0 00-3.413-.387m4.5 8.006c-.194.165-.42.295-.673.38A23.978 23.978 0 0112 15.75c-2.648 0-5.195-.429-7.577-1.22a2.016 2.016 0 01-.673-.38m0 0A2.18 2.18 0 013 12.489V8.706c0-1.081.768-2.015 1.837-2.175a48.111 48.111 0 013.413-.387m7.5 0V5.25A2.25 2.25 0 0013.5 3h-3a2.25 2.25 0 00-2.25 2.25v.894m7.5 0a48.667 48.667 0 00-7.5 0M12 12.75h.008v.008H12v-.008z" />
|
||||
</svg>
|
||||
<span>{role}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<!-- Action buttons -->
|
||||
{(url || repo) && (
|
||||
<div class="flex flex-wrap gap-3">
|
||||
{url && (
|
||||
<Button href={url} target="_blank" rel="noopener noreferrer" size="md">
|
||||
<Icon name="external-link" size="sm" />
|
||||
View live site
|
||||
</Button>
|
||||
)}
|
||||
{repo && (
|
||||
<Button href={repo} target="_blank" rel="noopener noreferrer" variant="outline" size="md">
|
||||
<Icon name="github" size="sm" />
|
||||
View source
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{image && (
|
||||
<div class="relative mx-auto max-w-5xl px-6 mt-[var(--space-section)] animate-hero-slide-up [animation-delay:200ms]">
|
||||
<div class="relative overflow-hidden rounded-xl border border-border shadow-2xl">
|
||||
<Image
|
||||
src={image}
|
||||
alt={imageAlt || title}
|
||||
widths={[640, 960, 1280, 1920]}
|
||||
sizes="(max-width: 640px) 640px, (max-width: 960px) 960px, (max-width: 1280px) 1280px, 1920px"
|
||||
class="aspect-video w-full object-cover"
|
||||
loading="eager"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
import { cn } from '@/lib/cn';
|
||||
import JsonLd from './JsonLd.astro';
|
||||
import { createBreadcrumbSchema } from '@/lib/schema';
|
||||
import siteConfig from '@/config/site.config';
|
||||
|
||||
interface BreadcrumbItem {
|
||||
label: string;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items: BreadcrumbItem[];
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const { items, class: className } = Astro.props;
|
||||
|
||||
// Build schema items with full URLs
|
||||
const schemaItems = items.map((item) => ({
|
||||
name: item.label,
|
||||
url: item.href ? new URL(item.href, siteConfig.url).toString() : siteConfig.url,
|
||||
}));
|
||||
|
||||
const schema = createBreadcrumbSchema(schemaItems);
|
||||
---
|
||||
|
||||
<JsonLd schema={schema} />
|
||||
|
||||
<nav aria-label="Breadcrumb" class={cn('text-sm', className)}>
|
||||
<ol class="flex flex-wrap items-center gap-2">
|
||||
{
|
||||
items.map((item, index) => (
|
||||
<li class="flex items-center gap-2">
|
||||
{index > 0 && (
|
||||
<svg
|
||||
class="h-4 w-4 text-muted-foreground"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
)}
|
||||
{item.href && index !== items.length - 1 ? (
|
||||
<a href={item.href} class="text-muted-foreground hover:text-foreground transition-colors">
|
||||
{item.label}
|
||||
</a>
|
||||
) : (
|
||||
<span class="text-foreground font-medium" aria-current={index === items.length - 1 ? 'page' : undefined}>
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ol>
|
||||
</nav>
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
import type { Thing, WithContext } from 'schema-dts';
|
||||
|
||||
interface Props {
|
||||
schema: WithContext<Thing> | WithContext<Thing>[];
|
||||
}
|
||||
|
||||
const { schema } = Astro.props;
|
||||
|
||||
const schemas = Array.isArray(schema) ? schema : [schema];
|
||||
---
|
||||
|
||||
{
|
||||
schemas.map((s) => (
|
||||
<script is:inline type="application/ld+json" set:html={JSON.stringify(s, null, 0)} />
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
---
|
||||
import siteConfig from '@/config/site.config';
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
description?: string;
|
||||
image?: string;
|
||||
imageAlt?: string;
|
||||
article?: {
|
||||
publishedTime?: Date;
|
||||
modifiedTime?: Date;
|
||||
authors?: string[];
|
||||
tags?: string[];
|
||||
};
|
||||
noindex?: boolean;
|
||||
nofollow?: boolean;
|
||||
}
|
||||
|
||||
const {
|
||||
title,
|
||||
description = siteConfig.description,
|
||||
image,
|
||||
imageAlt,
|
||||
article,
|
||||
noindex = false,
|
||||
nofollow = false,
|
||||
} = Astro.props;
|
||||
|
||||
const pageTitle = title ? `${title} — ${siteConfig.name}` : siteConfig.name;
|
||||
|
||||
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
|
||||
|
||||
// Process image - fall back to the static default OG image
|
||||
let ogImage: string;
|
||||
if (image) {
|
||||
ogImage = image.startsWith('http') ? image : new URL(image, Astro.site).toString();
|
||||
} else {
|
||||
ogImage = new URL(siteConfig.ogImage, Astro.site).toString();
|
||||
}
|
||||
|
||||
const robotsContent = [noindex ? 'noindex' : 'index', nofollow ? 'nofollow' : 'follow'].join(', ');
|
||||
|
||||
// Normalize BCP-47 locale (e.g. "en") to OG language_TERRITORY format (e.g. "en_US")
|
||||
const localeMap: Record<string, string> = {
|
||||
en: 'en_US', fr: 'fr_FR', de: 'de_DE', es: 'es_ES', it: 'it_IT',
|
||||
pt: 'pt_BR', nl: 'nl_NL', ja: 'ja_JP', ko: 'ko_KR', zh: 'zh_CN',
|
||||
ru: 'ru_RU', ar: 'ar_SA', hi: 'hi_IN', pl: 'pl_PL', sv: 'sv_SE',
|
||||
};
|
||||
const rawLocale = Astro.currentLocale || 'en_US';
|
||||
const locale = rawLocale.includes('_') || rawLocale.includes('-')
|
||||
? rawLocale.replace('-', '_')
|
||||
: (localeMap[rawLocale] || `${rawLocale}_${rawLocale.toUpperCase()}`);
|
||||
|
||||
// Determine OG image MIME type from file extension
|
||||
const extToMime: Record<string, string> = {
|
||||
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
||||
'.webp': 'image/webp', '.gif': 'image/gif', '.svg': 'image/svg+xml',
|
||||
};
|
||||
function getImageType(url: string): string | undefined {
|
||||
const ext = url.match(/(\.\w+)(?:\?|$)/)?.[1]?.toLowerCase();
|
||||
return ext ? extToMime[ext] : undefined;
|
||||
}
|
||||
const ogImageType = getImageType(ogImage);
|
||||
---
|
||||
|
||||
<!-- Primary Meta Tags -->
|
||||
<title>{pageTitle}</title>
|
||||
<meta name="description" content={description} />
|
||||
<link rel="canonical" href={canonicalURL.toString()} />
|
||||
<meta name="robots" content={robotsContent} />
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:title" content={pageTitle} />
|
||||
<meta property="og:type" content={article ? 'article' : 'website'} />
|
||||
<meta property="og:image" content={ogImage} />
|
||||
<meta property="og:url" content={canonicalURL.toString()} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:site_name" content={siteConfig.name} />
|
||||
<meta property="og:locale" content={locale} />
|
||||
<meta property="og:image:alt" content={imageAlt || pageTitle} />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
{ogImageType && <meta property="og:image:type" content={ogImageType} />}
|
||||
|
||||
<!-- Article Metadata -->
|
||||
{article?.publishedTime && <meta property="article:published_time" content={article.publishedTime.toISOString()} />}
|
||||
{article?.modifiedTime && <meta property="article:modified_time" content={article.modifiedTime.toISOString()} />}
|
||||
{article?.authors?.map((author) => <meta property="article:author" content={author} />)}
|
||||
{article?.tags?.map((tag) => <meta property="article:tag" content={tag} />)}
|
||||
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={pageTitle} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
<meta name="twitter:image" content={ogImage} />
|
||||
{siteConfig.twitter?.site && <meta name="twitter:site" content={siteConfig.twitter.site} />}
|
||||
{siteConfig.twitter?.creator && <meta name="twitter:creator" content={siteConfig.twitter.creator} />}
|
||||
|
||||
<!-- Verification -->
|
||||
{siteConfig.verification?.google && <meta name="google-site-verification" content={siteConfig.verification.google} />}
|
||||
{siteConfig.verification?.bing && <meta name="msvalidate.01" content={siteConfig.verification.bing} />}
|
||||
@@ -0,0 +1,115 @@
|
||||
---
|
||||
interface Props {
|
||||
/** Array of strings to cycle through */
|
||||
words: string[];
|
||||
/** Typing speed in ms per character */
|
||||
typeSpeed?: number;
|
||||
/** Deleting speed in ms per character */
|
||||
deleteSpeed?: number;
|
||||
/** Pause after fully typed, in ms */
|
||||
pauseAfterType?: number;
|
||||
/** Pause after fully deleted, in ms */
|
||||
pauseAfterDelete?: number;
|
||||
}
|
||||
|
||||
const {
|
||||
words,
|
||||
typeSpeed = 120,
|
||||
deleteSpeed = 70,
|
||||
pauseAfterType = 1800,
|
||||
pauseAfterDelete = 400,
|
||||
} = Astro.props;
|
||||
|
||||
const id = `typing-${Math.random().toString(36).slice(2, 8)}`;
|
||||
---
|
||||
|
||||
<span id={id} class="typing-effect" aria-label={words.join(', ')}>
|
||||
<span class="typing-text"></span><span class="typing-cursor" aria-hidden="true">|</span>
|
||||
</span>
|
||||
|
||||
<style>
|
||||
.typing-effect {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
.typing-cursor {
|
||||
display: inline-block;
|
||||
margin-left: 1px;
|
||||
animation: blink 0.75s step-end infinite;
|
||||
color: var(--color-brand-500, currentColor);
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script define:vars={{ id, words, typeSpeed, deleteSpeed, pauseAfterType, pauseAfterDelete }}>
|
||||
function startTyping() {
|
||||
const root = document.getElementById(id);
|
||||
if (!root) return;
|
||||
const textEl = root.querySelector('.typing-text');
|
||||
|
||||
// Lock the element width to the widest word so the layout never shifts
|
||||
const measurer = document.createElement('span');
|
||||
measurer.setAttribute('aria-hidden', 'true');
|
||||
measurer.style.cssText = 'visibility:hidden;position:absolute;white-space:nowrap;pointer-events:none;';
|
||||
const cs = getComputedStyle(root);
|
||||
measurer.style.font = cs.font;
|
||||
measurer.style.letterSpacing = cs.letterSpacing;
|
||||
document.body.appendChild(measurer);
|
||||
|
||||
let maxWidth = 0;
|
||||
for (const word of words) {
|
||||
measurer.textContent = word + '|'; // include cursor character in measurement
|
||||
maxWidth = Math.max(maxWidth, measurer.offsetWidth);
|
||||
}
|
||||
document.body.removeChild(measurer);
|
||||
root.style.minWidth = maxWidth + 'px';
|
||||
|
||||
let wordIndex = 0;
|
||||
let charIndex = 0;
|
||||
let isDeleting = false;
|
||||
let timer;
|
||||
|
||||
function tick() {
|
||||
const current = words[wordIndex];
|
||||
|
||||
if (isDeleting) {
|
||||
charIndex--;
|
||||
textEl.textContent = current.slice(0, charIndex);
|
||||
|
||||
if (charIndex === 0) {
|
||||
isDeleting = false;
|
||||
wordIndex = (wordIndex + 1) % words.length;
|
||||
timer = setTimeout(tick, pauseAfterDelete);
|
||||
return;
|
||||
}
|
||||
timer = setTimeout(tick, deleteSpeed);
|
||||
} else {
|
||||
charIndex++;
|
||||
textEl.textContent = current.slice(0, charIndex);
|
||||
|
||||
if (charIndex === current.length) {
|
||||
isDeleting = true;
|
||||
timer = setTimeout(tick, pauseAfterType);
|
||||
return;
|
||||
}
|
||||
timer = setTimeout(tick, typeSpeed);
|
||||
}
|
||||
}
|
||||
|
||||
// Start after a short initial delay so the page paint settles
|
||||
timer = setTimeout(tick, 600);
|
||||
|
||||
// Clean up pending timer when navigating away
|
||||
document.addEventListener('astro:before-swap', () => clearTimeout(timer), { once: true });
|
||||
}
|
||||
|
||||
// Run on initial load and on every client-side navigation back to this page
|
||||
document.addEventListener('astro:page-load', startTyping);
|
||||
</script>
|
||||
@@ -0,0 +1,113 @@
|
||||
---
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
interface Props {
|
||||
code: string;
|
||||
filename?: string;
|
||||
showLineNumbers?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
code,
|
||||
filename,
|
||||
showLineNumbers = true,
|
||||
class: className,
|
||||
} = Astro.props;
|
||||
|
||||
const lines = code.trim().split('\n');
|
||||
const codeId = `code-${Math.random().toString(36).slice(2, 9)}`;
|
||||
---
|
||||
|
||||
<div class={cn(
|
||||
"group relative w-full overflow-hidden rounded-md border border-border bg-background-secondary shadow-sm font-mono text-sm",
|
||||
className
|
||||
)}>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-border bg-card px-4 py-2.5">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex gap-1.5">
|
||||
<div class="h-2.5 w-2.5 rounded-full bg-gray-300 dark:bg-gray-600"></div>
|
||||
<div class="h-2.5 w-2.5 rounded-full bg-gray-300 dark:bg-gray-600"></div>
|
||||
<div class="h-2.5 w-2.5 rounded-full bg-gray-300 dark:bg-gray-600"></div>
|
||||
</div>
|
||||
{filename && (
|
||||
<span class="text-xs font-medium text-foreground-muted font-sans">{filename}</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="copy-button flex items-center gap-1.5 rounded px-2 py-0.5 text-xs font-medium text-foreground-muted transition-colors hover:bg-secondary hover:text-foreground focus:outline-none"
|
||||
data-code-id={codeId}
|
||||
aria-label="Copy code to clipboard"
|
||||
>
|
||||
<svg class="copy-icon h-3 w-3" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||
</svg>
|
||||
<svg class="check-icon hidden h-3 w-3 text-success" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"></polyline>
|
||||
</svg>
|
||||
<span class="copy-text">Copy</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Code Area -->
|
||||
<div class="overflow-x-auto p-4 bg-card">
|
||||
<pre id={codeId} class="flex flex-col leading-6">{lines.map((line, i) => (
|
||||
<div class="table-row">
|
||||
{showLineNumbers && (
|
||||
<span class="table-cell select-none pr-4 text-right text-xs text-foreground-subtle w-8">
|
||||
{i + 1}
|
||||
</span>
|
||||
)}
|
||||
<span class="table-cell whitespace-pre text-foreground-secondary">{line || ' '}</span>
|
||||
</div>
|
||||
))}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function initCodeBlocks() {
|
||||
const copyButtons = document.querySelectorAll<HTMLButtonElement>('.copy-button');
|
||||
copyButtons.forEach((button) => {
|
||||
button.addEventListener('click', async () => {
|
||||
const codeId = button.dataset.codeId;
|
||||
if (!codeId) return;
|
||||
|
||||
const codeEl = document.getElementById(codeId);
|
||||
if (!codeEl) return;
|
||||
|
||||
const code = codeEl.textContent || '';
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
|
||||
const copyIcon = button.querySelector('.copy-icon');
|
||||
const checkIcon = button.querySelector('.check-icon');
|
||||
const copyText = button.querySelector('.copy-text');
|
||||
|
||||
if (copyIcon && checkIcon && copyText) {
|
||||
copyIcon.classList.add('hidden');
|
||||
checkIcon.classList.remove('hidden');
|
||||
copyText.textContent = 'Copied';
|
||||
|
||||
setTimeout(() => {
|
||||
copyIcon.classList.remove('hidden');
|
||||
checkIcon.classList.add('hidden');
|
||||
copyText.textContent = 'Copy';
|
||||
}, 2000);
|
||||
}
|
||||
} catch {
|
||||
// Clipboard API failed - user will need to copy manually
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize on page load
|
||||
initCodeBlocks();
|
||||
|
||||
// Re-initialize on view transitions (Astro)
|
||||
document.addEventListener('astro:page-load', initCodeBlocks);
|
||||
</script>
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './CodeBlock.astro';
|
||||
@@ -0,0 +1,2 @@
|
||||
// Content Display Components
|
||||
export { default as CodeBlock } from './CodeBlock';
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
/**
|
||||
* Avatar Component
|
||||
* Displays user avatars with fallback initials
|
||||
*/
|
||||
import type { HTMLAttributes } from 'astro/types';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { avatarVariants } from './avatar.variants';
|
||||
|
||||
interface Props extends HTMLAttributes<'div'> {
|
||||
src?: string;
|
||||
alt?: string;
|
||||
fallback?: string;
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
}
|
||||
|
||||
const { src, alt = '', fallback, size = 'md', class: className, ...attrs } = Astro.props;
|
||||
|
||||
// Generate initials from alt text or fallback
|
||||
const initials = fallback || alt
|
||||
.split(' ')
|
||||
.map((word) => word[0])
|
||||
.join('')
|
||||
.slice(0, 2)
|
||||
.toUpperCase();
|
||||
---
|
||||
|
||||
<div class={cn(avatarVariants({ size }), className)} {...attrs}>
|
||||
{src ? (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
class="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
data-avatar-img
|
||||
/>
|
||||
<span class="hidden items-center justify-center w-full h-full" aria-hidden="true" data-avatar-fallback>
|
||||
{initials || '?'}
|
||||
</span>
|
||||
) : (
|
||||
<span aria-hidden="true">{initials || '?'}</span>
|
||||
)}
|
||||
<span class="sr-only">{alt || 'User avatar'}</span>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function initAvatarFallbacks() {
|
||||
const avatarImgs = document.querySelectorAll<HTMLImageElement>('[data-avatar-img]');
|
||||
avatarImgs.forEach((img) => {
|
||||
if (img.dataset.avatarInit) return;
|
||||
img.dataset.avatarInit = 'true';
|
||||
|
||||
img.addEventListener('error', () => {
|
||||
img.classList.add('hidden');
|
||||
const fallback = img.nextElementSibling as HTMLElement | null;
|
||||
if (fallback && fallback.hasAttribute('data-avatar-fallback')) {
|
||||
fallback.classList.remove('hidden');
|
||||
fallback.classList.add('flex');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
initAvatarFallbacks();
|
||||
document.addEventListener('astro:page-load', initAvatarFallbacks);
|
||||
</script>
|
||||
@@ -0,0 +1,47 @@
|
||||
import { type HTMLAttributes, type Ref, useState } from 'react';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { avatarVariants, type AvatarVariants } from './avatar.variants';
|
||||
|
||||
interface AvatarProps extends Omit<HTMLAttributes<HTMLDivElement>, 'ref'> {
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
src?: string;
|
||||
alt?: string;
|
||||
fallback?: string;
|
||||
size?: AvatarVariants['size'];
|
||||
}
|
||||
|
||||
export function Avatar({ ref, src, alt = '', fallback, size = 'md', className, ...rest }: AvatarProps) {
|
||||
const [imgError, setImgError] = useState(false);
|
||||
|
||||
const initials = fallback || alt
|
||||
.split(' ')
|
||||
.map((word) => word[0])
|
||||
.join('')
|
||||
.slice(0, 2)
|
||||
.toUpperCase();
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn(avatarVariants({ size }), className)} {...rest}>
|
||||
{src && !imgError ? (
|
||||
<>
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={() => setImgError(true)}
|
||||
/>
|
||||
<span className="sr-only">{alt || 'User avatar'}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span aria-hidden="true">{initials || '?'}</span>
|
||||
<span className="sr-only">{alt || 'User avatar'}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Avatar;
|
||||
@@ -0,0 +1,26 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
export const avatarVariants = cva(
|
||||
[
|
||||
'relative inline-flex items-center justify-center',
|
||||
'rounded-full overflow-hidden',
|
||||
'bg-secondary text-secondary-foreground font-medium',
|
||||
'ring-2 ring-background',
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
xs: 'w-6 h-6 text-[10px]',
|
||||
sm: 'w-8 h-8 text-xs',
|
||||
md: 'w-10 h-10 text-sm',
|
||||
lg: 'w-12 h-12 text-base',
|
||||
xl: 'w-16 h-16 text-lg',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'md',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export type AvatarVariants = VariantProps<typeof avatarVariants>;
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default } from './Avatar.astro';
|
||||
export { Avatar } from './Avatar';
|
||||
export { avatarVariants, type AvatarVariants } from './avatar.variants';
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
/**
|
||||
* AvatarGroup Component
|
||||
* Displays stacked avatars with an optional "+N" overflow indicator.
|
||||
*/
|
||||
import type { HTMLAttributes } from 'astro/types';
|
||||
import { cn } from '@/lib/cn';
|
||||
import Avatar from '../Avatar/Avatar.astro';
|
||||
|
||||
interface AvatarItem {
|
||||
src?: string;
|
||||
alt?: string;
|
||||
fallback?: string;
|
||||
}
|
||||
|
||||
interface Props extends HTMLAttributes<'div'> {
|
||||
avatars: AvatarItem[];
|
||||
/** Maximum number of avatars to show before "+N" */
|
||||
max?: number;
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
const {
|
||||
avatars,
|
||||
max = 4,
|
||||
size = 'md',
|
||||
class: className,
|
||||
...attrs
|
||||
} = Astro.props;
|
||||
|
||||
const visibleAvatars = avatars.slice(0, max);
|
||||
const overflowCount = Math.max(0, avatars.length - max);
|
||||
|
||||
const overflowSizes = {
|
||||
xs: 'w-6 h-6 text-[8px]',
|
||||
sm: 'w-8 h-8 text-[10px]',
|
||||
md: 'w-10 h-10 text-xs',
|
||||
lg: 'w-12 h-12 text-sm',
|
||||
};
|
||||
---
|
||||
|
||||
<div class={cn('flex -space-x-2', className)} {...attrs}>
|
||||
{visibleAvatars.map((avatar) => (
|
||||
<Avatar
|
||||
src={avatar.src}
|
||||
alt={avatar.alt || ''}
|
||||
fallback={avatar.fallback}
|
||||
size={size}
|
||||
class="ring-2 ring-background"
|
||||
/>
|
||||
))}
|
||||
{overflowCount > 0 && (
|
||||
<div
|
||||
class={cn(
|
||||
'relative inline-flex items-center justify-center',
|
||||
'rounded-full overflow-hidden',
|
||||
'bg-secondary text-foreground-muted font-semibold',
|
||||
'ring-2 ring-background',
|
||||
overflowSizes[size]
|
||||
)}
|
||||
aria-label={`${overflowCount} more`}
|
||||
>
|
||||
+{overflowCount}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -0,0 +1,61 @@
|
||||
import { type HTMLAttributes, type Ref } from 'react';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { Avatar } from '../Avatar/Avatar';
|
||||
import type { AvatarVariants } from '../Avatar/avatar.variants';
|
||||
|
||||
interface AvatarItem {
|
||||
src?: string;
|
||||
alt?: string;
|
||||
fallback?: string;
|
||||
}
|
||||
|
||||
interface AvatarGroupProps extends Omit<HTMLAttributes<HTMLDivElement>, 'ref'> {
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
avatars: AvatarItem[];
|
||||
max?: number;
|
||||
size?: NonNullable<AvatarVariants['size']>;
|
||||
}
|
||||
|
||||
const overflowSizes: Record<string, string> = {
|
||||
xs: 'w-6 h-6 text-[8px]',
|
||||
sm: 'w-8 h-8 text-[10px]',
|
||||
md: 'w-10 h-10 text-xs',
|
||||
lg: 'w-12 h-12 text-sm',
|
||||
xl: 'w-14 h-14 text-base',
|
||||
};
|
||||
|
||||
export function AvatarGroup({ ref, avatars, max = 4, size = 'md', className, ...rest }: AvatarGroupProps) {
|
||||
const visibleAvatars = avatars.slice(0, max);
|
||||
const overflowCount = Math.max(0, avatars.length - max);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn('flex -space-x-2', className)} {...rest}>
|
||||
{visibleAvatars.map((avatar, i) => (
|
||||
<Avatar
|
||||
key={i}
|
||||
src={avatar.src}
|
||||
alt={avatar.alt || ''}
|
||||
fallback={avatar.fallback}
|
||||
size={size}
|
||||
className="ring-2 ring-background"
|
||||
/>
|
||||
))}
|
||||
{overflowCount > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
'relative inline-flex items-center justify-center',
|
||||
'rounded-full overflow-hidden',
|
||||
'bg-secondary text-foreground-muted font-semibold',
|
||||
'ring-2 ring-background',
|
||||
overflowSizes[size]
|
||||
)}
|
||||
aria-label={`${overflowCount} more`}
|
||||
>
|
||||
+{overflowCount}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AvatarGroup;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from './AvatarGroup.astro';
|
||||
export { AvatarGroup } from './AvatarGroup';
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
/**
|
||||
* Badge Component
|
||||
* Displays a small status indicator or label with proper icon spacing
|
||||
*/
|
||||
import type { HTMLAttributes } from 'astro/types';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { badgeVariants } from './badge.variants';
|
||||
|
||||
interface Props extends HTMLAttributes<'span'> {
|
||||
variant?: 'default' | 'success' | 'warning' | 'error' | 'info' | 'brand';
|
||||
size?: 'sm' | 'md';
|
||||
/** Show a pulsing dot indicator */
|
||||
pulse?: boolean;
|
||||
/** Use pill styling (fully rounded with shadow) */
|
||||
pill?: boolean;
|
||||
}
|
||||
|
||||
const { variant = 'default', size = 'md', pulse = false, pill = false, class: className, ...attrs } = Astro.props;
|
||||
---
|
||||
|
||||
<span class={cn(badgeVariants({ variant, size, pill }), className)} {...attrs}>
|
||||
{pulse && (
|
||||
<span class="relative flex h-2 w-2 shrink-0" aria-hidden="true">
|
||||
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-brand-500 opacity-75" />
|
||||
<span class="relative inline-flex h-2 w-2 rounded-full bg-brand-500" />
|
||||
</span>
|
||||
)}
|
||||
<slot />
|
||||
</span>
|
||||
@@ -0,0 +1,25 @@
|
||||
import { type HTMLAttributes, type Ref, type ReactNode } from 'react';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { badgeVariants, type BadgeVariants } from './badge.variants';
|
||||
|
||||
interface BadgeProps extends Omit<HTMLAttributes<HTMLSpanElement>, 'ref'> {
|
||||
ref?: Ref<HTMLSpanElement>;
|
||||
variant?: BadgeVariants['variant'];
|
||||
size?: BadgeVariants['size'];
|
||||
pulse?: boolean;
|
||||
pill?: boolean;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function Badge({ ref, variant = 'default', size = 'md', pulse = false, pill = false, className, children, ...rest }: BadgeProps) {
|
||||
return (
|
||||
<span ref={ref} className={cn(badgeVariants({ variant, size, pill }), className)} {...rest}>
|
||||
{pulse && (
|
||||
<span className="flex h-2 w-2 shrink-0 animate-pulse rounded-full bg-brand-500" aria-hidden="true" />
|
||||
)}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default Badge;
|
||||
@@ -0,0 +1,44 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
export const badgeVariants = cva(
|
||||
[
|
||||
'inline-flex items-center font-medium border',
|
||||
'transition-colors',
|
||||
'[&>svg]:shrink-0 [&>svg]:h-3 [&>svg]:w-3',
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-secondary text-secondary-foreground border-border',
|
||||
success:
|
||||
'bg-[var(--success-light)] text-[var(--success-foreground)] border-[var(--success)]/20',
|
||||
warning:
|
||||
'bg-[var(--warning-light)] text-[var(--warning-foreground)] border-[var(--warning)]/20',
|
||||
error:
|
||||
'bg-[var(--error-light)] text-[var(--error-foreground)] border-[var(--error)]/20',
|
||||
info: 'bg-[var(--info-light)] text-[var(--info-foreground)] border-[var(--info)]/20',
|
||||
brand:
|
||||
'bg-brand-500/10 text-brand-600 border-brand-500/20 dark:text-brand-400',
|
||||
},
|
||||
size: {
|
||||
sm: 'text-[10px] px-2 py-0.5 gap-1',
|
||||
md: 'text-sm sm:text-xs px-2.5 py-1 gap-1.5',
|
||||
},
|
||||
pill: {
|
||||
true: 'rounded-full shadow-sm',
|
||||
false: 'rounded-md',
|
||||
},
|
||||
},
|
||||
compoundVariants: [
|
||||
{ pill: true, size: 'sm', class: 'px-2.5' },
|
||||
{ pill: true, size: 'md', class: 'px-3.5 sm:px-3' },
|
||||
],
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'md',
|
||||
pill: false,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export type BadgeVariants = VariantProps<typeof badgeVariants>;
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default } from './Badge.astro';
|
||||
export { Badge } from './Badge';
|
||||
export { badgeVariants, type BadgeVariants } from './badge.variants';
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
import type { HTMLAttributes } from 'astro/types';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { cardVariants } from './card.variants';
|
||||
|
||||
interface Props extends HTMLAttributes<'div'>, Pick<HTMLAttributes<'a'>, 'target' | 'rel'> {
|
||||
variant?: 'default' | 'solid' | 'outline' | 'ghost' | 'elevated';
|
||||
padding?: 'none' | 'sm' | 'md' | 'lg';
|
||||
hover?: boolean;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
variant = 'default',
|
||||
padding = 'md',
|
||||
hover = false,
|
||||
href,
|
||||
class: className,
|
||||
...attrs
|
||||
} = Astro.props;
|
||||
|
||||
const Element = href ? 'a' : 'div';
|
||||
|
||||
const cardStyles = cn(
|
||||
cardVariants({ variant, padding, hover }),
|
||||
href && 'block cursor-pointer',
|
||||
className
|
||||
);
|
||||
---
|
||||
|
||||
<Element class={cardStyles} href={href} {...attrs}>
|
||||
<slot />
|
||||
</Element>
|
||||
@@ -0,0 +1,156 @@
|
||||
import { type HTMLAttributes, type Ref, type ReactNode } from 'react';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { cardVariants, type CardVariants } from './card.variants';
|
||||
|
||||
type CardShadow = 'none' | 'sm' | 'md' | 'lg';
|
||||
|
||||
interface CardProps extends Omit<HTMLAttributes<HTMLDivElement>, 'ref'> {
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
padding?: CardVariants['padding'];
|
||||
shadow?: CardShadow;
|
||||
hover?: boolean;
|
||||
/** Visual style variant */
|
||||
variant?: CardVariants['variant'];
|
||||
/** Icon element to display in the card header */
|
||||
icon?: ReactNode;
|
||||
/** Card title */
|
||||
title?: string;
|
||||
/** Card subtitle/byline */
|
||||
subtitle?: string;
|
||||
/** Card description */
|
||||
description?: string;
|
||||
/** Whether to use the structured layout with icon/title/description */
|
||||
structured?: boolean;
|
||||
}
|
||||
|
||||
const shadows: Record<CardShadow, string> = {
|
||||
none: '',
|
||||
sm: 'shadow-sm',
|
||||
md: 'shadow-md',
|
||||
lg: 'shadow-lg',
|
||||
};
|
||||
|
||||
export function Card({
|
||||
ref,
|
||||
padding = 'md',
|
||||
shadow = 'none',
|
||||
hover = false,
|
||||
variant = 'default',
|
||||
icon,
|
||||
title,
|
||||
subtitle,
|
||||
description,
|
||||
structured = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CardProps) {
|
||||
const cardStyles = cn(
|
||||
cardVariants({ variant, padding, hover }),
|
||||
shadows[shadow],
|
||||
className
|
||||
);
|
||||
|
||||
// If using structured layout with icon/title/description
|
||||
if (structured || icon || title) {
|
||||
return (
|
||||
<div ref={ref} className={cardStyles} {...props}>
|
||||
<div className="flex items-start gap-4">
|
||||
{icon && (
|
||||
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-500/5 flex items-center justify-center text-brand-500 shrink-0">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
{(title || subtitle) && (
|
||||
<div className="flex-1 min-w-0">
|
||||
{title && (
|
||||
<h3 className="text-base font-semibold text-foreground">{title}</h3>
|
||||
)}
|
||||
{subtitle && (
|
||||
<p className="text-xs text-foreground-subtle mt-0.5 font-medium">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{description && (
|
||||
<div className="mt-4">
|
||||
<p className="text-sm text-foreground-muted leading-relaxed">{description}</p>
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cardStyles} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Card sub-components with refined spacing
|
||||
interface CardSubComponentProps extends Omit<HTMLAttributes<HTMLDivElement>, 'ref'> {
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
interface CardTitleProps extends Omit<HTMLAttributes<HTMLHeadingElement>, 'ref'> {
|
||||
ref?: Ref<HTMLHeadingElement>;
|
||||
}
|
||||
|
||||
interface CardTextProps extends Omit<HTMLAttributes<HTMLParagraphElement>, 'ref'> {
|
||||
ref?: Ref<HTMLParagraphElement>;
|
||||
}
|
||||
|
||||
export function CardHeader({ ref, className, ...props }: CardSubComponentProps) {
|
||||
return <div ref={ref} className={cn('flex flex-col gap-1', className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardTitle({ ref, className, ...props }: CardTitleProps) {
|
||||
return (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-base font-black leading-tight tracking-tight text-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardByline({ ref, className, ...props }: CardTextProps) {
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn('text-xs text-foreground-subtle mt-0.5 font-medium', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardDescription({ ref, className, ...props }: CardTextProps) {
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn('text-sm text-foreground-muted leading-relaxed mt-1.5', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardContent({ ref, className, ...props }: CardSubComponentProps) {
|
||||
return <div ref={ref} className={cn('mt-4', className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardFooter({ ref, className, ...props }: CardSubComponentProps) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex items-center mt-4 pt-4 border-t border-border', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default Card;
|
||||
@@ -0,0 +1,31 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
export const cardVariants = cva(
|
||||
['rounded-xl', 'transition-all duration-200 ease-out'],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-card border border-brand-500/30 hover:border-brand-500/70',
|
||||
solid: 'bg-secondary border border-transparent',
|
||||
outline: 'bg-transparent border-2 border-brand-500/30 hover:border-brand-500/70',
|
||||
ghost: 'bg-transparent border border-transparent',
|
||||
elevated: 'bg-card border border-brand-500/30 shadow-lg hover:border-brand-500/70',
|
||||
},
|
||||
padding: {
|
||||
none: '',
|
||||
sm: 'p-4',
|
||||
md: 'p-6',
|
||||
lg: 'p-8',
|
||||
},
|
||||
hover: {
|
||||
true: 'hover:border-brand-500 hover:shadow-md hover:-translate-y-0.5',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
padding: 'md',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export type CardVariants = VariantProps<typeof cardVariants>;
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default } from './Card.astro';
|
||||
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './Card';
|
||||
export { cardVariants, type CardVariants } from './card.variants';
|
||||
@@ -0,0 +1,378 @@
|
||||
---
|
||||
/**
|
||||
* GoogleMap — consent-aware Google Maps embed
|
||||
*
|
||||
* 3 states:
|
||||
* 1. No API key → setup prompt with instructions
|
||||
* 2. API key + consent required but not granted → placeholder with "Load Map" button
|
||||
* 3. API key + consent granted (or consent disabled) → iframe loads immediately
|
||||
*/
|
||||
import type { HTMLAttributes } from 'astro/types';
|
||||
import { PUBLIC_GOOGLE_MAPS_API_KEY, PUBLIC_CONSENT_ENABLED } from 'astro:env/client';
|
||||
import siteConfig from '@/config/site.config';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { googleMapVariants } from './googleMap.variants';
|
||||
|
||||
interface Props extends HTMLAttributes<'div'> {
|
||||
lat?: number;
|
||||
lng?: number;
|
||||
address?: string;
|
||||
zoom?: number;
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
||||
mode?: 'place' | 'view' | 'directions' | 'streetview' | 'search';
|
||||
mapType?: 'roadmap' | 'satellite';
|
||||
consentCategory?: string;
|
||||
ariaLabel?: string;
|
||||
placeholderTitle?: string;
|
||||
placeholderDescription?: string;
|
||||
externalLinkText?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
lat,
|
||||
lng,
|
||||
address,
|
||||
zoom = 15,
|
||||
size = 'md',
|
||||
mode = 'place',
|
||||
mapType = 'roadmap',
|
||||
consentCategory = 'marketing',
|
||||
ariaLabel = 'Google Maps',
|
||||
placeholderTitle = 'Map',
|
||||
placeholderDescription = 'Accept cookies to load the interactive map.',
|
||||
externalLinkText = 'View on Google Maps',
|
||||
class: className,
|
||||
...rest
|
||||
} = Astro.props;
|
||||
|
||||
const hasApiKey = !!PUBLIC_GOOGLE_MAPS_API_KEY;
|
||||
|
||||
// Build query — prefer lat/lng, fall back to address, then siteConfig.address
|
||||
let query = '';
|
||||
if (lat !== undefined && lng !== undefined) {
|
||||
query = `${lat},${lng}`;
|
||||
} else if (address) {
|
||||
query = address;
|
||||
} else if (siteConfig.address) {
|
||||
const a = siteConfig.address;
|
||||
query = [a.street, a.city, a.state, a.zip, a.country].filter(Boolean).join(', ');
|
||||
}
|
||||
|
||||
// Build iframe src (only when key exists)
|
||||
let iframeSrc = '';
|
||||
if (hasApiKey) {
|
||||
const params = new URLSearchParams({
|
||||
key: PUBLIC_GOOGLE_MAPS_API_KEY,
|
||||
q: query,
|
||||
zoom: String(zoom),
|
||||
maptype: mapType,
|
||||
});
|
||||
iframeSrc = `https://www.google.com/maps/embed/v1/${mode}?${params.toString()}`;
|
||||
}
|
||||
|
||||
// External link for placeholder
|
||||
const externalUrl = query
|
||||
? `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`
|
||||
: 'https://maps.google.com';
|
||||
|
||||
const consentEnabled = PUBLIC_CONSENT_ENABLED;
|
||||
|
||||
// Config for client script
|
||||
const mapConfig = JSON.stringify({
|
||||
consentCategory,
|
||||
consentEnabled,
|
||||
});
|
||||
|
||||
const id = `google-map-${Math.random().toString(36).slice(2, 9)}`;
|
||||
---
|
||||
|
||||
<div
|
||||
class={cn(googleMapVariants({ size }), className)}
|
||||
data-google-map={id}
|
||||
{...rest}
|
||||
>
|
||||
{!hasApiKey ? (
|
||||
/* No API key — setup prompt */
|
||||
<div class="google-map-setup">
|
||||
<div class="google-map-setup__inner">
|
||||
{/* Lucide map-pin-off */}
|
||||
<svg class="google-map-setup__icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M5.43 5.43A8.06 8.06 0 0 0 4 10c0 6 8 12 8 12a29.94 29.94 0 0 0 5-5" />
|
||||
<path d="M19.18 13.52A8.66 8.66 0 0 0 20 10a8 8 0 0 0-8-8 7.88 7.88 0 0 0-3.52.82" />
|
||||
<path d="M9.13 9.13a3 3 0 0 0 3.74 3.74" />
|
||||
<path d="M14.9 9.25a3 3 0 0 0-2.15-2.16" />
|
||||
<line x1="2" x2="22" y1="2" y2="22" />
|
||||
</svg>
|
||||
<p class="google-map-setup__title">Google Maps</p>
|
||||
<p class="google-map-setup__desc">
|
||||
Add <code>PUBLIC_GOOGLE_MAPS_API_KEY</code> to your <code>.env</code> file to enable the map.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Consent placeholder — shown when consent is required but not granted */}
|
||||
<div class="google-map-placeholder" data-map-placeholder={id}>
|
||||
<div class="google-map-placeholder__icon">
|
||||
{/* Lucide map-pin */}
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0" />
|
||||
<circle cx="12" cy="10" r="3" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{query && <p class="google-map-placeholder__address">{query}</p>}
|
||||
|
||||
<p class="google-map-placeholder__title">{placeholderTitle}</p>
|
||||
<p class="google-map-placeholder__desc">{placeholderDescription}</p>
|
||||
|
||||
<button class="google-map-placeholder__btn" data-map-load={id} type="button">
|
||||
Load Map
|
||||
</button>
|
||||
|
||||
<a
|
||||
href={externalUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="google-map-placeholder__link"
|
||||
>
|
||||
{/* Lucide external-link */}
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M15 3h6v6" />
|
||||
<path d="M10 14 21 3" />
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
||||
</svg>
|
||||
{externalLinkText}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Iframe — hidden until consent granted */}
|
||||
<iframe
|
||||
data-map-iframe={id}
|
||||
data-src={iframeSrc}
|
||||
hidden
|
||||
width="100%"
|
||||
height="100%"
|
||||
style="border:0;"
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer-when-downgrade"
|
||||
allow="fullscreen"
|
||||
aria-label={ariaLabel}
|
||||
></iframe>
|
||||
|
||||
<script type="application/json" data-google-map-config={id} set:html={mapConfig} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
interface MapWindow extends Window {
|
||||
__consentState?: { decided: boolean; categories: Record<string, boolean> };
|
||||
}
|
||||
|
||||
function initGoogleMaps() {
|
||||
const maps = document.querySelectorAll<HTMLElement>('[data-google-map]');
|
||||
|
||||
maps.forEach((container) => {
|
||||
const id = container.dataset.googleMap!;
|
||||
const configEl = container.querySelector<HTMLScriptElement>(`[data-google-map-config="${id}"]`);
|
||||
if (!configEl) return;
|
||||
|
||||
const config = JSON.parse(configEl.textContent!);
|
||||
const iframe = container.querySelector<HTMLIFrameElement>(`[data-map-iframe="${id}"]`);
|
||||
const placeholder = container.querySelector<HTMLElement>(`[data-map-placeholder="${id}"]`);
|
||||
const loadBtn = container.querySelector<HTMLButtonElement>(`[data-map-load="${id}"]`);
|
||||
|
||||
if (!iframe) return;
|
||||
|
||||
// Already loaded (idempotent)
|
||||
if (iframe.src && iframe.src !== 'about:blank') return;
|
||||
|
||||
const w = window as unknown as MapWindow;
|
||||
|
||||
function loadMap() {
|
||||
const src = iframe!.dataset.src;
|
||||
if (!src || (iframe!.src && iframe!.src !== 'about:blank')) return;
|
||||
iframe!.src = src;
|
||||
iframe!.removeAttribute('hidden');
|
||||
if (placeholder) placeholder.hidden = true;
|
||||
}
|
||||
|
||||
function hasConsent(): boolean {
|
||||
if (!config.consentEnabled) return true;
|
||||
if (!w.__consentState?.decided) return false;
|
||||
return !!w.__consentState.categories[config.consentCategory];
|
||||
}
|
||||
|
||||
// Check if consent is already granted
|
||||
if (hasConsent()) {
|
||||
loadMap();
|
||||
return;
|
||||
}
|
||||
|
||||
// Consent required — show placeholder
|
||||
if (config.consentEnabled) {
|
||||
if (placeholder) placeholder.hidden = false;
|
||||
|
||||
// "Load Map" button grants consent for this embed only
|
||||
if (loadBtn) {
|
||||
loadBtn.addEventListener('click', loadMap, { once: true });
|
||||
}
|
||||
|
||||
// Listen for consent-updated event
|
||||
window.addEventListener('consent-updated', function onConsent(e: Event) {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
if (detail?.categories?.[config.consentCategory]) {
|
||||
loadMap();
|
||||
window.removeEventListener('consent-updated', onConsent);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// No consent system — load immediately
|
||||
loadMap();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
initGoogleMaps();
|
||||
document.addEventListener('astro:page-load', initGoogleMaps);
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* ── No API key: setup prompt ── */
|
||||
.google-map-setup {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
background: repeating-linear-gradient(
|
||||
-45deg,
|
||||
transparent,
|
||||
transparent 8px,
|
||||
color-mix(in srgb, var(--foreground-muted) 4%, transparent) 8px,
|
||||
color-mix(in srgb, var(--foreground-muted) 4%, transparent) 9px
|
||||
);
|
||||
}
|
||||
|
||||
.google-map-setup__inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 1.5rem 2rem;
|
||||
background-color: var(--card);
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.google-map-setup__icon {
|
||||
color: var(--foreground-muted);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.google-map-setup__title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.google-map-setup__desc {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--foreground-muted);
|
||||
max-width: 22rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.google-map-setup__desc code {
|
||||
font-family: ui-monospace, 'SF Mono', 'Cascadia Code', monospace;
|
||||
font-size: 0.6875rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
background-color: var(--secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.25rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Consent placeholder ── */
|
||||
.google-map-placeholder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
background-color: var(--secondary);
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.google-map-placeholder__icon {
|
||||
color: var(--brand-500);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.google-map-placeholder__address {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--foreground);
|
||||
max-width: 24rem;
|
||||
}
|
||||
|
||||
.google-map-placeholder__title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.google-map-placeholder__desc {
|
||||
font-size: 0.875rem;
|
||||
color: var(--foreground-muted);
|
||||
max-width: 20rem;
|
||||
}
|
||||
|
||||
.google-map-placeholder__btn {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem 1.25rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: white;
|
||||
background-color: var(--brand-500);
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: opacity var(--transition-fast) ease;
|
||||
}
|
||||
|
||||
.google-map-placeholder__btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.google-map-placeholder__link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--foreground-muted);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-fast) ease;
|
||||
}
|
||||
|
||||
.google-map-placeholder__link:hover {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
/* ── Iframe ── */
|
||||
[data-map-iframe] {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,21 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
export const googleMapVariants = cva(
|
||||
'relative w-full overflow-hidden rounded-xl border border-border',
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
sm: 'h-[250px]',
|
||||
md: 'h-[400px]',
|
||||
lg: 'h-[500px]',
|
||||
xl: 'h-[600px]',
|
||||
full: 'h-[70vh]',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'md',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export type GoogleMapVariants = VariantProps<typeof googleMapVariants>;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from './GoogleMap.astro';
|
||||
export { googleMapVariants, type GoogleMapVariants } from './googleMap.variants';
|
||||
@@ -0,0 +1,135 @@
|
||||
---
|
||||
/**
|
||||
* Pagination Component
|
||||
* Page navigation with prev/next and numbered pages.
|
||||
*/
|
||||
import type { HTMLAttributes } from 'astro/types';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { paginationItemVariants } from './pagination.variants';
|
||||
import Icon from '../../primitives/Icon/Icon.astro';
|
||||
|
||||
interface Props extends HTMLAttributes<'nav'> {
|
||||
/** Current active page (1-indexed) */
|
||||
currentPage: number;
|
||||
/** Total number of pages */
|
||||
totalPages: number;
|
||||
/** Base URL for page links (page number appended) */
|
||||
baseUrl?: string;
|
||||
/** Maximum number of visible page buttons */
|
||||
maxVisible?: number;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
const {
|
||||
currentPage,
|
||||
totalPages,
|
||||
baseUrl = '?page=',
|
||||
maxVisible = 5,
|
||||
size = 'md',
|
||||
class: className,
|
||||
...attrs
|
||||
} = Astro.props;
|
||||
|
||||
/**
|
||||
* Build the array of page numbers and ellipsis markers to render.
|
||||
*
|
||||
* @param current - The 1-indexed active page.
|
||||
* @param total - Total number of pages.
|
||||
* @param max - Size of the central sliding window. When `total <= max`,
|
||||
* every page is returned directly. Otherwise a window of
|
||||
* `max` pages is centered around `current`, and first/last
|
||||
* pages plus `'...'` ellipsis markers are added outside the
|
||||
* window as needed — so the returned array can contain more
|
||||
* than `max` entries.
|
||||
* @returns An array of page numbers and `'...'` separators.
|
||||
*/
|
||||
function getPageRange(current: number, total: number, max: number): (number | '...')[] {
|
||||
if (total <= max) {
|
||||
return Array.from({ length: total }, (_, i) => i + 1);
|
||||
}
|
||||
|
||||
const pages: (number | '...')[] = [];
|
||||
const half = Math.floor(max / 2);
|
||||
let start = Math.max(1, current - half);
|
||||
let end = Math.min(total, start + max - 1);
|
||||
|
||||
if (end - start < max - 1) {
|
||||
start = Math.max(1, end - max + 1);
|
||||
}
|
||||
|
||||
if (start > 1) {
|
||||
pages.push(1);
|
||||
if (start > 2) pages.push('...');
|
||||
}
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
|
||||
if (end < total) {
|
||||
if (end < total - 1) pages.push('...');
|
||||
pages.push(total);
|
||||
}
|
||||
|
||||
return pages;
|
||||
}
|
||||
|
||||
const pages = getPageRange(currentPage, totalPages, maxVisible);
|
||||
const prevUrl = currentPage > 1 ? `${baseUrl}${currentPage - 1}` : undefined;
|
||||
const nextUrl = currentPage < totalPages ? `${baseUrl}${currentPage + 1}` : undefined;
|
||||
---
|
||||
|
||||
<nav class={cn('flex items-center gap-1', className)} aria-label="Pagination" {...attrs}>
|
||||
{/* Previous */}
|
||||
{prevUrl ? (
|
||||
<a
|
||||
href={prevUrl}
|
||||
class={cn(paginationItemVariants({ variant: 'default', size }))}
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<Icon name="chevron-left" size="sm" />
|
||||
</a>
|
||||
) : (
|
||||
<span class={cn(paginationItemVariants({ variant: 'disabled', size }))} aria-disabled="true">
|
||||
<Icon name="chevron-left" size="sm" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Page Numbers */}
|
||||
{pages.map((page) =>
|
||||
page === '...' ? (
|
||||
<span class={cn(paginationItemVariants({ variant: 'default', size }), 'cursor-default hover:bg-transparent')} role="separator" aria-label="More pages">
|
||||
<span aria-hidden="true">...</span>
|
||||
</span>
|
||||
) : page === currentPage ? (
|
||||
<span
|
||||
class={cn(paginationItemVariants({ variant: 'active', size }))}
|
||||
aria-current="page"
|
||||
>
|
||||
{page}
|
||||
</span>
|
||||
) : (
|
||||
<a
|
||||
href={`${baseUrl}${page}`}
|
||||
class={cn(paginationItemVariants({ variant: 'default', size }))}
|
||||
>
|
||||
{page}
|
||||
</a>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Next */}
|
||||
{nextUrl ? (
|
||||
<a
|
||||
href={nextUrl}
|
||||
class={cn(paginationItemVariants({ variant: 'default', size }))}
|
||||
aria-label="Next page"
|
||||
>
|
||||
<Icon name="chevron-right" size="sm" />
|
||||
</a>
|
||||
) : (
|
||||
<span class={cn(paginationItemVariants({ variant: 'disabled', size }))} aria-disabled="true">
|
||||
<Icon name="chevron-right" size="sm" />
|
||||
</span>
|
||||
)}
|
||||
</nav>
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Pagination Component (React)
|
||||
* Page navigation with prev/next and numbered pages.
|
||||
*/
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { paginationItemVariants } from './pagination.variants';
|
||||
|
||||
interface PaginationProps extends HTMLAttributes<HTMLElement> {
|
||||
/** Current active page (1-indexed) */
|
||||
currentPage: number;
|
||||
/** Total number of pages */
|
||||
totalPages: number;
|
||||
/** Callback when a page is selected */
|
||||
onPageChange: (page: number) => void;
|
||||
/** Maximum number of visible page buttons */
|
||||
maxVisible?: number;
|
||||
/** Button size variant */
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the array of page numbers and ellipsis markers to render.
|
||||
*
|
||||
* @param current - The 1-indexed active page.
|
||||
* @param total - Total number of pages.
|
||||
* @param max - Size of the central sliding window. When `total <= max`,
|
||||
* every page is returned directly. Otherwise a window of
|
||||
* `max` pages is centered around `current`, and first/last
|
||||
* pages plus `'...'` ellipsis markers are added outside the
|
||||
* window as needed — so the returned array can contain more
|
||||
* than `max` entries.
|
||||
* @returns An array of page numbers and `'...'` separators.
|
||||
*/
|
||||
function getPageRange(current: number, total: number, max: number): (number | '...')[] {
|
||||
if (total <= max) {
|
||||
return Array.from({ length: total }, (_, i) => i + 1);
|
||||
}
|
||||
|
||||
const pages: (number | '...')[] = [];
|
||||
const half = Math.floor(max / 2);
|
||||
let start = Math.max(1, current - half);
|
||||
const end = Math.min(total, start + max - 1);
|
||||
|
||||
if (end - start < max - 1) {
|
||||
start = Math.max(1, end - max + 1);
|
||||
}
|
||||
|
||||
if (start > 1) {
|
||||
pages.push(1);
|
||||
if (start > 2) pages.push('...');
|
||||
}
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
|
||||
if (end < total) {
|
||||
if (end < total - 1) pages.push('...');
|
||||
pages.push(total);
|
||||
}
|
||||
|
||||
return pages;
|
||||
}
|
||||
|
||||
export function Pagination({
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
maxVisible = 5,
|
||||
size = 'md',
|
||||
className,
|
||||
...attrs
|
||||
}: PaginationProps) {
|
||||
const pages = getPageRange(currentPage, totalPages, maxVisible);
|
||||
const hasPrev = currentPage > 1;
|
||||
const hasNext = currentPage < totalPages;
|
||||
|
||||
return (
|
||||
<nav className={cn('flex items-center gap-1', className)} aria-label="Pagination" {...attrs}>
|
||||
{/* Previous */}
|
||||
{hasPrev ? (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(paginationItemVariants({ variant: 'default', size }))}
|
||||
aria-label="Previous page"
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M15 18l-6-6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
className={cn(paginationItemVariants({ variant: 'disabled', size }))}
|
||||
aria-disabled="true"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M15 18l-6-6 6-6" />
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Page Numbers */}
|
||||
{pages.map((page, index) =>
|
||||
page === '...' ? (
|
||||
<span
|
||||
key={`ellipsis-${index}`}
|
||||
className={cn(
|
||||
paginationItemVariants({ variant: 'default', size }),
|
||||
'cursor-default hover:bg-transparent',
|
||||
)}
|
||||
role="separator"
|
||||
aria-label="More pages"
|
||||
>
|
||||
<span aria-hidden="true">...</span>
|
||||
</span>
|
||||
) : page === currentPage ? (
|
||||
<span
|
||||
key={page}
|
||||
className={cn(paginationItemVariants({ variant: 'active', size }))}
|
||||
aria-current="page"
|
||||
>
|
||||
{page}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
key={page}
|
||||
type="button"
|
||||
className={cn(paginationItemVariants({ variant: 'default', size }))}
|
||||
onClick={() => onPageChange(page)}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
|
||||
{/* Next */}
|
||||
{hasNext ? (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(paginationItemVariants({ variant: 'default', size }))}
|
||||
aria-label="Next page"
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M9 18l6-6-6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
className={cn(paginationItemVariants({ variant: 'disabled', size }))}
|
||||
aria-disabled="true"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M9 18l6-6-6-6" />
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export default Pagination;
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
@@ -0,0 +1,4 @@
|
||||
// Feedback Components
|
||||
export * from './Alert';
|
||||
export * from './Toast';
|
||||
export * from './Tooltip';
|
||||
@@ -0,0 +1,84 @@
|
||||
---
|
||||
import type { HTMLAttributes } from 'astro/types';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { isExternalUrl } from '@/lib/utils';
|
||||
import { buttonVariants } from './button.variants';
|
||||
import Icon from '@/components/ui/primitives/Icon/Icon.astro';
|
||||
|
||||
interface Props extends HTMLAttributes<'button'> {
|
||||
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'link' | 'destructive';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
loading?: boolean;
|
||||
fullWidth?: boolean;
|
||||
href?: string;
|
||||
target?: string;
|
||||
rel?: string;
|
||||
icon?: boolean;
|
||||
}
|
||||
|
||||
const {
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
loading = false,
|
||||
fullWidth = false,
|
||||
href,
|
||||
target,
|
||||
icon = false,
|
||||
class: className,
|
||||
disabled,
|
||||
...attrs
|
||||
} = Astro.props;
|
||||
|
||||
// Auto-detect external URLs and apply appropriate attributes
|
||||
const isExternal = href ? isExternalUrl(href) : false;
|
||||
const linkTarget = target ?? (isExternal ? '_blank' : undefined);
|
||||
const linkRel = isExternal ? 'noopener noreferrer' : undefined;
|
||||
|
||||
const Element = href ? 'a' : 'button';
|
||||
const isDisabled = disabled || loading;
|
||||
const isDisabledLink = href && isDisabled;
|
||||
|
||||
const classes = cn(
|
||||
buttonVariants({ variant, size, fullWidth, icon }),
|
||||
isDisabledLink && 'pointer-events-none',
|
||||
className
|
||||
);
|
||||
---
|
||||
|
||||
<Element
|
||||
class={classes}
|
||||
disabled={!href ? isDisabled : undefined}
|
||||
href={isDisabledLink ? undefined : href}
|
||||
target={isDisabledLink ? undefined : linkTarget}
|
||||
rel={isDisabledLink ? undefined : linkRel}
|
||||
aria-disabled={isDisabledLink ? 'true' : undefined}
|
||||
tabindex={isDisabledLink ? '-1' : undefined}
|
||||
{...attrs}
|
||||
>
|
||||
{
|
||||
loading ? <Icon name="loader" size="sm" class="animate-spin" /> : null
|
||||
}
|
||||
<slot />
|
||||
</Element>
|
||||
|
||||
<style is:global>
|
||||
/*
|
||||
* Dark mode: soften the flat near-white primary button background with a
|
||||
* subtle top-to-bottom gradient — same principle as the hero H1 gradient.
|
||||
* color-mix blends 55% foreground + 45% foreground-secondary so the bottom
|
||||
* of the button is slightly dimmer and brand-tinted without looking grey.
|
||||
* Hover clears the gradient image so the Tailwind bg-foreground/90 takes over.
|
||||
*/
|
||||
.dark .btn-primary {
|
||||
border: 1px solid var(--color-foreground-secondary);
|
||||
background-image: linear-gradient(
|
||||
180deg,
|
||||
var(--color-foreground) 0%,
|
||||
color-mix(in oklch, var(--color-foreground) 55%, var(--color-foreground-secondary)) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.dark .btn-primary:hover {
|
||||
background-image: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,92 @@
|
||||
import { type ButtonHTMLAttributes, type AnchorHTMLAttributes, 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;
|
||||
fullWidth?: boolean;
|
||||
icon?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
type ButtonAsButton = BaseProps &
|
||||
Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'ref'> & {
|
||||
href?: never;
|
||||
};
|
||||
|
||||
type ButtonAsLink = BaseProps &
|
||||
Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'ref'> & {
|
||||
href: string;
|
||||
};
|
||||
|
||||
type ButtonProps = ButtonAsButton | ButtonAsLink;
|
||||
|
||||
const LoadingSpinner = () => (
|
||||
<svg
|
||||
className="animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export function Button(props: ButtonProps) {
|
||||
const {
|
||||
ref,
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
loading = false,
|
||||
fullWidth = false,
|
||||
icon = false,
|
||||
className,
|
||||
children,
|
||||
disabled,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const classes = cn(buttonVariants({ variant, size, fullWidth, icon }), className);
|
||||
|
||||
if ('href' in props && props.href) {
|
||||
const isExternal = isExternalUrl(props.href);
|
||||
const linkProps = rest as AnchorHTMLAttributes<HTMLAnchorElement>;
|
||||
|
||||
return (
|
||||
<a
|
||||
ref={ref as Ref<HTMLAnchorElement>}
|
||||
className={classes}
|
||||
target={linkProps.target ?? (isExternal ? '_blank' : undefined)}
|
||||
rel={isExternal ? 'noopener noreferrer' : linkProps.rel}
|
||||
{...linkProps}
|
||||
>
|
||||
{loading && <LoadingSpinner />}
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref as Ref<HTMLButtonElement>}
|
||||
className={classes}
|
||||
disabled={disabled || loading}
|
||||
{...(rest as ButtonHTMLAttributes<HTMLButtonElement>)}
|
||||
>
|
||||
{loading && <LoadingSpinner />}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default Button;
|
||||
@@ -0,0 +1,51 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
export const buttonVariants = cva(
|
||||
[
|
||||
'inline-flex items-center justify-center gap-2',
|
||||
'font-medium rounded-md',
|
||||
'transition-all duration-150 ease-out',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
'[&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
primary:
|
||||
'btn-primary bg-foreground text-background hover:bg-foreground/90 active:scale-[0.98]',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground border border-border hover:bg-secondary-hover hover:border-border-strong active:scale-[0.98]',
|
||||
outline:
|
||||
'border border-foreground/25 bg-transparent text-foreground hover:bg-secondary hover:border-foreground/40 active:scale-[0.98]',
|
||||
ghost:
|
||||
'text-foreground-secondary hover:text-foreground hover:bg-secondary active:scale-[0.98]',
|
||||
link: 'text-foreground-secondary hover:text-foreground underline-offset-4 hover:underline',
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground hover:bg-destructive/90 active:scale-[0.98]',
|
||||
},
|
||||
size: {
|
||||
sm: 'h-8 px-3 text-xs [&_svg]:h-4 [&_svg]:w-4',
|
||||
md: 'h-10 px-4 text-sm [&_svg]:h-5 [&_svg]:w-5',
|
||||
lg: 'h-12 px-5 text-base [&_svg]:h-5 [&_svg]:w-5',
|
||||
},
|
||||
fullWidth: {
|
||||
true: 'w-full',
|
||||
},
|
||||
icon: {
|
||||
true: 'rounded-md',
|
||||
},
|
||||
},
|
||||
compoundVariants: [
|
||||
{ icon: true, size: 'sm', class: 'h-8 w-8 px-0' },
|
||||
{ icon: true, size: 'md', class: 'h-10 w-10 px-0' },
|
||||
{ icon: true, size: 'lg', class: 'h-12 w-12 px-0' },
|
||||
],
|
||||
defaultVariants: {
|
||||
variant: 'primary',
|
||||
size: 'md',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export type ButtonVariants = VariantProps<typeof buttonVariants>;
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default } from './Button.astro';
|
||||
export { Button } from './Button';
|
||||
export { buttonVariants, type ButtonVariants } from './button.variants';
|
||||
@@ -0,0 +1,97 @@
|
||||
---
|
||||
import type { HTMLAttributes } from 'astro/types';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { generateId } from '@/lib/utils';
|
||||
|
||||
interface Props extends HTMLAttributes<'input'> {
|
||||
label?: string;
|
||||
description?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const { label, description, error, class: className, id, disabled, ...attrs } = Astro.props;
|
||||
|
||||
const checkboxId = id || generateId('checkbox');
|
||||
---
|
||||
|
||||
<div class={cn('group', className)}>
|
||||
<label
|
||||
for={checkboxId}
|
||||
class={cn(
|
||||
'relative flex items-start gap-3 cursor-pointer',
|
||||
'select-none',
|
||||
disabled && 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
>
|
||||
<!-- Custom checkbox visual -->
|
||||
<div class="relative flex items-center justify-center mt-0.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={checkboxId}
|
||||
class="peer sr-only"
|
||||
aria-invalid={error ? 'true' : undefined}
|
||||
aria-describedby={description ? `${checkboxId}-description` : undefined}
|
||||
disabled={disabled}
|
||||
{...attrs}
|
||||
/>
|
||||
<!-- Checkbox box -->
|
||||
<div
|
||||
class={cn(
|
||||
'h-[18px] w-[18px] shrink-0 rounded-[5px]',
|
||||
'border-2 border-border',
|
||||
'bg-background',
|
||||
'transition-all duration-150 ease-out',
|
||||
'peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-background',
|
||||
'peer-checked:bg-foreground peer-checked:border-foreground',
|
||||
'group-hover:border-foreground-muted',
|
||||
'peer-checked:group-hover:bg-foreground/90',
|
||||
error && 'border-destructive peer-focus-visible:ring-destructive'
|
||||
)}
|
||||
/>
|
||||
<!-- Checkmark icon -->
|
||||
<svg
|
||||
class={cn(
|
||||
'absolute h-3 w-3 text-background',
|
||||
'opacity-0 scale-50',
|
||||
'transition-all duration-150 ease-out',
|
||||
'peer-checked:opacity-100 peer-checked:scale-100'
|
||||
)}
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M2.5 6L5 8.5L9.5 3.5" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Label and description -->
|
||||
{
|
||||
(label || description) && (
|
||||
<div class="grid gap-0.5 leading-tight">
|
||||
{label && (
|
||||
<span class="text-sm font-medium text-foreground">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
{description && (
|
||||
<span
|
||||
id={`${checkboxId}-description`}
|
||||
class="text-xs text-foreground-subtle leading-normal"
|
||||
>
|
||||
{description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</label>
|
||||
|
||||
{
|
||||
error && (
|
||||
<p class="mt-2 text-sm text-destructive pl-[30px]">{error}</p>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,96 @@
|
||||
import { type InputHTMLAttributes, type Ref, useId } from 'react';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
interface CheckboxProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'ref'> {
|
||||
ref?: Ref<HTMLInputElement>;
|
||||
label?: string;
|
||||
description?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function Checkbox({ ref, label, description, error, className, id, ...props }: CheckboxProps) {
|
||||
const generatedId = useId();
|
||||
const checkboxId = id || generatedId;
|
||||
|
||||
return (
|
||||
<div className={cn('group', className)}>
|
||||
<label
|
||||
htmlFor={checkboxId}
|
||||
className={cn(
|
||||
'relative flex items-start gap-3 cursor-pointer',
|
||||
'select-none',
|
||||
props.disabled && 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
>
|
||||
{/* Custom checkbox visual */}
|
||||
<div className="relative flex items-center justify-center mt-0.5">
|
||||
<input
|
||||
ref={ref}
|
||||
type="checkbox"
|
||||
id={checkboxId}
|
||||
className="peer sr-only"
|
||||
aria-invalid={error ? 'true' : undefined}
|
||||
aria-describedby={description ? `${checkboxId}-description` : undefined}
|
||||
{...props}
|
||||
/>
|
||||
{/* Checkbox box */}
|
||||
<div
|
||||
className={cn(
|
||||
'h-[18px] w-[18px] shrink-0 rounded-[5px]',
|
||||
'border-2 border-border',
|
||||
'bg-background',
|
||||
'transition-all duration-150 ease-out',
|
||||
'peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-background',
|
||||
'peer-checked:bg-foreground peer-checked:border-foreground',
|
||||
'peer-hover:border-foreground-muted',
|
||||
'peer-checked:peer-hover:bg-foreground/90',
|
||||
error && 'border-destructive peer-focus-visible:ring-destructive'
|
||||
)}
|
||||
/>
|
||||
{/* Checkmark icon */}
|
||||
<svg
|
||||
className={cn(
|
||||
'absolute h-3 w-3 text-background',
|
||||
'opacity-0 scale-50',
|
||||
'transition-all duration-150 ease-out',
|
||||
'peer-checked:opacity-100 peer-checked:scale-100'
|
||||
)}
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M2.5 6L5 8.5L9.5 3.5" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Label and description */}
|
||||
{(label || description) && (
|
||||
<div className="grid gap-0.5 leading-tight">
|
||||
{label && (
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
{description && (
|
||||
<span
|
||||
id={`${checkboxId}-description`}
|
||||
className="text-xs text-foreground-subtle leading-normal"
|
||||
>
|
||||
{description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
|
||||
{error && (
|
||||
<p className="mt-2 text-sm text-destructive pl-[30px]">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Checkbox;
|
||||
@@ -0,0 +1,21 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
export const checkboxBoxVariants = cva(
|
||||
[
|
||||
'h-[18px] w-[18px] shrink-0 rounded-[5px]',
|
||||
'border-2 border-border',
|
||||
'bg-background',
|
||||
'transition-all duration-150 ease-out',
|
||||
'peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-background',
|
||||
'peer-checked:bg-foreground peer-checked:border-foreground',
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
error: {
|
||||
true: 'border-destructive peer-focus-visible:ring-destructive',
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export type CheckboxVariants = VariantProps<typeof checkboxBoxVariants>;
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default } from './Checkbox.astro';
|
||||
export { Checkbox } from './Checkbox';
|
||||
export { checkboxBoxVariants, type CheckboxVariants } from './checkbox.variants';
|
||||
@@ -0,0 +1,90 @@
|
||||
---
|
||||
import type { HTMLAttributes } from 'astro/types';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { generateId } from '@/lib/utils';
|
||||
import { inputVariants, inputSizeConfig } from './input.variants';
|
||||
|
||||
interface Props extends HTMLAttributes<'input'> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
hint?: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
/** Icon name to display at the start of the input */
|
||||
leadingIcon?: string;
|
||||
/** Icon name to display at the end of the input */
|
||||
trailingIcon?: string;
|
||||
}
|
||||
|
||||
const { label, error, hint, size = 'md', leadingIcon, trailingIcon, class: className, id, ...attrs } = Astro.props;
|
||||
|
||||
const inputId = id || generateId('input');
|
||||
const config = inputSizeConfig[size];
|
||||
|
||||
const inputStyles = cn(
|
||||
inputVariants({ size }),
|
||||
error && 'border-destructive focus-visible:ring-destructive',
|
||||
leadingIcon ? config.leadingPadding : config.baseLeftPadding,
|
||||
trailingIcon ? config.trailingPadding : config.baseRightPadding
|
||||
);
|
||||
|
||||
const iconStyles = cn(
|
||||
'absolute top-0 flex items-center justify-center h-full pointer-events-none',
|
||||
'text-foreground-muted',
|
||||
config.iconWrapper
|
||||
);
|
||||
|
||||
// Dynamic icon import
|
||||
import Icon from '../../primitives/Icon/Icon.astro';
|
||||
---
|
||||
|
||||
<div class={cn('space-y-1.5', className)}>
|
||||
{
|
||||
label && (
|
||||
<label for={inputId} class="text-sm font-medium leading-none">
|
||||
{label}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
<div class="relative">
|
||||
{
|
||||
leadingIcon && (
|
||||
<div class={cn(iconStyles, 'left-0')}>
|
||||
<Icon name={leadingIcon} size="sm" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<input
|
||||
id={inputId}
|
||||
class={inputStyles}
|
||||
aria-invalid={error ? 'true' : undefined}
|
||||
aria-describedby={error ? `${inputId}-error` : hint ? `${inputId}-hint` : undefined}
|
||||
{...attrs}
|
||||
/>
|
||||
|
||||
{
|
||||
trailingIcon && (
|
||||
<div class={cn(iconStyles, 'right-0')}>
|
||||
<Icon name={trailingIcon} size="sm" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</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>
|
||||
@@ -0,0 +1,83 @@
|
||||
import { type InputHTMLAttributes, type Ref, type ReactNode, useId } from 'react';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { inputVariants, inputSizeConfig } from './input.variants';
|
||||
|
||||
type InputSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'ref' | 'size'> {
|
||||
ref?: Ref<HTMLInputElement>;
|
||||
label?: string;
|
||||
error?: string;
|
||||
hint?: string;
|
||||
/** Icon to display at the start of the input */
|
||||
leadingIcon?: ReactNode;
|
||||
/** Icon to display at the end of the input */
|
||||
trailingIcon?: ReactNode;
|
||||
size?: InputSize;
|
||||
}
|
||||
|
||||
export function Input({ ref, label, error, hint, leadingIcon, trailingIcon, size = 'md', className, id, ...props }: InputProps) {
|
||||
const generatedId = useId();
|
||||
const inputId = id || generatedId;
|
||||
const config = inputSizeConfig[size];
|
||||
|
||||
const inputStyles = cn(
|
||||
inputVariants({ size }),
|
||||
error && 'border-destructive focus-visible:ring-destructive',
|
||||
leadingIcon ? config.leadingPadding : config.baseLeftPadding,
|
||||
trailingIcon ? config.trailingPadding : config.baseRightPadding
|
||||
);
|
||||
|
||||
const iconStyles = cn(
|
||||
'absolute top-0 flex items-center justify-center h-full pointer-events-none',
|
||||
'text-foreground-muted',
|
||||
config.iconWrapper
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-1.5', className)}>
|
||||
{label && (
|
||||
<label htmlFor={inputId} className="text-sm font-medium leading-none">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
{leadingIcon && (
|
||||
<div className={cn(iconStyles, 'left-0')}>
|
||||
{leadingIcon}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={ref}
|
||||
id={inputId}
|
||||
className={inputStyles}
|
||||
aria-invalid={error ? 'true' : undefined}
|
||||
aria-describedby={error ? `${inputId}-error` : hint ? `${inputId}-hint` : undefined}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
{trailingIcon && (
|
||||
<div className={cn(iconStyles, 'right-0')}>
|
||||
{trailingIcon}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p id={`${inputId}-error`} className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{hint && !error && (
|
||||
<p id={`${inputId}-hint`} className="text-sm text-muted-foreground">
|
||||
{hint}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Input;
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default } from './Input.astro';
|
||||
export { Input } from './Input';
|
||||
export { inputVariants, inputSizeConfig, type InputVariants } from './input.variants';
|
||||
@@ -0,0 +1,51 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
export const inputVariants = cva(
|
||||
[
|
||||
'w-full rounded-lg border',
|
||||
'transition-colors duration-(--transition-fast)',
|
||||
'focus-visible:outline-none focus-visible:ring-2',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'bg-background border-brand-500/30',
|
||||
'placeholder:text-muted-foreground',
|
||||
'focus-visible:ring-ring',
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
sm: 'h-8 text-sm',
|
||||
md: 'h-10 text-sm',
|
||||
lg: 'h-12 text-base',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'md',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export const inputSizeConfig = {
|
||||
sm: {
|
||||
iconWrapper: 'w-8',
|
||||
leadingPadding: 'pl-8',
|
||||
trailingPadding: 'pr-8',
|
||||
baseLeftPadding: 'pl-3',
|
||||
baseRightPadding: 'pr-3',
|
||||
},
|
||||
md: {
|
||||
iconWrapper: 'w-10',
|
||||
leadingPadding: 'pl-10',
|
||||
trailingPadding: 'pr-10',
|
||||
baseLeftPadding: 'pl-4',
|
||||
baseRightPadding: 'pr-4',
|
||||
},
|
||||
lg: {
|
||||
iconWrapper: 'w-12',
|
||||
leadingPadding: 'pl-12',
|
||||
trailingPadding: 'pr-12',
|
||||
baseLeftPadding: 'pl-4',
|
||||
baseRightPadding: 'pr-4',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type InputVariants = VariantProps<typeof inputVariants>;
|
||||
@@ -0,0 +1,170 @@
|
||||
---
|
||||
import type { HTMLAttributes } from 'astro/types';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { generateId } from '@/lib/utils';
|
||||
|
||||
interface Props extends HTMLAttributes<'input'> {
|
||||
label?: string;
|
||||
description?: string;
|
||||
error?: string;
|
||||
variant?: 'default' | 'card';
|
||||
}
|
||||
|
||||
const { label, description, error, variant = 'default', class: className, id, disabled, ...attrs } = Astro.props;
|
||||
|
||||
const radioId = id || generateId('radio');
|
||||
const isCard = variant === 'card';
|
||||
---
|
||||
|
||||
{isCard ? (
|
||||
<div class={cn('group', className)}>
|
||||
<label
|
||||
for={radioId}
|
||||
class={cn(
|
||||
'relative flex items-start gap-4 p-4 cursor-pointer',
|
||||
'rounded-xl border-2 border-border',
|
||||
'bg-background',
|
||||
'transition-all duration-150 ease-out',
|
||||
'hover:border-foreground-subtle hover:bg-secondary/30',
|
||||
'has-[:checked]:border-foreground has-[:checked]:bg-secondary/50',
|
||||
'has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring has-[:focus-visible]:ring-offset-2',
|
||||
disabled && 'cursor-not-allowed opacity-50',
|
||||
error && 'border-destructive'
|
||||
)}
|
||||
>
|
||||
<!-- Radio indicator with checkmark inside -->
|
||||
<div class="relative flex items-center justify-center mt-0.5 shrink-0">
|
||||
<input
|
||||
type="radio"
|
||||
id={radioId}
|
||||
class="peer sr-only"
|
||||
aria-invalid={error ? 'true' : undefined}
|
||||
aria-describedby={description ? `${radioId}-description` : undefined}
|
||||
disabled={disabled}
|
||||
{...attrs}
|
||||
/>
|
||||
<!-- Outer circle -->
|
||||
<div
|
||||
class={cn(
|
||||
'h-5 w-5 rounded-full',
|
||||
'border-2 border-border',
|
||||
'bg-background',
|
||||
'transition-all duration-150 ease-out',
|
||||
'peer-checked:border-foreground peer-checked:bg-foreground',
|
||||
'group-hover:border-foreground-muted'
|
||||
)}
|
||||
/>
|
||||
<!-- Checkmark inside circle -->
|
||||
<svg
|
||||
class={cn(
|
||||
'absolute h-3 w-3 text-background',
|
||||
'opacity-0 scale-50',
|
||||
'transition-all duration-150 ease-out',
|
||||
'peer-checked:opacity-100 peer-checked:scale-100'
|
||||
)}
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M2.5 6L5 8.5L9.5 3.5" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
{(label || description) && (
|
||||
<div class="flex-1 grid gap-0.5">
|
||||
{label && (
|
||||
<span class="text-sm font-semibold text-foreground">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
{description && (
|
||||
<span
|
||||
id={`${radioId}-description`}
|
||||
class="text-xs text-foreground-subtle leading-normal"
|
||||
>
|
||||
{description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
|
||||
{error && (
|
||||
<p class="mt-2 text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div class={cn('group', className)}>
|
||||
<label
|
||||
for={radioId}
|
||||
class={cn(
|
||||
'relative flex items-start gap-3 cursor-pointer',
|
||||
'select-none',
|
||||
disabled && 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
>
|
||||
<!-- Custom radio visual -->
|
||||
<div class="relative flex items-center justify-center mt-0.5 shrink-0">
|
||||
<input
|
||||
type="radio"
|
||||
id={radioId}
|
||||
class="peer sr-only"
|
||||
aria-invalid={error ? 'true' : undefined}
|
||||
aria-describedby={description ? `${radioId}-description` : undefined}
|
||||
disabled={disabled}
|
||||
{...attrs}
|
||||
/>
|
||||
<!-- Radio circle -->
|
||||
<div
|
||||
class={cn(
|
||||
'h-[18px] w-[18px] rounded-full',
|
||||
'border-2 border-border',
|
||||
'bg-background',
|
||||
'transition-all duration-150 ease-out',
|
||||
'peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-background',
|
||||
'peer-checked:border-foreground',
|
||||
'group-hover:border-foreground-muted',
|
||||
error && 'border-destructive peer-focus-visible:ring-destructive'
|
||||
)}
|
||||
/>
|
||||
<!-- Inner dot -->
|
||||
<div
|
||||
class={cn(
|
||||
'absolute h-2 w-2 rounded-full',
|
||||
'bg-foreground',
|
||||
'opacity-0 scale-0',
|
||||
'transition-all duration-150 ease-out',
|
||||
'peer-checked:opacity-100 peer-checked:scale-100'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Label and description -->
|
||||
{(label || description) && (
|
||||
<div class="grid gap-0.5 leading-tight">
|
||||
{label && (
|
||||
<span class="text-sm font-medium text-foreground">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
{description && (
|
||||
<span
|
||||
id={`${radioId}-description`}
|
||||
class="text-xs text-foreground-subtle leading-normal"
|
||||
>
|
||||
{description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
|
||||
{error && (
|
||||
<p class="mt-2 text-sm text-destructive pl-[30px]">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { type InputHTMLAttributes, type Ref, useId } from 'react';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
interface RadioProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'ref'> {
|
||||
ref?: Ref<HTMLInputElement>;
|
||||
label?: string;
|
||||
description?: string;
|
||||
error?: string;
|
||||
/** Use card-style selection UI */
|
||||
variant?: 'default' | 'card';
|
||||
}
|
||||
|
||||
export function Radio({ ref, label, description, error, variant = 'default', className, id, ...props }: RadioProps) {
|
||||
const generatedId = useId();
|
||||
const radioId = id || generatedId;
|
||||
|
||||
if (variant === 'card') {
|
||||
return (
|
||||
<div className={cn('group', className)}>
|
||||
<label
|
||||
htmlFor={radioId}
|
||||
className={cn(
|
||||
'relative flex items-start gap-4 p-4 cursor-pointer',
|
||||
'rounded-xl border-2 border-border',
|
||||
'bg-background',
|
||||
'transition-all duration-150 ease-out',
|
||||
'hover:border-foreground-subtle hover:bg-secondary/30',
|
||||
'has-[:checked]:border-foreground has-[:checked]:bg-secondary/50',
|
||||
'has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring has-[:focus-visible]:ring-offset-2',
|
||||
props.disabled && 'cursor-not-allowed opacity-50',
|
||||
error && 'border-destructive'
|
||||
)}
|
||||
>
|
||||
{/* Radio indicator with checkmark inside */}
|
||||
<div className="relative flex items-center justify-center mt-0.5 shrink-0">
|
||||
<input
|
||||
ref={ref}
|
||||
type="radio"
|
||||
id={radioId}
|
||||
className="peer sr-only"
|
||||
aria-invalid={error ? 'true' : undefined}
|
||||
aria-describedby={description ? `${radioId}-description` : undefined}
|
||||
{...props}
|
||||
/>
|
||||
{/* Outer circle */}
|
||||
<div
|
||||
className={cn(
|
||||
'h-5 w-5 rounded-full',
|
||||
'border-2 border-border',
|
||||
'bg-background',
|
||||
'transition-all duration-150 ease-out',
|
||||
'peer-checked:border-foreground peer-checked:bg-foreground',
|
||||
'group-hover:border-foreground-muted'
|
||||
)}
|
||||
/>
|
||||
{/* Checkmark inside circle */}
|
||||
<svg
|
||||
className={cn(
|
||||
'absolute h-3 w-3 text-background',
|
||||
'opacity-0 scale-50',
|
||||
'transition-all duration-150 ease-out',
|
||||
'peer-checked:opacity-100 peer-checked:scale-100'
|
||||
)}
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M2.5 6L5 8.5L9.5 3.5" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{(label || description) && (
|
||||
<div className="flex-1 grid gap-0.5">
|
||||
{label && (
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
{description && (
|
||||
<span
|
||||
id={`${radioId}-description`}
|
||||
className="text-xs text-foreground-subtle leading-normal"
|
||||
>
|
||||
{description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
|
||||
{error && (
|
||||
<p className="mt-2 text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Default radio style
|
||||
return (
|
||||
<div className={cn('group', className)}>
|
||||
<label
|
||||
htmlFor={radioId}
|
||||
className={cn(
|
||||
'relative flex items-start gap-3 cursor-pointer',
|
||||
'select-none',
|
||||
props.disabled && 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
>
|
||||
{/* Custom radio visual */}
|
||||
<div className="relative flex items-center justify-center mt-0.5 shrink-0">
|
||||
<input
|
||||
ref={ref}
|
||||
type="radio"
|
||||
id={radioId}
|
||||
className="peer sr-only"
|
||||
aria-invalid={error ? 'true' : undefined}
|
||||
aria-describedby={description ? `${radioId}-description` : undefined}
|
||||
{...props}
|
||||
/>
|
||||
{/* Radio circle */}
|
||||
<div
|
||||
className={cn(
|
||||
'h-[18px] w-[18px] rounded-full',
|
||||
'border-2 border-border',
|
||||
'bg-background',
|
||||
'transition-all duration-150 ease-out',
|
||||
'peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-background',
|
||||
'peer-checked:border-foreground',
|
||||
'group-hover:border-foreground-muted',
|
||||
error && 'border-destructive peer-focus-visible:ring-destructive'
|
||||
)}
|
||||
/>
|
||||
{/* Inner dot */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute h-2 w-2 rounded-full',
|
||||
'bg-foreground',
|
||||
'opacity-0 scale-0',
|
||||
'transition-all duration-150 ease-out',
|
||||
'peer-checked:opacity-100 peer-checked:scale-100'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Label and description */}
|
||||
{(label || description) && (
|
||||
<div className="grid gap-0.5 leading-tight">
|
||||
{label && (
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
{description && (
|
||||
<span
|
||||
id={`${radioId}-description`}
|
||||
className="text-xs text-foreground-subtle leading-normal"
|
||||
>
|
||||
{description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
|
||||
{error && (
|
||||
<p className="mt-2 text-sm text-destructive pl-[30px]">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Radio;
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default } from './Radio.astro';
|
||||
export { Radio } from './Radio';
|
||||
export { radioCircleVariants, radioCardVariants, type RadioVariants } from './radio.variants';
|
||||
@@ -0,0 +1,50 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
export const radioCircleVariants = cva(
|
||||
[
|
||||
'h-[18px] w-[18px] rounded-full',
|
||||
'border-2 border-border',
|
||||
'bg-background',
|
||||
'transition-all duration-150 ease-out',
|
||||
'peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-background',
|
||||
'peer-checked:border-foreground',
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: '',
|
||||
card: 'h-5 w-5 peer-checked:bg-foreground',
|
||||
},
|
||||
error: {
|
||||
true: 'border-destructive peer-focus-visible:ring-destructive',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export const radioCardVariants = cva(
|
||||
[
|
||||
'relative flex items-start gap-4 p-4 cursor-pointer',
|
||||
'rounded-xl border-2 border-border',
|
||||
'bg-background',
|
||||
'transition-all duration-150 ease-out',
|
||||
'hover:border-foreground-subtle hover:bg-secondary/30',
|
||||
'has-[:checked]:border-foreground has-[:checked]:bg-secondary/50',
|
||||
'has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring has-[:focus-visible]:ring-offset-2',
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
disabled: {
|
||||
true: 'cursor-not-allowed opacity-50',
|
||||
},
|
||||
error: {
|
||||
true: 'border-destructive',
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export type RadioVariants = VariantProps<typeof radioCircleVariants>;
|
||||
@@ -0,0 +1,99 @@
|
||||
---
|
||||
import type { HTMLAttributes } from 'astro/types';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { generateId } from '@/lib/utils';
|
||||
import { selectVariants } from './select.variants';
|
||||
|
||||
interface Option {
|
||||
value: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface Props extends HTMLAttributes<'select'> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
hint?: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
options: Option[];
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
label,
|
||||
error,
|
||||
hint,
|
||||
size = 'md',
|
||||
options,
|
||||
placeholder,
|
||||
class: className,
|
||||
id,
|
||||
...attrs
|
||||
} = Astro.props;
|
||||
|
||||
const selectId = id || generateId('select');
|
||||
|
||||
const selectStyles = cn(
|
||||
selectVariants({ size }),
|
||||
error && 'border-destructive focus-visible:ring-destructive'
|
||||
);
|
||||
---
|
||||
|
||||
<div class={cn('space-y-1.5', className)}>
|
||||
{
|
||||
label && (
|
||||
<label for={selectId} class="text-sm font-medium leading-none">
|
||||
{label}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
<div class="relative">
|
||||
<select
|
||||
id={selectId}
|
||||
class={selectStyles}
|
||||
aria-invalid={error ? 'true' : undefined}
|
||||
aria-describedby={error ? `${selectId}-error` : hint ? `${selectId}-hint` : undefined}
|
||||
{...attrs}
|
||||
>
|
||||
{placeholder && <option value="" disabled selected>{placeholder}</option>}
|
||||
{options.map((option) => (
|
||||
<option value={option.value} disabled={option.disabled}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
<svg
|
||||
class="h-4 w-4 text-muted-foreground"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{
|
||||
error && (
|
||||
<p id={`${selectId}-error`} class="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
hint && !error && (
|
||||
<p id={`${selectId}-hint`} class="text-sm text-muted-foreground">
|
||||
{hint}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user