Reorganize components

This commit is contained in:
adilallo
2026-02-05 22:37:00 -07:00
parent db3c0274f6
commit 6f178e934f
61 changed files with 45 additions and 44 deletions
@@ -0,0 +1,103 @@
"use client";
import { memo } from "react";
import { getAssetPath, ASSETS } from "../../../../lib/assetUtils";
import ContentContainerView from "./ContentContainer.view";
import type { ContentContainerProps } from "./ContentContainer.types";
import { normalizeContentContainerSize } from "../../../../lib/propNormalization";
const ContentContainerContainer = memo<ContentContainerProps>(
({ post, width = "200px", size: sizeProp = "responsive" }) => {
// Normalize props to handle both PascalCase (Figma) and lowercase (codebase)
const size = normalizeContentContainerSize(sizeProp);
// Get the corresponding icon based on the same logic as background images
const getIconImage = (slug: string): string => {
const icons = [
getAssetPath(ASSETS.ICON_1),
getAssetPath(ASSETS.ICON_2),
getAssetPath(ASSETS.ICON_3),
];
if (!slug) return icons[0];
// Use the same cycling logic as background images to ensure matching
const slugOrder = [
"building-community-trust",
"operational-security-mutual-aid",
"making-decisions-without-hierarchy",
"resolving-active-conflicts",
];
const index = slugOrder.indexOf(slug);
const finalIndex = index >= 0 ? index % icons.length : 0;
return icons[finalIndex];
};
const iconImage = getIconImage(post.slug);
// Format date
const formattedDate = new Date(post.frontmatter.date).toLocaleDateString(
"en-US",
{
year: "numeric",
month: "long",
},
);
// Choose styling based on size prop
const containerClasses =
size === "xs"
? "relative z-20 h-full flex flex-col gap-[var(--measures-spacing-012)]"
: "relative z-20 h-full flex flex-col gap-[var(--measures-spacing-012)] sm:gap-[var(--measures-spacing-016)] md:gap-[18px] lg:gap-[var(--measures-spacing-024)]";
const contentGapClasses =
size === "xs"
? "flex flex-col gap-[var(--measures-spacing-008)]"
: "flex flex-col gap-[var(--measures-spacing-008)] sm:gap-[var(--measures-spacing-012)] md:gap-[var(--measures-spacing-008)] lg:gap-[var(--measures-spacing-016)] xl:gap-[var(--measures-spacing-004)]";
const textGapClasses =
size === "xs"
? "flex flex-col gap-[var(--measures-spacing-004)]"
: "flex flex-col gap-[var(--measures-spacing-004)] md:gap-[var(--measures-spacing-002)] lg:gap-[var(--measures-spacing-004)]";
const titleClasses =
size === "xs"
? "font-bricolage font-medium text-[18px] leading-[120%] text-[var(--color-content-inverse-brand-royal)] group-hover:text-blue-200 transition-colors"
: "font-bricolage font-medium text-[18px] leading-[120%] sm:text-[24px] sm:leading-[24px] md:text-[32px] md:leading-[110%] lg:text-[44px] lg:leading-[110%] xl:text-[64px] xl:leading-[110%] text-[var(--color-content-inverse-brand-royal)] group-hover:text-blue-200 transition-colors";
const descriptionClasses =
size === "xs"
? "font-inter font-normal text-[12px] leading-[16px] text-[var(--color-content-inverse-brand-royal)] max-w-md"
: "font-inter font-normal text-[12px] leading-[16px] sm:text-[14px] sm:leading-[20px] md:text-[14px] md:leading-[20px] lg:text-[18px] lg:leading-[130%] xl:text-[24px] xl:leading-[32px] text-[var(--color-content-inverse-brand-royal)]";
const authorClasses =
size === "xs"
? "font-inter font-normal text-[10px] leading-[14px] text-[var(--color-content-inverse-brand-royal)]"
: "font-inter font-normal text-[10px] leading-[14px] md:text-[12px] md:leading-[16px] lg:text-[14px] lg:leading-[20px] xl:text-[18px] xl:leading-[130%] text-[var(--color-content-inverse-brand-royal)]";
const dateClasses =
size === "xs"
? "font-inter font-normal text-[10px] leading-[14px] text-[var(--color-content-inverse-brand-royal)]"
: "font-inter font-normal text-[10px] leading-[14px] md:text-[12px] md:leading-[16px] lg:text-[14px] lg:leading-[20px] xl:text-[18px] xl:leading-[130%] text-[var(--color-content-inverse-brand-royal)]";
return (
<ContentContainerView
post={post}
width={width}
size={size}
iconImage={iconImage}
containerClasses={containerClasses}
contentGapClasses={contentGapClasses}
textGapClasses={textGapClasses}
titleClasses={titleClasses}
descriptionClasses={descriptionClasses}
authorClasses={authorClasses}
dateClasses={dateClasses}
formattedDate={formattedDate}
/>
);
},
);
ContentContainerContainer.displayName = "ContentContainer";
export default ContentContainerContainer;
@@ -0,0 +1,28 @@
import type { BlogPost } from "../../../../lib/content";
export type ContentContainerSizeValue = "xs" | "responsive" | "Xs" | "Responsive";
export interface ContentContainerProps {
post: BlogPost;
width?: string;
/**
* Content container size. Accepts both lowercase and PascalCase (case-insensitive).
* Figma uses PascalCase, codebase uses lowercase - both are supported.
*/
size?: ContentContainerSizeValue;
}
export interface ContentContainerViewProps {
post: BlogPost;
width: string;
size: "xs" | "responsive";
iconImage: string;
containerClasses: string;
contentGapClasses: string;
textGapClasses: string;
titleClasses: string;
descriptionClasses: string;
authorClasses: string;
dateClasses: string;
formattedDate: string;
}
@@ -0,0 +1,59 @@
import { memo } from "react";
import type { ContentContainerViewProps } from "./ContentContainer.types";
function ContentContainerView({
post,
width,
size,
iconImage,
containerClasses,
contentGapClasses,
textGapClasses,
titleClasses,
descriptionClasses,
authorClasses,
dateClasses,
formattedDate,
}: ContentContainerViewProps) {
return (
<div
className={containerClasses}
style={size === "responsive" ? {} : { width }}
>
{/* Content Container - gap between icon and text */}
<div className={contentGapClasses}>
{/* Icon */}
<div className="w-[60px] h-[30px] flex items-center justify-center">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={iconImage}
alt={`Icon for ${post.frontmatter.title}`}
className="w-[60px] h-[30px] object-contain"
/>
</div>
{/* Text Container */}
<div className={textGapClasses}>
{/* Title */}
<h3 className={titleClasses}>{post.frontmatter.title}</h3>
{/* Description */}
<p className={descriptionClasses}>{post.frontmatter.description}</p>
</div>
</div>
{/* Metadata Container - horizontal with 8px gap */}
<div className="flex items-center gap-[var(--measures-spacing-008)]">
{/* Author Name */}
<span className={authorClasses}>{post.frontmatter.author}</span>
{/* Date */}
<span className={dateClasses}>{formattedDate}</span>
</div>
</div>
);
}
ContentContainerView.displayName = "ContentContainerView";
export default memo(ContentContainerView);
@@ -0,0 +1,2 @@
export { default } from "./ContentContainer.container";
export type { ContentContainerProps } from "./ContentContainer.types";
@@ -0,0 +1,55 @@
"use client";
import { memo } from "react";
import { getAssetPath, ASSETS } from "../../../../lib/assetUtils";
import ContentThumbnailTemplateView from "./ContentThumbnailTemplate.view";
import type { ContentThumbnailTemplateProps } from "./ContentThumbnailTemplate.types";
import { normalizeContentThumbnailVariant } from "../../../../lib/propNormalization";
const ContentThumbnailTemplateContainer = memo<ContentThumbnailTemplateProps>(
({ post, className = "", variant: variantProp = "vertical" }) => {
// Normalize props to handle both PascalCase (Figma) and lowercase (codebase)
const variant = normalizeContentThumbnailVariant(variantProp);
// Get article-specific background image from frontmatter
const getBackgroundImage = (
post: ContentThumbnailTemplateProps["post"],
variant: "vertical" | "horizontal",
): string => {
// Check if post has thumbnail images defined in frontmatter
if (post.frontmatter?.thumbnail) {
const imageName =
variant === "vertical"
? post.frontmatter.thumbnail.vertical
: post.frontmatter.thumbnail.horizontal;
if (imageName) {
// Return path to image in public/content/blog directory
return `/content/blog/${imageName}`;
}
}
// Fallback to default images if no thumbnail specified
const fallbackImages: Record<string, string> = {
vertical: getAssetPath(ASSETS.VERTICAL_1),
horizontal: getAssetPath(ASSETS.HORIZONTAL_1),
};
return fallbackImages[variant] || fallbackImages.vertical;
};
const backgroundImage = getBackgroundImage(post, variant);
return (
<ContentThumbnailTemplateView
post={post}
className={className}
variant={variant}
backgroundImage={backgroundImage}
/>
);
},
);
ContentThumbnailTemplateContainer.displayName = "ContentThumbnailTemplate";
export default ContentThumbnailTemplateContainer;
@@ -0,0 +1,21 @@
import type { BlogPost } from "../../../../lib/content";
export type ContentThumbnailTemplateVariantValue = "vertical" | "horizontal" | "Vertical" | "Horizontal";
export interface ContentThumbnailTemplateProps {
post: BlogPost;
className?: string;
/**
* Content thumbnail variant. Accepts both lowercase and PascalCase (case-insensitive).
* Figma uses PascalCase, codebase uses lowercase - both are supported.
*/
variant?: ContentThumbnailTemplateVariantValue;
slugOrder?: string[];
}
export interface ContentThumbnailTemplateViewProps {
post: BlogPost;
className: string;
variant: "vertical" | "horizontal";
backgroundImage: string;
}
@@ -0,0 +1,66 @@
import { memo } from "react";
import Link from "next/link";
import ContentContainer from "../ContentContainer";
import type { ContentThumbnailTemplateViewProps } from "./ContentThumbnailTemplate.types";
function ContentThumbnailTemplateView({
post,
className,
variant,
backgroundImage,
}: ContentThumbnailTemplateViewProps) {
if (variant === "vertical") {
return (
<Link
href={`/blog/${post.slug}`}
className={`block transition-transform duration-200 hover:scale-[1.02] ${className}`}
>
<div className="relative w-full aspect-[2/3] overflow-hidden pt-[18px] pl-[18px] pr-[42px] pb-[212px]">
{/* Background SVG - fills container with maintained aspect */}
<div className="absolute inset-0 z-0">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={backgroundImage}
alt={`Background for ${post.frontmatter.title}`}
className="w-full h-full object-cover"
/>
{/* Gradient overlay for better text readability */}
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-transparent to-black/60 z-10" />
</div>
{/* Content Section - positioned within the padding constraints */}
<ContentContainer post={post} width="200px" size="xs" />
</div>
</Link>
);
}
// Horizontal variant
return (
<Link
href={`/blog/${post.slug}`}
className={`block transition-transform duration-200 hover:scale-[1.02] ${className}`}
>
<div className="relative min-w-[320px] max-w-[800px] h-[225.5px] overflow-hidden pt-[13.75px] pr-[76px] pb-[73.75px] pl-[14px]">
{/* Background SVG - sized to fit the 320x225.5 container exactly */}
<div className="absolute inset-0 z-0">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={backgroundImage}
alt={`Background for ${post.frontmatter.title}`}
className="w-full h-[225.5px] object-cover"
/>
{/* Gradient overlay */}
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-transparent to-black/70 z-10" />
</div>
{/* Content - positioned within the padding constraints */}
<ContentContainer post={post} width="230px" size="xs" />
</div>
</Link>
);
}
ContentThumbnailTemplateView.displayName = "ContentThumbnailTemplateView";
export default memo(ContentThumbnailTemplateView);
@@ -0,0 +1,2 @@
export { default } from "./ContentThumbnailTemplate.container";
export type { ContentThumbnailTemplateProps } from "./ContentThumbnailTemplate.types";