Civilization Card

Apercu de civilisation avec banniere principale, chronologie des eres BCE/CE, statistiques cles, realisations et dirigeants notables. Installez Civilization Card depuis le registre VLLNT UI avec la CLI shadcn.

Signaler un bug

Apercu de civilisation avec banniere principale, chronologie des eres BCE/CE, statistiques cles, realisations et dirigeants notables. Fait partie de la famille educational dans VLLNT UI, il est publie comme entree de registre lisible par machine — copiez la source directement dans votre application avec la CLI shadcn et possedez-la, sans dependance d'execution a une bibliotheque de composants.

Apercu

Basculez entre clair et sombre pour inspecter l'apercu Storybook integre.

Installation

Ajoutez Civilization Card a votre projet avec la CLI shadcn. La source arrive dans votre code, prete a etre adaptee :

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/civilization-card.json

Source

import { Globe } from "lucide-react"; import type { ComponentPropsWithoutRef, ReactNode } from "react"; import { cn } from "../../lib/utils"; import { Badge } from "../badge/badge"; const FALLBACK_ERA_MIN_YEAR = -3000; const FALLBACK_ERA_SPAN_YEARS = 5000; const CIVILIZATION_COLOR_VARIANTS: Record< CivilizationCardColor, { gradient: string; ring: string } > = { amber: { gradient: "from-amber-500/20 to-amber-700/40", ring: "ring-amber-500/30", }, blue: { gradient: "from-blue-500/20 to-blue-700/40", ring: "ring-blue-500/30", }, emerald: { gradient: "from-emerald-500/20 to-emerald-700/40", ring: "ring-emerald-500/30", }, neutral: { gradient: "from-muted to-muted-foreground/10", ring: "ring-border", }, purple: { gradient: "from-purple-500/20 to-purple-700/40", ring: "ring-purple-500/30", }, red: { gradient: "from-red-500/20 to-red-700/40", ring: "ring-red-500/30", }, }; /** * Color theme for the {@link CivilizationCard} hero band. * * @public */ export type CivilizationCardColor = | "amber" | "blue" | "emerald" | "neutral" | "purple" | "red"; /** * Era span (start / end years) for {@link CivilizationCardProps}. * * Use positive integers for CE / negative integers for BCE. `end` may be * omitted when the civilization is extant. * * @public */ export type CivilizationCardEra = { /** End year. Negative for BCE; omit when extant. */ end?: number; /** Start year. Negative for BCE. */ start: number; }; /** * Localizable strings. * * @public */ export type CivilizationCardLabels = { /** Heading above the achievements list. Defaults to `"Achievements"`. */ achievements?: string; /** Caption for the capital row. Defaults to `"Capital"`. */ capital?: string; /** Caption for the duration stat. Defaults to `"Duration"`. */ duration?: string; /** Heading above the leaders list. Defaults to `"Notable leaders"`. */ leaders?: string; /** Caption for the peak population stat. Defaults to `"Peak population"`. */ peakPopulation?: string; /** Aria-label on the era timeline bar. Defaults to `"Era timeline"`. */ timeline?: string; }; /** * Props for {@link CivilizationCard}. * * @public */ export type CivilizationCardProps = { /** Notable achievements / cultural contributions. */ achievements?: ReactNode[]; /** Optional primary CTA href. Renders the card as a link card when set. */ actionHref?: string; /** Optional capital city. */ capital?: ReactNode; /** Color theme for the hero band. Defaults to `"neutral"`. */ color?: CivilizationCardColor; /** Era span. */ era?: CivilizationCardEra; /** Optional hero image src. Falls back to a globe icon. */ image?: string; /** Localizable captions. */ labels?: CivilizationCardLabels; /** Notable leaders. */ leaders?: ReactNode[]; /** Display name. */ name: ReactNode; /** Optional peak population stat (string). */ peakPopulation?: ReactNode; /** Optional geographic region. */ region?: ReactNode; } & ComponentPropsWithoutRef<"article">; const DEFAULT_LABELS = { achievements: "Achievements", capital: "Capital", duration: "Duration", leaders: "Notable leaders", peakPopulation: "Peak population", timeline: "Era timeline", } as const satisfies Required<CivilizationCardLabels>; function formatEraYear(year: number): string { if (year < 0) return `${Math.abs(year).toString()} BCE`; return `${year.toString()} CE`; } function formatEra(era: CivilizationCardEra): string { const start = formatEraYear(era.start); if (era.end === undefined) return `${start} – present`; return `${start}${formatEraYear(era.end)}`; } function getDuration(era: CivilizationCardEra | undefined): string | undefined { if (!era) return undefined; const end = era.end ?? new Date().getFullYear(); const years = end - era.start; if (years <= 0) return undefined; return `${years.toString()} years`; } type HeroProps = { color: CivilizationCardColor; image?: string; imageAlt?: string; }; function CivilizationHero({ color, image, imageAlt }: HeroProps): ReactNode { const palette = CIVILIZATION_COLOR_VARIANTS[color]; return ( <div className={cn( "relative h-32 w-full overflow-hidden rounded-t-2xl bg-gradient-to-br", palette.gradient, )} > {image ? ( <img alt={imageAlt ?? ""} className="h-full w-full object-cover mix-blend-multiply" src={image} /> ) : ( <div className="flex h-full w-full items-center justify-center text-muted-foreground/50"> <Globe aria-hidden="true" className="size-12" /> </div> )} </div> ); } type EraTimelineProps = { era: CivilizationCardEra; label: string; }; function getEraBarGeometry(era: CivilizationCardEra): { left: number; width: number; } { const end = era.end ?? new Date().getFullYear(); const min = Math.min(era.start, FALLBACK_ERA_MIN_YEAR); const range = Math.max(end, min + FALLBACK_ERA_SPAN_YEARS) - min || 1; const left = ((era.start - min) / range) * 100; const width = Math.max(((end - era.start) / range) * 100, 1); return { left, width }; } function EraTimeline({ era, label }: EraTimelineProps): ReactNode { const eraLabel = formatEra(era); const { left, width } = getEraBarGeometry(era); return ( <div aria-label={`${label}: ${eraLabel}`} className="flex flex-col gap-1" role="img" > <span className="text-xs font-medium uppercase tracking-wide text-muted-foreground"> {eraLabel} </span> <div className="relative h-1.5 w-full rounded-full bg-muted"> <span className="absolute h-full rounded-full bg-primary" style={{ left: `${left.toString()}%`, width: `${width.toString()}%` }} /> </div> </div> ); } type StatsProps = { capital?: ReactNode; capitalCaption: string; durationCaption: string; durationValue?: string; peakCaption: string; peakPopulation?: ReactNode; }; function CivilizationStats({ capital, capitalCaption, durationCaption, durationValue, peakCaption, peakPopulation, }: StatsProps): ReactNode { const items: { caption: string; value: ReactNode }[] = []; if (capital) items.push({ caption: capitalCaption, value: capital }); if (peakPopulation) { items.push({ caption: peakCaption, value: peakPopulation }); } if (durationValue) { items.push({ caption: durationCaption, value: durationValue }); } if (items.length === 0) return null; return ( <dl className="grid grid-cols-2 gap-x-3 gap-y-2 text-sm"> {items.map((item) => ( <div className="flex flex-col" key={item.caption}> <dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground"> {item.caption} </dt> <dd className="font-medium text-foreground">{item.value}</dd> </div> ))} </dl> ); } type ListBlockProps = { emptyHidden?: boolean; heading: string; items: ReactNode[]; variant: "badge" | "list"; }; function CivilizationListBlock({ heading, items, variant, }: ListBlockProps): ReactNode { if (items.length === 0) return null; return ( <div className="flex flex-col gap-2"> <h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> {heading} </h4> {variant === "badge" ? ( <div className="flex flex-wrap gap-1.5"> {items.map((item, index) => ( <Badge key={`${heading}-${index.toString()}`} variant="secondary"> {item} </Badge> ))} </div> ) : ( <ul className="flex flex-col gap-1 text-sm text-foreground"> {items.map((item, index) => ( <li className="leading-tight" key={`${heading}-${index.toString()}`} > {item} </li> ))} </ul> )} </div> ); } /** * Overview card for historical civilizations: hero band with optional image * + color theme, era timeline (BCE / CE / present), key stats, achievement * chips, notable leaders, and an optional follow-up link. * * @example * ```tsx * <CivilizationCard * name="Roman Empire" * era={{ start: -27, end: 476 }} * region="Mediterranean" * capital="Rome" * peakPopulation="70 million" * color="red" * achievements={["Aqueducts", "Roads", "Law", "Architecture"]} * leaders={["Augustus", "Trajan", "Marcus Aurelius"]} * actionHref="/civilizations/rome" * /> * ``` * * @public */ type CivilizationBodyProps = { achievements?: ReactNode[]; actionHref?: string; capital?: ReactNode; era?: CivilizationCardEra; labels: Required<CivilizationCardLabels>; leaders?: ReactNode[]; name: ReactNode; peakPopulation?: ReactNode; region?: ReactNode; }; function CivilizationBody({ achievements, actionHref, capital, era, labels, leaders, name, peakPopulation, region, }: CivilizationBodyProps): ReactNode { const durationValue = getDuration(era); return ( <div className="flex flex-col gap-4 p-5"> <header className="flex flex-col gap-1"> <h3 className="text-lg font-semibold leading-tight tracking-tight"> {name} </h3> {region ? ( <p className="text-sm text-muted-foreground">{region}</p> ) : null} </header> {era ? <EraTimeline era={era} label={labels.timeline} /> : null} <CivilizationStats capital={capital} capitalCaption={labels.capital} durationCaption={labels.duration} durationValue={durationValue} peakCaption={labels.peakPopulation} peakPopulation={peakPopulation} /> {achievements && achievements.length > 0 ? ( <CivilizationListBlock heading={labels.achievements} items={achievements} variant="badge" /> ) : null} {leaders && leaders.length > 0 ? ( <CivilizationListBlock heading={labels.leaders} items={leaders} variant="list" /> ) : null} {actionHref ? ( <a className="text-sm font-medium text-primary underline-offset-4 hover:underline" href={actionHref} > Explore → </a> ) : null} </div> ); } export const CivilizationCard = ({ ref, ...props }: CivilizationCardProps & { ref?: React.Ref<HTMLElement> }) => { const { achievements, actionHref, capital, className, color = "neutral", era, image, labels, leaders, name, peakPopulation, region, ...rest } = props; const resolvedLabels = { ...DEFAULT_LABELS, ...labels }; const palette = CIVILIZATION_COLOR_VARIANTS[color]; const altName = typeof name === "string" ? name : undefined; return ( <article className={cn( "flex flex-col overflow-hidden rounded-2xl border bg-background text-foreground shadow-sm ring-1", palette.ring, className, )} ref={ref} {...rest} > <CivilizationHero color={color} image={image} imageAlt={altName} /> <CivilizationBody achievements={achievements} actionHref={actionHref} capital={capital} era={era} labels={resolvedLabels} leaders={leaders} name={name} peakPopulation={peakPopulation} region={region} /> </article> ); }; CivilizationCard.displayName = "CivilizationCard"; /** * Props for {@link CivilizationComparison}. * * @public */ export type CivilizationComparisonProps = ComponentPropsWithoutRef<"div">; /** * Side-by-side comparison container for {@link CivilizationCard}s. Renders * children in a responsive grid (single column on mobile, two columns on * `md`, three on `lg`). * * @public */ export const CivilizationComparison = ({ children, className, ref, ...rest }: CivilizationComparisonProps & { ref?: React.Ref<HTMLDivElement> }) => { return ( <div className={cn( "grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3", className, )} ref={ref} {...rest} > {children} </div> ); }; CivilizationComparison.displayName = "CivilizationComparison";

Stories

Explorez chaque variante et etat dans le Storybook interactif :

Apercu

Basculez entre clair et sombre pour inspecter l'apercu Storybook integre.

Dependances

  • @vllnt/ui@^0.3.0
  • lucide-react