First Release v1.0.0
Deploy to Azure Static Web Apps / build_and_deploy (push) Has been cancelled
Deploy to Azure Static Web Apps / close_pull_request (push) Has been cancelled

This commit is contained in:
Daniel Krähenbühl
2026-06-16 21:52:55 +02:00
commit 4f304b8ed4
297 changed files with 32673 additions and 0 deletions
@@ -0,0 +1,67 @@
---
/**
* Avatar Component
* Displays user avatars with fallback initials
*/
import type { HTMLAttributes } from 'astro/types';
import { cn } from '@/lib/cn';
import { avatarVariants } from './avatar.variants';
interface Props extends HTMLAttributes<'div'> {
src?: string;
alt?: string;
fallback?: string;
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
}
const { src, alt = '', fallback, size = 'md', class: className, ...attrs } = Astro.props;
// Generate initials from alt text or fallback
const initials = fallback || alt
.split(' ')
.map((word) => word[0])
.join('')
.slice(0, 2)
.toUpperCase();
---
<div class={cn(avatarVariants({ size }), className)} {...attrs}>
{src ? (
<img
src={src}
alt={alt}
class="w-full h-full object-cover"
loading="lazy"
decoding="async"
data-avatar-img
/>
<span class="hidden items-center justify-center w-full h-full" aria-hidden="true" data-avatar-fallback>
{initials || '?'}
</span>
) : (
<span aria-hidden="true">{initials || '?'}</span>
)}
<span class="sr-only">{alt || 'User avatar'}</span>
</div>
<script>
function initAvatarFallbacks() {
const avatarImgs = document.querySelectorAll<HTMLImageElement>('[data-avatar-img]');
avatarImgs.forEach((img) => {
if (img.dataset.avatarInit) return;
img.dataset.avatarInit = 'true';
img.addEventListener('error', () => {
img.classList.add('hidden');
const fallback = img.nextElementSibling as HTMLElement | null;
if (fallback && fallback.hasAttribute('data-avatar-fallback')) {
fallback.classList.remove('hidden');
fallback.classList.add('flex');
}
});
});
}
initAvatarFallbacks();
document.addEventListener('astro:page-load', initAvatarFallbacks);
</script>
@@ -0,0 +1,47 @@
import { type HTMLAttributes, type Ref, useState } from 'react';
import { cn } from '@/lib/cn';
import { avatarVariants, type AvatarVariants } from './avatar.variants';
interface AvatarProps extends Omit<HTMLAttributes<HTMLDivElement>, 'ref'> {
ref?: Ref<HTMLDivElement>;
src?: string;
alt?: string;
fallback?: string;
size?: AvatarVariants['size'];
}
export function Avatar({ ref, src, alt = '', fallback, size = 'md', className, ...rest }: AvatarProps) {
const [imgError, setImgError] = useState(false);
const initials = fallback || alt
.split(' ')
.map((word) => word[0])
.join('')
.slice(0, 2)
.toUpperCase();
return (
<div ref={ref} className={cn(avatarVariants({ size }), className)} {...rest}>
{src && !imgError ? (
<>
<img
src={src}
alt={alt}
className="w-full h-full object-cover"
loading="lazy"
decoding="async"
onError={() => setImgError(true)}
/>
<span className="sr-only">{alt || 'User avatar'}</span>
</>
) : (
<>
<span aria-hidden="true">{initials || '?'}</span>
<span className="sr-only">{alt || 'User avatar'}</span>
</>
)}
</div>
);
}
export default Avatar;
@@ -0,0 +1,26 @@
import { cva, type VariantProps } from 'class-variance-authority';
export const avatarVariants = cva(
[
'relative inline-flex items-center justify-center',
'rounded-full overflow-hidden',
'bg-secondary text-secondary-foreground font-medium',
'ring-2 ring-background',
],
{
variants: {
size: {
xs: 'w-6 h-6 text-[10px]',
sm: 'w-8 h-8 text-xs',
md: 'w-10 h-10 text-sm',
lg: 'w-12 h-12 text-base',
xl: 'w-16 h-16 text-lg',
},
},
defaultVariants: {
size: 'md',
},
}
);
export type AvatarVariants = VariantProps<typeof avatarVariants>;
@@ -0,0 +1,3 @@
export { default } from './Avatar.astro';
export { Avatar } from './Avatar';
export { avatarVariants, type AvatarVariants } from './avatar.variants';
@@ -0,0 +1,66 @@
---
/**
* AvatarGroup Component
* Displays stacked avatars with an optional "+N" overflow indicator.
*/
import type { HTMLAttributes } from 'astro/types';
import { cn } from '@/lib/cn';
import Avatar from '../Avatar/Avatar.astro';
interface AvatarItem {
src?: string;
alt?: string;
fallback?: string;
}
interface Props extends HTMLAttributes<'div'> {
avatars: AvatarItem[];
/** Maximum number of avatars to show before "+N" */
max?: number;
size?: 'xs' | 'sm' | 'md' | 'lg';
}
const {
avatars,
max = 4,
size = 'md',
class: className,
...attrs
} = Astro.props;
const visibleAvatars = avatars.slice(0, max);
const overflowCount = Math.max(0, avatars.length - max);
const overflowSizes = {
xs: 'w-6 h-6 text-[8px]',
sm: 'w-8 h-8 text-[10px]',
md: 'w-10 h-10 text-xs',
lg: 'w-12 h-12 text-sm',
};
---
<div class={cn('flex -space-x-2', className)} {...attrs}>
{visibleAvatars.map((avatar) => (
<Avatar
src={avatar.src}
alt={avatar.alt || ''}
fallback={avatar.fallback}
size={size}
class="ring-2 ring-background"
/>
))}
{overflowCount > 0 && (
<div
class={cn(
'relative inline-flex items-center justify-center',
'rounded-full overflow-hidden',
'bg-secondary text-foreground-muted font-semibold',
'ring-2 ring-background',
overflowSizes[size]
)}
aria-label={`${overflowCount} more`}
>
+{overflowCount}
</div>
)}
</div>
@@ -0,0 +1,61 @@
import { type HTMLAttributes, type Ref } from 'react';
import { cn } from '@/lib/cn';
import { Avatar } from '../Avatar/Avatar';
import type { AvatarVariants } from '../Avatar/avatar.variants';
interface AvatarItem {
src?: string;
alt?: string;
fallback?: string;
}
interface AvatarGroupProps extends Omit<HTMLAttributes<HTMLDivElement>, 'ref'> {
ref?: Ref<HTMLDivElement>;
avatars: AvatarItem[];
max?: number;
size?: NonNullable<AvatarVariants['size']>;
}
const overflowSizes: Record<string, string> = {
xs: 'w-6 h-6 text-[8px]',
sm: 'w-8 h-8 text-[10px]',
md: 'w-10 h-10 text-xs',
lg: 'w-12 h-12 text-sm',
xl: 'w-14 h-14 text-base',
};
export function AvatarGroup({ ref, avatars, max = 4, size = 'md', className, ...rest }: AvatarGroupProps) {
const visibleAvatars = avatars.slice(0, max);
const overflowCount = Math.max(0, avatars.length - max);
return (
<div ref={ref} className={cn('flex -space-x-2', className)} {...rest}>
{visibleAvatars.map((avatar, i) => (
<Avatar
key={i}
src={avatar.src}
alt={avatar.alt || ''}
fallback={avatar.fallback}
size={size}
className="ring-2 ring-background"
/>
))}
{overflowCount > 0 && (
<div
className={cn(
'relative inline-flex items-center justify-center',
'rounded-full overflow-hidden',
'bg-secondary text-foreground-muted font-semibold',
'ring-2 ring-background',
overflowSizes[size]
)}
aria-label={`${overflowCount} more`}
>
+{overflowCount}
</div>
)}
</div>
);
}
export default AvatarGroup;
@@ -0,0 +1,2 @@
export { default } from './AvatarGroup.astro';
export { AvatarGroup } from './AvatarGroup';
@@ -0,0 +1,30 @@
---
/**
* Badge Component
* Displays a small status indicator or label with proper icon spacing
*/
import type { HTMLAttributes } from 'astro/types';
import { cn } from '@/lib/cn';
import { badgeVariants } from './badge.variants';
interface Props extends HTMLAttributes<'span'> {
variant?: 'default' | 'success' | 'warning' | 'error' | 'info' | 'brand';
size?: 'sm' | 'md';
/** Show a pulsing dot indicator */
pulse?: boolean;
/** Use pill styling (fully rounded with shadow) */
pill?: boolean;
}
const { variant = 'default', size = 'md', pulse = false, pill = false, class: className, ...attrs } = Astro.props;
---
<span class={cn(badgeVariants({ variant, size, pill }), className)} {...attrs}>
{pulse && (
<span class="relative flex h-2 w-2 shrink-0" aria-hidden="true">
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-brand-500 opacity-75" />
<span class="relative inline-flex h-2 w-2 rounded-full bg-brand-500" />
</span>
)}
<slot />
</span>
@@ -0,0 +1,25 @@
import { type HTMLAttributes, type Ref, type ReactNode } from 'react';
import { cn } from '@/lib/cn';
import { badgeVariants, type BadgeVariants } from './badge.variants';
interface BadgeProps extends Omit<HTMLAttributes<HTMLSpanElement>, 'ref'> {
ref?: Ref<HTMLSpanElement>;
variant?: BadgeVariants['variant'];
size?: BadgeVariants['size'];
pulse?: boolean;
pill?: boolean;
children?: ReactNode;
}
export function Badge({ ref, variant = 'default', size = 'md', pulse = false, pill = false, className, children, ...rest }: BadgeProps) {
return (
<span ref={ref} className={cn(badgeVariants({ variant, size, pill }), className)} {...rest}>
{pulse && (
<span className="flex h-2 w-2 shrink-0 animate-pulse rounded-full bg-brand-500" aria-hidden="true" />
)}
{children}
</span>
);
}
export default Badge;
@@ -0,0 +1,44 @@
import { cva, type VariantProps } from 'class-variance-authority';
export const badgeVariants = cva(
[
'inline-flex items-center font-medium border',
'transition-colors',
'[&>svg]:shrink-0 [&>svg]:h-3 [&>svg]:w-3',
],
{
variants: {
variant: {
default: 'bg-secondary text-secondary-foreground border-border',
success:
'bg-[var(--success-light)] text-[var(--success-foreground)] border-[var(--success)]/20',
warning:
'bg-[var(--warning-light)] text-[var(--warning-foreground)] border-[var(--warning)]/20',
error:
'bg-[var(--error-light)] text-[var(--error-foreground)] border-[var(--error)]/20',
info: 'bg-[var(--info-light)] text-[var(--info-foreground)] border-[var(--info)]/20',
brand:
'bg-brand-500/10 text-brand-600 border-brand-500/20 dark:text-brand-400',
},
size: {
sm: 'text-[10px] px-2 py-0.5 gap-1',
md: 'text-sm sm:text-xs px-2.5 py-1 gap-1.5',
},
pill: {
true: 'rounded-full shadow-sm',
false: 'rounded-md',
},
},
compoundVariants: [
{ pill: true, size: 'sm', class: 'px-2.5' },
{ pill: true, size: 'md', class: 'px-3.5 sm:px-3' },
],
defaultVariants: {
variant: 'default',
size: 'md',
pill: false,
},
}
);
export type BadgeVariants = VariantProps<typeof badgeVariants>;
@@ -0,0 +1,3 @@
export { default } from './Badge.astro';
export { Badge } from './Badge';
export { badgeVariants, type BadgeVariants } from './badge.variants';
@@ -0,0 +1,33 @@
---
import type { HTMLAttributes } from 'astro/types';
import { cn } from '@/lib/cn';
import { cardVariants } from './card.variants';
interface Props extends HTMLAttributes<'div'>, Pick<HTMLAttributes<'a'>, 'target' | 'rel'> {
variant?: 'default' | 'solid' | 'outline' | 'ghost' | 'elevated';
padding?: 'none' | 'sm' | 'md' | 'lg';
hover?: boolean;
href?: string;
}
const {
variant = 'default',
padding = 'md',
hover = false,
href,
class: className,
...attrs
} = Astro.props;
const Element = href ? 'a' : 'div';
const cardStyles = cn(
cardVariants({ variant, padding, hover }),
href && 'block cursor-pointer',
className
);
---
<Element class={cardStyles} href={href} {...attrs}>
<slot />
</Element>
@@ -0,0 +1,156 @@
import { type HTMLAttributes, type Ref, type ReactNode } from 'react';
import { cn } from '@/lib/cn';
import { cardVariants, type CardVariants } from './card.variants';
type CardShadow = 'none' | 'sm' | 'md' | 'lg';
interface CardProps extends Omit<HTMLAttributes<HTMLDivElement>, 'ref'> {
ref?: Ref<HTMLDivElement>;
padding?: CardVariants['padding'];
shadow?: CardShadow;
hover?: boolean;
/** Visual style variant */
variant?: CardVariants['variant'];
/** Icon element to display in the card header */
icon?: ReactNode;
/** Card title */
title?: string;
/** Card subtitle/byline */
subtitle?: string;
/** Card description */
description?: string;
/** Whether to use the structured layout with icon/title/description */
structured?: boolean;
}
const shadows: Record<CardShadow, string> = {
none: '',
sm: 'shadow-sm',
md: 'shadow-md',
lg: 'shadow-lg',
};
export function Card({
ref,
padding = 'md',
shadow = 'none',
hover = false,
variant = 'default',
icon,
title,
subtitle,
description,
structured = false,
className,
children,
...props
}: CardProps) {
const cardStyles = cn(
cardVariants({ variant, padding, hover }),
shadows[shadow],
className
);
// If using structured layout with icon/title/description
if (structured || icon || title) {
return (
<div ref={ref} className={cardStyles} {...props}>
<div className="flex items-start gap-4">
{icon && (
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-500/5 flex items-center justify-center text-brand-500 shrink-0">
{icon}
</div>
)}
{(title || subtitle) && (
<div className="flex-1 min-w-0">
{title && (
<h3 className="text-base font-semibold text-foreground">{title}</h3>
)}
{subtitle && (
<p className="text-xs text-foreground-subtle mt-0.5 font-medium">{subtitle}</p>
)}
</div>
)}
</div>
{description && (
<div className="mt-4">
<p className="text-sm text-foreground-muted leading-relaxed">{description}</p>
</div>
)}
{children}
</div>
);
}
return (
<div ref={ref} className={cardStyles} {...props}>
{children}
</div>
);
}
// Card sub-components with refined spacing
interface CardSubComponentProps extends Omit<HTMLAttributes<HTMLDivElement>, 'ref'> {
ref?: Ref<HTMLDivElement>;
}
interface CardTitleProps extends Omit<HTMLAttributes<HTMLHeadingElement>, 'ref'> {
ref?: Ref<HTMLHeadingElement>;
}
interface CardTextProps extends Omit<HTMLAttributes<HTMLParagraphElement>, 'ref'> {
ref?: Ref<HTMLParagraphElement>;
}
export function CardHeader({ ref, className, ...props }: CardSubComponentProps) {
return <div ref={ref} className={cn('flex flex-col gap-1', className)} {...props} />;
}
export function CardTitle({ ref, className, ...props }: CardTitleProps) {
return (
<h3
ref={ref}
className={cn(
'text-base font-black leading-tight tracking-tight text-foreground',
className
)}
{...props}
/>
);
}
export function CardByline({ ref, className, ...props }: CardTextProps) {
return (
<p
ref={ref}
className={cn('text-xs text-foreground-subtle mt-0.5 font-medium', className)}
{...props}
/>
);
}
export function CardDescription({ ref, className, ...props }: CardTextProps) {
return (
<p
ref={ref}
className={cn('text-sm text-foreground-muted leading-relaxed mt-1.5', className)}
{...props}
/>
);
}
export function CardContent({ ref, className, ...props }: CardSubComponentProps) {
return <div ref={ref} className={cn('mt-4', className)} {...props} />;
}
export function CardFooter({ ref, className, ...props }: CardSubComponentProps) {
return (
<div
ref={ref}
className={cn('flex items-center mt-4 pt-4 border-t border-border', className)}
{...props}
/>
);
}
export default Card;
@@ -0,0 +1,31 @@
import { cva, type VariantProps } from 'class-variance-authority';
export const cardVariants = cva(
['rounded-xl', 'transition-all duration-200 ease-out'],
{
variants: {
variant: {
default: 'bg-card border border-brand-500/30 hover:border-brand-500/70',
solid: 'bg-secondary border border-transparent',
outline: 'bg-transparent border-2 border-brand-500/30 hover:border-brand-500/70',
ghost: 'bg-transparent border border-transparent',
elevated: 'bg-card border border-brand-500/30 shadow-lg hover:border-brand-500/70',
},
padding: {
none: '',
sm: 'p-4',
md: 'p-6',
lg: 'p-8',
},
hover: {
true: 'hover:border-brand-500 hover:shadow-md hover:-translate-y-0.5',
},
},
defaultVariants: {
variant: 'default',
padding: 'md',
},
}
);
export type CardVariants = VariantProps<typeof cardVariants>;
@@ -0,0 +1,3 @@
export { default } from './Card.astro';
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './Card';
export { cardVariants, type CardVariants } from './card.variants';
@@ -0,0 +1,378 @@
---
/**
* GoogleMap — consent-aware Google Maps embed
*
* 3 states:
* 1. No API key → setup prompt with instructions
* 2. API key + consent required but not granted → placeholder with "Load Map" button
* 3. API key + consent granted (or consent disabled) → iframe loads immediately
*/
import type { HTMLAttributes } from 'astro/types';
import { PUBLIC_GOOGLE_MAPS_API_KEY, PUBLIC_CONSENT_ENABLED } from 'astro:env/client';
import siteConfig from '@/config/site.config';
import { cn } from '@/lib/cn';
import { googleMapVariants } from './googleMap.variants';
interface Props extends HTMLAttributes<'div'> {
lat?: number;
lng?: number;
address?: string;
zoom?: number;
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
mode?: 'place' | 'view' | 'directions' | 'streetview' | 'search';
mapType?: 'roadmap' | 'satellite';
consentCategory?: string;
ariaLabel?: string;
placeholderTitle?: string;
placeholderDescription?: string;
externalLinkText?: string;
}
const {
lat,
lng,
address,
zoom = 15,
size = 'md',
mode = 'place',
mapType = 'roadmap',
consentCategory = 'marketing',
ariaLabel = 'Google Maps',
placeholderTitle = 'Map',
placeholderDescription = 'Accept cookies to load the interactive map.',
externalLinkText = 'View on Google Maps',
class: className,
...rest
} = Astro.props;
const hasApiKey = !!PUBLIC_GOOGLE_MAPS_API_KEY;
// Build query — prefer lat/lng, fall back to address, then siteConfig.address
let query = '';
if (lat !== undefined && lng !== undefined) {
query = `${lat},${lng}`;
} else if (address) {
query = address;
} else if (siteConfig.address) {
const a = siteConfig.address;
query = [a.street, a.city, a.state, a.zip, a.country].filter(Boolean).join(', ');
}
// Build iframe src (only when key exists)
let iframeSrc = '';
if (hasApiKey) {
const params = new URLSearchParams({
key: PUBLIC_GOOGLE_MAPS_API_KEY,
q: query,
zoom: String(zoom),
maptype: mapType,
});
iframeSrc = `https://www.google.com/maps/embed/v1/${mode}?${params.toString()}`;
}
// External link for placeholder
const externalUrl = query
? `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`
: 'https://maps.google.com';
const consentEnabled = PUBLIC_CONSENT_ENABLED;
// Config for client script
const mapConfig = JSON.stringify({
consentCategory,
consentEnabled,
});
const id = `google-map-${Math.random().toString(36).slice(2, 9)}`;
---
<div
class={cn(googleMapVariants({ size }), className)}
data-google-map={id}
{...rest}
>
{!hasApiKey ? (
/* No API key — setup prompt */
<div class="google-map-setup">
<div class="google-map-setup__inner">
{/* Lucide map-pin-off */}
<svg class="google-map-setup__icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M5.43 5.43A8.06 8.06 0 0 0 4 10c0 6 8 12 8 12a29.94 29.94 0 0 0 5-5" />
<path d="M19.18 13.52A8.66 8.66 0 0 0 20 10a8 8 0 0 0-8-8 7.88 7.88 0 0 0-3.52.82" />
<path d="M9.13 9.13a3 3 0 0 0 3.74 3.74" />
<path d="M14.9 9.25a3 3 0 0 0-2.15-2.16" />
<line x1="2" x2="22" y1="2" y2="22" />
</svg>
<p class="google-map-setup__title">Google Maps</p>
<p class="google-map-setup__desc">
Add <code>PUBLIC_GOOGLE_MAPS_API_KEY</code> to your <code>.env</code> file to enable the map.
</p>
</div>
</div>
) : (
<>
{/* Consent placeholder — shown when consent is required but not granted */}
<div class="google-map-placeholder" data-map-placeholder={id}>
<div class="google-map-placeholder__icon">
{/* Lucide map-pin */}
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0" />
<circle cx="12" cy="10" r="3" />
</svg>
</div>
{query && <p class="google-map-placeholder__address">{query}</p>}
<p class="google-map-placeholder__title">{placeholderTitle}</p>
<p class="google-map-placeholder__desc">{placeholderDescription}</p>
<button class="google-map-placeholder__btn" data-map-load={id} type="button">
Load Map
</button>
<a
href={externalUrl}
target="_blank"
rel="noopener noreferrer"
class="google-map-placeholder__link"
>
{/* Lucide external-link */}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M15 3h6v6" />
<path d="M10 14 21 3" />
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
</svg>
{externalLinkText}
</a>
</div>
{/* Iframe — hidden until consent granted */}
<iframe
data-map-iframe={id}
data-src={iframeSrc}
hidden
width="100%"
height="100%"
style="border:0;"
loading="lazy"
referrerpolicy="no-referrer-when-downgrade"
allow="fullscreen"
aria-label={ariaLabel}
></iframe>
<script type="application/json" data-google-map-config={id} set:html={mapConfig} />
</>
)}
</div>
<script>
interface MapWindow extends Window {
__consentState?: { decided: boolean; categories: Record<string, boolean> };
}
function initGoogleMaps() {
const maps = document.querySelectorAll<HTMLElement>('[data-google-map]');
maps.forEach((container) => {
const id = container.dataset.googleMap!;
const configEl = container.querySelector<HTMLScriptElement>(`[data-google-map-config="${id}"]`);
if (!configEl) return;
const config = JSON.parse(configEl.textContent!);
const iframe = container.querySelector<HTMLIFrameElement>(`[data-map-iframe="${id}"]`);
const placeholder = container.querySelector<HTMLElement>(`[data-map-placeholder="${id}"]`);
const loadBtn = container.querySelector<HTMLButtonElement>(`[data-map-load="${id}"]`);
if (!iframe) return;
// Already loaded (idempotent)
if (iframe.src && iframe.src !== 'about:blank') return;
const w = window as unknown as MapWindow;
function loadMap() {
const src = iframe!.dataset.src;
if (!src || (iframe!.src && iframe!.src !== 'about:blank')) return;
iframe!.src = src;
iframe!.removeAttribute('hidden');
if (placeholder) placeholder.hidden = true;
}
function hasConsent(): boolean {
if (!config.consentEnabled) return true;
if (!w.__consentState?.decided) return false;
return !!w.__consentState.categories[config.consentCategory];
}
// Check if consent is already granted
if (hasConsent()) {
loadMap();
return;
}
// Consent required — show placeholder
if (config.consentEnabled) {
if (placeholder) placeholder.hidden = false;
// "Load Map" button grants consent for this embed only
if (loadBtn) {
loadBtn.addEventListener('click', loadMap, { once: true });
}
// Listen for consent-updated event
window.addEventListener('consent-updated', function onConsent(e: Event) {
const detail = (e as CustomEvent).detail;
if (detail?.categories?.[config.consentCategory]) {
loadMap();
window.removeEventListener('consent-updated', onConsent);
}
});
} else {
// No consent system — load immediately
loadMap();
}
});
}
initGoogleMaps();
document.addEventListener('astro:page-load', initGoogleMaps);
</script>
<style>
/* ── No API key: setup prompt ── */
.google-map-setup {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 2rem;
text-align: center;
background: repeating-linear-gradient(
-45deg,
transparent,
transparent 8px,
color-mix(in srgb, var(--foreground-muted) 4%, transparent) 8px,
color-mix(in srgb, var(--foreground-muted) 4%, transparent) 9px
);
}
.google-map-setup__inner {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.375rem;
padding: 1.5rem 2rem;
background-color: var(--card);
border: 1px dashed var(--border);
border-radius: 0.75rem;
}
.google-map-setup__icon {
color: var(--foreground-muted);
opacity: 0.6;
}
.google-map-setup__title {
font-size: 0.875rem;
font-weight: 600;
color: var(--foreground);
}
.google-map-setup__desc {
font-size: 0.8125rem;
color: var(--foreground-muted);
max-width: 22rem;
line-height: 1.6;
}
.google-map-setup__desc code {
font-family: ui-monospace, 'SF Mono', 'Cascadia Code', monospace;
font-size: 0.6875rem;
padding: 0.125rem 0.375rem;
background-color: var(--secondary);
border: 1px solid var(--border);
border-radius: 0.25rem;
white-space: nowrap;
}
/* ── Consent placeholder ── */
.google-map-placeholder {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
background-color: var(--secondary);
padding: 2rem;
text-align: center;
}
.google-map-placeholder__icon {
color: var(--brand-500);
margin-bottom: 0.25rem;
}
.google-map-placeholder__address {
font-size: 0.875rem;
font-weight: 500;
color: var(--foreground);
max-width: 24rem;
}
.google-map-placeholder__title {
font-size: 1.125rem;
font-weight: 600;
color: var(--foreground);
}
.google-map-placeholder__desc {
font-size: 0.875rem;
color: var(--foreground-muted);
max-width: 20rem;
}
.google-map-placeholder__btn {
margin-top: 0.5rem;
padding: 0.5rem 1.25rem;
font-size: 0.875rem;
font-weight: 500;
color: white;
background-color: var(--brand-500);
border: none;
border-radius: 0.5rem;
cursor: pointer;
transition: opacity var(--transition-fast) ease;
}
.google-map-placeholder__btn:hover {
opacity: 0.9;
}
.google-map-placeholder__link {
display: inline-flex;
align-items: center;
gap: 0.375rem;
margin-top: 0.25rem;
font-size: 0.8125rem;
color: var(--foreground-muted);
text-decoration: none;
transition: color var(--transition-fast) ease;
}
.google-map-placeholder__link:hover {
color: var(--foreground);
}
/* ── Iframe ── */
[data-map-iframe] {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
</style>
@@ -0,0 +1,21 @@
import { cva, type VariantProps } from 'class-variance-authority';
export const googleMapVariants = cva(
'relative w-full overflow-hidden rounded-xl border border-border',
{
variants: {
size: {
sm: 'h-[250px]',
md: 'h-[400px]',
lg: 'h-[500px]',
xl: 'h-[600px]',
full: 'h-[70vh]',
},
},
defaultVariants: {
size: 'md',
},
}
);
export type GoogleMapVariants = VariantProps<typeof googleMapVariants>;
@@ -0,0 +1,2 @@
export { default } from './GoogleMap.astro';
export { googleMapVariants, type GoogleMapVariants } from './googleMap.variants';
@@ -0,0 +1,135 @@
---
/**
* Pagination Component
* Page navigation with prev/next and numbered pages.
*/
import type { HTMLAttributes } from 'astro/types';
import { cn } from '@/lib/cn';
import { paginationItemVariants } from './pagination.variants';
import Icon from '../../primitives/Icon/Icon.astro';
interface Props extends HTMLAttributes<'nav'> {
/** Current active page (1-indexed) */
currentPage: number;
/** Total number of pages */
totalPages: number;
/** Base URL for page links (page number appended) */
baseUrl?: string;
/** Maximum number of visible page buttons */
maxVisible?: number;
size?: 'sm' | 'md' | 'lg';
}
const {
currentPage,
totalPages,
baseUrl = '?page=',
maxVisible = 5,
size = 'md',
class: className,
...attrs
} = Astro.props;
/**
* Build the array of page numbers and ellipsis markers to render.
*
* @param current - The 1-indexed active page.
* @param total - Total number of pages.
* @param max - Size of the central sliding window. When `total <= max`,
* every page is returned directly. Otherwise a window of
* `max` pages is centered around `current`, and first/last
* pages plus `'...'` ellipsis markers are added outside the
* window as needed — so the returned array can contain more
* than `max` entries.
* @returns An array of page numbers and `'...'` separators.
*/
function getPageRange(current: number, total: number, max: number): (number | '...')[] {
if (total <= max) {
return Array.from({ length: total }, (_, i) => i + 1);
}
const pages: (number | '...')[] = [];
const half = Math.floor(max / 2);
let start = Math.max(1, current - half);
let end = Math.min(total, start + max - 1);
if (end - start < max - 1) {
start = Math.max(1, end - max + 1);
}
if (start > 1) {
pages.push(1);
if (start > 2) pages.push('...');
}
for (let i = start; i <= end; i++) {
pages.push(i);
}
if (end < total) {
if (end < total - 1) pages.push('...');
pages.push(total);
}
return pages;
}
const pages = getPageRange(currentPage, totalPages, maxVisible);
const prevUrl = currentPage > 1 ? `${baseUrl}${currentPage - 1}` : undefined;
const nextUrl = currentPage < totalPages ? `${baseUrl}${currentPage + 1}` : undefined;
---
<nav class={cn('flex items-center gap-1', className)} aria-label="Pagination" {...attrs}>
{/* Previous */}
{prevUrl ? (
<a
href={prevUrl}
class={cn(paginationItemVariants({ variant: 'default', size }))}
aria-label="Previous page"
>
<Icon name="chevron-left" size="sm" />
</a>
) : (
<span class={cn(paginationItemVariants({ variant: 'disabled', size }))} aria-disabled="true">
<Icon name="chevron-left" size="sm" />
</span>
)}
{/* Page Numbers */}
{pages.map((page) =>
page === '...' ? (
<span class={cn(paginationItemVariants({ variant: 'default', size }), 'cursor-default hover:bg-transparent')} role="separator" aria-label="More pages">
<span aria-hidden="true">...</span>
</span>
) : page === currentPage ? (
<span
class={cn(paginationItemVariants({ variant: 'active', size }))}
aria-current="page"
>
{page}
</span>
) : (
<a
href={`${baseUrl}${page}`}
class={cn(paginationItemVariants({ variant: 'default', size }))}
>
{page}
</a>
)
)}
{/* Next */}
{nextUrl ? (
<a
href={nextUrl}
class={cn(paginationItemVariants({ variant: 'default', size }))}
aria-label="Next page"
>
<Icon name="chevron-right" size="sm" />
</a>
) : (
<span class={cn(paginationItemVariants({ variant: 'disabled', size }))} aria-disabled="true">
<Icon name="chevron-right" size="sm" />
</span>
)}
</nav>
@@ -0,0 +1,208 @@
/**
* Pagination Component (React)
* Page navigation with prev/next and numbered pages.
*/
import type { HTMLAttributes } from 'react';
import { cn } from '@/lib/cn';
import { paginationItemVariants } from './pagination.variants';
interface PaginationProps extends HTMLAttributes<HTMLElement> {
/** Current active page (1-indexed) */
currentPage: number;
/** Total number of pages */
totalPages: number;
/** Callback when a page is selected */
onPageChange: (page: number) => void;
/** Maximum number of visible page buttons */
maxVisible?: number;
/** Button size variant */
size?: 'sm' | 'md' | 'lg';
}
/**
* Build the array of page numbers and ellipsis markers to render.
*
* @param current - The 1-indexed active page.
* @param total - Total number of pages.
* @param max - Size of the central sliding window. When `total <= max`,
* every page is returned directly. Otherwise a window of
* `max` pages is centered around `current`, and first/last
* pages plus `'...'` ellipsis markers are added outside the
* window as needed — so the returned array can contain more
* than `max` entries.
* @returns An array of page numbers and `'...'` separators.
*/
function getPageRange(current: number, total: number, max: number): (number | '...')[] {
if (total <= max) {
return Array.from({ length: total }, (_, i) => i + 1);
}
const pages: (number | '...')[] = [];
const half = Math.floor(max / 2);
let start = Math.max(1, current - half);
const end = Math.min(total, start + max - 1);
if (end - start < max - 1) {
start = Math.max(1, end - max + 1);
}
if (start > 1) {
pages.push(1);
if (start > 2) pages.push('...');
}
for (let i = start; i <= end; i++) {
pages.push(i);
}
if (end < total) {
if (end < total - 1) pages.push('...');
pages.push(total);
}
return pages;
}
export function Pagination({
currentPage,
totalPages,
onPageChange,
maxVisible = 5,
size = 'md',
className,
...attrs
}: PaginationProps) {
const pages = getPageRange(currentPage, totalPages, maxVisible);
const hasPrev = currentPage > 1;
const hasNext = currentPage < totalPages;
return (
<nav className={cn('flex items-center gap-1', className)} aria-label="Pagination" {...attrs}>
{/* Previous */}
{hasPrev ? (
<button
type="button"
className={cn(paginationItemVariants({ variant: 'default', size }))}
aria-label="Previous page"
onClick={() => onPageChange(currentPage - 1)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M15 18l-6-6 6-6" />
</svg>
</button>
) : (
<span
className={cn(paginationItemVariants({ variant: 'disabled', size }))}
aria-disabled="true"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M15 18l-6-6 6-6" />
</svg>
</span>
)}
{/* Page Numbers */}
{pages.map((page, index) =>
page === '...' ? (
<span
key={`ellipsis-${index}`}
className={cn(
paginationItemVariants({ variant: 'default', size }),
'cursor-default hover:bg-transparent',
)}
role="separator"
aria-label="More pages"
>
<span aria-hidden="true">...</span>
</span>
) : page === currentPage ? (
<span
key={page}
className={cn(paginationItemVariants({ variant: 'active', size }))}
aria-current="page"
>
{page}
</span>
) : (
<button
key={page}
type="button"
className={cn(paginationItemVariants({ variant: 'default', size }))}
onClick={() => onPageChange(page)}
>
{page}
</button>
),
)}
{/* Next */}
{hasNext ? (
<button
type="button"
className={cn(paginationItemVariants({ variant: 'default', size }))}
aria-label="Next page"
onClick={() => onPageChange(currentPage + 1)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M9 18l6-6-6-6" />
</svg>
</button>
) : (
<span
className={cn(paginationItemVariants({ variant: 'disabled', size }))}
aria-disabled="true"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M9 18l6-6-6-6" />
</svg>
</span>
)}
</nav>
);
}
export default Pagination;
@@ -0,0 +1,3 @@
export { default } from './Pagination.astro';
export { Pagination } from './Pagination';
export { paginationItemVariants, type PaginationVariants } from './pagination.variants';
@@ -0,0 +1,29 @@
import { cva, type VariantProps } from 'class-variance-authority';
export const paginationItemVariants = cva(
[
'inline-flex items-center justify-center rounded-md text-sm font-medium',
'transition-colors duration-150',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
],
{
variants: {
variant: {
default: 'hover:bg-secondary text-foreground-muted hover:text-foreground',
active: 'bg-foreground text-background',
disabled: 'text-foreground-subtle cursor-not-allowed opacity-50',
},
size: {
sm: 'h-8 w-8 text-xs',
md: 'h-9 w-9 text-sm',
lg: 'h-10 w-10 text-base',
},
},
defaultVariants: {
variant: 'default',
size: 'md',
},
}
);
export type PaginationVariants = VariantProps<typeof paginationItemVariants>;
@@ -0,0 +1,64 @@
---
/**
* Progress Component
* A bar indicator showing determinate or indeterminate progress.
*/
import type { HTMLAttributes } from 'astro/types';
import { cn } from '@/lib/cn';
import { progressTrackVariants, progressBarVariants } from './progress.variants';
interface Props extends HTMLAttributes<'div'> {
/** Progress value between 0 and 100. Omit for indeterminate mode. */
value?: number;
/** Maximum value (default 100) */
max?: number;
variant?: 'default' | 'brand' | 'success' | 'warning' | 'error';
size?: 'sm' | 'md' | 'lg';
/** Show percentage label */
showLabel?: boolean;
}
const {
value,
max = 100,
variant = 'default',
size = 'md',
showLabel = false,
class: className,
...attrs
} = Astro.props;
const isIndeterminate = value === undefined;
const percentage = isIndeterminate || max <= 0 ? 0 : Math.min(100, Math.max(0, (value / max) * 100));
---
<div class={cn('w-full', className)} {...attrs}>
{showLabel && !isIndeterminate && (
<div class="flex justify-between mb-1.5">
<slot />
<span class="text-xs font-medium text-foreground-muted">{Math.round(percentage)}%</span>
</div>
)}
<div
class={cn(progressTrackVariants({ size }))}
role="progressbar"
aria-valuenow={isIndeterminate ? undefined : Math.round(percentage)}
aria-valuemin={0}
aria-valuemax={max}
>
<div
class={cn(progressBarVariants({ variant, indeterminate: isIndeterminate }))}
style={!isIndeterminate ? `width: ${percentage}%` : undefined}
/>
</div>
</div>
<style>
@keyframes indeterminate {
0% { transform: translateX(-100%); }
100% { transform: translateX(400%); }
}
.animate-indeterminate {
animation: indeterminate 1.5s cubic-bezier(0.4, 0, 0.2, 1) infinite;
}
</style>
@@ -0,0 +1,43 @@
import { type HTMLAttributes, type Ref, type ReactNode } from 'react';
import { cn } from '@/lib/cn';
import { progressTrackVariants, progressBarVariants, type ProgressVariants } from './progress.variants';
interface ProgressProps extends Omit<HTMLAttributes<HTMLDivElement>, 'ref'> {
ref?: Ref<HTMLDivElement>;
value?: number;
max?: number;
variant?: ProgressVariants['variant'];
size?: ProgressVariants['size'];
showLabel?: boolean;
children?: ReactNode;
}
export function Progress({ ref, value, max = 100, variant = 'default', size = 'md', showLabel = false, className, children, ...rest }: ProgressProps) {
const isIndeterminate = value === undefined;
const percentage = isIndeterminate || max <= 0 ? 0 : Math.min(100, Math.max(0, (value / max) * 100));
return (
<div ref={ref} className={cn('w-full', className)} {...rest}>
{showLabel && !isIndeterminate && (
<div className="flex justify-between mb-1.5">
{children}
<span className="text-xs font-medium text-foreground-muted">{Math.round(percentage)}%</span>
</div>
)}
<div
className={cn(progressTrackVariants({ size }))}
role="progressbar"
aria-valuenow={isIndeterminate ? undefined : Math.round(percentage)}
aria-valuemin={0}
aria-valuemax={max}
>
<div
className={cn(progressBarVariants({ variant, indeterminate: isIndeterminate }))}
style={!isIndeterminate ? { width: `${percentage}%` } : undefined}
/>
</div>
</div>
);
}
export default Progress;
@@ -0,0 +1,3 @@
export { default } from './Progress.astro';
export { Progress } from './Progress';
export { progressTrackVariants, progressBarVariants, type ProgressVariants } from './progress.variants';
@@ -0,0 +1,41 @@
import { cva, type VariantProps } from 'class-variance-authority';
export const progressTrackVariants = cva(
'relative w-full overflow-hidden rounded-full bg-secondary',
{
variants: {
size: {
sm: 'h-1.5',
md: 'h-2.5',
lg: 'h-4',
},
},
defaultVariants: {
size: 'md',
},
}
);
export const progressBarVariants = cva(
'h-full rounded-full transition-all duration-500 ease-out',
{
variants: {
variant: {
default: 'bg-foreground',
brand: 'bg-brand-500',
success: 'bg-[var(--success)]',
warning: 'bg-[var(--warning)]',
error: 'bg-[var(--error)]',
},
indeterminate: {
true: 'animate-indeterminate w-1/3',
},
},
defaultVariants: {
variant: 'default',
},
}
);
export type ProgressVariants = VariantProps<typeof progressTrackVariants> &
VariantProps<typeof progressBarVariants>;
@@ -0,0 +1,53 @@
---
/**
* Skeleton Component
* Loading placeholder with pulse animation
*/
import type { HTMLAttributes } from 'astro/types';
import { cn } from '@/lib/cn';
import { skeletonVariants } from './skeleton.variants';
interface Props extends HTMLAttributes<'div'> {
variant?: 'default' | 'circular' | 'text';
width?: string;
height?: string;
animated?: boolean;
}
const {
variant = 'default',
width,
height,
animated = true,
class: className,
...attrs
} = Astro.props;
const style = [
width && `width: ${width}`,
height && `height: ${height}`,
].filter(Boolean).join('; ');
---
<div
class={cn(skeletonVariants({ variant, animated }), className)}
style={style || undefined}
aria-hidden="true"
role="presentation"
{...attrs}
/>
<style>
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.animate-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
</style>
@@ -0,0 +1,26 @@
import { type HTMLAttributes, type Ref } from 'react';
import { cn } from '@/lib/cn';
import { skeletonVariants, type SkeletonVariants } from './skeleton.variants';
interface SkeletonProps extends Omit<HTMLAttributes<HTMLDivElement>, 'ref'> {
ref?: Ref<HTMLDivElement>;
variant?: SkeletonVariants['variant'];
width?: string;
height?: string;
animated?: boolean;
}
export function Skeleton({ ref, variant = 'default', width, height, animated = true, className, style, ...rest }: SkeletonProps) {
return (
<div
ref={ref}
className={cn(skeletonVariants({ variant, animated }), className)}
style={{ ...style, width, height }}
aria-hidden="true"
role="presentation"
{...rest}
/>
);
}
export default Skeleton;
@@ -0,0 +1,3 @@
export { default } from './Skeleton.astro';
export { Skeleton } from './Skeleton';
export { skeletonVariants, type SkeletonVariants } from './skeleton.variants';
@@ -0,0 +1,20 @@
import { cva, type VariantProps } from 'class-variance-authority';
export const skeletonVariants = cva('bg-secondary', {
variants: {
variant: {
default: 'rounded-md',
circular: 'rounded-full',
text: 'rounded h-4',
},
animated: {
true: 'animate-pulse',
},
},
defaultVariants: {
variant: 'default',
animated: true,
},
});
export type SkeletonVariants = VariantProps<typeof skeletonVariants>;
@@ -0,0 +1,90 @@
---
/**
* Table Component
* A styled data table with hover rows and responsive design.
*/
import type { HTMLAttributes } from 'astro/types';
import { cn } from '@/lib/cn';
interface Column {
key: string;
label: string;
align?: 'left' | 'center' | 'right';
class?: string;
}
interface Props extends HTMLAttributes<'div'> {
columns: Column[];
rows: Record<string, unknown>[];
/** Enable row hover highlighting */
hoverable?: boolean;
/** Enable row striping */
striped?: boolean;
/** Make table compact */
compact?: boolean;
}
const {
columns,
rows,
hoverable = true,
striped = false,
compact = false,
class: className,
...attrs
} = Astro.props;
const alignClasses = {
left: 'text-left',
center: 'text-center',
right: 'text-right',
};
const cellPadding = compact ? 'px-3 py-2' : 'px-4 py-3';
---
<div class={cn('w-full overflow-auto rounded-lg border border-border', className)} {...attrs}>
<table class="w-full text-sm">
<thead>
<tr class="border-b border-border bg-secondary/50">
{columns.map((col) => (
<th
class={cn(
cellPadding,
'font-medium text-foreground-muted',
alignClasses[col.align || 'left'],
col.class
)}
>
{col.label}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, i) => (
<tr
class={cn(
'border-b border-border last:border-b-0',
'transition-colors',
hoverable && 'hover:bg-secondary/30',
striped && i % 2 === 1 && 'bg-secondary/20'
)}
>
{columns.map((col) => (
<td
class={cn(
cellPadding,
'text-foreground',
alignClasses[col.align || 'left'],
col.class
)}
>
{String(row[col.key] ?? '')}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
@@ -0,0 +1,98 @@
/**
* Table Component (React)
* A styled data table with hover rows and responsive design.
*/
import type { HTMLAttributes } from 'react';
import { cn } from '@/lib/cn';
interface Column {
key: string;
label: string;
align?: 'left' | 'center' | 'right';
class?: string;
}
interface TableProps extends HTMLAttributes<HTMLDivElement> {
columns: Column[];
rows: Record<string, unknown>[];
/** Enable row hover highlighting */
hoverable?: boolean;
/** Enable row striping */
striped?: boolean;
/** Make table compact */
compact?: boolean;
}
const alignClasses = {
left: 'text-left',
center: 'text-center',
right: 'text-right',
};
export function Table({
columns,
rows,
hoverable = true,
striped = false,
compact = false,
className,
...attrs
}: TableProps) {
const cellPadding = compact ? 'px-3 py-2' : 'px-4 py-3';
return (
<div
className={cn('overflow-auto rounded-lg border border-border', className)}
{...attrs}
>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-secondary/50">
{columns.map((col) => (
<th
key={col.key}
className={cn(
cellPadding,
'font-medium text-foreground-muted',
alignClasses[col.align || 'left'],
col.class,
)}
>
{col.label}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, i) => (
<tr
key={i}
className={cn(
'border-b border-border last:border-b-0',
'transition-colors',
hoverable && 'hover:bg-secondary/30',
striped && i % 2 === 1 && 'bg-secondary/20',
)}
>
{columns.map((col) => (
<td
key={col.key}
className={cn(
cellPadding,
'text-foreground',
alignClasses[col.align || 'left'],
col.class,
)}
>
{String(row[col.key] ?? '')}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
export default Table;
@@ -0,0 +1,2 @@
export { default } from './Table.astro';
export { Table } from './Table';
+10
View File
@@ -0,0 +1,10 @@
// Data Display Components
export * from './Card';
export * from './Badge';
export * from './Avatar';
export * from './AvatarGroup';
export * from './Table';
export * from './Pagination';
export * from './Progress';
export * from './Skeleton';
export * from './GoogleMap';