First Release v1.0.0
This commit is contained in:
@@ -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';
|
||||
Reference in New Issue
Block a user