From 84146b075a09054fdf0ed4f359e59d3430640de7 Mon Sep 17 00:00:00 2001 From: Logan Farci Date: Sat, 29 Nov 2025 12:40:02 +0100 Subject: [PATCH 1/2] Refine card components to be more generic --- src/src/app/about/page.tsx | 5 +- src/src/app/articles/page.tsx | 2 +- src/src/components/cards/ArticleCard.tsx | 44 ++++++ src/src/components/cards/Card.tsx | 139 ++++++++++++++++++ .../{shared => }/cards/SmallInfoCard.tsx | 49 ++++-- .../cards/SmallInfoCardHeading.tsx | 0 .../cards/SmallInfoCardsSection.tsx | 7 +- .../{shared => }/cards/ThumbnailCard.tsx | 26 ++-- src/src/components/cards/index.ts | 18 +++ src/src/components/home/FeaturedArticles.tsx | 2 +- .../shared/ThumbnailGridSection.tsx | 20 +-- .../components/shared/cards/ArticleCard.tsx | 40 ----- src/src/components/shared/cards/Card.tsx | 13 -- .../shared/cards/SmallInfoCardImage.tsx | 47 ------ src/src/components/shared/cards/index.ts | 5 - 15 files changed, 261 insertions(+), 156 deletions(-) create mode 100644 src/src/components/cards/ArticleCard.tsx create mode 100644 src/src/components/cards/Card.tsx rename src/src/components/{shared => }/cards/SmallInfoCard.tsx (50%) rename src/src/components/{shared => }/cards/SmallInfoCardHeading.tsx (100%) rename src/src/components/{shared => }/cards/SmallInfoCardsSection.tsx (72%) rename src/src/components/{shared => }/cards/ThumbnailCard.tsx (51%) create mode 100644 src/src/components/cards/index.ts delete mode 100644 src/src/components/shared/cards/ArticleCard.tsx delete mode 100644 src/src/components/shared/cards/Card.tsx delete mode 100644 src/src/components/shared/cards/SmallInfoCardImage.tsx delete mode 100644 src/src/components/shared/cards/index.ts diff --git a/src/src/app/about/page.tsx b/src/src/app/about/page.tsx index b644c80..41d7c55 100644 --- a/src/src/app/about/page.tsx +++ b/src/src/app/about/page.tsx @@ -1,8 +1,7 @@ import Section from "@/components/shared/Section"; import React from "react"; import MarkdownSection from "@/components/shared/MarkdownSection"; -import SmallInfoCard, { SmallInfoCardProps } from "@/components/shared/cards/SmallInfoCard"; -import SmallInfoCardsGridSection from "@/components/shared/cards/SmallInfoCardsSection"; +import { SmallInfoCard, SmallInfoCardProps, SmallInfoCardsSection } from "@/components/cards"; import { Certification, SkillCategory } from "@/types"; import { MarkdownPreview } from "@/components/shared/preview"; import { getCertifications, getDiploma, getExperiences, getProfile, getSkillCategories } from "@/core/data"; @@ -76,7 +75,7 @@ export default function About() { {diploma.description} - +
{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, +}) => ( +
+ {media.alt} +
+); + +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/shared/cards/ThumbnailCard.tsx b/src/src/components/cards/ThumbnailCard.tsx similarity index 51% rename from src/src/components/shared/cards/ThumbnailCard.tsx rename to src/src/components/cards/ThumbnailCard.tsx index 12ce639..58122f4 100644 --- a/src/src/components/shared/cards/ThumbnailCard.tsx +++ b/src/src/components/cards/ThumbnailCard.tsx @@ -1,7 +1,6 @@ -import Image from "next/image"; import { ImageProps } from "../../../types"; -import Card from "./Card"; -import { Heading3, Text } from "@/components/shared/typography"; +import Card, { CardBody, CardMedia, CardTitle } from "./Card"; +import { Text } from "@/components/shared/typography"; export type ThumbnailCardImageSize = "small" | "medium" | "large" | "full"; @@ -19,20 +18,17 @@ const ThumbnailCard: React.FC = ({ title, description, image
{image && ( -
- {image.alt} -
+ )} -
- {title} + + {title} {description && {description}} -
+
); 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 ( -
- {image.alt} -
- ); -}; - -export default SmallInfoCardImage; 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"; From 61f48e09cae6d934445a6632c804bfb2345b2ffc Mon Sep 17 00:00:00 2001 From: Logan Farci Date: Sat, 29 Nov 2025 12:43:56 +0100 Subject: [PATCH 2/2] Fix import in the ThumbnailCard component --- src/src/components/cards/ThumbnailCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/src/components/cards/ThumbnailCard.tsx b/src/src/components/cards/ThumbnailCard.tsx index 58122f4..b229e8e 100644 --- a/src/src/components/cards/ThumbnailCard.tsx +++ b/src/src/components/cards/ThumbnailCard.tsx @@ -1,4 +1,4 @@ -import { ImageProps } from "../../../types"; +import { ImageProps } from "@/types"; import Card, { CardBody, CardMedia, CardTitle } from "./Card"; import { Text } from "@/components/shared/typography";