{skillCategories.map((category: SkillCategory) => (
diff --git a/src/src/app/articles/page.tsx b/src/src/app/articles/page.tsx
index d0ff29d..72fb68e 100644
--- a/src/src/app/articles/page.tsx
+++ b/src/src/app/articles/page.tsx
@@ -1,5 +1,5 @@
import { getAllArticles } from "@/core/articles";
-import ArticleCard from "@/components/shared/cards/ArticleCard";
+import { ArticleCard } from "@/components/cards";
import EmptyState from "@/components/shared/EmptyState";
import { Heading1 } from "@/components/shared/typography";
diff --git a/src/src/components/cards/ArticleCard.tsx b/src/src/components/cards/ArticleCard.tsx
new file mode 100644
index 0000000..cb8e7c3
--- /dev/null
+++ b/src/src/components/cards/ArticleCard.tsx
@@ -0,0 +1,44 @@
+import Link from "next/link";
+import { Article } from "@/types/article";
+import Card, { CardBody, CardFooter, CardHeader, CardSubtitle, CardTitle } from "./Card";
+import { formatDate } from "@/core/date";
+import IconTag from "@/components/shared/IconTag";
+import { Text } from "@/components/shared/typography";
+
+interface ArticleCardProps {
+ article: Article;
+ showTags?: boolean;
+}
+
+export default function ArticleCard({ article, showTags = false }: ArticleCardProps) {
+ return (
+
+
+
+
+ {article.title}
+
+
+
+ {formatDate(article.publishedAt)}
+
+
+ {article.description && (
+
+ {article.description}
+
+ )}
+
+ {showTags && article.tags.length > 0 && (
+
+ {article.tags.map((tag) => (
+ {tag}
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/src/components/cards/Card.tsx b/src/src/components/cards/Card.tsx
new file mode 100644
index 0000000..cc67a3b
--- /dev/null
+++ b/src/src/components/cards/Card.tsx
@@ -0,0 +1,139 @@
+import type React from "react";
+import Link from "next/link";
+import Image from "next/image";
+import { Heading3 } from "@/components/shared/typography";
+import { ImageProps } from "@/types";
+
+type PolymorphicProps
= {
+ as?: E;
+ className?: string;
+ children?: React.ReactNode;
+} & Omit, "as" | "className" | "children">;
+
+type SimpleProps = {
+ className?: string;
+ children?: React.ReactNode;
+};
+
+type CardGridProps = SimpleProps & { columns?: number };
+
+type CardMediaProps = {
+ media: ImageProps;
+ size?: "small" | "medium" | "large" | "full";
+ align?: "center" | "start";
+ className?: string;
+ containerClassName?: string;
+};
+
+type CardLinkProps = {
+ href: string;
+ external?: boolean;
+ className?: string;
+ children: React.ReactNode;
+};
+
+const baseCardClass = "p-6 bg-surface rounded-3xl border border-border-light shadow-md h-full";
+
+const gridColumns: Record = {
+ 1: "grid-cols-1",
+ 2: "grid-cols-1 sm:grid-cols-2",
+ 3: "grid-cols-1 sm:grid-cols-3",
+ 4: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4",
+ 5: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-5",
+ 6: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6",
+};
+
+const mediaSizes: Record, string> = {
+ small: "w-1/3",
+ medium: "w-1/2",
+ large: "w-3/4",
+ full: "w-full",
+};
+
+const cn = (...classes: Array) => classes.filter(Boolean).join(" ");
+
+const Card = ({
+ as,
+ className,
+ children,
+ ...props
+}: PolymorphicProps) => {
+ const Component = as ?? "div";
+ return (
+ )}>
+ {children}
+
+ );
+};
+
+export const CardHeader: React.FC = ({ children, className }) => (
+ {children}
+);
+
+export const CardTitle: React.FC = ({ children, className }) => (
+ {children}
+);
+
+export const CardSubtitle: React.FC> = ({ as, children, className, ...props }) => {
+ const Component = (as as React.ElementType) ?? "p";
+ return (
+
+ {children}
+
+ );
+};
+
+export const CardBody: React.FC = ({ children, className }) => (
+ {children}
+);
+
+export const CardFooter: React.FC = ({ children, className }) => (
+ {children}
+);
+
+export const CardMedia: React.FC = ({
+ media,
+ size = "medium",
+ align = "center",
+ className,
+ containerClassName,
+}) => (
+
+
+
+);
+
+export const CardGrid: React.FC = ({ columns = 3, className, children }) => (
+ {children}
+);
+
+export const CardLink: React.FC = ({ href, external = false, className, children }) => {
+ const linkClass = cn(
+ "block h-full transition-opacity hover:opacity-80 focus-visible:outline focus-visible:outline-2 focus-visible:outline-primary",
+ className,
+ );
+
+ return external ? (
+
+ {children}
+
+ ) : (
+
+ {children}
+
+ );
+};
+
+export default Card;
diff --git a/src/src/components/shared/cards/SmallInfoCard.tsx b/src/src/components/cards/SmallInfoCard.tsx
similarity index 50%
rename from src/src/components/shared/cards/SmallInfoCard.tsx
rename to src/src/components/cards/SmallInfoCard.tsx
index 628deea..0261559 100644
--- a/src/src/components/shared/cards/SmallInfoCard.tsx
+++ b/src/src/components/cards/SmallInfoCard.tsx
@@ -1,11 +1,13 @@
import React from "react";
-import SmallInfoCardImage, { SmallInfoCardImageSize, SmallInfoCardImagePosition } from "./SmallInfoCardImage";
-import SmallInfoCardHeading from "@/components/shared/cards/SmallInfoCardHeading";
-import Card from "./Card";
+import Card, { CardBody, CardFooter, CardMedia } from "./Card";
+import SmallInfoCardHeading from "./SmallInfoCardHeading";
import { Secondary, Text } from "@/components/shared/typography";
import { ImageProps } from "@/types";
import { createId } from "@/core/string";
+export type SmallInfoCardImageSize = "small" | "medium" | "large";
+export type SmallInfoCardImagePosition = "center" | "start";
+
export type SmallInfoCardProps = {
image?: ImageProps;
heading: string;
@@ -19,6 +21,18 @@ export type SmallInfoCardProps = {
id?: string;
};
+const mediaSizeClasses: Record = {
+ small: "w-10 h-10 md:w-16 md:h-16",
+ medium: "w-16 h-16 md:w-20 md:h-20",
+ large: "w-20 h-20 md:w-24 md:h-24",
+};
+
+const mediaContainerClass = (imageSize: SmallInfoCardImageSize, imagePosition: SmallInfoCardImagePosition) => {
+ const positionClass = imagePosition === "start" ? "items-start mt-1 mr-4" : "items-center";
+ const sizeClass = imageSize !== "medium" && imagePosition !== "start" ? "mt-1 mr-4" : "";
+ return ["flex shrink-0 justify-center", positionClass, sizeClass].filter(Boolean).join(" ");
+};
+
const SmallInfoCard: React.FC = ({
image,
heading,
@@ -30,21 +44,28 @@ const SmallInfoCard: React.FC = ({
id,
flex = "row",
}) => {
- const isMediumSize = imageSize === "medium";
- const isStartPosition = imagePosition === "start";
+ const cardId = id ?? createId(heading);
const bodyFlexDirectionClass = flex === "row" ? "flex-row" : "flex-col";
const containerClass = [
- "flex w-full mx-auto min-w-0",
- isMediumSize && !isStartPosition ? "gap-4 items-stretch" : "items-stretch",
- ].join(" ");
-
- const cardId = id ?? createId(heading);
+ "flex w-full mx-auto min-w-0 items-stretch",
+ imageSize === "medium" && imagePosition !== "start" ? "gap-4" : "",
+ ]
+ .filter(Boolean)
+ .join(" ");
return (
- {image &&
}
+ {image && (
+
+ )}
{heading}
{subtitle &&
{subtitle}}
@@ -54,7 +75,11 @@ const SmallInfoCard: React.FC
= ({
))}
{children && (
- {children}
+
+ {children}
+
)}
diff --git a/src/src/components/shared/cards/SmallInfoCardHeading.tsx b/src/src/components/cards/SmallInfoCardHeading.tsx
similarity index 100%
rename from src/src/components/shared/cards/SmallInfoCardHeading.tsx
rename to src/src/components/cards/SmallInfoCardHeading.tsx
diff --git a/src/src/components/shared/cards/SmallInfoCardsSection.tsx b/src/src/components/cards/SmallInfoCardsSection.tsx
similarity index 72%
rename from src/src/components/shared/cards/SmallInfoCardsSection.tsx
rename to src/src/components/cards/SmallInfoCardsSection.tsx
index bfe6816..3ad6706 100644
--- a/src/src/components/shared/cards/SmallInfoCardsSection.tsx
+++ b/src/src/components/cards/SmallInfoCardsSection.tsx
@@ -1,16 +1,17 @@
import React from "react";
-import { SmallInfoCard, SmallInfoCardProps } from "@/components/shared/cards";
+import { CardGrid } from "./Card";
+import SmallInfoCard, { SmallInfoCardProps } from "./SmallInfoCard";
import Section from "@/components/shared/Section";
type SmallInfoCardsSectionProps = { heading: string; items: SmallInfoCardProps[] };
const SmallInfoCardsGridSection: React.FC = ({ heading, items }) => (
-
+
{items.map((item, index) => (
))}
-
+
);
diff --git a/src/src/components/cards/ThumbnailCard.tsx b/src/src/components/cards/ThumbnailCard.tsx
new file mode 100644
index 0000000..b229e8e
--- /dev/null
+++ b/src/src/components/cards/ThumbnailCard.tsx
@@ -0,0 +1,37 @@
+import { ImageProps } from "@/types";
+import Card, { CardBody, CardMedia, CardTitle } from "./Card";
+import { Text } from "@/components/shared/typography";
+
+export type ThumbnailCardImageSize = "small" | "medium" | "large" | "full";
+
+export type ThumbnailCardProps = {
+ title: string;
+ description?: string;
+ image?: ImageProps;
+ size?: ThumbnailCardImageSize;
+};
+
+const sizeClasses = { small: "w-1/4", medium: "w-1/2", large: "w-3/4", full: "w-full" };
+
+const ThumbnailCard: React.FC = ({ title, description, image, size = "medium" }) => {
+ return (
+
+
+ {image && (
+
+ )}
+
+ {title}
+ {description && {description}}
+
+
+
+ );
+};
+
+export default ThumbnailCard;
diff --git a/src/src/components/cards/index.ts b/src/src/components/cards/index.ts
new file mode 100644
index 0000000..f310826
--- /dev/null
+++ b/src/src/components/cards/index.ts
@@ -0,0 +1,18 @@
+export { default as ArticleCard } from "./ArticleCard";
+export { default as SmallInfoCard } from "./SmallInfoCard";
+export type { SmallInfoCardProps } from "./SmallInfoCard";
+export { default as SmallInfoCardHeading } from "./SmallInfoCardHeading";
+export { default as SmallInfoCardsSection } from "./SmallInfoCardsSection";
+export { default as ThumbnailCard } from "./ThumbnailCard";
+export type { ThumbnailCardImageSize, ThumbnailCardProps } from "./ThumbnailCard";
+export {
+ default as Card,
+ CardBody,
+ CardFooter,
+ CardGrid,
+ CardHeader,
+ CardLink,
+ CardMedia,
+ CardSubtitle,
+ CardTitle,
+} from "./Card";
diff --git a/src/src/components/home/FeaturedArticles.tsx b/src/src/components/home/FeaturedArticles.tsx
index d88d669..220beb5 100644
--- a/src/src/components/home/FeaturedArticles.tsx
+++ b/src/src/components/home/FeaturedArticles.tsx
@@ -1,6 +1,6 @@
import { getFeaturedArticles } from "@/core/articles";
import Section from "@/components/shared/Section";
-import ArticleCard from "@/components/shared/cards/ArticleCard";
+import { ArticleCard } from "@/components/cards";
import EmptyState from "@/components/shared/EmptyState";
export default function FeaturedArticles() {
diff --git a/src/src/components/shared/ThumbnailGridSection.tsx b/src/src/components/shared/ThumbnailGridSection.tsx
index 569e847..e3435bf 100644
--- a/src/src/components/shared/ThumbnailGridSection.tsx
+++ b/src/src/components/shared/ThumbnailGridSection.tsx
@@ -1,7 +1,7 @@
+import Link from "next/link";
import Section from "./Section";
-import ThumbnailCard, { ThumbnailCardImageSize } from "./cards/ThumbnailCard";
+import { ThumbnailCard, ThumbnailCardImageSize, CardGrid } from "@/components/cards";
import NewTabLink from "./NewTabLink";
-import Link from "next/link";
interface ImageProps {
src: string;
@@ -27,18 +27,6 @@ interface ThumbnailGridSectionProps {
redirectLabel?: string;
}
-const getGridClasses = (columns: number): string => {
- const columnMap: Record = {
- 1: "grid-cols-1",
- 2: "grid-cols-1 sm:grid-cols-2",
- 3: "grid-cols-1 sm:grid-cols-3",
- 4: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4",
- 5: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-5",
- 6: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6",
- };
- return columnMap[columns] || "grid-cols-1 sm:grid-cols-3";
-};
-
const ThumbnailGridSection: React.FC = ({
heading,
items,
@@ -48,7 +36,7 @@ const ThumbnailGridSection: React.FC = ({
redirectLabel,
}) => (
-
+
{items.map((item, index) => {
const card = (
= ({
{card}
);
})}
-
+
);
diff --git a/src/src/components/shared/cards/ArticleCard.tsx b/src/src/components/shared/cards/ArticleCard.tsx
deleted file mode 100644
index 19b836a..0000000
--- a/src/src/components/shared/cards/ArticleCard.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-import Link from "next/link";
-import { Article } from "@/types/article";
-import Card from "@/components/shared/cards/Card";
-import { formatDate } from "@/core/date";
-import IconTag from "@/components/shared/IconTag";
-import { Text, Heading3 } from "@/components/shared/typography";
-
-interface ArticleCardProps {
- article: Article;
- showTags?: boolean;
-}
-
-export default function ArticleCard({ article, showTags = false }: ArticleCardProps) {
- return (
-
-
-
-
- {article.title}
-
-
-
- {article.description &&
{article.description}}
-
- {showTags && article.tags.length > 0 && (
-
- {article.tags.map((tag) => (
- {tag}
- ))}
-
- )}
-
-
- );
-}
diff --git a/src/src/components/shared/cards/Card.tsx b/src/src/components/shared/cards/Card.tsx
deleted file mode 100644
index fd5758e..0000000
--- a/src/src/components/shared/cards/Card.tsx
+++ /dev/null
@@ -1,13 +0,0 @@
-type CardProps = {
- children?: React.ReactNode;
- id?: string;
- className?: string;
-};
-
-const Card: React.FC = ({ children, id, className }) => (
-
- {children}
-
-);
-
-export default Card;
diff --git a/src/src/components/shared/cards/SmallInfoCardImage.tsx b/src/src/components/shared/cards/SmallInfoCardImage.tsx
deleted file mode 100644
index 881f126..0000000
--- a/src/src/components/shared/cards/SmallInfoCardImage.tsx
+++ /dev/null
@@ -1,47 +0,0 @@
-import React from "react";
-import Image from "next/image";
-import { ImageProps } from "@/types";
-
-export type SmallInfoCardImageSize = "small" | "medium" | "large";
-export type SmallInfoCardImagePosition = "center" | "start";
-
-type SmallInfoCardImageProps = {
- image: ImageProps;
- imageSize: SmallInfoCardImageSize;
- imagePosition: SmallInfoCardImagePosition;
-};
-
-const getImageSize = (imageSize: SmallInfoCardImageSize = "medium") => {
- const sizeMap = {
- small: "w-10 h-10 md:w-16 md:h-16",
- medium: "w-16 h-16 md:w-20 md:h-20",
- large: "w-20 h-20 md:w-24 md:h-24",
- };
- return sizeMap[imageSize];
-};
-
-const getImageContainerClass = (imageSize: SmallInfoCardImageSize, imagePosition: SmallInfoCardImagePosition) => {
- const baseClass = "flex justify-center shrink-0 ";
- const positionClass = imagePosition === "start" ? "items-start mt-1 mr-4" : "items-center mt-0";
- const sizeClass = imageSize !== "medium" && imagePosition !== "start" ? "mt-1 mr-4" : "";
- return `${baseClass} ${positionClass} ${sizeClass}`.trim();
-};
-
-const SmallInfoCardImage: React.FC = ({ image, imageSize, imagePosition }) => {
- const selectedImageSize = getImageSize(imageSize);
- const imageContainerClass = getImageContainerClass(imageSize, imagePosition);
-
- return (
-
-
-
- );
-};
-
-export default SmallInfoCardImage;
diff --git a/src/src/components/shared/cards/ThumbnailCard.tsx b/src/src/components/shared/cards/ThumbnailCard.tsx
deleted file mode 100644
index 12ce639..0000000
--- a/src/src/components/shared/cards/ThumbnailCard.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-import Image from "next/image";
-import { ImageProps } from "../../../types";
-import Card from "./Card";
-import { Heading3, Text } from "@/components/shared/typography";
-
-export type ThumbnailCardImageSize = "small" | "medium" | "large" | "full";
-
-export type ThumbnailCardProps = {
- title: string;
- description?: string;
- image?: ImageProps;
- size?: ThumbnailCardImageSize;
-};
-
-const sizeClasses = { small: "w-1/4", medium: "w-1/2", large: "w-3/4", full: "w-full" };
-
-const ThumbnailCard: React.FC = ({ title, description, image, size = "medium" }) => {
- return (
-
-
- {image && (
-
-
-
- )}
-
- {title}
- {description && {description}}
-
-
-
- );
-};
-
-export default ThumbnailCard;
diff --git a/src/src/components/shared/cards/index.ts b/src/src/components/shared/cards/index.ts
deleted file mode 100644
index de5a31e..0000000
--- a/src/src/components/shared/cards/index.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-export { default as ArticleCard } from "@/components/shared/cards/ArticleCard";
-export { default as SmallInfoCard } from "@/components/shared/cards/SmallInfoCard";
-export type { SmallInfoCardProps } from "@/components/shared/cards/SmallInfoCard";
-export { default as SmallInfoCardHeading } from "@/components/shared/cards/SmallInfoCardHeading";
-export { default as SmallInfoCardsSection } from "@/components/shared/cards/SmallInfoCardsSection";