Convert from JSX to TSX
CI Pipeline / test (20) (pull_request) Failing after 1m17s
CI Pipeline / test (18) (pull_request) Failing after 1m28s
CI Pipeline / e2e (chromium) (pull_request) Failing after 1m33s
CI Pipeline / e2e (firefox) (pull_request) Failing after 1m27s
CI Pipeline / e2e (webkit) (pull_request) Failing after 1m34s
CI Pipeline / visual-regression (pull_request) Failing after 2m9s
CI Pipeline / storybook (pull_request) Failing after 1m5s
CI Pipeline / performance (pull_request) Failing after 1m42s
CI Pipeline / lint (pull_request) Failing after 49s
CI Pipeline / build (pull_request) Failing after 1m29s

This commit is contained in:
adilallo
2025-12-10 22:14:17 -07:00
parent 1ad6bc85b4
commit f6a0673082
68 changed files with 1527 additions and 635 deletions
@@ -1,20 +1,52 @@
import { NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
const WEB_VITALS_DIR = path.join(process.cwd(), ".next", "web-vitals"); const WEB_VITALS_DIR = path.join(process.cwd(), ".next", "web-vitals");
interface WebVitalData {
metric: string;
data: {
value: number;
rating: string;
};
url: string;
userAgent: string;
timestamp: string;
receivedAt: string;
}
interface WebVitalMetrics {
[metric: string]: {
count: number;
average: number;
min: number;
max: number;
goodCount: number;
needsImprovementCount: number;
poorCount: number;
lastUpdated: string;
};
}
// Ensure web-vitals directory exists // Ensure web-vitals directory exists
if (!fs.existsSync(WEB_VITALS_DIR)) { if (!fs.existsSync(WEB_VITALS_DIR)) {
fs.mkdirSync(WEB_VITALS_DIR, { recursive: true }); fs.mkdirSync(WEB_VITALS_DIR, { recursive: true });
} }
export async function POST(request) { export async function POST(request: NextRequest) {
try { try {
const { metric, data, url, userAgent, timestamp } = await request.json(); const body = await request.json();
const { metric, data, url, userAgent, timestamp } = body as {
metric: string;
data: { value: number; rating: string };
url: string;
userAgent: string;
timestamp: string;
};
// Store the metric data // Store the metric data
const vitalsData = { const vitalsData: WebVitalData = {
metric, metric,
data, data,
url, url,
@@ -25,13 +57,15 @@ export async function POST(request) {
// Save to file (in production, you would save to a database) // Save to file (in production, you would save to a database)
const filePath = path.join(WEB_VITALS_DIR, `${metric}.json`); const filePath = path.join(WEB_VITALS_DIR, `${metric}.json`);
let existingData = []; let existingData: WebVitalData[] = [];
if (fs.existsSync(filePath)) { if (fs.existsSync(filePath)) {
try { try {
existingData = JSON.parse(fs.readFileSync(filePath, "utf8")); const fileContent = fs.readFileSync(filePath, "utf8");
existingData = JSON.parse(fileContent) as WebVitalData[];
} catch (error) { } catch (error) {
console.warn("Could not parse existing vitals data:", error.message); const err = error as Error;
console.warn("Could not parse existing vitals data:", err.message);
} }
} }
@@ -61,7 +95,7 @@ export async function POST(request) {
export async function GET() { export async function GET() {
try { try {
const metrics = {}; const metrics: WebVitalMetrics = {};
if (fs.existsSync(WEB_VITALS_DIR)) { if (fs.existsSync(WEB_VITALS_DIR)) {
const files = fs.readdirSync(WEB_VITALS_DIR); const files = fs.readdirSync(WEB_VITALS_DIR);
@@ -69,9 +103,11 @@ export async function GET() {
files.forEach((file) => { files.forEach((file) => {
if (file.endsWith(".json")) { if (file.endsWith(".json")) {
const metric = file.replace(".json", ""); const metric = file.replace(".json", "");
const data = JSON.parse( const fileContent = fs.readFileSync(
fs.readFileSync(path.join(WEB_VITALS_DIR, file), "utf8"), path.join(WEB_VITALS_DIR, file),
"utf8",
); );
const data = JSON.parse(fileContent) as WebVitalData[];
if (data.length > 0) { if (data.length > 0) {
const values = data const values = data
@@ -96,7 +132,7 @@ export async function GET() {
(r) => r === "needs-improvement", (r) => r === "needs-improvement",
).length, ).length,
poorCount: ratings.filter((r) => r === "poor").length, poorCount: ratings.filter((r) => r === "poor").length,
lastUpdated: data[data.length - 1]?.receivedAt, lastUpdated: data[data.length - 1]?.receivedAt || "",
}; };
} }
} }
@@ -1,8 +1,9 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import Link from "next/link"; import type { Metadata } from "next";
import { import {
getBlogPostBySlug, getBlogPostBySlug,
getAllBlogPosts as getAllPosts, getAllBlogPosts as getAllPosts,
type BlogPost,
} from "../../../lib/content"; } from "../../../lib/content";
import ContentBanner from "../../components/ContentBanner"; import ContentBanner from "../../components/ContentBanner";
import RelatedArticles from "../../components/RelatedArticles"; import RelatedArticles from "../../components/RelatedArticles";
@@ -17,6 +18,10 @@ const askOrganizerData = {
buttonHref: "#contact", buttonHref: "#contact",
}; };
interface PageProps {
params: Promise<{ slug: string }>;
}
/** /**
* Generate static params for all blog posts * Generate static params for all blog posts
* This enables static generation for all blog posts at build time * This enables static generation for all blog posts at build time
@@ -36,7 +41,9 @@ export async function generateStaticParams() {
/** /**
* Generate metadata for each blog post * Generate metadata for each blog post
*/ */
export async function generateMetadata({ params }) { export async function generateMetadata({
params,
}: PageProps): Promise<Metadata> {
try { try {
const { slug } = await params; const { slug } = await params;
const post = getBlogPostBySlug(slug); const post = getBlogPostBySlug(slug);
@@ -80,7 +87,7 @@ export async function generateMetadata({ params }) {
/** /**
* Dynamic blog post page * Dynamic blog post page
*/ */
export default async function BlogPostPage({ params }) { export default async function BlogPostPage({ params }: PageProps) {
// Get the blog post data // Get the blog post data
const { slug } = await params; const { slug } = await params;
const post = getBlogPostBySlug(slug); const post = getBlogPostBySlug(slug);
@@ -97,7 +104,11 @@ export default async function BlogPostPage({ params }) {
const slugOrder = allPosts.map((post) => post.slug); const slugOrder = allPosts.map((post) => post.slug);
// Simple related articles algorithm based on content similarity // Simple related articles algorithm based on content similarity
const getRelatedArticles = (currentPost, allPosts, limit = 3) => { const getRelatedArticles = (
currentPost: BlogPost,
allPosts: BlogPost[],
limit = 3,
): BlogPost[] => {
const otherPosts = allPosts.filter((p) => p.slug !== currentPost.slug); const otherPosts = allPosts.filter((p) => p.slug !== currentPost.slug);
// Score posts based on content similarity // Score posts based on content similarity
@@ -202,7 +213,7 @@ export default async function BlogPostPage({ params }) {
}; };
// Get article-specific background color from frontmatter // Get article-specific background color from frontmatter
const getBackgroundColor = (post) => { const getBackgroundColor = (post: BlogPost): string => {
if (post.frontmatter?.background?.color) { if (post.frontmatter?.background?.color) {
return post.frontmatter.background.color; return post.frontmatter.background.color;
} }
+2 -1
View File
@@ -1,8 +1,9 @@
import { getAllBlogPosts } from "../../lib/content"; import { getAllBlogPosts } from "../../lib/content";
import ContentThumbnailTemplate from "../components/ContentThumbnailTemplate"; import ContentThumbnailTemplate from "../components/ContentThumbnailTemplate";
import ContentContainer from "../components/ContentContainer"; import ContentContainer from "../components/ContentContainer";
import type { Metadata } from "next";
export const metadata = { export const metadata: Metadata = {
title: "Blog - CommunityRule", title: "Blog - CommunityRule",
description: description:
"Learn about community governance, decision-making, and building successful organizations.", "Learn about community governance, decision-making, and building successful organizations.",
@@ -4,7 +4,35 @@ import React, { memo } from "react";
import ContentLockup from "./ContentLockup"; import ContentLockup from "./ContentLockup";
import Button from "./Button"; import Button from "./Button";
const AskOrganizer = memo( interface AskOrganizerProps {
title?: string;
subtitle?: string;
description?: string;
buttonText?: string;
buttonHref?: string;
className?: string;
variant?: "centered" | "left-aligned" | "compact" | "inverse";
onContactClick?: (data: {
event: string;
component: string;
variant: string;
buttonText: string;
buttonHref: string;
timestamp: string;
}) => void;
}
declare global {
interface Window {
gtag?: (
command: string,
eventName: string,
params?: Record<string, unknown>
) => void;
}
}
const AskOrganizer = memo<AskOrganizerProps>(
({ ({
title, title,
subtitle, subtitle,
@@ -12,11 +40,13 @@ const AskOrganizer = memo(
buttonText = "Ask an organizer", buttonText = "Ask an organizer",
buttonHref = "#", buttonHref = "#",
className = "", className = "",
variant = "centered", // centered, left-aligned, compact variant = "centered",
onContactClick, // Analytics callback onContactClick,
}) => { }) => {
// Analytics tracking for contact button clicks // Analytics tracking for contact button clicks
const handleContactClick = (event) => { const handleContactClick = (
event: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>
) => {
// Track contact button interaction // Track contact button interaction
if (onContactClick) { if (onContactClick) {
onContactClick({ onContactClick({
@@ -40,7 +70,10 @@ const AskOrganizer = memo(
}; };
// Variant-specific styling // Variant-specific styling
const variantStyles = { const variantStyles: Record<
string,
{ container: string; buttonContainer: string }
> = {
centered: { centered: {
container: "text-center", container: "text-center",
buttonContainer: "flex justify-center", buttonContainer: "flex justify-center",
@@ -98,7 +131,7 @@ const AskOrganizer = memo(
variant={variant === "inverse" ? "primary" : "default"} variant={variant === "inverse" ? "primary" : "default"}
className="xl:!px-[var(--spacing-scale-020)] xl:!py-[var(--spacing-scale-012)] xl:!text-[24px] xl:!leading-[28px]" className="xl:!px-[var(--spacing-scale-020)] xl:!py-[var(--spacing-scale-012)] xl:!text-[24px] xl:!leading-[28px]"
onClick={handleContactClick} onClick={handleContactClick}
aria-label={`${buttonText} - Contact an organizer for help`} ariaLabel={`${buttonText} - Contact an organizer for help`}
> >
{buttonText} {buttonText}
</Button> </Button>
@@ -106,7 +139,7 @@ const AskOrganizer = memo(
</div> </div>
</section> </section>
); );
}, }
); );
AskOrganizer.displayName = "AskOrganizer"; AskOrganizer.displayName = "AskOrganizer";
@@ -1,8 +1,15 @@
import React, { memo } from "react"; import React, { memo } from "react";
const Avatar = memo( interface AvatarProps extends React.ImgHTMLAttributes<HTMLImageElement> {
src: string;
alt: string;
size?: "small" | "medium" | "large" | "xlarge";
className?: string;
}
const Avatar = memo<AvatarProps>(
({ src, alt, size = "small", className = "", ...props }) => { ({ src, alt, size = "small", className = "", ...props }) => {
const sizeStyles = { const sizeStyles: Record<string, string> = {
small: "w-[var(--spacing-scale-016)] h-[var(--spacing-scale-016)]", small: "w-[var(--spacing-scale-016)] h-[var(--spacing-scale-016)]",
medium: "w-[18px] h-[18px]", medium: "w-[18px] h-[18px]",
large: "w-[var(--spacing-scale-024)] h-[var(--spacing-scale-024)]", large: "w-[var(--spacing-scale-024)] h-[var(--spacing-scale-024)]",
@@ -12,7 +19,7 @@ const Avatar = memo(
const baseStyles = `rounded-[var(--radius-measures-radius-full)] object-cover ${sizeStyles[size]} ${className}`; const baseStyles = `rounded-[var(--radius-measures-radius-full)] object-cover ${sizeStyles[size]} ${className}`;
return <img src={src} alt={alt} className={baseStyles} {...props} />; return <img src={src} alt={alt} className={baseStyles} {...props} />;
}, }
); );
Avatar.displayName = "Avatar"; Avatar.displayName = "Avatar";
@@ -1,8 +1,14 @@
import React, { memo } from "react"; import React, { memo } from "react";
const AvatarContainer = memo( interface AvatarContainerProps extends React.HTMLAttributes<HTMLDivElement> {
children?: React.ReactNode;
size?: "small" | "medium" | "large" | "xlarge";
className?: string;
}
const AvatarContainer = memo<AvatarContainerProps>(
({ children, size = "small", className = "", ...props }) => { ({ children, size = "small", className = "", ...props }) => {
const sizeStyles = { const sizeStyles: Record<string, string> = {
small: "flex -space-x-[var(--spacing-scale-008)]", small: "flex -space-x-[var(--spacing-scale-008)]",
medium: "flex -space-x-[9px]", medium: "flex -space-x-[9px]",
large: "flex -space-x-[var(--spacing-scale-010)]", large: "flex -space-x-[var(--spacing-scale-010)]",
@@ -16,7 +22,7 @@ const AvatarContainer = memo(
{children} {children}
</div> </div>
); );
}, }
); );
AvatarContainer.displayName = "AvatarContainer"; AvatarContainer.displayName = "AvatarContainer";
@@ -1,6 +1,20 @@
import React, { memo } from "react"; import React, { memo } from "react";
const Button = memo( interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
children: React.ReactNode;
variant?: "default" | "secondary" | "primary" | "outlined" | "dark" | "inverse";
size?: "xsmall" | "small" | "medium" | "large" | "xlarge";
className?: string;
disabled?: boolean;
type?: "button" | "submit" | "reset";
onClick?: (e: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => void;
href?: string;
target?: string;
rel?: string;
ariaLabel?: string;
}
const Button = memo<ButtonProps>(
({ ({
children, children,
variant = "default", variant = "default",
@@ -15,7 +29,7 @@ const Button = memo(
ariaLabel, ariaLabel,
...props ...props
}) => { }) => {
const sizeStyles = { const sizeStyles: Record<string, string> = {
xsmall: xsmall:
"px-[var(--spacing-scale-006)] py-[var(--spacing-scale-004)] gap-[var(--spacing-scale-001)]", "px-[var(--spacing-scale-006)] py-[var(--spacing-scale-004)] gap-[var(--spacing-scale-001)]",
small: small:
@@ -27,7 +41,7 @@ const Button = memo(
"px-[var(--spacing-scale-020)] py-[var(--spacing-scale-012)] gap-[var(--spacing-scale-008)]", "px-[var(--spacing-scale-020)] py-[var(--spacing-scale-012)] gap-[var(--spacing-scale-008)]",
}; };
const fontStyles = { const fontStyles: Record<string, string> = {
xsmall: "font-inter text-[10px] leading-[12px] font-medium tracking-[0%]", xsmall: "font-inter text-[10px] leading-[12px] font-medium tracking-[0%]",
small: "font-inter text-[12px] leading-[14px] font-medium tracking-[0%]", small: "font-inter text-[12px] leading-[14px] font-medium tracking-[0%]",
medium: "font-inter text-[14px] leading-[16px] font-medium tracking-[0%]", medium: "font-inter text-[14px] leading-[16px] font-medium tracking-[0%]",
@@ -35,7 +49,7 @@ const Button = memo(
xlarge: "font-inter text-[24px] leading-[28px] font-normal tracking-[0%]", xlarge: "font-inter text-[24px] leading-[28px] font-normal tracking-[0%]",
}; };
const variantStyles = { const variantStyles: Record<string, string> = {
default: default:
"bg-[var(--color-surface-inverse-primary)] text-[var(--color-content-inverse-primary)] hover:bg-[var(--color-surface-inverse-primary)] hover:text-[var(--color-content-inverse-brand-primary)] hover:outline-[var(--border-color-default-brandprimary)] hover:outline-inset hover:scale-[1.02] hover:shadow-lg focus:shadow-[0_0_10px_1px_var(--color-surface-default-brand-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-content-default-brand-primary)] focus:ring-offset-1 focus:scale-[1.02] active:bg-[var(--color-surface-inverse-brand-primary)] active:text-[var(--color-content-inverse-primary)] active:outline-[var(--border-color-default-brandprimary)] active:outline-offset-1 active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-default-secondary)] disabled:text-[var(--color-content-inverse-tertiary)] disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100 disabled:hover:shadow-none disabled:hover:outline-none", "bg-[var(--color-surface-inverse-primary)] text-[var(--color-content-inverse-primary)] hover:bg-[var(--color-surface-inverse-primary)] hover:text-[var(--color-content-inverse-brand-primary)] hover:outline-[var(--border-color-default-brandprimary)] hover:outline-inset hover:scale-[1.02] hover:shadow-lg focus:shadow-[0_0_10px_1px_var(--color-surface-default-brand-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-content-default-brand-primary)] focus:ring-offset-1 focus:scale-[1.02] active:bg-[var(--color-surface-inverse-brand-primary)] active:text-[var(--color-content-inverse-primary)] active:outline-[var(--border-color-default-brandprimary)] active:outline-offset-1 active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-default-secondary)] disabled:text-[var(--color-content-inverse-tertiary)] disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100 disabled:hover:shadow-none disabled:hover:outline-none",
secondary: secondary:
@@ -49,7 +63,7 @@ const Button = memo(
"bg-transparent text-[var(--color-content-inverse-primary)] hover:text-[var(--color-content-inverse-brand-primary)] hover:scale-[1.02] hover:bg-transparent hover:outline-none focus:outline-1 focus:outline-inset focus:outline-[var(--border-color-default-tertiary)] focus:shadow-[0_0_10px_1px_var(--color-surface-default-tertiary)] focus:blur-[0px] active:bg-[var(--color-surface-default-brand-primary)] active:text-[var(--color-content-inverse-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-inverse-secondary)] disabled:text-[var(--color-content-default-primary)] disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100", "bg-transparent text-[var(--color-content-inverse-primary)] hover:text-[var(--color-content-inverse-brand-primary)] hover:scale-[1.02] hover:bg-transparent hover:outline-none focus:outline-1 focus:outline-inset focus:outline-[var(--border-color-default-tertiary)] focus:shadow-[0_0_10px_1px_var(--color-surface-default-tertiary)] focus:blur-[0px] active:bg-[var(--color-surface-default-brand-primary)] active:text-[var(--color-content-inverse-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-inverse-secondary)] disabled:text-[var(--color-content-default-primary)] disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100",
}; };
const hoverOutlineStyles = { const hoverOutlineStyles: Record<string, string> = {
xsmall: "hover:outline-1", xsmall: "hover:outline-1",
small: "hover:outline-1", small: "hover:outline-1",
medium: "hover:outline-1", medium: "hover:outline-1",
@@ -2,12 +2,30 @@
import React, { memo, useId } from "react"; import React, { memo, useId } from "react";
interface CheckboxProps {
checked?: boolean;
mode?: "standard" | "inverse";
state?: "default" | "hover" | "focus";
disabled?: boolean;
label?: string;
className?: string;
onChange?: (data: {
checked: boolean;
value?: string;
event: React.MouseEvent | React.KeyboardEvent;
}) => void;
id?: string;
name?: string;
value?: string;
ariaLabel?: string;
}
/** /**
* Checkbox * Checkbox
* A basic controlled checkbox with visual modes and interaction states. * A basic controlled checkbox with visual modes and interaction states.
* This is a minimal first pass; visuals will be refined collaboratively. * This is a minimal first pass; visuals will be refined collaboratively.
*/ */
const Checkbox = memo( const Checkbox = memo<CheckboxProps>(
({ ({
checked = false, checked = false,
mode = "standard", // "standard" | "inverse" mode = "standard", // "standard" | "inverse"
@@ -38,7 +56,7 @@ const Checkbox = memo(
// Visual container depending on state // Visual container depending on state
const baseBox = `flex items-center justify-center shrink-0 w-[var(--measures-sizing-024)] h-[var(--measures-sizing-024)] rounded-[var(--measures-radius-medium)] transition-all duration-200 ease-in-out`; const baseBox = `flex items-center justify-center shrink-0 w-[var(--measures-sizing-024)] h-[var(--measures-sizing-024)] rounded-[var(--measures-radius-medium)] transition-all duration-200 ease-in-out`;
const stateStyles = { const stateStyles: Record<string, string> = {
default: "", default: "",
hover: "", hover: "",
focus: "", focus: "",
@@ -73,7 +91,7 @@ const Checkbox = memo(
const conditionalFocusClass = const conditionalFocusClass =
"focus:outline focus:outline-1 focus:outline-[var(--color-border-default-utility-info)] focus:shadow-[0_0_10px_1px_var(--color-surface-inverse-brand-primary)]"; "focus:outline focus:outline-1 focus:outline-[var(--color-border-default-utility-info)] focus:shadow-[0_0_10px_1px_var(--color-surface-inverse-brand-primary)]";
const handleToggle = (e) => { const handleToggle = (e: React.MouseEvent | React.KeyboardEvent) => {
if (disabled) return; if (disabled) return;
onChange?.({ onChange?.({
checked: !checked, checked: !checked,
@@ -87,7 +105,7 @@ const Checkbox = memo(
const checkboxId = id || `checkbox-${generatedId}`; const checkboxId = id || `checkbox-${generatedId}`;
const accessibilityProps = { const accessibilityProps = {
role: "checkbox", role: "checkbox" as const,
"aria-checked": checked ? "true" : "false", "aria-checked": checked ? "true" : "false",
...(disabled && { "aria-disabled": "true", tabIndex: -1 }), ...(disabled && { "aria-disabled": "true", tabIndex: -1 }),
...(!disabled && { tabIndex: 0 }), ...(!disabled && { tabIndex: 0 }),
@@ -160,7 +178,7 @@ const Checkbox = memo(
/> />
</label> </label>
); );
}, }
); );
Checkbox.displayName = "Checkbox"; Checkbox.displayName = "Checkbox";
@@ -3,10 +3,15 @@
import React, { memo } from "react"; import React, { memo } from "react";
import { getAssetPath } from "../../lib/assetUtils"; import { getAssetPath } from "../../lib/assetUtils";
import ContentContainer from "./ContentContainer"; import ContentContainer from "./ContentContainer";
import type { BlogPost } from "../../lib/content";
const ContentBanner = memo(({ post }) => { interface ContentBannerProps {
post: BlogPost;
}
const ContentBanner = memo<ContentBannerProps>(({ post }) => {
// Get article-specific horizontal thumbnail (small) and banner (md+) // Get article-specific horizontal thumbnail (small) and banner (md+)
const getBackgroundImage = (post) => { const getBackgroundImage = (post: BlogPost): string => {
if (post.frontmatter?.thumbnail?.horizontal) { if (post.frontmatter?.thumbnail?.horizontal) {
return `/content/blog/${post.frontmatter.thumbnail.horizontal}`; return `/content/blog/${post.frontmatter.thumbnail.horizontal}`;
} }
@@ -14,7 +19,7 @@ const ContentBanner = memo(({ post }) => {
return getAssetPath("assets/Content_Banner.svg"); return getAssetPath("assets/Content_Banner.svg");
}; };
const getBannerImageMd = (post) => { const getBannerImageMd = (post: BlogPost): string => {
// Use banner.horizontal when provided; fallback to horizontal thumbnail // Use banner.horizontal when provided; fallback to horizontal thumbnail
if (post.frontmatter?.banner?.horizontal) { if (post.frontmatter?.banner?.horizontal) {
return `/content/blog/${post.frontmatter.banner.horizontal}`; return `/content/blog/${post.frontmatter.banner.horizontal}`;
@@ -2,11 +2,18 @@
import React, { memo } from "react"; import React, { memo } from "react";
import { getAssetPath, ASSETS } from "../../lib/assetUtils"; import { getAssetPath, ASSETS } from "../../lib/assetUtils";
import type { BlogPost } from "../../lib/content";
const ContentContainer = memo( interface ContentContainerProps {
post: BlogPost;
width?: string;
size?: "xs" | "responsive";
}
const ContentContainer = memo<ContentContainerProps>(
({ post, width = "200px", size = "responsive" }) => { ({ post, width = "200px", size = "responsive" }) => {
// Get the corresponding icon based on the same logic as background images // Get the corresponding icon based on the same logic as background images
const getIconImage = (slug) => { const getIconImage = (slug: string): string => {
const icons = [ const icons = [
getAssetPath(ASSETS.ICON_1), getAssetPath(ASSETS.ICON_1),
getAssetPath(ASSETS.ICON_2), getAssetPath(ASSETS.ICON_2),
@@ -123,7 +130,7 @@ const ContentContainer = memo(
</div> </div>
</div> </div>
); );
}, }
); );
ContentContainer.displayName = "ContentContainer"; ContentContainer.displayName = "ContentContainer";
@@ -4,7 +4,31 @@ import React, { memo } from "react";
import Button from "./Button"; import Button from "./Button";
import { getAssetPath } from "../../lib/assetUtils"; import { getAssetPath } from "../../lib/assetUtils";
const ContentLockup = memo( interface ContentLockupProps {
title?: string;
subtitle?: string;
description?: string;
ctaText?: string;
ctaHref?: string;
buttonClassName?: string;
variant?: "hero" | "feature" | "learn" | "ask" | "ask-inverse";
linkText?: string;
linkHref?: string;
alignment?: "center" | "left";
}
interface VariantStyle {
container: string;
textContainer: string;
titleGroup: string;
titleContainer: string;
title: string;
subtitle: string;
description?: string;
shape: string;
}
const ContentLockup = memo<ContentLockupProps>(
({ ({
title, title,
subtitle, subtitle,
@@ -15,10 +39,10 @@ const ContentLockup = memo(
variant = "hero", variant = "hero",
linkText, linkText,
linkHref, linkHref,
alignment = "center", // center, left alignment = "center",
}) => { }) => {
// Variant-specific styling // Variant-specific styling
const variantStyles = { const variantStyles: Record<string, VariantStyle> = {
hero: { hero: {
container: container:
"flex flex-col gap-[var(--spacing-scale-006)] sm:gap-[var(--spacing-scale-012)] md:gap-[var(--spacing-scale-020)] lg:gap-[var(--spacing-scale-020)] relative z-10", "flex flex-col gap-[var(--spacing-scale-006)] sm:gap-[var(--spacing-scale-012)] md:gap-[var(--spacing-scale-020)] lg:gap-[var(--spacing-scale-020)] relative z-10",
@@ -179,7 +203,7 @@ const ContentLockup = memo(
)} )}
</div> </div>
); );
}, }
); );
ContentLockup.displayName = "ContentLockup"; ContentLockup.displayName = "ContentLockup";
@@ -4,19 +4,26 @@ import React, { memo } from "react";
import Link from "next/link"; import Link from "next/link";
import ContentContainer from "./ContentContainer"; import ContentContainer from "./ContentContainer";
import { getAssetPath, ASSETS } from "../../lib/assetUtils"; import { getAssetPath, ASSETS } from "../../lib/assetUtils";
import type { BlogPost } from "../../lib/content";
/** /**
* ContentThumbnailTemplate component for displaying blog post previews * ContentThumbnailTemplate component for displaying blog post previews
* Simplified version to debug infinite loop * Simplified version to debug infinite loop
*/ */
const ContentThumbnailTemplate = memo( interface ContentThumbnailTemplateProps {
({ post: BlogPost;
post, className?: string;
className = "", variant?: "vertical" | "horizontal";
variant = "vertical", // Internal prop for testing/development slugOrder?: string[];
}) => { }
const ContentThumbnailTemplate = memo<ContentThumbnailTemplateProps>(
({ post, className = "", variant = "vertical", slugOrder }) => {
// Get article-specific background image from frontmatter // Get article-specific background image from frontmatter
const getBackgroundImage = (post, variant) => { const getBackgroundImage = (
post: BlogPost,
variant: "vertical" | "horizontal"
): string => {
// Check if post has thumbnail images defined in frontmatter // Check if post has thumbnail images defined in frontmatter
if (post.frontmatter?.thumbnail) { if (post.frontmatter?.thumbnail) {
const imageName = const imageName =
@@ -31,7 +38,7 @@ const ContentThumbnailTemplate = memo(
} }
// Fallback to default images if no thumbnail specified // Fallback to default images if no thumbnail specified
const fallbackImages = { const fallbackImages: Record<string, string> = {
vertical: getAssetPath(ASSETS.VERTICAL_1), vertical: getAssetPath(ASSETS.VERTICAL_1),
horizontal: getAssetPath(ASSETS.HORIZONTAL_1), horizontal: getAssetPath(ASSETS.HORIZONTAL_1),
}; };
@@ -91,7 +98,7 @@ const ContentThumbnailTemplate = memo(
</div> </div>
</Link> </Link>
); );
}, }
); );
ContentThumbnailTemplate.displayName = "ContentThumbnailTemplate"; ContentThumbnailTemplate.displayName = "ContentThumbnailTemplate";
@@ -2,7 +2,12 @@
import React, { forwardRef, memo } from "react"; import React, { forwardRef, memo } from "react";
const ContextMenu = forwardRef( interface ContextMenuProps extends React.HTMLAttributes<HTMLDivElement> {
className?: string;
children?: React.ReactNode;
}
const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(
({ className = "", children, ...props }, ref) => { ({ className = "", children, ...props }, ref) => {
const menuClasses = ` const menuClasses = `
bg-black bg-black
@@ -28,7 +33,7 @@ const ContextMenu = forwardRef(
{children} {children}
</div> </div>
); );
}, }
); );
ContextMenu.displayName = "ContextMenu"; ContextMenu.displayName = "ContextMenu";
-21
View File
@@ -1,21 +0,0 @@
"use client";
import React, { forwardRef, memo } from "react";
const ContextMenuDivider = forwardRef(({ className = "", ...props }, ref) => {
const dividerClasses = `
border-t border-[var(--color-border-default-tertiary)]
my-1
${className}
`
.trim()
.replace(/\s+/g, " ");
return (
<div ref={ref} className={dividerClasses} role="separator" {...props} />
);
});
ContextMenuDivider.displayName = "ContextMenuDivider";
export default memo(ContextMenuDivider);
+27
View File
@@ -0,0 +1,27 @@
"use client";
import React, { forwardRef, memo } from "react";
interface ContextMenuDividerProps extends React.HTMLAttributes<HTMLDivElement> {
className?: string;
}
const ContextMenuDivider = forwardRef<HTMLDivElement, ContextMenuDividerProps>(
({ className = "", ...props }, ref) => {
const dividerClasses = `
border-t border-[var(--color-border-default-tertiary)]
my-1
${className}
`
.trim()
.replace(/\s+/g, " ");
return (
<div ref={ref} className={dividerClasses} role="separator" {...props} />
);
}
);
ContextMenuDivider.displayName = "ContextMenuDivider";
export default memo(ContextMenuDivider);
@@ -2,7 +2,19 @@
import React, { forwardRef, memo, useCallback } from "react"; import React, { forwardRef, memo, useCallback } from "react";
const ContextMenuItem = forwardRef( interface ContextMenuItemProps extends React.HTMLAttributes<HTMLDivElement> {
children?: React.ReactNode;
selected?: boolean;
hasSubmenu?: boolean;
disabled?: boolean;
className?: string;
onClick?: (
e: React.MouseEvent<HTMLDivElement> | React.KeyboardEvent<HTMLDivElement>
) => void;
size?: "small" | "medium" | "large";
}
const ContextMenuItem = forwardRef<HTMLDivElement, ContextMenuItemProps>(
( (
{ {
children, children,
@@ -14,9 +26,9 @@ const ContextMenuItem = forwardRef(
size = "medium", size = "medium",
...props ...props
}, },
ref, ref
) => { ) => {
const getTextSize = () => { const getTextSize = (): string => {
switch (size) { switch (size) {
case "small": case "small":
return "text-[10px] leading-[14px]"; return "text-[10px] leading-[14px]";
@@ -52,16 +64,16 @@ const ContextMenuItem = forwardRef(
.replace(/\s+/g, " "); .replace(/\s+/g, " ");
const handleClick = useCallback( const handleClick = useCallback(
(e) => { (e: React.MouseEvent<HTMLDivElement>) => {
if (!disabled && onClick) { if (!disabled && onClick) {
onClick(e); onClick(e);
} }
}, },
[disabled, onClick], [disabled, onClick]
); );
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(e) => { (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Enter" || e.key === " ") { if (e.key === "Enter" || e.key === " ") {
e.preventDefault(); e.preventDefault();
if (!disabled && onClick) { if (!disabled && onClick) {
@@ -69,7 +81,7 @@ const ContextMenuItem = forwardRef(
} }
} }
}, },
[disabled, onClick], [disabled, onClick]
); );
return ( return (
@@ -119,7 +131,7 @@ const ContextMenuItem = forwardRef(
)} )}
</div> </div>
); );
}, }
); );
ContextMenuItem.displayName = "ContextMenuItem"; ContextMenuItem.displayName = "ContextMenuItem";
@@ -2,7 +2,13 @@
import React, { forwardRef, memo } from "react"; import React, { forwardRef, memo } from "react";
const ContextMenuSection = forwardRef( interface ContextMenuSectionProps extends React.HTMLAttributes<HTMLDivElement> {
title?: string;
children?: React.ReactNode;
className?: string;
}
const ContextMenuSection = forwardRef<HTMLDivElement, ContextMenuSectionProps>(
({ title, children, className = "", ...props }, ref) => { ({ title, children, className = "", ...props }, ref) => {
const sectionClasses = ` const sectionClasses = `
${className} ${className}
@@ -22,7 +28,7 @@ const ContextMenuSection = forwardRef(
{children} {children}
</div> </div>
); );
}, }
); );
ContextMenuSection.displayName = "ContextMenuSection"; ContextMenuSection.displayName = "ContextMenuSection";
@@ -1,19 +1,28 @@
"use client"; "use client";
import React, { Component } from "react"; import React, { Component, type ReactNode } from "react";
class ErrorBoundary extends Component { interface ErrorBoundaryProps {
constructor(props) { children: ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props); super(props);
this.state = { hasError: false, error: null }; this.state = { hasError: false, error: null };
} }
static getDerivedStateFromError(error) { static getDerivedStateFromError(error: Error): ErrorBoundaryState {
// Update state so the next render will show the fallback UI // Update state so the next render will show the fallback UI
return { hasError: true, error }; return { hasError: true, error };
} }
componentDidCatch(error, errorInfo) { componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
// Log the error to an error reporting service // Log the error to an error reporting service
console.error("ErrorBoundary caught an error:", error, errorInfo); console.error("ErrorBoundary caught an error:", error, errorInfo);
} }
-93
View File
@@ -1,93 +0,0 @@
"use client";
import React, { memo, useMemo } from "react";
import ContentLockup from "./ContentLockup";
import MiniCard from "./MiniCard";
import Image from "next/image";
const FeatureGrid = memo(({ title, subtitle, className = "" }) => {
// Memoize the feature data to prevent unnecessary re-renders
const features = useMemo(
() => [
{
backgroundColor: "bg-[var(--color-surface-default-brand-royal)]",
labelLine1: "Decision-making",
labelLine2: "support",
panelContent: "/assets/Feature_Support.png",
ariaLabel: "Decision-making support tools",
href: "#decision-making",
},
{
backgroundColor: "bg-[#D1FFE2]",
labelLine1: "Values alignment",
labelLine2: "exercises",
panelContent: "/assets/Feature_Exercises.png",
ariaLabel: "Values alignment exercises",
href: "#values-alignment",
},
{
backgroundColor: "bg-[#F4CAFF]",
labelLine1: "Membership",
labelLine2: "guidance",
panelContent: "/assets/Feature_Guidance.png",
ariaLabel: "Membership guidance resources",
href: "#membership-guidance",
},
{
backgroundColor: "bg-[#CBDDFF]",
labelLine1: "Conflict resolution",
labelLine2: "tools",
panelContent: "/assets/Feature_Tools.png",
ariaLabel: "Conflict resolution tools",
href: "#conflict-resolution",
},
],
[],
);
return (
<section
className={`p-0 lg:p-[var(--spacing-scale-064)] ${className}`}
aria-labelledby="feature-grid-headline"
role="region"
tabIndex={-1}
>
<div className="py-[var(--spacing-scale-032)] px-[var(--spacing-scale-020)] md:pt-[var(--spacing-scale-076)] md:pb-[var(--spacing-scale-048)] lg:pb-[var(--spacing-scale-076)] md:px-[var(--spacing-scale-048)] bg-[#171717] rounded-[var(--radius-measures-radius-xlarge)] focus-within:ring-2 focus-within:ring-[var(--color-surface-default-brand-royal)] focus-within:ring-offset-2">
<div className="w-full mx-auto gap-[var(--spacing-scale-048)] lg:flex lg:items-start lg:gap-[var(--spacing-scale-048)] [container-type:inline-size]">
{/* Feature Content Lockup */}
<div className="lg:shrink lg:min-w-0">
<ContentLockup
title={title}
subtitle={subtitle}
variant="feature"
linkText="Learn more"
linkHref="#"
/>
</div>
{/* MiniCard Grid */}
<div
className="grid grid-cols-2 md:grid-cols-4 gap-[var(--spacing-scale-012)] mt-[var(--spacing-scale-048)] lg:mt-0 lg:flex-grow lg:shrink-0"
role="grid"
aria-label="Feature tools and services"
>
{features.map((feature, index) => (
<MiniCard
key={index}
backgroundColor={feature.backgroundColor}
labelLine1={feature.labelLine1}
labelLine2={feature.labelLine2}
panelContent={feature.panelContent}
ariaLabel={feature.ariaLabel}
href={feature.href}
/>
))}
</div>
</div>
</div>
</section>
);
});
FeatureGrid.displayName = "FeatureGrid";
export default FeatureGrid;
+100
View File
@@ -0,0 +1,100 @@
"use client";
import React, { memo, useMemo } from "react";
import ContentLockup from "./ContentLockup";
import MiniCard from "./MiniCard";
interface FeatureGridProps {
title?: string;
subtitle?: string;
className?: string;
}
const FeatureGrid = memo<FeatureGridProps>(
({ title, subtitle, className = "" }) => {
// Memoize the feature data to prevent unnecessary re-renders
const features = useMemo(
() => [
{
backgroundColor: "bg-[var(--color-surface-default-brand-royal)]",
labelLine1: "Decision-making",
labelLine2: "support",
panelContent: "/assets/Feature_Support.png",
ariaLabel: "Decision-making support tools",
href: "#decision-making",
},
{
backgroundColor: "bg-[#D1FFE2]",
labelLine1: "Values alignment",
labelLine2: "exercises",
panelContent: "/assets/Feature_Exercises.png",
ariaLabel: "Values alignment exercises",
href: "#values-alignment",
},
{
backgroundColor: "bg-[#F4CAFF]",
labelLine1: "Membership",
labelLine2: "guidance",
panelContent: "/assets/Feature_Guidance.png",
ariaLabel: "Membership guidance resources",
href: "#membership-guidance",
},
{
backgroundColor: "bg-[#CBDDFF]",
labelLine1: "Conflict resolution",
labelLine2: "tools",
panelContent: "/assets/Feature_Tools.png",
ariaLabel: "Conflict resolution tools",
href: "#conflict-resolution",
},
],
[]
);
return (
<section
className={`p-0 lg:p-[var(--spacing-scale-064)] ${className}`}
aria-labelledby="feature-grid-headline"
role="region"
tabIndex={-1}
>
<div className="py-[var(--spacing-scale-032)] px-[var(--spacing-scale-020)] md:pt-[var(--spacing-scale-076)] md:pb-[var(--spacing-scale-048)] lg:pb-[var(--spacing-scale-076)] md:px-[var(--spacing-scale-048)] bg-[#171717] rounded-[var(--radius-measures-radius-xlarge)] focus-within:ring-2 focus-within:ring-[var(--color-surface-default-brand-royal)] focus-within:ring-offset-2">
<div className="w-full mx-auto gap-[var(--spacing-scale-048)] lg:flex lg:items-start lg:gap-[var(--spacing-scale-048)] [container-type:inline-size]">
{/* Feature Content Lockup */}
<div className="lg:shrink lg:min-w-0">
<ContentLockup
title={title}
subtitle={subtitle}
variant="feature"
linkText="Learn more"
linkHref="#"
/>
</div>
{/* MiniCard Grid */}
<div
className="grid grid-cols-2 md:grid-cols-4 gap-[var(--spacing-scale-012)] mt-[var(--spacing-scale-048)] lg:mt-0 lg:flex-grow lg:shrink-0"
role="grid"
aria-label="Feature tools and services"
>
{features.map((feature, index) => (
<MiniCard
key={index}
backgroundColor={feature.backgroundColor}
labelLine1={feature.labelLine1}
labelLine2={feature.labelLine2}
panelContent={feature.panelContent}
ariaLabel={feature.ariaLabel}
href={feature.href}
/>
))}
</div>
</div>
</div>
</section>
);
}
);
FeatureGrid.displayName = "FeatureGrid";
export default FeatureGrid;
@@ -101,56 +101,29 @@ const Footer = memo(() => {
</div> </div>
</div> </div>
{/* Navigation Section */} {/* Links Section */}
<div className="flex flex-col items-start gap-[var(--spacing-measures-spacing-032,32px)] order-1 sm:order-2 sm:items-end"> <div className="flex flex-col items-start gap-[var(--spacing-measures-spacing-016,16px)] order-1 sm:order-2">
<a <a
href="#" href="/learn"
className="text-[var(--color-content-default-primary)] font-inter text-base leading-5 font-medium tracking-[0%] lg:text-2xl lg:leading-7 lg:font-normal hover:opacity-80 active:opacity-60 focus:opacity-80 focus:outline-none focus:ring-2 focus:ring-[var(--color-content-default-primary)] focus:ring-offset-2 focus:ring-offset-[var(--color-surface-default-primary)] transition-opacity p-2 -m-2 cursor-pointer"
>
Use cases
</a>
<a
href="#"
className="text-[var(--color-content-default-primary)] font-inter text-base leading-5 font-medium tracking-[0%] lg:text-2xl lg:leading-7 lg:font-normal hover:opacity-80 active:opacity-60 focus:opacity-80 focus:outline-none focus:ring-2 focus:ring-[var(--color-content-default-primary)] focus:ring-offset-2 focus:ring-offset-[var(--color-surface-default-primary)] transition-opacity p-2 -m-2 cursor-pointer" className="text-[var(--color-content-default-primary)] font-inter text-base leading-5 font-medium tracking-[0%] lg:text-2xl lg:leading-7 lg:font-normal hover:opacity-80 active:opacity-60 focus:opacity-80 focus:outline-none focus:ring-2 focus:ring-[var(--color-content-default-primary)] focus:ring-offset-2 focus:ring-offset-[var(--color-surface-default-primary)] transition-opacity p-2 -m-2 cursor-pointer"
> >
Learn Learn
</a> </a>
<a <a
href="#" href="/blog"
className="text-[var(--color-content-default-primary)] font-inter text-base leading-5 font-medium tracking-[0%] lg:text-2xl lg:leading-7 lg:font-normal hover:opacity-80 active:opacity-60 focus:opacity-80 focus:outline-none focus:ring-2 focus:ring-[var(--color-content-default-primary)] focus:ring-offset-2 focus:ring-offset-[var(--color-surface-default-primary)] transition-opacity p-2 -m-2 cursor-pointer" className="text-[var(--color-content-default-primary)] font-inter text-base leading-5 font-medium tracking-[0%] lg:text-2xl lg:leading-7 lg:font-normal hover:opacity-80 active:opacity-60 focus:opacity-80 focus:outline-none focus:ring-2 focus:ring-[var(--color-content-default-primary)] focus:ring-offset-2 focus:ring-offset-[var(--color-surface-default-primary)] transition-opacity p-2 -m-2 cursor-pointer"
> >
About Blog
</a> </a>
</div> </div>
</div> </div>
<Separator /> <Separator />
{/* Bottom section */} {/* Copyright */}
<div className="flex flex-col items-start gap-[var(--spacing-measures-spacing-032,32px)] sm:flex-row sm:justify-between sm:items-center w-full"> <div className="text-[var(--color-content-default-secondary)] font-inter text-sm leading-5 font-normal tracking-[0%] lg:text-base lg:leading-6">
<div className="text-[var(--color-content-default-secondary)] font-inter text-sm leading-5 font-medium sm:text-xs sm:leading-4 lg:text-sm lg:leading-5 lg:font-normal"> © {new Date().getFullYear()} Media Economies Design Lab. All rights
© All right reserved reserved.
</div>
<div className="flex flex-col items-start gap-[var(--spacing-measures-spacing-040,40px)] sm:flex-row sm:gap-[var(--spacing-measures-spacing-040,40px)]">
<a
href="#"
className="text-[var(--color-content-default-secondary)] font-inter text-sm leading-5 font-medium underline sm:text-xs sm:leading-4 sm:no-underline lg:text-sm lg:leading-5 lg:font-normal hover:opacity-80 active:opacity-60 focus:opacity-80 focus:outline-none focus:ring-2 focus:ring-[var(--color-content-default-secondary)] focus:ring-offset-2 focus:ring-offset-[var(--color-surface-default-primary)] transition-opacity p-2 -m-2 cursor-pointer"
>
Privacy Policy
</a>
<a
href="#"
className="text-[var(--color-content-default-secondary)] font-inter text-sm leading-5 font-medium underline sm:text-xs sm:leading-4 sm:no-underline lg:text-sm lg:leading-5 lg:font-normal hover:opacity-80 active:opacity-60 focus:opacity-80 focus:outline-none focus:ring-2 focus:ring-[var(--color-content-default-secondary)] focus:ring-offset-2 focus:ring-offset-[var(--color-surface-default-primary)] transition-opacity p-2 -m-2 cursor-pointer"
>
Terms of Service
</a>
<a
href="#"
className="text-[var(--color-content-default-secondary)] font-inter text-sm leading-5 font-medium underline sm:text-xs sm:leading-4 sm:no-underline lg:text-sm lg:leading-5 lg:font-normal hover:opacity-80 active:opacity-60 focus:opacity-80 focus:outline-none focus:ring-2 focus:ring-[var(--color-content-default-secondary)] focus:ring-offset-2 focus:ring-offset-[var(--color-surface-default-primary)] transition-opacity p-2 -m-2 cursor-pointer"
>
Cookies Settings
</a>
</div>
</div> </div>
</div> </div>
</footer> </footer>
@@ -24,19 +24,23 @@ export const avatarImages = [
]; ];
export const logoConfig = [ export const logoConfig = [
{ breakpoint: "block sm:hidden", size: "header", showText: false }, { breakpoint: "block sm:hidden", size: "header" as const, showText: false },
{ breakpoint: "hidden sm:block md:hidden", size: "header", showText: true }, {
breakpoint: "hidden sm:block md:hidden",
size: "header" as const,
showText: true,
},
{ {
breakpoint: "hidden md:block lg:hidden", breakpoint: "hidden md:block lg:hidden",
size: "headerMd", size: "headerMd" as const,
showText: true, showText: true,
}, },
{ {
breakpoint: "hidden lg:block xl:hidden", breakpoint: "hidden lg:block xl:hidden",
size: "headerLg", size: "headerLg" as const,
showText: true, showText: true,
}, },
{ breakpoint: "hidden xl:block", size: "headerXl", showText: true }, { breakpoint: "hidden xl:block", size: "headerXl" as const, showText: true },
]; ];
const Header = memo(() => { const Header = memo(() => {
@@ -55,7 +59,7 @@ const Header = memo(() => {
}, },
}; };
const renderNavigationItems = (size) => { const renderNavigationItems = (size: string) => {
return navigationItems.map((item, index) => ( return navigationItems.map((item, index) => (
<MenuBarItem <MenuBarItem
key={index} key={index}
@@ -69,7 +73,10 @@ const Header = memo(() => {
)); ));
}; };
const renderAvatarGroup = (containerSize, avatarSize) => { const renderAvatarGroup = (
containerSize: "small" | "medium" | "large" | "xlarge",
avatarSize: "small" | "medium" | "large" | "xlarge"
) => {
return ( return (
<AvatarContainer size={containerSize}> <AvatarContainer size={containerSize}>
{avatarImages.map((avatar, index) => ( {avatarImages.map((avatar, index) => (
@@ -84,7 +91,7 @@ const Header = memo(() => {
); );
}; };
const renderLoginButton = (size) => { const renderLoginButton = (size: string) => {
return ( return (
<MenuBarItem href="#" size={size} ariaLabel="Log in to your account"> <MenuBarItem href="#" size={size} ariaLabel="Log in to your account">
Log in Log in
@@ -92,7 +99,11 @@ const Header = memo(() => {
); );
}; };
const renderCreateRuleButton = (buttonSize, containerSize, avatarSize) => { const renderCreateRuleButton = (
buttonSize: string,
containerSize: "small" | "medium" | "large" | "xlarge",
avatarSize: "small" | "medium" | "large" | "xlarge"
) => {
return ( return (
<Button <Button
size={buttonSize} size={buttonSize}
@@ -104,7 +115,22 @@ const Header = memo(() => {
); );
}; };
const renderLogo = (size, showText) => { const renderLogo = (
size:
| "default"
| "homeHeaderXsmall"
| "homeHeaderSm"
| "homeHeaderMd"
| "homeHeaderLg"
| "homeHeaderXl"
| "header"
| "headerMd"
| "headerLg"
| "headerXl"
| "footer"
| "footerLg",
showText: boolean
) => {
return <Logo size={size} showText={showText} />; return <Logo size={size} showText={showText} />;
}; };
@@ -1,7 +1,13 @@
import React, { memo } from "react"; import React, { memo } from "react";
import { getAssetPath } from "../../lib/assetUtils"; import { getAssetPath } from "../../lib/assetUtils";
const HeaderTab = memo( interface HeaderTabProps extends React.HTMLAttributes<HTMLDivElement> {
children?: React.ReactNode;
className?: string;
stretch?: boolean;
}
const HeaderTab = memo<HeaderTabProps>(
({ children, className = "", stretch = false, ...props }) => { ({ children, className = "", stretch = false, ...props }) => {
const stretchClasses = stretch const stretchClasses = stretch
? "flex-1 sm:mr-[var(--spacing-scale-008)] md:mr-[185px] lg:mr-[var(--spacing-scale-024)] xl:mr-[var(--spacing-scale-032)]" ? "flex-1 sm:mr-[var(--spacing-scale-008)] md:mr-[185px] lg:mr-[var(--spacing-scale-024)] xl:mr-[var(--spacing-scale-032)]"
@@ -33,7 +39,7 @@ const HeaderTab = memo(
/> />
</div> </div>
); );
}, }
); );
HeaderTab.displayName = "HeaderTab"; HeaderTab.displayName = "HeaderTab";
@@ -5,7 +5,15 @@ import ContentLockup from "./ContentLockup";
import HeroDecor from "./HeroDecor"; import HeroDecor from "./HeroDecor";
import { getAssetPath } from "../../lib/assetUtils"; import { getAssetPath } from "../../lib/assetUtils";
const HeroBanner = memo( interface HeroBannerProps {
title?: string;
subtitle?: string;
description?: string;
ctaText?: string;
ctaHref?: string;
}
const HeroBanner = memo<HeroBannerProps>(
({ title, subtitle, description, ctaText, ctaHref }) => { ({ title, subtitle, description, ctaText, ctaHref }) => {
return ( return (
<section className="bg-transparent px-[var(--spacing-scale-008)] sm:px-[var(--spacing-scale-010)] md:px-[var(--spacing-scale-016)] lg:px-[var(--spacing-scale-024)] xl:px-[var(--spacing-scale-048)]"> <section className="bg-transparent px-[var(--spacing-scale-008)] sm:px-[var(--spacing-scale-010)] md:px-[var(--spacing-scale-016)] lg:px-[var(--spacing-scale-024)] xl:px-[var(--spacing-scale-048)]">
@@ -46,7 +54,7 @@ const HeroBanner = memo(
</div> </div>
</section> </section>
); );
}, }
); );
HeroBanner.displayName = "HeroBanner"; HeroBanner.displayName = "HeroBanner";
@@ -2,7 +2,11 @@
import React, { memo } from "react"; import React, { memo } from "react";
const HeroDecor = memo(({ className = "" }) => { interface HeroDecorProps {
className?: string;
}
const HeroDecor = memo<HeroDecorProps>(({ className = "" }) => {
return ( return (
<svg <svg
className={`text-[var(--color-surface-default-brand-lighter-accent)] opacity-50 ${className}`} className={`text-[var(--color-surface-default-brand-lighter-accent)] opacity-50 ${className}`}
@@ -42,28 +42,32 @@ const HomeHeader = memo(() => {
const logoConfig = [ const logoConfig = [
{ {
breakpoint: "block sm:hidden", breakpoint: "block sm:hidden",
size: "homeHeaderXsmall", size: "homeHeaderXsmall" as const,
showText: false, showText: false,
}, },
{ {
breakpoint: "hidden sm:block md:hidden", breakpoint: "hidden sm:block md:hidden",
size: "homeHeaderSm", size: "homeHeaderSm" as const,
showText: true, showText: true,
}, },
{ {
breakpoint: "hidden md:block lg:hidden", breakpoint: "hidden md:block lg:hidden",
size: "homeHeaderMd", size: "homeHeaderMd" as const,
showText: true, showText: true,
}, },
{ {
breakpoint: "hidden lg:block xl:hidden", breakpoint: "hidden lg:block xl:hidden",
size: "homeHeaderLg", size: "homeHeaderLg" as const,
showText: true,
},
{
breakpoint: "hidden xl:block",
size: "homeHeaderXl" as const,
showText: true, showText: true,
}, },
{ breakpoint: "hidden xl:block", size: "homeHeaderXl", showText: true },
]; ];
const renderNavigationItems = (size) => { const renderNavigationItems = (size: string) => {
return navigationItems.map((item, index) => ( return navigationItems.map((item, index) => (
<MenuBarItem <MenuBarItem
key={index} key={index}
@@ -79,10 +83,10 @@ const HomeHeader = memo(() => {
? size === "home" || size === "homeMd" ? size === "home" || size === "homeMd"
? "homeMd" ? "homeMd"
: size === "large" : size === "large"
? "large" ? "large"
: size === "homeXlarge" : size === "homeXlarge"
? "homeXlarge" ? "homeXlarge"
: "xsmallUseCases" : "xsmallUseCases"
: size : size
} }
variant={ variant={
@@ -103,7 +107,10 @@ const HomeHeader = memo(() => {
)); ));
}; };
const renderAvatarGroup = (containerSize, avatarSize) => { const renderAvatarGroup = (
containerSize: "small" | "medium" | "large" | "xlarge",
avatarSize: "small" | "medium" | "large" | "xlarge"
) => {
return ( return (
<AvatarContainer size={containerSize}> <AvatarContainer size={containerSize}>
{avatarImages.map((avatar, index) => ( {avatarImages.map((avatar, index) => (
@@ -118,7 +125,7 @@ const HomeHeader = memo(() => {
); );
}; };
const renderLoginButton = (size) => { const renderLoginButton = (size: string) => {
return ( return (
<MenuBarItem <MenuBarItem
href="#" href="#"
@@ -131,7 +138,11 @@ const HomeHeader = memo(() => {
); );
}; };
const renderCreateRuleButton = (buttonSize, containerSize, avatarSize) => { const renderCreateRuleButton = (
buttonSize: string,
containerSize: "small" | "medium" | "large" | "xlarge",
avatarSize: "small" | "medium" | "large" | "xlarge"
) => {
return ( return (
<Button <Button
size={buttonSize} size={buttonSize}
@@ -144,7 +155,22 @@ const HomeHeader = memo(() => {
); );
}; };
const renderLogo = (size, showText) => { const renderLogo = (
size:
| "default"
| "homeHeaderXsmall"
| "homeHeaderSm"
| "homeHeaderMd"
| "homeHeaderLg"
| "homeHeaderXl"
| "header"
| "headerMd"
| "headerLg"
| "headerXl"
| "footer"
| "footerLg",
showText: boolean
) => {
return <Logo size={size} showText={showText} />; return <Logo size={size} showText={showText} />;
}; };
@@ -2,11 +2,19 @@
import React, { memo } from "react"; import React, { memo } from "react";
interface ImagePlaceholderProps {
width?: number;
height?: number;
text?: string;
color?: "blue" | "green" | "purple" | "red" | "orange" | "teal";
className?: string;
}
/** /**
* Simple image placeholder component for testing * Simple image placeholder component for testing
* Generates colored backgrounds with text overlays * Generates colored backgrounds with text overlays
*/ */
const ImagePlaceholder = memo( const ImagePlaceholder = memo<ImagePlaceholderProps>(
({ ({
width = 260, width = 260,
height = 390, height = 390,
@@ -14,7 +22,7 @@ const ImagePlaceholder = memo(
color = "blue", color = "blue",
className = "", className = "",
}) => { }) => {
const colors = { const colors: Record<string, string> = {
blue: "bg-blue-500", blue: "bg-blue-500",
green: "bg-green-500", green: "bg-green-500",
purple: "bg-purple-500", purple: "bg-purple-500",
@@ -33,7 +41,7 @@ const ImagePlaceholder = memo(
{text} {text}
</div> </div>
); );
}, }
); );
ImagePlaceholder.displayName = "ImagePlaceholder"; ImagePlaceholder.displayName = "ImagePlaceholder";
@@ -2,7 +2,26 @@
import React, { memo, useCallback, forwardRef, useId } from "react"; import React, { memo, useCallback, forwardRef, useId } from "react";
const Input = forwardRef( interface InputProps
extends Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"size" | "onChange" | "onFocus" | "onBlur"
> {
size?: "small" | "medium" | "large";
labelVariant?: "default" | "horizontal";
state?: "default" | "active" | "hover" | "focus";
disabled?: boolean;
error?: boolean;
label?: string;
placeholder?: string;
value?: string;
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
onFocus?: (e: React.FocusEvent<HTMLInputElement>) => void;
onBlur?: (e: React.FocusEvent<HTMLInputElement>) => void;
className?: string;
}
const Input = forwardRef<HTMLInputElement, InputProps>(
( (
{ {
size = "medium", size = "medium",
@@ -22,14 +41,22 @@ const Input = forwardRef(
className = "", className = "",
...props ...props
}, },
ref, ref
) => { ) => {
// Generate unique ID for accessibility if not provided // Generate unique ID for accessibility if not provided
const generatedId = useId(); const generatedId = useId();
const inputId = id || `input-${generatedId}`; const inputId = id || `input-${generatedId}`;
// Size variants // Size variants
const sizeStyles = { const sizeStyles: Record<
string,
{
input: string;
label: string;
container: string;
radius: string;
}
> = {
small: { small: {
input: input:
labelVariant === "horizontal" labelVariant === "horizontal"
@@ -54,7 +81,10 @@ const Input = forwardRef(
}; };
// State styles // State styles
const getStateStyles = () => { const getStateStyles = (): {
input: string;
label: string;
} => {
if (disabled) { if (disabled) {
return { return {
input: input:
@@ -122,30 +152,30 @@ const Input = forwardRef(
`.trim(); `.trim();
const handleChange = useCallback( const handleChange = useCallback(
(e) => { (e: React.ChangeEvent<HTMLInputElement>) => {
if (!disabled && onChange) { if (!disabled && onChange) {
onChange(e); onChange(e);
} }
}, },
[disabled, onChange], [disabled, onChange]
); );
const handleFocus = useCallback( const handleFocus = useCallback(
(e) => { (e: React.FocusEvent<HTMLInputElement>) => {
if (!disabled && onFocus) { if (!disabled && onFocus) {
onFocus(e); onFocus(e);
} }
}, },
[disabled, onFocus], [disabled, onFocus]
); );
const handleBlur = useCallback( const handleBlur = useCallback(
(e) => { (e: React.FocusEvent<HTMLInputElement>) => {
if (!disabled && onBlur) { if (!disabled && onBlur) {
onBlur(e); onBlur(e);
} }
}, },
[disabled, onBlur], [disabled, onBlur]
); );
return ( return (
@@ -177,7 +207,7 @@ const Input = forwardRef(
</div> </div>
</div> </div>
); );
}, }
); );
Input.displayName = "Input"; Input.displayName = "Input";
@@ -2,9 +2,34 @@ import React, { memo } from "react";
import Link from "next/link"; import Link from "next/link";
import { getAssetPath, ASSETS } from "../../lib/assetUtils"; import { getAssetPath, ASSETS } from "../../lib/assetUtils";
const Logo = memo(({ size = "default", showText = true }) => { interface LogoProps {
size?:
| "default"
| "homeHeaderXsmall"
| "homeHeaderSm"
| "homeHeaderMd"
| "homeHeaderLg"
| "homeHeaderXl"
| "header"
| "headerMd"
| "headerLg"
| "headerXl"
| "footer"
| "footerLg";
showText?: boolean;
}
interface SizeConfig {
containerHeight: string;
gap: string;
textSize: string;
lineHeight: string;
iconSize: string;
}
const Logo = memo<LogoProps>(({ size = "default", showText = true }) => {
// Size configurations // Size configurations
const sizes = { const sizes: Record<string, SizeConfig> = {
default: { default: {
containerHeight: "h-[41px]", containerHeight: "h-[41px]",
gap: "gap-[8.28px]", gap: "gap-[8.28px]",
@@ -3,11 +3,22 @@
import React, { useState, useEffect, memo } from "react"; import React, { useState, useEffect, memo } from "react";
import Image from "next/image"; import Image from "next/image";
const LogoWall = memo(({ logos = [] }) => { interface Logo {
src: string;
alt: string;
size?: string;
order?: string;
}
interface LogoWallProps {
logos?: Logo[];
}
const LogoWall = memo<LogoWallProps>(({ logos = [] }) => {
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
// Default logos if none provided - ordered for mobile (3 rows × 2 columns) // Default logos if none provided - ordered for mobile (3 rows × 2 columns)
const defaultLogos = [ const defaultLogos: Logo[] = [
{ {
src: "/assets/Section/Logo_FoodNotBombs.png", src: "/assets/Section/Logo_FoodNotBombs.png",
alt: "Food Not Bombs", alt: "Food Not Bombs",
@@ -1,8 +1,14 @@
import React, { memo } from "react"; import React, { memo } from "react";
const MenuBar = memo( interface MenuBarProps extends React.HTMLAttributes<HTMLElement> {
children?: React.ReactNode;
className?: string;
size?: "xsmall" | "default" | "medium" | "large";
}
const MenuBar = memo<MenuBarProps>(
({ children, className = "", size = "default", ...props }) => { ({ children, className = "", size = "default", ...props }) => {
const sizeStyles = { const sizeStyles: Record<string, string> = {
xsmall: xsmall:
"px-[var(--spacing-scale-004)] py-[var(--spacing-scale-004)] gap-[var(--spacing-scale-001)] rounded-[4px]", "px-[var(--spacing-scale-004)] py-[var(--spacing-scale-004)] gap-[var(--spacing-scale-001)] rounded-[4px]",
default: default:
@@ -25,7 +31,7 @@ const MenuBar = memo(
{children} {children}
</nav> </nav>
); );
}, }
); );
MenuBar.displayName = "MenuBar"; MenuBar.displayName = "MenuBar";
@@ -1,6 +1,28 @@
import React, { memo } from "react"; import React, { memo } from "react";
const MenuBarItem = memo( interface MenuBarItemProps
extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
href?: string;
children?: React.ReactNode;
variant?: "default" | "home";
size?:
| "default"
| "xsmall"
| "xsmallUseCases"
| "home"
| "homeMd"
| "homeUseCases"
| "large"
| "largeUseCases"
| "homeXlarge"
| "xlarge";
className?: string;
disabled?: boolean;
isActive?: boolean;
ariaLabel?: string;
}
const MenuBarItem = memo<MenuBarItemProps>(
({ ({
href = "#", href = "#",
children, children,
@@ -12,19 +34,19 @@ const MenuBarItem = memo(
ariaLabel, ariaLabel,
...props ...props
}) => { }) => {
const variantStyles = { const variantStyles: Record<string, string> = {
default: default:
"bg-transparent text-[var(--color-content-default-brand-primary)] hover:bg-[var(--color-surface-default-tertiary)] hover:text-[var(--color-content-default-brand-primary)] hover:scale-[1.02] active:bg-transparent active:text-[var(--color-content-default-brand-primary)] active:scale-[0.98] disabled:bg-[var(--color-surface-default-tertiary)] disabled:text-[var(--color-content-default-tertiary)] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100 disabled:active:scale-100", "bg-transparent text-[var(--color-content-default-brand-primary)] hover:bg-[var(--color-surface-default-tertiary)] hover:text-[var(--color-content-default-brand-primary)] hover:scale-[1.02] active:bg-transparent active:text-[var(--color-content-default-brand-primary)] active:scale-[0.98] disabled:bg-[var(--color-surface-default-tertiary)] disabled:text-[var(--color-content-default-tertiary)] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100 disabled:active:scale-100",
home: "bg-transparent text-[var(--color-content-inverse-primary)] hover:bg-[var(--color-content-default-brand-accent)] hover:text-[var(--color-content-inverse-primary)] hover:scale-[1.02] active:bg-transparent active:text-[var(--color-content-inverse-primary)] active:scale-[0.98] disabled:bg-[var(--color-surface-default-tertiary)] disabled:text-[var(--color-content-default-tertiary)] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100 disabled:active:scale-100", home: "bg-transparent text-[var(--color-content-inverse-primary)] hover:bg-[var(--color-content-default-brand-accent)] hover:text-[var(--color-content-inverse-primary)] hover:scale-[1.02] active:bg-transparent active:text-[var(--color-content-inverse-primary)] active:scale-[0.98] disabled:bg-[var(--color-surface-default-tertiary)] disabled:text-[var(--color-content-default-tertiary)] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100 disabled:active:scale-100",
}; };
const activeOutlineStyles = { const activeOutlineStyles: Record<string, string> = {
xsmall: xsmall:
"active:outline-1 active:outline-[var(--color-content-default-primary)] focus:outline-1 focus:outline-[var(--color-content-default-primary)]", "active:outline-1 active:outline-[var(--color-content-default-primary)] focus:outline-1 focus:outline-[var(--color-content-default-primary)]",
xsmallUseCases: xsmallUseCases:
"active:outline-1 active:outline-[var(--color-content-default-primary)] focus:outline-1 focus:outline-[var(--color-content-default-primary)]", "active:outline-1 active:outline-[var(--color-content-default-primary)] focus:outline-1 focus:outline-[var(--color-content-default-primary)]",
default: default:
"active:outline-1 active:outline-[var(--color-content-default-brand-primary)] focus:outline-1 focus:outline-[var(--color-content-default-brand-primary)]", "active:outline-[1.5px] active:outline-[var(--color-content-default-brand-primary)] focus:outline-[1.5px] focus:outline-[var(--color-content-default-brand-primary)]",
homeMd: homeMd:
"active:outline-[1.5px] active:outline-[var(--color-content-default-brand-primary)] focus:outline-[1.5px] focus:outline-[var(--color-content-default-brand-primary)]", "active:outline-[1.5px] active:outline-[var(--color-content-default-brand-primary)] focus:outline-[1.5px] focus:outline-[var(--color-content-default-brand-primary)]",
homeUseCases: homeUseCases:
@@ -39,7 +61,7 @@ const MenuBarItem = memo(
"active:outline-2 active:outline-[var(--color-content-default-brand-primary)] focus:outline-2 focus:outline-[var(--color-content-default-brand-primary)]", "active:outline-2 active:outline-[var(--color-content-default-brand-primary)] focus:outline-2 focus:outline-[var(--color-content-default-brand-primary)]",
}; };
const homeOutlineStyles = { const homeOutlineStyles: Record<string, string> = {
xsmall: xsmall:
"active:outline-1 active:outline-[var(--color-content-default-primary)] focus:outline-1 focus:outline-[var(--color-content-default-primary)]", "active:outline-1 active:outline-[var(--color-content-default-primary)] focus:outline-1 focus:outline-[var(--color-content-default-primary)]",
xsmallUseCases: xsmallUseCases:
@@ -60,7 +82,7 @@ const MenuBarItem = memo(
"active:outline-2 active:outline-[var(--color-content-default-primary)] focus:outline-2 focus:outline-[var(--color-content-default-primary)]", "active:outline-2 active:outline-[var(--color-content-default-primary)] focus:outline-2 focus:outline-[var(--color-content-default-primary)]",
}; };
const activeStateStyles = { const activeStateStyles: Record<string, string> = {
xsmall: xsmall:
"!outline-1 !outline-[var(--color-content-default-brand-primary)] !text-[var(--color-content-default-brand-primary)] focus:!outline-1 focus:!outline-[var(--color-content-default-brand-primary)]", "!outline-1 !outline-[var(--color-content-default-brand-primary)] !text-[var(--color-content-default-brand-primary)] focus:!outline-1 focus:!outline-[var(--color-content-default-brand-primary)]",
xsmallUseCases: xsmallUseCases:
@@ -81,7 +103,7 @@ const MenuBarItem = memo(
"!outline-2 !outline-[var(--color-content-default-brand-primary)] !text-[var(--color-content-default-brand-primary)] focus:!outline-2 focus:!outline-[var(--color-content-default-brand-primary)]", "!outline-2 !outline-[var(--color-content-default-brand-primary)] !text-[var(--color-content-default-brand-primary)] focus:!outline-2 focus:!outline-[var(--color-content-default-brand-primary)]",
}; };
const sizeStyles = { const sizeStyles: Record<string, string> = {
default: default:
"px-[var(--spacing-measures-spacing-016)] py-[var(--spacing-measures-spacing-016)] gap-[var(--spacing-scale-004)]", "px-[var(--spacing-measures-spacing-016)] py-[var(--spacing-measures-spacing-016)] gap-[var(--spacing-scale-004)]",
xsmall: xsmall:
@@ -111,7 +133,7 @@ const MenuBarItem = memo(
const xlargeTextStyle = const xlargeTextStyle =
"font-inter text-[24px] leading-[28px] font-normal tracking-[0%]"; "font-inter text-[24px] leading-[28px] font-normal tracking-[0%]";
const textStyles = { const textStyles: Record<string, string> = {
default: smallTextStyle, default: smallTextStyle,
xsmall: smallTextStyle, xsmall: smallTextStyle,
xsmallUseCases: smallTextStyle, xsmallUseCases: smallTextStyle,
@@ -140,7 +162,7 @@ const MenuBarItem = memo(
const accessibilityProps = { const accessibilityProps = {
...(ariaLabel && { "aria-label": ariaLabel }), ...(ariaLabel && { "aria-label": ariaLabel }),
...(disabled && { "aria-disabled": "true" }), ...(disabled && { "aria-disabled": "true" }),
role: "menuitem", role: "menuitem" as const,
tabIndex: disabled ? -1 : 0, tabIndex: disabled ? -1 : 0,
...props, ...props,
}; };
@@ -158,7 +180,7 @@ const MenuBarItem = memo(
{children} {children}
</a> </a>
); );
}, }
); );
MenuBarItem.displayName = "MenuBarItem"; MenuBarItem.displayName = "MenuBarItem";
@@ -3,7 +3,20 @@
import React, { memo } from "react"; import React, { memo } from "react";
import Image from "next/image"; import Image from "next/image";
const MiniCard = memo( interface MiniCardProps {
children?: React.ReactNode;
className?: string;
backgroundColor?: string;
panelContent?: string;
label?: string;
labelLine1?: string;
labelLine2?: string;
onClick?: () => void;
href?: string;
ariaLabel?: string;
}
const MiniCard = memo<MiniCardProps>(
({ ({
children, children,
className = "", className = "",
@@ -116,7 +129,7 @@ const MiniCard = memo(
{cardContent} {cardContent}
</div> </div>
); );
}, }
); );
MiniCard.displayName = "MiniCard"; MiniCard.displayName = "MiniCard";
@@ -1,6 +1,16 @@
import React, { memo } from "react"; import React, { memo } from "react";
const NavigationItem = memo( interface NavigationItemProps
extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
href?: string;
children?: React.ReactNode;
variant?: "default";
size?: "default" | "xsmall";
className?: string;
disabled?: boolean;
}
const NavigationItem = memo<NavigationItemProps>(
({ ({
href = "#", href = "#",
children, children,
@@ -11,13 +21,13 @@ const NavigationItem = memo(
...props ...props
}) => { }) => {
// Variant styles // Variant styles
const variantStyles = { const variantStyles: Record<string, string> = {
default: default:
"bg-transparent text-[var(--color-content-default-brand-primary)] border border-transparent hover:bg-[var(--color-surface-default-tertiary)] hover:text-[var(--color-content-default-brand-primary)] active:bg-transparent active:text-[var(--color-content-default-brand-primary)] active:border-[var(--color-content-default-brand-primary)] disabled:bg-[var(--color-surface-default-tertiary)] disabled:text-[var(--color-content-default-tertiary)] disabled:border-[var(--color-content-default-tertiary)] disabled:opacity-50 disabled:cursor-not-allowed", "bg-transparent text-[var(--color-content-default-brand-primary)] border border-transparent hover:bg-[var(--color-surface-default-tertiary)] hover:text-[var(--color-content-default-brand-primary)] active:bg-transparent active:text-[var(--color-content-default-brand-primary)] active:border-[var(--color-content-default-brand-primary)] disabled:bg-[var(--color-surface-default-tertiary)] disabled:text-[var(--color-content-default-tertiary)] disabled:border-[var(--color-content-default-tertiary)] disabled:opacity-50 disabled:cursor-not-allowed",
}; };
// Size styles // Size styles
const sizeStyles = { const sizeStyles: Record<string, string> = {
default: default:
"px-[var(--spacing-scale-016)] py-[var(--spacing-scale-016)] gap-[var(--spacing-scale-004)]", "px-[var(--spacing-scale-016)] py-[var(--spacing-scale-016)] gap-[var(--spacing-scale-004)]",
xsmall: xsmall:
@@ -25,7 +35,7 @@ const NavigationItem = memo(
}; };
// Text styles based on size // Text styles based on size
const textStyles = { const textStyles: Record<string, string> = {
default: default:
"font-inter text-[10px] leading-[12px] font-medium tracking-[0%]", "font-inter text-[10px] leading-[12px] font-medium tracking-[0%]",
xsmall: "font-inter text-[10px] leading-[12px] font-medium tracking-[0%]", xsmall: "font-inter text-[10px] leading-[12px] font-medium tracking-[0%]",
@@ -54,7 +64,7 @@ const NavigationItem = memo(
{children} {children}
</a> </a>
); );
}, }
); );
NavigationItem.displayName = "NavigationItem"; NavigationItem.displayName = "NavigationItem";
-26
View File
@@ -1,26 +0,0 @@
"use client";
import React, { memo } from "react";
import SectionNumber from "./SectionNumber";
const NumberedCard = memo(({ number, text, iconShape, iconColor }) => {
return (
<div className="bg-[var(--color-surface-inverse-primary)] rounded-[12px] p-5 shadow-lg flex flex-col gap-4 sm:p-8 sm:gap-8 sm:flex-row sm:items-center lg:p-8 lg:gap-0 lg:flex-row lg:items-stretch lg:relative lg:h-[238px]">
{/* Section Number - Top right (lg breakpoint) */}
<div className="flex justify-end sm:justify-start sm:flex-shrink-0 lg:absolute lg:top-8 lg:right-8">
<SectionNumber number={number} />
</div>
{/* Card Content - Bottom left (lg breakpoint) */}
<div className="sm:flex-1 lg:absolute lg:bottom-8 lg:left-8 lg:right-16">
<p className="font-bricolage-grotesque font-medium text-[24px] leading-[32px] sm:font-normal sm:leading-[24px] sm:text-[24px] lg:text-[24px] lg:leading-[24px] xl:text-[32px] xl:leading-[32px] text-[#141414]">
{text}
</p>
</div>
</div>
);
});
NumberedCard.displayName = "NumberedCard";
export default NumberedCard;
+35
View File
@@ -0,0 +1,35 @@
"use client";
import React, { memo } from "react";
import SectionNumber from "./SectionNumber";
interface NumberedCardProps {
number: number;
text: string;
iconShape?: string;
iconColor?: string;
}
const NumberedCard = memo<NumberedCardProps>(
({ number, text, iconShape, iconColor }) => {
return (
<div className="bg-[var(--color-surface-inverse-primary)] rounded-[12px] p-5 shadow-lg flex flex-col gap-4 sm:p-8 sm:gap-8 sm:flex-row sm:items-center lg:p-8 lg:gap-0 lg:flex-row lg:items-stretch lg:relative lg:h-[238px]">
{/* Section Number - Top right (lg breakpoint) */}
<div className="flex justify-end sm:justify-start sm:flex-shrink-0 lg:absolute lg:top-8 lg:right-8">
<SectionNumber number={number} />
</div>
{/* Card Content - Bottom left (lg breakpoint) */}
<div className="sm:flex-1 lg:absolute lg:bottom-8 lg:left-8 lg:right-16">
<p className="font-bricolage-grotesque font-medium text-[24px] leading-[32px] sm:font-normal sm:leading-[24px] sm:text-[24px] lg:text-[24px] lg:leading-[24px] xl:text-[32px] xl:leading-[32px] text-[#141414]">
{text}
</p>
</div>
</div>
);
}
);
NumberedCard.displayName = "NumberedCard";
export default NumberedCard;
@@ -5,7 +5,19 @@ import NumberedCard from "./NumberedCard";
import SectionHeader from "./SectionHeader"; import SectionHeader from "./SectionHeader";
import Button from "./Button"; import Button from "./Button";
const NumberedCards = memo(({ title, subtitle, cards }) => { interface Card {
text: string;
iconShape?: string;
iconColor?: string;
}
interface NumberedCardsProps {
title: string;
subtitle: string;
cards: Card[];
}
const NumberedCards = memo<NumberedCardsProps>(({ title, subtitle, cards }) => {
// Memoize schema data to prevent unnecessary re-computations // Memoize schema data to prevent unnecessary re-computations
const schemaData = useMemo( const schemaData = useMemo(
() => ({ () => ({
@@ -20,7 +32,7 @@ const NumberedCards = memo(({ title, subtitle, cards }) => {
text: card.text, text: card.text,
})), })),
}), }),
[title, subtitle, cards], [title, subtitle, cards]
); );
return ( return (
@@ -4,7 +4,26 @@ import React, { useState, memo } from "react";
import Image from "next/image"; import Image from "next/image";
import QuoteDecor from "./QuoteDecor"; import QuoteDecor from "./QuoteDecor";
const QuoteBlock = memo( interface QuoteBlockProps {
variant?: "compact" | "standard" | "extended";
className?: string;
quote?: string;
author?: string;
source?: string;
avatarSrc?: string;
id?: string;
fallbackAvatarSrc?: string;
onError?: (error: {
type: string;
message: string;
author?: string;
avatarSrc?: string;
error?: unknown;
quote?: boolean;
}) => void;
}
const QuoteBlock = memo<QuoteBlockProps>(
({ ({
variant = "standard", variant = "standard",
className = "", className = "",
@@ -13,14 +32,26 @@ const QuoteBlock = memo(
source = "The Tyranny of Structurelessness", source = "The Tyranny of Structurelessness",
avatarSrc = "/assets/Quote_Avatar.svg", avatarSrc = "/assets/Quote_Avatar.svg",
id, id,
fallbackAvatarSrc = "/assets/Quote_Avatar.svg", // Fallback avatar fallbackAvatarSrc = "/assets/Quote_Avatar.svg",
onError, // Error callback onError,
}) => { }) => {
const [imageError, setImageError] = useState(false); const [imageError, setImageError] = useState(false);
const [imageLoading, setImageLoading] = useState(true); const [imageLoading, setImageLoading] = useState(true);
// Variant configurations // Variant configurations
const variants = { interface VariantConfig {
container: string;
card: string;
gap: string;
avatarGap: string;
avatar: string;
quote: string;
author: string;
source: string;
showDecor: boolean;
}
const variants: Record<string, VariantConfig> = {
compact: { compact: {
container: container:
"py-[var(--spacing-scale-032)] px-[var(--spacing-scale-016)]", "py-[var(--spacing-scale-032)] px-[var(--spacing-scale-016)]",
@@ -77,10 +108,10 @@ const QuoteBlock = memo(
const authorId = `${baseId}-author`; const authorId = `${baseId}-author`;
// Error handling functions // Error handling functions
const handleImageError = (error) => { const handleImageError = (error: unknown) => {
console.warn( console.warn(
`QuoteBlock: Failed to load avatar image for ${author}:`, `QuoteBlock: Failed to load avatar image for ${author}:`,
error, error
); );
setImageError(true); setImageError(true);
setImageLoading(false); setImageLoading(false);
@@ -244,7 +275,7 @@ const QuoteBlock = memo(
</div> </div>
</section> </section>
); );
}, }
); );
QuoteBlock.displayName = "QuoteBlock"; QuoteBlock.displayName = "QuoteBlock";
@@ -2,7 +2,11 @@
import React, { memo } from "react"; import React, { memo } from "react";
const QuoteDecor = memo(({ className = "" }) => { interface QuoteDecorProps {
className?: string;
}
const QuoteDecor = memo<QuoteDecorProps>(({ className = "" }) => {
return ( return (
<svg <svg
className={`text-[var(--color-surface-inverse-brand-primary)] opacity-100 w-full h-full md:max-w-[640px] lg:max-w-[850px] xl:max-w-[1100px] ${className}`} className={`text-[var(--color-surface-inverse-brand-primary)] opacity-100 w-full h-full md:max-w-[640px] lg:max-w-[850px] xl:max-w-[1100px] ${className}`}
@@ -2,6 +2,20 @@
import React, { memo, useCallback, useId } from "react"; import React, { memo, useCallback, useId } from "react";
interface RadioButtonProps {
checked?: boolean;
mode?: "standard" | "inverse";
state?: "default" | "hover" | "focus";
disabled?: boolean;
label?: string;
onChange?: (data: { checked: boolean; value?: string }) => void;
id?: string;
name?: string;
value?: string;
ariaLabel?: string;
className?: string;
}
const RadioButton = ({ const RadioButton = ({
checked = false, checked = false,
mode = "standard", mode = "standard",
@@ -15,7 +29,7 @@ const RadioButton = ({
ariaLabel, ariaLabel,
className = "", className = "",
...props ...props
}) => { }: RadioButtonProps) => {
const isInverse = mode === "inverse"; const isInverse = mode === "inverse";
// Base tokens (using same design tokens as Checkbox) // Base tokens (using same design tokens as Checkbox)
@@ -32,7 +46,7 @@ const RadioButton = ({
// Visual container depending on state // Visual container depending on state
const baseBox = `flex items-center justify-center shrink-0 w-[var(--measures-sizing-024)] h-[var(--measures-sizing-024)] rounded-[var(--measures-radius-medium)] transition-all duration-200 ease-in-out`; const baseBox = `flex items-center justify-center shrink-0 w-[var(--measures-sizing-024)] h-[var(--measures-sizing-024)] rounded-[var(--measures-radius-medium)] transition-all duration-200 ease-in-out`;
const stateStyles = { const stateStyles: Record<string, string> = {
default: "", default: "",
hover: "", hover: "",
focus: "", focus: "",
@@ -75,12 +89,12 @@ const RadioButton = ({
const radioId = id || `radio-${generatedId}`; const radioId = id || `radio-${generatedId}`;
const handleToggle = useCallback( const handleToggle = useCallback(
(e) => { (e: React.MouseEvent | React.KeyboardEvent) => {
if (!disabled && onChange && !checked) { if (!disabled && onChange && !checked) {
onChange({ checked: true, value }); onChange({ checked: true, value });
} }
}, },
[disabled, onChange, checked, value], [disabled, onChange, checked, value]
); );
return ( return (
@@ -3,6 +3,24 @@
import React, { memo, useCallback, useId } from "react"; import React, { memo, useCallback, useId } from "react";
import RadioButton from "./RadioButton"; import RadioButton from "./RadioButton";
interface RadioOption {
value: string;
label: string;
ariaLabel?: string;
}
interface RadioGroupProps {
name?: string;
value?: string;
onChange?: (data: { value: string }) => void;
mode?: "standard" | "inverse";
state?: "default" | "hover" | "focus";
disabled?: boolean;
options?: RadioOption[];
className?: string;
"aria-label"?: string;
}
const RadioGroup = ({ const RadioGroup = ({
name, name,
value, value,
@@ -13,18 +31,18 @@ const RadioGroup = ({
options = [], options = [],
className = "", className = "",
...props ...props
}) => { }: RadioGroupProps) => {
// Generate unique ID for accessibility if not provided // Generate unique ID for accessibility if not provided
const generatedId = useId(); const generatedId = useId();
const groupId = name || `radio-group-${generatedId}`; const groupId = name || `radio-group-${generatedId}`;
const handleChange = useCallback( const handleChange = useCallback(
(optionValue) => { (optionValue: string) => {
if (!disabled && onChange) { if (!disabled && onChange) {
onChange({ value: optionValue }); onChange({ value: optionValue });
} }
}, },
[disabled, onChange], [disabled, onChange]
); );
return ( return (
@@ -34,7 +52,7 @@ const RadioGroup = ({
aria-label={props["aria-label"]} aria-label={props["aria-label"]}
{...props} {...props}
> >
{options.map((option, index) => { {options.map((option) => {
const isSelected = value === option.value; const isSelected = value === option.value;
return ( return (
@@ -2,13 +2,20 @@
import React, { useState, useEffect, memo, useMemo, useCallback } from "react"; import React, { useState, useEffect, memo, useMemo, useCallback } from "react";
import ContentThumbnailTemplate from "./ContentThumbnailTemplate"; import ContentThumbnailTemplate from "./ContentThumbnailTemplate";
import type { BlogPost } from "../../lib/content";
const RelatedArticles = memo( interface RelatedArticlesProps {
relatedPosts: BlogPost[];
currentPostSlug: string;
slugOrder?: string[];
}
const RelatedArticles = memo<RelatedArticlesProps>(
({ relatedPosts, currentPostSlug, slugOrder = [] }) => { ({ relatedPosts, currentPostSlug, slugOrder = [] }) => {
// Memoize filtered posts to prevent unnecessary re-computations // Memoize filtered posts to prevent unnecessary re-computations
const filteredPosts = useMemo( const filteredPosts = useMemo(
() => relatedPosts.filter((post) => post.slug !== currentPostSlug), () => relatedPosts.filter((post) => post.slug !== currentPostSlug),
[relatedPosts, currentPostSlug], [relatedPosts, currentPostSlug]
); );
const [currentIndex, setCurrentIndex] = useState(0); const [currentIndex, setCurrentIndex] = useState(0);
@@ -16,25 +23,28 @@ const RelatedArticles = memo(
const [isMobile, setIsMobile] = useState(true); const [isMobile, setIsMobile] = useState(true);
// Memoize the mouse down handler to prevent unnecessary re-renders // Memoize the mouse down handler to prevent unnecessary re-renders
const handleMouseDown = useCallback((e) => { const handleMouseDown = useCallback(
const slider = e.currentTarget; (e: React.MouseEvent<HTMLDivElement>) => {
const startX = e.pageX - slider.offsetLeft; const slider = e.currentTarget;
const scrollLeft = slider.scrollLeft; const startX = e.pageX - slider.offsetLeft;
const scrollLeft = slider.scrollLeft;
const handleMouseMove = (e) => { const handleMouseMove = (e: MouseEvent) => {
const x = e.pageX - slider.offsetLeft; const x = e.pageX - slider.offsetLeft;
const walk = (x - startX) * 2; const walk = (x - startX) * 2;
slider.scrollLeft = scrollLeft - walk; slider.scrollLeft = scrollLeft - walk;
}; };
const handleMouseUp = () => { const handleMouseUp = () => {
document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp); document.removeEventListener("mouseup", handleMouseUp);
}; };
document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp); document.addEventListener("mouseup", handleMouseUp);
}, []); },
[]
);
// Memoize transform style to prevent unnecessary recalculations // Memoize transform style to prevent unnecessary recalculations
const transformStyle = useMemo( const transformStyle = useMemo(
@@ -44,20 +54,20 @@ const RelatedArticles = memo(
: "none", : "none",
scrollBehavior: !isMobile ? "smooth" : "auto", scrollBehavior: !isMobile ? "smooth" : "auto",
}), }),
[isMobile, currentIndex], [isMobile, currentIndex]
); );
// Memoize progress bar style calculation // Memoize progress bar style calculation
const getProgressStyle = useCallback( const getProgressStyle = useCallback(
(index) => ({ (index: number): React.CSSProperties => ({
width: width:
index === currentIndex index === currentIndex
? `${progress}%` ? `${progress}%`
: index < currentIndex : index < currentIndex
? "100%" ? "100%"
: "0%", : "0%",
}), }),
[currentIndex, progress], [currentIndex, progress]
); );
// Check if we're on mobile (below lg breakpoint) // Check if we're on mobile (below lg breakpoint)
@@ -121,7 +131,7 @@ const RelatedArticles = memo(
style={transformStyle} style={transformStyle}
onMouseDown={!isMobile ? handleMouseDown : undefined} onMouseDown={!isMobile ? handleMouseDown : undefined}
> >
{filteredPosts.map((relatedPost, index) => ( {filteredPosts.map((relatedPost) => (
<div <div
key={relatedPost.slug} key={relatedPost.slug}
className="flex flex-col items-center flex-shrink-0" className="flex flex-col items-center flex-shrink-0"
@@ -155,7 +165,7 @@ const RelatedArticles = memo(
</div> </div>
</section> </section>
); );
}, }
); );
RelatedArticles.displayName = "RelatedArticles"; RelatedArticles.displayName = "RelatedArticles";
@@ -2,7 +2,29 @@
import React, { memo } from "react"; import React, { memo } from "react";
const RuleCard = memo( interface RuleCardProps {
title: string;
description?: string;
icon?: React.ReactNode;
backgroundColor?: string;
className?: string;
onClick?: () => void;
}
declare global {
interface Window {
gtag?: (
command: string,
eventName: string,
params?: Record<string, unknown>
) => void;
analytics?: {
track: (eventName: string, params?: Record<string, unknown>) => void;
};
}
}
const RuleCard = memo<RuleCardProps>(
({ ({
title, title,
description, description,
@@ -31,7 +53,7 @@ const RuleCard = memo(
if (onClick) onClick(); if (onClick) onClick();
}; };
const handleKeyDown = (event) => { const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "Enter" || event.key === " ") { if (event.key === "Enter" || event.key === " ") {
event.preventDefault(); event.preventDefault();
handleClick(); handleClick();
@@ -71,7 +93,7 @@ const RuleCard = memo(
)} )}
</div> </div>
); );
}, }
); );
RuleCard.displayName = "RuleCard"; RuleCard.displayName = "RuleCard";
@@ -6,8 +6,25 @@ import RuleCard from "./RuleCard";
import Button from "./Button"; import Button from "./Button";
import { getAssetPath } from "../../lib/assetUtils"; import { getAssetPath } from "../../lib/assetUtils";
const RuleStack = memo(({ className = "" }) => { interface RuleStackProps {
const handleTemplateClick = (templateName) => { className?: string;
}
declare global {
interface Window {
gtag?: (
command: string,
eventName: string,
params?: Record<string, unknown>
) => void;
analytics?: {
track: (eventName: string, params?: Record<string, unknown>) => void;
};
}
}
const RuleStack = memo<RuleStackProps>(({ className = "" }) => {
const handleTemplateClick = (templateName: string) => {
// Basic analytics tracking // Basic analytics tracking
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
if (window.gtag) { if (window.gtag) {
@@ -2,7 +2,14 @@
import React, { memo } from "react"; import React, { memo } from "react";
const SectionHeader = memo( interface SectionHeaderProps {
title: string;
subtitle: string;
titleLg?: string;
variant?: "default" | "multi-line";
}
const SectionHeader = memo<SectionHeaderProps>(
({ title, subtitle, titleLg, variant = "default" }) => { ({ title, subtitle, titleLg, variant = "default" }) => {
return ( return (
<div <div
@@ -52,7 +59,7 @@ const SectionHeader = memo(
</div> </div>
</div> </div>
); );
}, }
); );
SectionHeader.displayName = "SectionHeader"; SectionHeader.displayName = "SectionHeader";
@@ -2,8 +2,12 @@
import React, { memo } from "react"; import React, { memo } from "react";
const SectionNumber = memo(({ number }) => { interface SectionNumberProps {
const getImageSrc = (num) => { number: number;
}
const SectionNumber = memo<SectionNumberProps>(({ number }) => {
const getImageSrc = (num: number): string => {
switch (num) { switch (num) {
case 1: case 1:
return "/assets/SectionNumber_1.png"; return "/assets/SectionNumber_1.png";
@@ -12,7 +12,28 @@ import React, {
import SelectDropdown from "./SelectDropdown"; import SelectDropdown from "./SelectDropdown";
import SelectOption from "./SelectOption"; import SelectOption from "./SelectOption";
const Select = forwardRef( interface SelectOptionData {
value: string;
label: string;
}
interface SelectProps {
id?: string;
label?: string;
labelVariant?: "default" | "horizontal";
size?: "small" | "medium" | "large";
state?: "default" | "hover" | "focus";
disabled?: boolean;
error?: boolean;
placeholder?: string;
className?: string;
children?: React.ReactNode;
value?: string;
onChange?: (data: { target: { value: string; text: string } }) => void;
options?: SelectOptionData[];
}
const Select = forwardRef<HTMLButtonElement, SelectProps>(
( (
{ {
id, id,
@@ -27,26 +48,27 @@ const Select = forwardRef(
children, children,
value, value,
onChange, onChange,
options,
...props ...props
}, },
ref, ref
) => { ) => {
const generatedId = useId(); const generatedId = useId();
const selectId = id || `select-${generatedId}`; const selectId = id || `select-${generatedId}`;
const labelId = `${selectId}-label`; const labelId = `${selectId}-label`;
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [selectedValue, setSelectedValue] = useState(value || ""); const [selectedValue, setSelectedValue] = useState(value || "");
const selectRef = useRef(null); const selectRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef(null); const menuRef = useRef<HTMLDivElement>(null);
// Handle click outside to close menu // Handle click outside to close menu
useEffect(() => { useEffect(() => {
const handleClickOutside = (event) => { const handleClickOutside = (event: MouseEvent) => {
if ( if (
menuRef.current && menuRef.current &&
!menuRef.current.contains(event.target) && !menuRef.current.contains(event.target as Node) &&
selectRef.current && selectRef.current &&
!selectRef.current.contains(event.target) !selectRef.current.contains(event.target as Node)
) { ) {
setIsOpen(false); setIsOpen(false);
} }
@@ -61,7 +83,7 @@ const Select = forwardRef(
// Handle option selection // Handle option selection
const handleOptionSelect = useCallback( const handleOptionSelect = useCallback(
(optionValue, optionText) => { (optionValue: string, optionText: string) => {
setSelectedValue(optionValue); setSelectedValue(optionValue);
setIsOpen(false); setIsOpen(false);
if (onChange) { if (onChange) {
@@ -72,7 +94,7 @@ const Select = forwardRef(
selectRef.current.focus(); selectRef.current.focus();
} }
}, },
[onChange], [onChange]
); );
// Handle select button click // Handle select button click
@@ -84,7 +106,7 @@ const Select = forwardRef(
// Handle keyboard navigation // Handle keyboard navigation
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(e) => { (e: React.KeyboardEvent<HTMLButtonElement>) => {
if (disabled) return; if (disabled) return;
if (e.key === "Enter" || e.key === " ") { if (e.key === "Enter" || e.key === " ") {
@@ -94,10 +116,10 @@ const Select = forwardRef(
setIsOpen(false); setIsOpen(false);
} }
}, },
[disabled, isOpen], [disabled, isOpen]
); );
const getSizeStyles = () => { const getSizeStyles = (): string => {
const baseStyles = "w-full"; const baseStyles = "w-full";
switch (size) { switch (size) {
@@ -114,7 +136,7 @@ const Select = forwardRef(
} }
}; };
const getLabelSizeStyles = () => { const getLabelSizeStyles = (): string => {
switch (size) { switch (size) {
case "small": case "small":
return "text-[12px] leading-[14px]"; return "text-[12px] leading-[14px]";
@@ -127,7 +149,10 @@ const Select = forwardRef(
} }
}; };
const getStateStyles = () => { const getStateStyles = (): {
select: string;
label: string;
} => {
if (disabled) { if (disabled) {
return { return {
select: select:
@@ -164,7 +189,7 @@ const Select = forwardRef(
} }
}; };
const getBorderRadius = () => { const getBorderRadius = (): string => {
switch (size) { switch (size) {
case "small": case "small":
return "rounded-[var(--measures-radius-small)]"; return "rounded-[var(--measures-radius-small)]";
@@ -222,22 +247,27 @@ const Select = forwardRef(
: "flex flex-col"; : "flex flex-col";
// Get display text for selected value // Get display text for selected value
const getDisplayText = () => { const getDisplayText = (): string => {
if (!selectedValue) return placeholder; if (!selectedValue) return placeholder;
// Handle options prop // Handle options prop
if (props.options && Array.isArray(props.options)) { if (options && Array.isArray(options)) {
const selectedOption = props.options.find( const selectedOption = options.find(
(option) => option.value === selectedValue, (option) => option.value === selectedValue
); );
return selectedOption ? selectedOption.label : placeholder; return selectedOption ? selectedOption.label : placeholder;
} }
// Handle children (option elements) // Handle children (option elements)
const selectedOption = React.Children.toArray(children).find( const selectedOption = React.Children.toArray(children).find(
(child) => child.props.value === selectedValue, (child) =>
); React.isValidElement(child) && child.props.value === selectedValue
return selectedOption ? selectedOption.props.children : placeholder; ) as
| React.ReactElement<{ value: string; children: React.ReactNode }>
| undefined;
return selectedOption
? String(selectedOption.props.children)
: placeholder;
}; };
return ( return (
@@ -293,8 +323,8 @@ const Select = forwardRef(
className="absolute top-full left-0 right-0 z-50 mt-1" className="absolute top-full left-0 right-0 z-50 mt-1"
> >
<SelectDropdown> <SelectDropdown>
{props.options && Array.isArray(props.options) {options && Array.isArray(options)
? props.options.map((option) => ( ? options.map((option) => (
<SelectOption <SelectOption
key={option.value} key={option.value}
selected={option.value === selectedValue} selected={option.value === selectedValue}
@@ -307,20 +337,27 @@ const Select = forwardRef(
</SelectOption> </SelectOption>
)) ))
: React.Children.map(children, (child) => { : React.Children.map(children, (child) => {
if (child.type === "option") { if (
React.isValidElement(child) &&
child.type === "option"
) {
const optionProps = child.props as {
value: string;
children: React.ReactNode;
};
return ( return (
<SelectOption <SelectOption
key={child.props.value} key={optionProps.value}
selected={child.props.value === selectedValue} selected={optionProps.value === selectedValue}
size={size} size={size}
onClick={() => onClick={() =>
handleOptionSelect( handleOptionSelect(
child.props.value, optionProps.value,
child.props.children, String(optionProps.children)
) )
} }
> >
{child.props.children} {optionProps.children}
</SelectOption> </SelectOption>
); );
} }
@@ -332,7 +369,7 @@ const Select = forwardRef(
</div> </div>
</div> </div>
); );
}, }
); );
Select.displayName = "Select"; Select.displayName = "Select";
@@ -2,7 +2,12 @@
import React, { forwardRef, memo } from "react"; import React, { forwardRef, memo } from "react";
const SelectDropdown = forwardRef( interface SelectDropdownProps extends React.HTMLAttributes<HTMLDivElement> {
className?: string;
children?: React.ReactNode;
}
const SelectDropdown = forwardRef<HTMLDivElement, SelectDropdownProps>(
({ className = "", children, ...props }, ref) => { ({ className = "", children, ...props }, ref) => {
const menuClasses = ` const menuClasses = `
bg-black bg-black
@@ -29,7 +34,7 @@ const SelectDropdown = forwardRef(
{children} {children}
</div> </div>
); );
}, }
); );
SelectDropdown.displayName = "SelectDropdown"; SelectDropdown.displayName = "SelectDropdown";
@@ -2,7 +2,18 @@
import React, { forwardRef, memo, useCallback } from "react"; import React, { forwardRef, memo, useCallback } from "react";
const SelectOption = forwardRef( interface SelectOptionProps {
children?: React.ReactNode;
selected?: boolean;
disabled?: boolean;
className?: string;
onClick?: (
e: React.MouseEvent<HTMLDivElement> | React.KeyboardEvent<HTMLDivElement>
) => void;
size?: "small" | "medium" | "large";
}
const SelectOption = forwardRef<HTMLDivElement, SelectOptionProps>(
( (
{ {
children, children,
@@ -13,9 +24,9 @@ const SelectOption = forwardRef(
size = "medium", size = "medium",
...props ...props
}, },
ref, ref
) => { ) => {
const getTextSize = () => { const getTextSize = (): string => {
switch (size) { switch (size) {
case "small": case "small":
return "text-[10px] leading-[14px]"; return "text-[10px] leading-[14px]";
@@ -51,16 +62,16 @@ const SelectOption = forwardRef(
.replace(/\s+/g, " "); .replace(/\s+/g, " ");
const handleClick = useCallback( const handleClick = useCallback(
(e) => { (e: React.MouseEvent<HTMLDivElement>) => {
if (!disabled && onClick) { if (!disabled && onClick) {
onClick(e); onClick(e);
} }
}, },
[disabled, onClick], [disabled, onClick]
); );
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(e) => { (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Enter" || e.key === " ") { if (e.key === "Enter" || e.key === " ") {
e.preventDefault(); e.preventDefault();
if (!disabled && onClick) { if (!disabled && onClick) {
@@ -68,7 +79,7 @@ const SelectOption = forwardRef(
} }
} }
}, },
[disabled, onClick], [disabled, onClick]
); );
return ( return (
@@ -103,7 +114,7 @@ const SelectOption = forwardRef(
</div> </div>
</div> </div>
); );
}, }
); );
SelectOption.displayName = "SelectOption"; SelectOption.displayName = "SelectOption";
@@ -1,7 +1,22 @@
import React, { memo, useCallback, useId, forwardRef } from "react"; import React, { memo, useCallback, useId, forwardRef } from "react";
interface SwitchProps
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onChange"> {
checked?: boolean;
onChange?: (
e:
| React.MouseEvent<HTMLButtonElement>
| React.KeyboardEvent<HTMLButtonElement>
) => void;
onFocus?: (e: React.FocusEvent<HTMLButtonElement>) => void;
onBlur?: (e: React.FocusEvent<HTMLButtonElement>) => void;
state?: "default" | "hover" | "focus";
label?: string;
className?: string;
}
const Switch = memo( const Switch = memo(
forwardRef((props, ref) => { forwardRef<HTMLButtonElement, SwitchProps>((props, ref) => {
const { const {
checked = false, checked = false,
onChange, onChange,
@@ -16,16 +31,16 @@ const Switch = memo(
const switchId = useId(); const switchId = useId();
const handleClick = useCallback( const handleClick = useCallback(
(e) => { (e: React.MouseEvent<HTMLButtonElement>) => {
if (onChange) { if (onChange) {
onChange(e); onChange(e);
} }
}, },
[onChange], [onChange]
); );
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(e) => { (e: React.KeyboardEvent<HTMLButtonElement>) => {
if (e.key === "Enter" || e.key === " ") { if (e.key === "Enter" || e.key === " ") {
e.preventDefault(); e.preventDefault();
if (onChange) { if (onChange) {
@@ -33,25 +48,25 @@ const Switch = memo(
} }
} }
}, },
[onChange], [onChange]
); );
const handleFocus = useCallback( const handleFocus = useCallback(
(e) => { (e: React.FocusEvent<HTMLButtonElement>) => {
if (onFocus) { if (onFocus) {
onFocus(e); onFocus(e);
} }
}, },
[onFocus], [onFocus]
); );
const handleBlur = useCallback( const handleBlur = useCallback(
(e) => { (e: React.FocusEvent<HTMLButtonElement>) => {
if (onBlur) { if (onBlur) {
onBlur(e); onBlur(e);
} }
}, },
[onBlur], [onBlur]
); );
// Switch track styles based on checked state // Switch track styles based on checked state
@@ -155,7 +170,7 @@ const Switch = memo(
{label && <span className={labelClasses}>{label}</span>} {label && <span className={labelClasses}>{label}</span>}
</div> </div>
); );
}), })
); );
Switch.displayName = "Switch"; Switch.displayName = "Switch";
@@ -2,7 +2,27 @@
import React, { memo, useCallback, forwardRef, useId } from "react"; import React, { memo, useCallback, forwardRef, useId } from "react";
const TextArea = forwardRef( interface TextAreaProps
extends Omit<
React.TextareaHTMLAttributes<HTMLTextAreaElement>,
"size" | "onChange" | "onFocus" | "onBlur"
> {
size?: "small" | "medium" | "large";
labelVariant?: "default" | "horizontal";
state?: "default" | "active" | "hover" | "focus";
disabled?: boolean;
error?: boolean;
label?: string;
placeholder?: string;
value?: string;
onChange?: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
onFocus?: (e: React.FocusEvent<HTMLTextAreaElement>) => void;
onBlur?: (e: React.FocusEvent<HTMLTextAreaElement>) => void;
className?: string;
rows?: number;
}
const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(
( (
{ {
size = "medium", size = "medium",
@@ -22,14 +42,22 @@ const TextArea = forwardRef(
rows, rows,
...props ...props
}, },
ref, ref
) => { ) => {
// Generate unique ID for accessibility if not provided // Generate unique ID for accessibility if not provided
const generatedId = useId(); const generatedId = useId();
const textareaId = id || `textarea-${generatedId}`; const textareaId = id || `textarea-${generatedId}`;
// Size variants with specific heights and radius for TextArea // Size variants with specific heights and radius for TextArea
const sizeStyles = { const sizeStyles: Record<
string,
{
textarea: string;
label: string;
container: string;
radius: string;
}
> = {
small: { small: {
textarea: textarea:
labelVariant === "horizontal" labelVariant === "horizontal"
@@ -57,7 +85,10 @@ const TextArea = forwardRef(
}; };
// State styles // State styles
const getStateStyles = () => { const getStateStyles = (): {
textarea: string;
label: string;
} => {
if (disabled) { if (disabled) {
return { return {
textarea: textarea:
@@ -125,30 +156,30 @@ const TextArea = forwardRef(
`.trim(); `.trim();
const handleChange = useCallback( const handleChange = useCallback(
(e) => { (e: React.ChangeEvent<HTMLTextAreaElement>) => {
if (!disabled && onChange) { if (!disabled && onChange) {
onChange(e); onChange(e);
} }
}, },
[disabled, onChange], [disabled, onChange]
); );
const handleFocus = useCallback( const handleFocus = useCallback(
(e) => { (e: React.FocusEvent<HTMLTextAreaElement>) => {
if (!disabled && onFocus) { if (!disabled && onFocus) {
onFocus(e); onFocus(e);
} }
}, },
[disabled, onFocus], [disabled, onFocus]
); );
const handleBlur = useCallback( const handleBlur = useCallback(
(e) => { (e: React.FocusEvent<HTMLTextAreaElement>) => {
if (!disabled && onBlur) { if (!disabled && onBlur) {
onBlur(e); onBlur(e);
} }
}, },
[disabled, onBlur], [disabled, onBlur]
); );
return ( return (
@@ -182,7 +213,7 @@ const TextArea = forwardRef(
</div> </div>
</div> </div>
); );
}, }
); );
TextArea.displayName = "TextArea"; TextArea.displayName = "TextArea";
@@ -1,6 +1,26 @@
import React, { memo, useCallback, useId, forwardRef } from "react"; import React, { memo, useCallback, useId, forwardRef } from "react";
const Toggle = forwardRef( interface ToggleProps
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onChange"> {
label?: string;
checked?: boolean;
onChange?: (
e:
| React.MouseEvent<HTMLButtonElement>
| React.KeyboardEvent<HTMLButtonElement>
) => void;
onFocus?: (e: React.FocusEvent<HTMLButtonElement>) => void;
onBlur?: (e: React.FocusEvent<HTMLButtonElement>) => void;
disabled?: boolean;
state?: "default" | "hover" | "focus";
showIcon?: boolean;
showText?: boolean;
icon?: string;
text?: string;
className?: string;
}
const Toggle = forwardRef<HTMLButtonElement, ToggleProps>(
( (
{ {
label, label,
@@ -17,7 +37,7 @@ const Toggle = forwardRef(
className = "", className = "",
...props ...props
}, },
ref, ref
) => { ) => {
const toggleId = useId(); const toggleId = useId();
const labelId = useId(); const labelId = useId();
@@ -29,7 +49,10 @@ const Toggle = forwardRef(
}; };
// State styles // State styles
const getStateStyles = () => { const getStateStyles = (): {
toggle: string;
label: string;
} => {
if (disabled) { if (disabled) {
return { return {
toggle: toggle:
@@ -115,34 +138,38 @@ const Toggle = forwardRef(
.replace(/\s+/g, " "); .replace(/\s+/g, " ");
const handleChange = useCallback( const handleChange = useCallback(
(e) => { (
e:
| React.MouseEvent<HTMLButtonElement>
| React.KeyboardEvent<HTMLButtonElement>
) => {
if (!disabled && onChange) { if (!disabled && onChange) {
onChange(e); onChange(e);
} }
}, },
[disabled, onChange], [disabled, onChange]
); );
const handleFocus = useCallback( const handleFocus = useCallback(
(e) => { (e: React.FocusEvent<HTMLButtonElement>) => {
if (!disabled && onFocus) { if (!disabled && onFocus) {
onFocus(e); onFocus(e);
} }
}, },
[disabled, onFocus], [disabled, onFocus]
); );
const handleBlur = useCallback( const handleBlur = useCallback(
(e) => { (e: React.FocusEvent<HTMLButtonElement>) => {
if (!disabled && onBlur) { if (!disabled && onBlur) {
onBlur(e); onBlur(e);
} }
}, },
[disabled, onBlur], [disabled, onBlur]
); );
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(e) => { (e: React.KeyboardEvent<HTMLButtonElement>) => {
if (!disabled && (e.key === "Enter" || e.key === " ")) { if (!disabled && (e.key === "Enter" || e.key === " ")) {
e.preventDefault(); e.preventDefault();
if (onChange) { if (onChange) {
@@ -150,7 +177,7 @@ const Toggle = forwardRef(
} }
} }
}, },
[disabled, onChange], [disabled, onChange]
); );
return ( return (
@@ -186,7 +213,7 @@ const Toggle = forwardRef(
</div> </div>
</div> </div>
); );
}, }
); );
Toggle.displayName = "Toggle"; Toggle.displayName = "Toggle";
@@ -1,7 +1,24 @@
import React, { memo, useCallback, useId, forwardRef } from "react"; import React, { memo, useCallback, useId, forwardRef } from "react";
interface ToggleGroupProps
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onChange"> {
children?: React.ReactNode;
className?: string;
position?: "left" | "middle" | "right";
state?: "default" | "hover" | "focus" | "selected";
showText?: boolean;
ariaLabel?: string;
onChange?: (
e:
| React.MouseEvent<HTMLButtonElement>
| React.KeyboardEvent<HTMLButtonElement>
) => void;
onFocus?: (e: React.FocusEvent<HTMLButtonElement>) => void;
onBlur?: (e: React.FocusEvent<HTMLButtonElement>) => void;
}
const ToggleGroup = memo( const ToggleGroup = memo(
forwardRef((props, ref) => { forwardRef<HTMLButtonElement, ToggleGroupProps>((props, ref) => {
const { const {
children, children,
className = "", className = "",
@@ -18,7 +35,7 @@ const ToggleGroup = memo(
const groupId = useId(); const groupId = useId();
// Position-based styling for border radius // Position-based styling for border radius
const getPositionStyles = useCallback((pos) => { const getPositionStyles = useCallback((pos: string): string => {
switch (pos) { switch (pos) {
case "left": case "left":
return "rounded-l-[var(--measures-radius-medium)] rounded-r-none"; return "rounded-l-[var(--measures-radius-medium)] rounded-r-none";
@@ -32,7 +49,7 @@ const ToggleGroup = memo(
}, []); }, []);
// State-based styling // State-based styling
const getStateStyles = useCallback((state) => { const getStateStyles = useCallback((state: string): string => {
switch (state) { switch (state) {
case "hover": case "hover":
return "bg-[var(--color-magenta-magenta100)] text-[var(--color-content-default-primary)]"; return "bg-[var(--color-magenta-magenta100)] text-[var(--color-content-default-primary)]";
@@ -50,34 +67,34 @@ const ToggleGroup = memo(
const stateStyles = getStateStyles(state); const stateStyles = getStateStyles(state);
const handleClick = useCallback( const handleClick = useCallback(
(e) => { (e: React.MouseEvent<HTMLButtonElement>) => {
if (onChange) { if (onChange) {
onChange(e); onChange(e);
} }
}, },
[onChange], [onChange]
); );
const handleFocus = useCallback( const handleFocus = useCallback(
(e) => { (e: React.FocusEvent<HTMLButtonElement>) => {
if (onFocus) { if (onFocus) {
onFocus(e); onFocus(e);
} }
}, },
[onFocus], [onFocus]
); );
const handleBlur = useCallback( const handleBlur = useCallback(
(e) => { (e: React.FocusEvent<HTMLButtonElement>) => {
if (onBlur) { if (onBlur) {
onBlur(e); onBlur(e);
} }
}, },
[onBlur], [onBlur]
); );
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(e) => { (e: React.KeyboardEvent<HTMLButtonElement>) => {
if (e.key === "Enter" || e.key === " ") { if (e.key === "Enter" || e.key === " ") {
e.preventDefault(); e.preventDefault();
if (onChange) { if (onChange) {
@@ -85,7 +102,7 @@ const ToggleGroup = memo(
} }
} }
}, },
[onChange], [onChange]
); );
const toggleClasses = ` const toggleClasses = `
@@ -129,7 +146,7 @@ const ToggleGroup = memo(
{showText ? children : children || "☰"} {showText ? children : children || "☰"}
</button> </button>
); );
}), })
); );
ToggleGroup.displayName = "ToggleGroup"; ToggleGroup.displayName = "ToggleGroup";
@@ -2,8 +2,36 @@
import React, { useState, useEffect, memo } from "react"; import React, { useState, useEffect, memo } from "react";
interface VitalData {
value: number;
rating: "good" | "needs-improvement" | "poor" | "unknown";
}
interface Vitals {
lcp: VitalData;
fid: VitalData;
cls: VitalData;
fcp: VitalData;
ttfb: VitalData;
}
interface MetricData {
count: number;
average: number;
min: number;
max: number;
goodCount: number;
needsImprovementCount: number;
poorCount: number;
lastUpdated?: string;
}
interface Metrics {
[key: string]: MetricData;
}
const WebVitalsDashboard = memo(() => { const WebVitalsDashboard = memo(() => {
const [vitals, setVitals] = useState({ const [vitals, setVitals] = useState<Vitals>({
lcp: { value: 0, rating: "unknown" }, lcp: { value: 0, rating: "unknown" },
fid: { value: 0, rating: "unknown" }, fid: { value: 0, rating: "unknown" },
cls: { value: 0, rating: "unknown" }, cls: { value: 0, rating: "unknown" },
@@ -11,7 +39,7 @@ const WebVitalsDashboard = memo(() => {
ttfb: { value: 0, rating: "unknown" }, ttfb: { value: 0, rating: "unknown" },
}); });
const [metrics, setMetrics] = useState({}); const [metrics, setMetrics] = useState<Metrics>({});
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
@@ -19,7 +47,7 @@ const WebVitalsDashboard = memo(() => {
const fetchVitals = async () => { const fetchVitals = async () => {
try { try {
const response = await fetch("/api/web-vitals"); const response = await fetch("/api/web-vitals");
const data = await response.json(); const data = (await response.json()) as { metrics?: Metrics };
setMetrics(data.metrics || {}); setMetrics(data.metrics || {});
} catch (error) { } catch (error) {
console.error("Error fetching web vitals:", error); console.error("Error fetching web vitals:", error);
@@ -88,12 +116,12 @@ const WebVitalsDashboard = memo(() => {
}, },
})); }));
}); });
}, }
); );
} }
}, []); }, []);
const getRatingColor = (rating) => { const getRatingColor = (rating: string): string => {
switch (rating) { switch (rating) {
case "good": case "good":
return "text-green-600 bg-green-50"; return "text-green-600 bg-green-50";
@@ -106,7 +134,7 @@ const WebVitalsDashboard = memo(() => {
} }
}; };
const getRatingIcon = (rating) => { const getRatingIcon = (rating: string): string => {
switch (rating) { switch (rating) {
case "good": case "good":
return "✅"; return "✅";
@@ -119,7 +147,7 @@ const WebVitalsDashboard = memo(() => {
} }
}; };
const formatValue = (metric, value) => { const formatValue = (metric: string, value: number): string => {
if (metric === "cls") { if (metric === "cls") {
return value.toFixed(3); return value.toFixed(3);
} }
+4 -2
View File
@@ -1,4 +1,6 @@
import { Inter, Bricolage_Grotesque, Space_Grotesk } from "next/font/google"; import { Inter, Bricolage_Grotesque, Space_Grotesk } from "next/font/google";
import type { Metadata } from "next";
import type { ReactNode } from "react";
import "./globals.css"; import "./globals.css";
import Header from "./components/Header"; import Header from "./components/Header";
import HomeHeader from "./components/HomeHeader"; import HomeHeader from "./components/HomeHeader";
@@ -32,7 +34,7 @@ const spaceGrotesk = Space_Grotesk({
fallback: ["system-ui", "arial"], fallback: ["system-ui", "arial"],
}); });
export const metadata = { export const metadata: Metadata = {
title: "CommunityRule - Build operating manuals for successful communities", title: "CommunityRule - Build operating manuals for successful communities",
description: description:
"Help your community make important decisions in a way that reflects its unique values.", "Help your community make important decisions in a way that reflects its unique values.",
@@ -77,7 +79,7 @@ export const metadata = {
}, },
}; };
export default function RootLayout({ children }) { export default function RootLayout({ children }: { children: ReactNode }) {
return ( return (
<html lang="en" className="font-sans"> <html lang="en" className="font-sans">
<head> <head>
View File
+4 -4
View File
@@ -6,10 +6,10 @@
/** /**
* Get the correct asset path based on environment * Get the correct asset path based on environment
* @param {string} assetPath - The asset path (e.g., "assets/Logo.svg") * @param assetPath - The asset path (e.g., "assets/Logo.svg")
* @returns {string} - The correct path for the current environment * @returns The correct path for the current environment
*/ */
export function getAssetPath(assetPath) { export function getAssetPath(assetPath: string): string {
// Check if we're in Storybook environment // Check if we're in Storybook environment
const isStorybook = const isStorybook =
typeof window !== "undefined" && typeof window !== "undefined" &&
@@ -55,4 +55,4 @@ export const ASSETS = {
// Content page decorative shapes // Content page decorative shapes
CONTENT_SHAPE_1: "assets/Content_Shape_1.svg", CONTENT_SHAPE_1: "assets/Content_Shape_1.svg",
CONTENT_SHAPE_2: "assets/Content_Shape_2.svg", CONTENT_SHAPE_2: "assets/Content_Shape_2.svg",
}; } as const;
+67 -50
View File
@@ -3,10 +3,10 @@
*/ */
// In-memory cache for blog posts // In-memory cache for blog posts
const blogPostCache = new Map(); const blogPostCache = new Map<string, CacheEntry<unknown>>();
const blogListCache = new Map(); const blogListCache = new Map<string, CacheEntry<unknown[]>>();
const tagCache = new Map(); const tagCache = new Map<string, CacheEntry<string[]>>();
const authorCache = new Map(); const authorCache = new Map<string, CacheEntry<string[]>>();
// Cache configuration // Cache configuration
const isDevelopment = const isDevelopment =
@@ -17,13 +17,16 @@ const MAX_CACHE_SIZE = 100; // Maximum number of cached items
/** /**
* Cache entry with timestamp * Cache entry with timestamp
*/ */
class CacheEntry { class CacheEntry<T> {
constructor(data) { data: T;
timestamp: number;
constructor(data: T) {
this.data = data; this.data = data;
this.timestamp = Date.now(); this.timestamp = Date.now();
} }
isExpired() { isExpired(): boolean {
// In development, always consider cache expired (no caching) // In development, always consider cache expired (no caching)
if (isDevelopment) return true; if (isDevelopment) return true;
return Date.now() - this.timestamp > CACHE_TTL; return Date.now() - this.timestamp > CACHE_TTL;
@@ -32,11 +35,11 @@ class CacheEntry {
/** /**
* Get cached blog post data * Get cached blog post data
* @param {string} key - Cache key * @param key - Cache key
* @returns {Object|null} Cached data or null if not found/expired * @returns Cached data or null if not found/expired
*/ */
function getCached(key) { function getCached<T>(key: string): T | null {
const entry = blogPostCache.get(key); const entry = blogPostCache.get(key) as CacheEntry<T> | undefined;
if (!entry || entry.isExpired()) { if (!entry || entry.isExpired()) {
blogPostCache.delete(key); blogPostCache.delete(key);
return null; return null;
@@ -46,10 +49,10 @@ function getCached(key) {
/** /**
* Set cached blog post data * Set cached blog post data
* @param {string} key - Cache key * @param key - Cache key
* @param {Object} data - Data to cache * @param data - Data to cache
*/ */
function setCached(key, data) { function setCached<T>(key: string, data: T): void {
// Implement LRU eviction if cache is full // Implement LRU eviction if cache is full
if (blogPostCache.size >= MAX_CACHE_SIZE) { if (blogPostCache.size >= MAX_CACHE_SIZE) {
const oldestKey = blogPostCache.keys().next().value; const oldestKey = blogPostCache.keys().next().value;
@@ -62,7 +65,7 @@ function setCached(key, data) {
/** /**
* Clear expired cache entries * Clear expired cache entries
*/ */
function clearExpiredCache() { function clearExpiredCache(): void {
for (const [key, entry] of blogPostCache.entries()) { for (const [key, entry] of blogPostCache.entries()) {
if (entry.isExpired()) { if (entry.isExpired()) {
blogPostCache.delete(key); blogPostCache.delete(key);
@@ -73,7 +76,7 @@ function clearExpiredCache() {
/** /**
* Clear all caches * Clear all caches
*/ */
export function clearAllCaches() { export function clearAllCaches(): void {
blogPostCache.clear(); blogPostCache.clear();
blogListCache.clear(); blogListCache.clear();
tagCache.clear(); tagCache.clear();
@@ -82,50 +85,50 @@ export function clearAllCaches() {
/** /**
* Get cached blog post by slug * Get cached blog post by slug
* @param {string} slug - Blog post slug * @param slug - Blog post slug
* @returns {Object|null} Cached blog post or null * @returns Cached blog post or null
*/ */
export function getCachedBlogPost(slug) { export function getCachedBlogPost<T>(slug: string): T | null {
return getCached(`post:${slug}`); return getCached<T>(`post:${slug}`);
} }
/** /**
* Cache blog post data * Cache blog post data
* @param {string} slug - Blog post slug * @param slug - Blog post slug
* @param {Object} postData - Blog post data * @param postData - Blog post data
*/ */
export function cacheBlogPost(slug, postData) { export function cacheBlogPost<T>(slug: string, postData: T): void {
setCached(`post:${slug}`, postData); setCached(`post:${slug}`, postData);
} }
/** /**
* Get cached blog post list * Get cached blog post list
* @param {string} key - Cache key for list (e.g., 'all', 'recent', 'tag:governance') * @param key - Cache key for list (e.g., 'all', 'recent', 'tag:governance')
* @returns {Array|null} Cached list or null * @returns Cached list or null
*/ */
export function getCachedBlogList(key) { export function getCachedBlogList<T>(key: string): T[] | null {
const entry = blogListCache.get(key); const entry = blogListCache.get(key);
if (!entry || entry.isExpired()) { if (!entry || entry.isExpired()) {
blogListCache.delete(key); blogListCache.delete(key);
return null; return null;
} }
return entry.data; return entry.data as T[];
} }
/** /**
* Cache blog post list * Cache blog post list
* @param {string} key - Cache key * @param key - Cache key
* @param {Array} listData - List data to cache * @param listData - List data to cache
*/ */
export function cacheBlogList(key, listData) { export function cacheBlogList<T>(key: string, listData: T[]): void {
blogListCache.set(key, new CacheEntry(listData)); blogListCache.set(key, new CacheEntry(listData));
} }
/** /**
* Get cached tags * Get cached tags
* @returns {Array|null} Cached tags or null * @returns Cached tags or null
*/ */
export function getCachedTags() { export function getCachedTags(): string[] | null {
const entry = tagCache.get("all"); const entry = tagCache.get("all");
if (!entry || entry.isExpired()) { if (!entry || entry.isExpired()) {
tagCache.delete("all"); tagCache.delete("all");
@@ -136,17 +139,17 @@ export function getCachedTags() {
/** /**
* Cache tags * Cache tags
* @param {Array} tags - Tags to cache * @param tags - Tags to cache
*/ */
export function cacheTags(tags) { export function cacheTags(tags: string[]): void {
tagCache.set("all", new CacheEntry(tags)); tagCache.set("all", new CacheEntry(tags));
} }
/** /**
* Get cached authors * Get cached authors
* @returns {Array|null} Cached authors or null * @returns Cached authors or null
*/ */
export function getCachedAuthors() { export function getCachedAuthors(): string[] | null {
const entry = authorCache.get("all"); const entry = authorCache.get("all");
if (!entry || entry.isExpired()) { if (!entry || entry.isExpired()) {
authorCache.delete("all"); authorCache.delete("all");
@@ -157,17 +160,17 @@ export function getCachedAuthors() {
/** /**
* Cache authors * Cache authors
* @param {Array} authors - Authors to cache * @param authors - Authors to cache
*/ */
export function cacheAuthors(authors) { export function cacheAuthors(authors: string[]): void {
authorCache.set("all", new CacheEntry(authors)); authorCache.set("all", new CacheEntry(authors));
} }
/** /**
* Invalidate cache for a specific blog post * Invalidate cache for a specific blog post
* @param {string} slug - Blog post slug * @param slug - Blog post slug
*/ */
export function invalidateBlogPostCache(slug) { export function invalidateBlogPostCache(slug: string): void {
blogPostCache.delete(`post:${slug}`); blogPostCache.delete(`post:${slug}`);
// Also invalidate list caches since they might contain this post // Also invalidate list caches since they might contain this post
blogListCache.clear(); blogListCache.clear();
@@ -176,15 +179,25 @@ export function invalidateBlogPostCache(slug) {
/** /**
* Invalidate all caches * Invalidate all caches
*/ */
export function invalidateAllCaches() { export function invalidateAllCaches(): void {
clearAllCaches(); clearAllCaches();
} }
export interface CacheStats {
blogPostCacheSize: number;
blogListCacheSize: number;
tagCacheSize: number;
authorCacheSize: number;
totalCacheSize: number;
maxCacheSize: number;
cacheTTL: number;
}
/** /**
* Get cache statistics * Get cache statistics
* @returns {Object} Cache statistics * @returns Cache statistics
*/ */
export function getCacheStats() { export function getCacheStats(): CacheStats {
clearExpiredCache(); clearExpiredCache();
return { return {
@@ -193,7 +206,7 @@ export function getCacheStats() {
tagCacheSize: tagCache.size, tagCacheSize: tagCache.size,
authorCacheSize: authorCache.size, authorCacheSize: authorCache.size,
totalCacheSize: totalCacheSize:
blogPostCache.size + blogListCache.size + tagCache.size + authorCacheSize, blogPostCache.size + blogListCache.size + tagCache.size + authorCache.size,
maxCacheSize: MAX_CACHE_SIZE, maxCacheSize: MAX_CACHE_SIZE,
cacheTTL: CACHE_TTL, cacheTTL: CACHE_TTL,
}; };
@@ -201,10 +214,13 @@ export function getCacheStats() {
/** /**
* Warm up cache with frequently accessed data * Warm up cache with frequently accessed data
* @param {Function} getAllPosts - Function to get all blog posts * @param getAllPosts - Function to get all blog posts
* @param {Function} getAllTags - Function to get all tags * @param getAllTags - Function to get all tags
*/ */
export async function warmCache(getAllPosts, getAllTags) { export async function warmCache<T>(
getAllPosts: () => T[],
getAllTags: () => string[],
): Promise<void> {
try { try {
// Cache all blog posts // Cache all blog posts
const allPosts = getAllPosts(); const allPosts = getAllPosts();
@@ -220,7 +236,8 @@ export async function warmCache(getAllPosts, getAllTags) {
// Cache individual posts (first 10) // Cache individual posts (first 10)
allPosts.slice(0, 10).forEach((post) => { allPosts.slice(0, 10).forEach((post) => {
cacheBlogPost(post.slug, post); const postWithSlug = post as { slug: string };
cacheBlogPost(postWithSlug.slug, post);
}); });
console.log("Cache warmed up successfully"); console.log("Cache warmed up successfully");
@@ -231,9 +248,9 @@ export async function warmCache(getAllPosts, getAllTags) {
/** /**
* Check if cache is healthy * Check if cache is healthy
* @returns {boolean} True if cache is healthy * @returns True if cache is healthy
*/ */
export function isCacheHealthy() { export function isCacheHealthy(): boolean {
try { try {
clearExpiredCache(); clearExpiredCache();
return blogPostCache.size < MAX_CACHE_SIZE; return blogPostCache.size < MAX_CACHE_SIZE;
+83 -55
View File
@@ -1,18 +1,31 @@
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import matter from "gray-matter"; import matter from "gray-matter";
import { validateBlogPost, sanitizeBlogPost } from "./validation.js"; import {
validateBlogPost,
sanitizeBlogPost,
type BlogPostFrontmatter,
} from "./validation";
/** /**
* Content processing utilities for blog posts * Content processing utilities for blog posts
*/ */
export interface BlogPost {
slug: string;
frontmatter: BlogPostFrontmatter;
content: string;
htmlContent: string;
filePath: string;
lastModified: Date;
}
/** /**
* Generate a URL-friendly slug from a string * Generate a URL-friendly slug from a string
* @param {string} text - Text to convert to slug * @param text - Text to convert to slug
* @returns {string} URL-friendly slug * @returns URL-friendly slug
*/ */
function generateSlug(text) { function generateSlug(text: string): string {
return text return text
.toLowerCase() .toLowerCase()
.replace(/[^\w\s-]/g, "") // Remove special characters .replace(/[^\w\s-]/g, "") // Remove special characters
@@ -23,9 +36,9 @@ function generateSlug(text) {
/** /**
* Get all blog post files from the content directory * Get all blog post files from the content directory
* @returns {Array} Array of file paths * @returns Array of file paths
*/ */
export function markdownToHtml(markdown) { export function markdownToHtml(markdown: string): string {
if (!markdown) return ""; if (!markdown) return "";
return ( return (
@@ -54,13 +67,13 @@ export function markdownToHtml(markdown) {
); );
} }
export function getBlogPostFiles() { export function getBlogPostFiles(): string[] {
const contentDirectory = path.join(process.cwd(), "content/blog"); const contentDirectory = path.join(process.cwd(), "content/blog");
try { try {
const files = fs.readdirSync(contentDirectory); const files = fs.readdirSync(contentDirectory);
return files.filter( return files.filter(
(file) => file.endsWith(".md") || file.endsWith(".mdx"), (file) => file.endsWith(".md") || file.endsWith(".mdx")
); );
} catch (error) { } catch (error) {
console.error("Error reading blog content directory:", error); console.error("Error reading blog content directory:", error);
@@ -70,10 +83,10 @@ export function getBlogPostFiles() {
/** /**
* Parse a single blog post file * Parse a single blog post file
* @param {string} filePath - Path to the markdown file * @param filePath - Path to the markdown file
* @returns {Object|null} Parsed blog post data or null if invalid * @returns Parsed blog post data or null if invalid
*/ */
export function parseBlogPost(filePath) { export function parseBlogPost(filePath: string): BlogPost | null {
const fullPath = path.join(process.cwd(), "content/blog", filePath); const fullPath = path.join(process.cwd(), "content/blog", filePath);
try { try {
@@ -84,7 +97,7 @@ export function parseBlogPost(filePath) {
if (!validationResult.isValid) { if (!validationResult.isValid) {
console.error( console.error(
`Validation errors for ${filePath}:`, `Validation errors for ${filePath}:`,
validationResult.errors, validationResult.errors
); );
return null; return null;
} }
@@ -108,51 +121,53 @@ export function parseBlogPost(filePath) {
/** /**
* Get all blog posts, sorted by date * Get all blog posts, sorted by date
* @returns {Array} Array of parsed blog post objects * @returns Array of parsed blog post objects
*/ */
export function getAllBlogPosts() { export function getAllBlogPosts(): BlogPost[] {
const fileNames = getBlogPostFiles(); const fileNames = getBlogPostFiles();
const allPosts = fileNames const allPosts = fileNames
.map((fileName) => parseBlogPost(fileName)) .map((fileName) => parseBlogPost(fileName))
.filter(Boolean) // Filter out nulls (invalid posts) .filter((post): post is BlogPost => post !== null) // Filter out nulls (invalid posts)
.sort( .sort(
(a, b) => new Date(b.frontmatter.date) - new Date(a.frontmatter.date), (a, b) =>
new Date(b.frontmatter.date).getTime() -
new Date(a.frontmatter.date).getTime()
); // Sort by date descending ); // Sort by date descending
return allPosts; return allPosts;
} }
/** /**
* Get a single blog post by its slug * Get a single blog post by its slug
* @param {string} slug - The slug of the blog post * @param slug - The slug of the blog post
* @returns {Object|null} The parsed blog post data or null if not found * @returns The parsed blog post data or null if not found
*/ */
export function getBlogPostBySlug(slug) { export function getBlogPostBySlug(slug: string): BlogPost | null {
const allPosts = getAllBlogPosts(); const allPosts = getAllBlogPosts();
return allPosts.find((post) => post.slug === slug) || null; return allPosts.find((post) => post.slug === slug) || null;
} }
/** /**
* Get related blog posts based on provided slugs or fallback to recent posts. * Get related blog posts based on provided slugs or fallback to recent posts.
* @param {string} currentPostSlug - The slug of the current post to exclude. * @param currentPostSlug - The slug of the current post to exclude.
* @param {string[]} relatedSlugs - Array of slugs for explicitly related posts. * @param relatedSlugs - Array of slugs for explicitly related posts.
* @param {number} limit - Maximum number of related posts to return. * @param limit - Maximum number of related posts to return.
* @returns {Array} Array of related blog post objects. * @returns Array of related blog post objects.
*/ */
export function getRelatedBlogPosts( export function getRelatedBlogPosts(
currentPostSlug, currentPostSlug: string,
relatedSlugs = [], relatedSlugs: string[] = [],
limit = 3, limit: number = 3
) { ): BlogPost[] {
const allPosts = getAllBlogPosts(); const allPosts = getAllBlogPosts();
const filteredPosts = allPosts.filter( const filteredPosts = allPosts.filter(
(post) => post.slug !== currentPostSlug, (post) => post.slug !== currentPostSlug
); );
let related = []; let related: BlogPost[] = [];
if (relatedSlugs && relatedSlugs.length > 0) { if (relatedSlugs && relatedSlugs.length > 0) {
related = relatedSlugs related = relatedSlugs
.map((slug) => filteredPosts.find((post) => post.slug === slug)) .map((slug) => filteredPosts.find((post) => post.slug === slug))
.filter(Boolean); // Filter out any related slugs that don't exist .filter((post): post is BlogPost => post !== undefined); // Filter out any related slugs that don't exist
} }
// If not enough related posts, or no related slugs provided, fill with recent posts // If not enough related posts, or no related slugs provided, fill with recent posts
@@ -170,11 +185,11 @@ export function getRelatedBlogPosts(
/** /**
* Get all unique tags from all blog posts. * Get all unique tags from all blog posts.
* @returns {string[]} Array of unique tags. * @returns Array of unique tags.
*/ */
export function getAllTags() { export function getAllTags(): string[] {
const allPosts = getAllBlogPosts(); const allPosts = getAllBlogPosts();
const tags = new Set(); const tags = new Set<string>();
allPosts.forEach((post) => { allPosts.forEach((post) => {
if (post.frontmatter.tags) { if (post.frontmatter.tags) {
post.frontmatter.tags.forEach((tag) => tags.add(tag)); post.frontmatter.tags.forEach((tag) => tags.add(tag));
@@ -185,23 +200,23 @@ export function getAllTags() {
/** /**
* Get blog posts filtered by a specific tag. * Get blog posts filtered by a specific tag.
* @param {string} tag - The tag to filter by. * @param tag - The tag to filter by.
* @returns {Object[]} Array of blog post objects matching the tag. * @returns Array of blog post objects matching the tag.
*/ */
export function getBlogPostsByTag(tag) { export function getBlogPostsByTag(tag: string): BlogPost[] {
const allPosts = getAllBlogPosts(); const allPosts = getAllBlogPosts();
return allPosts.filter( return allPosts.filter(
(post) => post.frontmatter.tags && post.frontmatter.tags.includes(tag), (post) => post.frontmatter.tags && post.frontmatter.tags.includes(tag)
); );
} }
/** /**
* Search blog posts by text content * Search blog posts by text content
* @param {string} query - Search query * @param query - Search query
* @param {number} limit - Maximum number of results * @param limit - Maximum number of results
* @returns {Object[]} Array of matching blog post objects * @returns Array of matching blog post objects
*/ */
export function searchBlogPosts(query, limit = 10) { export function searchBlogPosts(query: string, limit: number = 10): BlogPost[] {
if (!query || query.trim() === "") return []; if (!query || query.trim() === "") return [];
const searchTerm = query.toLowerCase().trim(); const searchTerm = query.toLowerCase().trim();
@@ -216,7 +231,7 @@ export function searchBlogPosts(query, limit = 10) {
.includes(searchTerm); .includes(searchTerm);
const contentMatch = post.content.toLowerCase().includes(searchTerm); const contentMatch = post.content.toLowerCase().includes(searchTerm);
const tagMatch = post.frontmatter.tags?.some((tag) => const tagMatch = post.frontmatter.tags?.some((tag) =>
tag.toLowerCase().includes(searchTerm), tag.toLowerCase().includes(searchTerm)
); );
return titleMatch || descriptionMatch || contentMatch || tagMatch; return titleMatch || descriptionMatch || contentMatch || tagMatch;
@@ -227,31 +242,42 @@ export function searchBlogPosts(query, limit = 10) {
/** /**
* Get blog posts by author * Get blog posts by author
* @param {string} author - Author name to filter by * @param author - Author name to filter by
* @returns {Object[]} Array of blog post objects by the author * @returns Array of blog post objects by the author
*/ */
export function getBlogPostsByAuthor(author) { export function getBlogPostsByAuthor(author: string): BlogPost[] {
const allPosts = getAllBlogPosts(); const allPosts = getAllBlogPosts();
return allPosts.filter( return allPosts.filter(
(post) => post.frontmatter.author.toLowerCase() === author.toLowerCase(), (post) => post.frontmatter.author.toLowerCase() === author.toLowerCase()
); );
} }
/** /**
* Get recent blog posts * Get recent blog posts
* @param {number} limit - Maximum number of posts to return * @param limit - Maximum number of posts to return
* @returns {Object[]} Array of recent blog post objects * @returns Array of recent blog post objects
*/ */
export function getRecentBlogPosts(limit = 5) { export function getRecentBlogPosts(limit: number = 5): BlogPost[] {
const allPosts = getAllBlogPosts(); const allPosts = getAllBlogPosts();
return allPosts.slice(0, limit); return allPosts.slice(0, limit);
} }
export interface BlogStats {
totalPosts: number;
totalTags: number;
totalAuthors: number;
dateRange: {
earliest: string | null;
latest: string | null;
};
averagePostsPerMonth: number;
}
/** /**
* Get blog post statistics * Get blog post statistics
* @returns {Object} Statistics about blog posts * @returns Statistics about blog posts
*/ */
export function getBlogStats() { export function getBlogStats(): BlogStats {
const allPosts = getAllBlogPosts(); const allPosts = getAllBlogPosts();
const tags = getAllTags(); const tags = getAllTags();
@@ -272,11 +298,13 @@ export function getBlogStats() {
(allPosts.length / (allPosts.length /
Math.max( Math.max(
1, 1,
(new Date(allPosts[0].frontmatter.date) - (new Date(allPosts[0].frontmatter.date).getTime() -
new Date(allPosts[allPosts.length - 1].frontmatter.date)) / new Date(
(1000 * 60 * 60 * 24 * 30), allPosts[allPosts.length - 1].frontmatter.date
).getTime()) /
(1000 * 60 * 60 * 24 * 30)
)) * )) *
10, 10
) / 10 ) / 10
: 0, : 0,
}; };
+79 -40
View File
@@ -2,11 +2,47 @@
* MDX processing utilities for enhanced markdown content * MDX processing utilities for enhanced markdown content
*/ */
export interface Heading {
level: number;
text: string;
id: string;
line: number;
}
export interface Link {
text: string;
url: string;
index: number;
}
export interface Image {
alt: string;
src: string;
index: number;
}
export interface ProcessedMarkdown {
content: string;
htmlContent: string;
headings: Heading[];
links: Link[];
images: Image[];
}
export interface ProcessedFrontmatter {
publishedDate: Date;
year: number;
month: number;
day: number;
isRecent: boolean;
[key: string]: unknown;
}
/** /**
* Format date consistently across the markdown pipeline * Format date consistently across the markdown pipeline
* Uses "Month Year" format (e.g., "April 2025") * Uses "Month Year" format (e.g., "April 2025")
*/ */
export function formatDate(dateString) { export function formatDate(dateString: string): string {
const date = new Date(dateString); const date = new Date(dateString);
return date.toLocaleDateString("en-US", { return date.toLocaleDateString("en-US", {
year: "numeric", year: "numeric",
@@ -16,10 +52,10 @@ export function formatDate(dateString) {
/** /**
* Process markdown content and extract metadata * Process markdown content and extract metadata
* @param {string} markdown - Raw markdown content * @param markdown - Raw markdown content
* @returns {object} Processed content with metadata * @returns Processed content with metadata
*/ */
export function processMarkdown(markdown) { export function processMarkdown(markdown: string): ProcessedMarkdown {
if (!markdown) { if (!markdown) {
return { return {
content: "", content: "",
@@ -53,13 +89,13 @@ export function processMarkdown(markdown) {
/** /**
* Extract all headings from markdown content * Extract all headings from markdown content
* @param {string} markdown - Raw markdown content * @param markdown - Raw markdown content
* @returns {Array} Array of heading objects with level, text, and id * @returns Array of heading objects with level, text, and id
*/ */
function extractHeadings(markdown) { function extractHeadings(markdown: string): Heading[] {
const headingRegex = /^(#{1,6})\s+(.+)$/gm; const headingRegex = /^(#{1,6})\s+(.+)$/gm;
const headings = []; const headings: Heading[] = [];
let match; let match: RegExpExecArray | null;
while ((match = headingRegex.exec(markdown)) !== null) { while ((match = headingRegex.exec(markdown)) !== null) {
const level = match[1].length; const level = match[1].length;
@@ -79,13 +115,13 @@ function extractHeadings(markdown) {
/** /**
* Extract all links from markdown content * Extract all links from markdown content
* @param {string} markdown - Raw markdown content * @param markdown - Raw markdown content
* @returns {Array} Array of link objects * @returns Array of link objects
*/ */
function extractLinks(markdown) { function extractLinks(markdown: string): Link[] {
const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g; const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
const links = []; const links: Link[] = [];
let match; let match: RegExpExecArray | null;
while ((match = linkRegex.exec(markdown)) !== null) { while ((match = linkRegex.exec(markdown)) !== null) {
links.push({ links.push({
@@ -100,13 +136,13 @@ function extractLinks(markdown) {
/** /**
* Extract all images from markdown content * Extract all images from markdown content
* @param {string} markdown - Raw markdown content * @param markdown - Raw markdown content
* @returns {Array} Array of image objects * @returns Array of image objects
*/ */
function extractImages(markdown) { function extractImages(markdown: string): Image[] {
const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g; const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
const images = []; const images: Image[] = [];
let match; let match: RegExpExecArray | null;
while ((match = imageRegex.exec(markdown)) !== null) { while ((match = imageRegex.exec(markdown)) !== null) {
images.push({ images.push({
@@ -121,10 +157,10 @@ function extractImages(markdown) {
/** /**
* Generate a unique ID for a heading * Generate a unique ID for a heading
* @param {string} text - Heading text * @param text - Heading text
* @returns {string} Unique ID * @returns Unique ID
*/ */
function generateHeadingId(text) { function generateHeadingId(text: string): string {
return text return text
.toLowerCase() .toLowerCase()
.replace(/[^\w\s-]/g, "") .replace(/[^\w\s-]/g, "")
@@ -137,10 +173,10 @@ function generateHeadingId(text) {
* Convert markdown to HTML with enhanced formatting * Convert markdown to HTML with enhanced formatting
* - Preserves extra blank lines between paragraphs as visible gaps * - Preserves extra blank lines between paragraphs as visible gaps
* (each extra blank line becomes <p class="md-gap">&nbsp;</p>) * (each extra blank line becomes <p class="md-gap">&nbsp;</p>)
* @param {string} markdown - Raw markdown content * @param markdown - Raw markdown content
* @returns {string} HTML content * @returns HTML content
*/ */
function markdownToHtml(markdown) { function markdownToHtml(markdown: string): string {
if (!markdown) return ""; if (!markdown) return "";
// Normalize line endings // Normalize line endings
@@ -266,10 +302,10 @@ function markdownToHtml(markdown) {
/** /**
* Generate a table of contents from headings * Generate a table of contents from headings
* @param {Array} headings - Array of heading objects * @param headings - Array of heading objects
* @returns {string} HTML table of contents * @returns HTML table of contents
*/ */
export function generateTableOfContents(headings) { export function generateTableOfContents(headings: Heading[]): string {
if (!headings || headings.length === 0) return ""; if (!headings || headings.length === 0) return "";
let toc = '<nav class="table-of-contents"><h4>Table of Contents</h4><ul>'; let toc = '<nav class="table-of-contents"><h4>Table of Contents</h4><ul>';
@@ -285,18 +321,21 @@ export function generateTableOfContents(headings) {
/** /**
* Process frontmatter with enhanced validation * Process frontmatter with enhanced validation
* @param {Object} frontmatter - Raw frontmatter data * @param frontmatter - Raw frontmatter data
* @returns {Object} Processed and validated frontmatter * @returns Processed and validated frontmatter
*/ */
export function processFrontmatter(frontmatter) { export function processFrontmatter(
frontmatter: Record<string, unknown>,
): ProcessedFrontmatter {
// Add computed fields // Add computed fields
const processed = { const date = frontmatter.date as string;
const processed: ProcessedFrontmatter = {
...frontmatter, ...frontmatter,
publishedDate: new Date(frontmatter.date), publishedDate: new Date(date),
year: new Date(frontmatter.date).getFullYear(), year: new Date(date).getFullYear(),
month: new Date(frontmatter.date).getMonth() + 1, month: new Date(date).getMonth() + 1,
day: new Date(frontmatter.date).getDate(), day: new Date(date).getDate(),
isRecent: isRecentPost(frontmatter.date), isRecent: isRecentPost(date),
}; };
return processed; return processed;
@@ -304,10 +343,10 @@ export function processFrontmatter(frontmatter) {
/** /**
* Check if a post is recent (within last 30 days) * Check if a post is recent (within last 30 days)
* @param {string} date - Post date string * @param date - Post date string
* @returns {boolean} True if post is recent * @returns True if post is recent
*/ */
function isRecentPost(date) { function isRecentPost(date: string): boolean {
const postDate = new Date(date); const postDate = new Date(date);
const thirtyDaysAgo = new Date(); const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
+37
View File
@@ -0,0 +1,37 @@
/**
* Shared type definitions for the CommunityRule application
*/
// Re-export types from other modules for convenience
export type {
BlogPost,
BlogStats,
} from "./content";
export type {
BlogPostFrontmatter,
ValidationResult,
} from "./validation";
export type {
Heading,
Link,
Image,
ProcessedMarkdown,
ProcessedFrontmatter,
} from "./mdx";
export type {
CacheStats,
} from "./cache";
// Additional shared types
export interface ComponentProps {
className?: string;
children?: React.ReactNode;
}
export interface PageProps {
params?: Record<string, string | string[]>;
searchParams?: Record<string, string | string[]>;
}
+82 -18
View File
@@ -2,10 +2,54 @@
* Content validation utilities for blog posts * Content validation utilities for blog posts
*/ */
export interface BlogPostSchemaField {
type: "string" | "array" | "object";
required: boolean;
minLength?: number;
maxLength?: number;
pattern?: RegExp;
default?: unknown;
items?: {
type: string;
minLength?: number;
maxLength?: number;
};
properties?: Record<string, BlogPostSchemaField>;
}
export interface BlogPostSchema {
[key: string]: BlogPostSchemaField;
}
export interface ValidationResult {
isValid: boolean;
errors: string[];
}
export interface BlogPostFrontmatter {
title: string;
description: string;
author: string;
date: string;
related?: string[];
thumbnail?: {
vertical?: string;
horizontal?: string;
};
banner?: {
horizontal?: string;
};
background?: {
color?: string;
};
tags?: string[];
[key: string]: unknown;
}
/** /**
* Blog post frontmatter schema * Blog post frontmatter schema
*/ */
export const BLOG_POST_SCHEMA = { export const BLOG_POST_SCHEMA: BlogPostSchema = {
title: { title: {
type: "string", type: "string",
required: true, required: true,
@@ -80,11 +124,13 @@ export const BLOG_POST_SCHEMA = {
/** /**
* Validate a blog post's frontmatter * Validate a blog post's frontmatter
* @param {Object} frontmatter - The frontmatter object to validate * @param frontmatter - The frontmatter object to validate
* @returns {Object} Validation result with isValid boolean and errors array * @returns Validation result with isValid boolean and errors array
*/ */
export function validateBlogPost(frontmatter) { export function validateBlogPost(
const errors = []; frontmatter: Record<string, unknown>,
): ValidationResult {
const errors: string[] = [];
// Check required fields first // Check required fields first
for (const [field, config] of Object.entries(BLOG_POST_SCHEMA)) { for (const [field, config] of Object.entries(BLOG_POST_SCHEMA)) {
@@ -116,12 +162,13 @@ export function validateBlogPost(frontmatter) {
// Length validation for strings // Length validation for strings
if (config.type === "string" && typeof frontmatter[field] === "string") { if (config.type === "string" && typeof frontmatter[field] === "string") {
if (config.minLength && frontmatter[field].length < config.minLength) { const fieldValue = frontmatter[field] as string;
if (config.minLength && fieldValue.length < config.minLength) {
errors.push( errors.push(
`Field ${field} must be at least ${config.minLength} characters`, `Field ${field} must be at least ${config.minLength} characters`,
); );
} }
if (config.maxLength && frontmatter[field].length > config.maxLength) { if (config.maxLength && fieldValue.length > config.maxLength) {
errors.push( errors.push(
`Field ${field} must be no more than ${config.maxLength} characters`, `Field ${field} must be no more than ${config.maxLength} characters`,
); );
@@ -129,23 +176,38 @@ export function validateBlogPost(frontmatter) {
} }
// Pattern validation // Pattern validation
if (config.pattern && !config.pattern.test(frontmatter[field])) { if (
config.pattern &&
typeof frontmatter[field] === "string" &&
!config.pattern.test(frontmatter[field] as string)
) {
errors.push(`Field ${field} format is invalid`); errors.push(`Field ${field} format is invalid`);
} }
// Array item validation // Array item validation
if (config.type === "array" && Array.isArray(frontmatter[field])) { if (config.type === "array" && Array.isArray(frontmatter[field])) {
for (let i = 0; i < frontmatter[field].length; i++) { const fieldArray = frontmatter[field] as unknown[];
const item = frontmatter[field][i]; for (let i = 0; i < fieldArray.length; i++) {
if (config.items.type === "string" && typeof item !== "string") { const item = fieldArray[i];
if (config.items?.type === "string" && typeof item !== "string") {
errors.push(`Item ${i} in ${field} must be a string`); errors.push(`Item ${i} in ${field} must be a string`);
} }
if (config.items.minLength && item.length < config.items.minLength) { if (
config.items &&
typeof item === "string" &&
config.items.minLength &&
item.length < config.items.minLength
) {
errors.push( errors.push(
`Item ${i} in ${field} must be at least ${config.items.minLength} characters`, `Item ${i} in ${field} must be at least ${config.items.minLength} characters`,
); );
} }
if (config.items.maxLength && item.length > config.items.maxLength) { if (
config.items &&
typeof item === "string" &&
config.items.maxLength &&
item.length > config.items.maxLength
) {
errors.push( errors.push(
`Item ${i} in ${field} must be no more than ${config.items.maxLength} characters`, `Item ${i} in ${field} must be no more than ${config.items.maxLength} characters`,
); );
@@ -163,11 +225,13 @@ export function validateBlogPost(frontmatter) {
/** /**
* Sanitize and normalize frontmatter data * Sanitize and normalize frontmatter data
* @param {Object} frontmatter - Raw frontmatter data * @param frontmatter - Raw frontmatter data
* @returns {Object} Sanitized frontmatter * @returns Sanitized frontmatter
*/ */
export function sanitizeBlogPost(frontmatter) { export function sanitizeBlogPost(
const sanitized = {}; frontmatter: Record<string, unknown>,
): BlogPostFrontmatter {
const sanitized: Record<string, unknown> = {};
for (const [field, config] of Object.entries(BLOG_POST_SCHEMA)) { for (const [field, config] of Object.entries(BLOG_POST_SCHEMA)) {
if (frontmatter[field] !== undefined) { if (frontmatter[field] !== undefined) {
@@ -187,5 +251,5 @@ export function sanitizeBlogPost(frontmatter) {
} }
} }
return sanitized; return sanitized as BlogPostFrontmatter;
} }
+6 -2
View File
@@ -9,16 +9,20 @@
"incremental": true, "incremental": true,
"module": "esnext", "module": "esnext",
"esModuleInterop": true, "esModuleInterop": true,
"moduleResolution": "node", "moduleResolution": "bundler",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"jsx": "preserve", "jsx": "preserve",
"forceConsistentCasingInFileNames": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"plugins": [ "plugins": [
{ {
"name": "next" "name": "next"
} }
] ]
}, },
"include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"], "include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"],
"exclude": ["node_modules"] "exclude": ["node_modules"]
} }