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 path from "path";
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
if (!fs.existsSync(WEB_VITALS_DIR)) {
fs.mkdirSync(WEB_VITALS_DIR, { recursive: true });
}
export async function POST(request) {
export async function POST(request: NextRequest) {
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
const vitalsData = {
const vitalsData: WebVitalData = {
metric,
data,
url,
@@ -25,13 +57,15 @@ export async function POST(request) {
// Save to file (in production, you would save to a database)
const filePath = path.join(WEB_VITALS_DIR, `${metric}.json`);
let existingData = [];
let existingData: WebVitalData[] = [];
if (fs.existsSync(filePath)) {
try {
existingData = JSON.parse(fs.readFileSync(filePath, "utf8"));
const fileContent = fs.readFileSync(filePath, "utf8");
existingData = JSON.parse(fileContent) as WebVitalData[];
} 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() {
try {
const metrics = {};
const metrics: WebVitalMetrics = {};
if (fs.existsSync(WEB_VITALS_DIR)) {
const files = fs.readdirSync(WEB_VITALS_DIR);
@@ -69,9 +103,11 @@ export async function GET() {
files.forEach((file) => {
if (file.endsWith(".json")) {
const metric = file.replace(".json", "");
const data = JSON.parse(
fs.readFileSync(path.join(WEB_VITALS_DIR, file), "utf8"),
const fileContent = fs.readFileSync(
path.join(WEB_VITALS_DIR, file),
"utf8",
);
const data = JSON.parse(fileContent) as WebVitalData[];
if (data.length > 0) {
const values = data
@@ -96,7 +132,7 @@ export async function GET() {
(r) => r === "needs-improvement",
).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 Link from "next/link";
import type { Metadata } from "next";
import {
getBlogPostBySlug,
getAllBlogPosts as getAllPosts,
type BlogPost,
} from "../../../lib/content";
import ContentBanner from "../../components/ContentBanner";
import RelatedArticles from "../../components/RelatedArticles";
@@ -17,6 +18,10 @@ const askOrganizerData = {
buttonHref: "#contact",
};
interface PageProps {
params: Promise<{ slug: string }>;
}
/**
* Generate static params for all blog posts
* 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
*/
export async function generateMetadata({ params }) {
export async function generateMetadata({
params,
}: PageProps): Promise<Metadata> {
try {
const { slug } = await params;
const post = getBlogPostBySlug(slug);
@@ -80,7 +87,7 @@ export async function generateMetadata({ params }) {
/**
* Dynamic blog post page
*/
export default async function BlogPostPage({ params }) {
export default async function BlogPostPage({ params }: PageProps) {
// Get the blog post data
const { slug } = await params;
const post = getBlogPostBySlug(slug);
@@ -97,7 +104,11 @@ export default async function BlogPostPage({ params }) {
const slugOrder = allPosts.map((post) => post.slug);
// 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);
// Score posts based on content similarity
@@ -202,7 +213,7 @@ export default async function BlogPostPage({ params }) {
};
// Get article-specific background color from frontmatter
const getBackgroundColor = (post) => {
const getBackgroundColor = (post: BlogPost): string => {
if (post.frontmatter?.background?.color) {
return post.frontmatter.background.color;
}
+2 -1
View File
@@ -1,8 +1,9 @@
import { getAllBlogPosts } from "../../lib/content";
import ContentThumbnailTemplate from "../components/ContentThumbnailTemplate";
import ContentContainer from "../components/ContentContainer";
import type { Metadata } from "next";
export const metadata = {
export const metadata: Metadata = {
title: "Blog - CommunityRule",
description:
"Learn about community governance, decision-making, and building successful organizations.",
@@ -4,7 +4,35 @@ import React, { memo } from "react";
import ContentLockup from "./ContentLockup";
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,
subtitle,
@@ -12,11 +40,13 @@ const AskOrganizer = memo(
buttonText = "Ask an organizer",
buttonHref = "#",
className = "",
variant = "centered", // centered, left-aligned, compact
onContactClick, // Analytics callback
variant = "centered",
onContactClick,
}) => {
// Analytics tracking for contact button clicks
const handleContactClick = (event) => {
const handleContactClick = (
event: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>
) => {
// Track contact button interaction
if (onContactClick) {
onContactClick({
@@ -40,7 +70,10 @@ const AskOrganizer = memo(
};
// Variant-specific styling
const variantStyles = {
const variantStyles: Record<
string,
{ container: string; buttonContainer: string }
> = {
centered: {
container: "text-center",
buttonContainer: "flex justify-center",
@@ -98,7 +131,7 @@ const AskOrganizer = memo(
variant={variant === "inverse" ? "primary" : "default"}
className="xl:!px-[var(--spacing-scale-020)] xl:!py-[var(--spacing-scale-012)] xl:!text-[24px] xl:!leading-[28px]"
onClick={handleContactClick}
aria-label={`${buttonText} - Contact an organizer for help`}
ariaLabel={`${buttonText} - Contact an organizer for help`}
>
{buttonText}
</Button>
@@ -106,7 +139,7 @@ const AskOrganizer = memo(
</div>
</section>
);
},
}
);
AskOrganizer.displayName = "AskOrganizer";
@@ -1,8 +1,15 @@
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 }) => {
const sizeStyles = {
const sizeStyles: Record<string, string> = {
small: "w-[var(--spacing-scale-016)] h-[var(--spacing-scale-016)]",
medium: "w-[18px] h-[18px]",
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}`;
return <img src={src} alt={alt} className={baseStyles} {...props} />;
},
}
);
Avatar.displayName = "Avatar";
@@ -1,8 +1,14 @@
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 }) => {
const sizeStyles = {
const sizeStyles: Record<string, string> = {
small: "flex -space-x-[var(--spacing-scale-008)]",
medium: "flex -space-x-[9px]",
large: "flex -space-x-[var(--spacing-scale-010)]",
@@ -16,7 +22,7 @@ const AvatarContainer = memo(
{children}
</div>
);
},
}
);
AvatarContainer.displayName = "AvatarContainer";
@@ -1,6 +1,20 @@
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,
variant = "default",
@@ -15,7 +29,7 @@ const Button = memo(
ariaLabel,
...props
}) => {
const sizeStyles = {
const sizeStyles: Record<string, string> = {
xsmall:
"px-[var(--spacing-scale-006)] py-[var(--spacing-scale-004)] gap-[var(--spacing-scale-001)]",
small:
@@ -27,7 +41,7 @@ const Button = memo(
"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%]",
small: "font-inter text-[12px] leading-[14px] 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%]",
};
const variantStyles = {
const variantStyles: Record<string, string> = {
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",
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",
};
const hoverOutlineStyles = {
const hoverOutlineStyles: Record<string, string> = {
xsmall: "hover:outline-1",
small: "hover:outline-1",
medium: "hover:outline-1",
@@ -2,12 +2,30 @@
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
* A basic controlled checkbox with visual modes and interaction states.
* This is a minimal first pass; visuals will be refined collaboratively.
*/
const Checkbox = memo(
const Checkbox = memo<CheckboxProps>(
({
checked = false,
mode = "standard", // "standard" | "inverse"
@@ -38,7 +56,7 @@ const Checkbox = memo(
// 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 stateStyles = {
const stateStyles: Record<string, string> = {
default: "",
hover: "",
focus: "",
@@ -73,7 +91,7 @@ const Checkbox = memo(
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)]";
const handleToggle = (e) => {
const handleToggle = (e: React.MouseEvent | React.KeyboardEvent) => {
if (disabled) return;
onChange?.({
checked: !checked,
@@ -87,7 +105,7 @@ const Checkbox = memo(
const checkboxId = id || `checkbox-${generatedId}`;
const accessibilityProps = {
role: "checkbox",
role: "checkbox" as const,
"aria-checked": checked ? "true" : "false",
...(disabled && { "aria-disabled": "true", tabIndex: -1 }),
...(!disabled && { tabIndex: 0 }),
@@ -160,7 +178,7 @@ const Checkbox = memo(
/>
</label>
);
},
}
);
Checkbox.displayName = "Checkbox";
@@ -3,10 +3,15 @@
import React, { memo } from "react";
import { getAssetPath } from "../../lib/assetUtils";
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+)
const getBackgroundImage = (post) => {
const getBackgroundImage = (post: BlogPost): string => {
if (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");
};
const getBannerImageMd = (post) => {
const getBannerImageMd = (post: BlogPost): string => {
// Use banner.horizontal when provided; fallback to horizontal thumbnail
if (post.frontmatter?.banner?.horizontal) {
return `/content/blog/${post.frontmatter.banner.horizontal}`;
@@ -2,11 +2,18 @@
import React, { memo } from "react";
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" }) => {
// Get the corresponding icon based on the same logic as background images
const getIconImage = (slug) => {
const getIconImage = (slug: string): string => {
const icons = [
getAssetPath(ASSETS.ICON_1),
getAssetPath(ASSETS.ICON_2),
@@ -123,7 +130,7 @@ const ContentContainer = memo(
</div>
</div>
);
},
}
);
ContentContainer.displayName = "ContentContainer";
@@ -4,7 +4,31 @@ import React, { memo } from "react";
import Button from "./Button";
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,
subtitle,
@@ -15,10 +39,10 @@ const ContentLockup = memo(
variant = "hero",
linkText,
linkHref,
alignment = "center", // center, left
alignment = "center",
}) => {
// Variant-specific styling
const variantStyles = {
const variantStyles: Record<string, VariantStyle> = {
hero: {
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",
@@ -179,7 +203,7 @@ const ContentLockup = memo(
)}
</div>
);
},
}
);
ContentLockup.displayName = "ContentLockup";
@@ -4,19 +4,26 @@ import React, { memo } from "react";
import Link from "next/link";
import ContentContainer from "./ContentContainer";
import { getAssetPath, ASSETS } from "../../lib/assetUtils";
import type { BlogPost } from "../../lib/content";
/**
* ContentThumbnailTemplate component for displaying blog post previews
* Simplified version to debug infinite loop
*/
const ContentThumbnailTemplate = memo(
({
post,
className = "",
variant = "vertical", // Internal prop for testing/development
}) => {
interface ContentThumbnailTemplateProps {
post: BlogPost;
className?: string;
variant?: "vertical" | "horizontal";
slugOrder?: string[];
}
const ContentThumbnailTemplate = memo<ContentThumbnailTemplateProps>(
({ post, className = "", variant = "vertical", slugOrder }) => {
// 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
if (post.frontmatter?.thumbnail) {
const imageName =
@@ -31,7 +38,7 @@ const ContentThumbnailTemplate = memo(
}
// Fallback to default images if no thumbnail specified
const fallbackImages = {
const fallbackImages: Record<string, string> = {
vertical: getAssetPath(ASSETS.VERTICAL_1),
horizontal: getAssetPath(ASSETS.HORIZONTAL_1),
};
@@ -91,7 +98,7 @@ const ContentThumbnailTemplate = memo(
</div>
</Link>
);
},
}
);
ContentThumbnailTemplate.displayName = "ContentThumbnailTemplate";
@@ -2,7 +2,12 @@
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) => {
const menuClasses = `
bg-black
@@ -28,7 +33,7 @@ const ContextMenu = forwardRef(
{children}
</div>
);
},
}
);
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";
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,
@@ -14,9 +26,9 @@ const ContextMenuItem = forwardRef(
size = "medium",
...props
},
ref,
ref
) => {
const getTextSize = () => {
const getTextSize = (): string => {
switch (size) {
case "small":
return "text-[10px] leading-[14px]";
@@ -52,16 +64,16 @@ const ContextMenuItem = forwardRef(
.replace(/\s+/g, " ");
const handleClick = useCallback(
(e) => {
(e: React.MouseEvent<HTMLDivElement>) => {
if (!disabled && onClick) {
onClick(e);
}
},
[disabled, onClick],
[disabled, onClick]
);
const handleKeyDown = useCallback(
(e) => {
(e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (!disabled && onClick) {
@@ -69,7 +81,7 @@ const ContextMenuItem = forwardRef(
}
}
},
[disabled, onClick],
[disabled, onClick]
);
return (
@@ -119,7 +131,7 @@ const ContextMenuItem = forwardRef(
)}
</div>
);
},
}
);
ContextMenuItem.displayName = "ContextMenuItem";
@@ -2,7 +2,13 @@
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) => {
const sectionClasses = `
${className}
@@ -22,7 +28,7 @@ const ContextMenuSection = forwardRef(
{children}
</div>
);
},
}
);
ContextMenuSection.displayName = "ContextMenuSection";
@@ -1,19 +1,28 @@
"use client";
import React, { Component } from "react";
import React, { Component, type ReactNode } from "react";
class ErrorBoundary extends Component {
constructor(props) {
interface ErrorBoundaryProps {
children: ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
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
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
// Log the error to an error reporting service
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>
{/* Navigation Section */}
<div className="flex flex-col items-start gap-[var(--spacing-measures-spacing-032,32px)] order-1 sm:order-2 sm:items-end">
{/* Links Section */}
<div className="flex flex-col items-start gap-[var(--spacing-measures-spacing-016,16px)] order-1 sm:order-2">
<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"
>
Use cases
</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"
>
Learn
</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"
>
About
Blog
</a>
</div>
</div>
<Separator />
{/* Bottom section */}
<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-medium sm:text-xs sm:leading-4 lg:text-sm lg:leading-5 lg:font-normal">
© All right 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>
{/* Copyright */}
<div className="text-[var(--color-content-default-secondary)] font-inter text-sm leading-5 font-normal tracking-[0%] lg:text-base lg:leading-6">
© {new Date().getFullYear()} Media Economies Design Lab. All rights
reserved.
</div>
</div>
</footer>
@@ -24,19 +24,23 @@ export const avatarImages = [
];
export const logoConfig = [
{ breakpoint: "block sm:hidden", size: "header", showText: false },
{ breakpoint: "hidden sm:block md:hidden", size: "header", showText: true },
{ breakpoint: "block sm:hidden", size: "header" as const, showText: false },
{
breakpoint: "hidden sm:block md:hidden",
size: "header" as const,
showText: true,
},
{
breakpoint: "hidden md:block lg:hidden",
size: "headerMd",
size: "headerMd" as const,
showText: true,
},
{
breakpoint: "hidden lg:block xl:hidden",
size: "headerLg",
size: "headerLg" as const,
showText: true,
},
{ breakpoint: "hidden xl:block", size: "headerXl", showText: true },
{ breakpoint: "hidden xl:block", size: "headerXl" as const, showText: true },
];
const Header = memo(() => {
@@ -55,7 +59,7 @@ const Header = memo(() => {
},
};
const renderNavigationItems = (size) => {
const renderNavigationItems = (size: string) => {
return navigationItems.map((item, index) => (
<MenuBarItem
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 (
<AvatarContainer size={containerSize}>
{avatarImages.map((avatar, index) => (
@@ -84,7 +91,7 @@ const Header = memo(() => {
);
};
const renderLoginButton = (size) => {
const renderLoginButton = (size: string) => {
return (
<MenuBarItem href="#" size={size} ariaLabel="Log in to your account">
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 (
<Button
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} />;
};
@@ -1,7 +1,13 @@
import React, { memo } from "react";
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 }) => {
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)]"
@@ -33,7 +39,7 @@ const HeaderTab = memo(
/>
</div>
);
},
}
);
HeaderTab.displayName = "HeaderTab";
@@ -5,7 +5,15 @@ import ContentLockup from "./ContentLockup";
import HeroDecor from "./HeroDecor";
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 }) => {
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)]">
@@ -46,7 +54,7 @@ const HeroBanner = memo(
</div>
</section>
);
},
}
);
HeroBanner.displayName = "HeroBanner";
@@ -2,7 +2,11 @@
import React, { memo } from "react";
const HeroDecor = memo(({ className = "" }) => {
interface HeroDecorProps {
className?: string;
}
const HeroDecor = memo<HeroDecorProps>(({ className = "" }) => {
return (
<svg
className={`text-[var(--color-surface-default-brand-lighter-accent)] opacity-50 ${className}`}
@@ -42,28 +42,32 @@ const HomeHeader = memo(() => {
const logoConfig = [
{
breakpoint: "block sm:hidden",
size: "homeHeaderXsmall",
size: "homeHeaderXsmall" as const,
showText: false,
},
{
breakpoint: "hidden sm:block md:hidden",
size: "homeHeaderSm",
size: "homeHeaderSm" as const,
showText: true,
},
{
breakpoint: "hidden md:block lg:hidden",
size: "homeHeaderMd",
size: "homeHeaderMd" as const,
showText: true,
},
{
breakpoint: "hidden lg:block xl:hidden",
size: "homeHeaderLg",
size: "homeHeaderLg" as const,
showText: true,
},
{
breakpoint: "hidden xl:block",
size: "homeHeaderXl" as const,
showText: true,
},
{ breakpoint: "hidden xl:block", size: "homeHeaderXl", showText: true },
];
const renderNavigationItems = (size) => {
const renderNavigationItems = (size: string) => {
return navigationItems.map((item, index) => (
<MenuBarItem
key={index}
@@ -79,10 +83,10 @@ const HomeHeader = memo(() => {
? size === "home" || size === "homeMd"
? "homeMd"
: size === "large"
? "large"
: size === "homeXlarge"
? "homeXlarge"
: "xsmallUseCases"
? "large"
: size === "homeXlarge"
? "homeXlarge"
: "xsmallUseCases"
: size
}
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 (
<AvatarContainer size={containerSize}>
{avatarImages.map((avatar, index) => (
@@ -118,7 +125,7 @@ const HomeHeader = memo(() => {
);
};
const renderLoginButton = (size) => {
const renderLoginButton = (size: string) => {
return (
<MenuBarItem
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 (
<Button
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} />;
};
@@ -2,11 +2,19 @@
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
* Generates colored backgrounds with text overlays
*/
const ImagePlaceholder = memo(
const ImagePlaceholder = memo<ImagePlaceholderProps>(
({
width = 260,
height = 390,
@@ -14,7 +22,7 @@ const ImagePlaceholder = memo(
color = "blue",
className = "",
}) => {
const colors = {
const colors: Record<string, string> = {
blue: "bg-blue-500",
green: "bg-green-500",
purple: "bg-purple-500",
@@ -33,7 +41,7 @@ const ImagePlaceholder = memo(
{text}
</div>
);
},
}
);
ImagePlaceholder.displayName = "ImagePlaceholder";
@@ -2,7 +2,26 @@
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",
@@ -22,14 +41,22 @@ const Input = forwardRef(
className = "",
...props
},
ref,
ref
) => {
// Generate unique ID for accessibility if not provided
const generatedId = useId();
const inputId = id || `input-${generatedId}`;
// Size variants
const sizeStyles = {
const sizeStyles: Record<
string,
{
input: string;
label: string;
container: string;
radius: string;
}
> = {
small: {
input:
labelVariant === "horizontal"
@@ -54,7 +81,10 @@ const Input = forwardRef(
};
// State styles
const getStateStyles = () => {
const getStateStyles = (): {
input: string;
label: string;
} => {
if (disabled) {
return {
input:
@@ -122,30 +152,30 @@ const Input = forwardRef(
`.trim();
const handleChange = useCallback(
(e) => {
(e: React.ChangeEvent<HTMLInputElement>) => {
if (!disabled && onChange) {
onChange(e);
}
},
[disabled, onChange],
[disabled, onChange]
);
const handleFocus = useCallback(
(e) => {
(e: React.FocusEvent<HTMLInputElement>) => {
if (!disabled && onFocus) {
onFocus(e);
}
},
[disabled, onFocus],
[disabled, onFocus]
);
const handleBlur = useCallback(
(e) => {
(e: React.FocusEvent<HTMLInputElement>) => {
if (!disabled && onBlur) {
onBlur(e);
}
},
[disabled, onBlur],
[disabled, onBlur]
);
return (
@@ -177,7 +207,7 @@ const Input = forwardRef(
</div>
</div>
);
},
}
);
Input.displayName = "Input";
@@ -2,9 +2,34 @@ import React, { memo } from "react";
import Link from "next/link";
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
const sizes = {
const sizes: Record<string, SizeConfig> = {
default: {
containerHeight: "h-[41px]",
gap: "gap-[8.28px]",
@@ -3,11 +3,22 @@
import React, { useState, useEffect, memo } from "react";
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);
// Default logos if none provided - ordered for mobile (3 rows × 2 columns)
const defaultLogos = [
const defaultLogos: Logo[] = [
{
src: "/assets/Section/Logo_FoodNotBombs.png",
alt: "Food Not Bombs",
@@ -1,8 +1,14 @@
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 }) => {
const sizeStyles = {
const sizeStyles: Record<string, string> = {
xsmall:
"px-[var(--spacing-scale-004)] py-[var(--spacing-scale-004)] gap-[var(--spacing-scale-001)] rounded-[4px]",
default:
@@ -25,7 +31,7 @@ const MenuBar = memo(
{children}
</nav>
);
},
}
);
MenuBar.displayName = "MenuBar";
@@ -1,6 +1,28 @@
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 = "#",
children,
@@ -12,19 +34,19 @@ const MenuBarItem = memo(
ariaLabel,
...props
}) => {
const variantStyles = {
const variantStyles: Record<string, string> = {
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",
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:
"active:outline-1 active:outline-[var(--color-content-default-primary)] focus:outline-1 focus:outline-[var(--color-content-default-primary)]",
xsmallUseCases:
"active:outline-1 active:outline-[var(--color-content-default-primary)] focus:outline-1 focus:outline-[var(--color-content-default-primary)]",
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:
"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:
@@ -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)]",
};
const homeOutlineStyles = {
const homeOutlineStyles: Record<string, string> = {
xsmall:
"active:outline-1 active:outline-[var(--color-content-default-primary)] focus:outline-1 focus:outline-[var(--color-content-default-primary)]",
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)]",
};
const activeStateStyles = {
const activeStateStyles: Record<string, string> = {
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)]",
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)]",
};
const sizeStyles = {
const sizeStyles: Record<string, string> = {
default:
"px-[var(--spacing-measures-spacing-016)] py-[var(--spacing-measures-spacing-016)] gap-[var(--spacing-scale-004)]",
xsmall:
@@ -111,7 +133,7 @@ const MenuBarItem = memo(
const xlargeTextStyle =
"font-inter text-[24px] leading-[28px] font-normal tracking-[0%]";
const textStyles = {
const textStyles: Record<string, string> = {
default: smallTextStyle,
xsmall: smallTextStyle,
xsmallUseCases: smallTextStyle,
@@ -140,7 +162,7 @@ const MenuBarItem = memo(
const accessibilityProps = {
...(ariaLabel && { "aria-label": ariaLabel }),
...(disabled && { "aria-disabled": "true" }),
role: "menuitem",
role: "menuitem" as const,
tabIndex: disabled ? -1 : 0,
...props,
};
@@ -158,7 +180,7 @@ const MenuBarItem = memo(
{children}
</a>
);
},
}
);
MenuBarItem.displayName = "MenuBarItem";
@@ -3,7 +3,20 @@
import React, { memo } from "react";
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,
className = "",
@@ -116,7 +129,7 @@ const MiniCard = memo(
{cardContent}
</div>
);
},
}
);
MiniCard.displayName = "MiniCard";
@@ -1,6 +1,16 @@
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 = "#",
children,
@@ -11,13 +21,13 @@ const NavigationItem = memo(
...props
}) => {
// Variant styles
const variantStyles = {
const variantStyles: Record<string, string> = {
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",
};
// Size styles
const sizeStyles = {
const sizeStyles: Record<string, string> = {
default:
"px-[var(--spacing-scale-016)] py-[var(--spacing-scale-016)] gap-[var(--spacing-scale-004)]",
xsmall:
@@ -25,7 +35,7 @@ const NavigationItem = memo(
};
// Text styles based on size
const textStyles = {
const textStyles: Record<string, string> = {
default:
"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}
</a>
);
},
}
);
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 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
const schemaData = useMemo(
() => ({
@@ -20,7 +32,7 @@ const NumberedCards = memo(({ title, subtitle, cards }) => {
text: card.text,
})),
}),
[title, subtitle, cards],
[title, subtitle, cards]
);
return (
@@ -4,7 +4,26 @@ import React, { useState, memo } from "react";
import Image from "next/image";
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",
className = "",
@@ -13,14 +32,26 @@ const QuoteBlock = memo(
source = "The Tyranny of Structurelessness",
avatarSrc = "/assets/Quote_Avatar.svg",
id,
fallbackAvatarSrc = "/assets/Quote_Avatar.svg", // Fallback avatar
onError, // Error callback
fallbackAvatarSrc = "/assets/Quote_Avatar.svg",
onError,
}) => {
const [imageError, setImageError] = useState(false);
const [imageLoading, setImageLoading] = useState(true);
// 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: {
container:
"py-[var(--spacing-scale-032)] px-[var(--spacing-scale-016)]",
@@ -77,10 +108,10 @@ const QuoteBlock = memo(
const authorId = `${baseId}-author`;
// Error handling functions
const handleImageError = (error) => {
const handleImageError = (error: unknown) => {
console.warn(
`QuoteBlock: Failed to load avatar image for ${author}:`,
error,
error
);
setImageError(true);
setImageLoading(false);
@@ -244,7 +275,7 @@ const QuoteBlock = memo(
</div>
</section>
);
},
}
);
QuoteBlock.displayName = "QuoteBlock";
@@ -2,7 +2,11 @@
import React, { memo } from "react";
const QuoteDecor = memo(({ className = "" }) => {
interface QuoteDecorProps {
className?: string;
}
const QuoteDecor = memo<QuoteDecorProps>(({ className = "" }) => {
return (
<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}`}
@@ -2,6 +2,20 @@
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 = ({
checked = false,
mode = "standard",
@@ -15,7 +29,7 @@ const RadioButton = ({
ariaLabel,
className = "",
...props
}) => {
}: RadioButtonProps) => {
const isInverse = mode === "inverse";
// Base tokens (using same design tokens as Checkbox)
@@ -32,7 +46,7 @@ const RadioButton = ({
// 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 stateStyles = {
const stateStyles: Record<string, string> = {
default: "",
hover: "",
focus: "",
@@ -75,12 +89,12 @@ const RadioButton = ({
const radioId = id || `radio-${generatedId}`;
const handleToggle = useCallback(
(e) => {
(e: React.MouseEvent | React.KeyboardEvent) => {
if (!disabled && onChange && !checked) {
onChange({ checked: true, value });
}
},
[disabled, onChange, checked, value],
[disabled, onChange, checked, value]
);
return (
@@ -3,6 +3,24 @@
import React, { memo, useCallback, useId } from "react";
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 = ({
name,
value,
@@ -13,18 +31,18 @@ const RadioGroup = ({
options = [],
className = "",
...props
}) => {
}: RadioGroupProps) => {
// Generate unique ID for accessibility if not provided
const generatedId = useId();
const groupId = name || `radio-group-${generatedId}`;
const handleChange = useCallback(
(optionValue) => {
(optionValue: string) => {
if (!disabled && onChange) {
onChange({ value: optionValue });
}
},
[disabled, onChange],
[disabled, onChange]
);
return (
@@ -34,7 +52,7 @@ const RadioGroup = ({
aria-label={props["aria-label"]}
{...props}
>
{options.map((option, index) => {
{options.map((option) => {
const isSelected = value === option.value;
return (
@@ -2,13 +2,20 @@
import React, { useState, useEffect, memo, useMemo, useCallback } from "react";
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 = [] }) => {
// Memoize filtered posts to prevent unnecessary re-computations
const filteredPosts = useMemo(
() => relatedPosts.filter((post) => post.slug !== currentPostSlug),
[relatedPosts, currentPostSlug],
[relatedPosts, currentPostSlug]
);
const [currentIndex, setCurrentIndex] = useState(0);
@@ -16,25 +23,28 @@ const RelatedArticles = memo(
const [isMobile, setIsMobile] = useState(true);
// Memoize the mouse down handler to prevent unnecessary re-renders
const handleMouseDown = useCallback((e) => {
const slider = e.currentTarget;
const startX = e.pageX - slider.offsetLeft;
const scrollLeft = slider.scrollLeft;
const handleMouseDown = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
const slider = e.currentTarget;
const startX = e.pageX - slider.offsetLeft;
const scrollLeft = slider.scrollLeft;
const handleMouseMove = (e) => {
const x = e.pageX - slider.offsetLeft;
const walk = (x - startX) * 2;
slider.scrollLeft = scrollLeft - walk;
};
const handleMouseMove = (e: MouseEvent) => {
const x = e.pageX - slider.offsetLeft;
const walk = (x - startX) * 2;
slider.scrollLeft = scrollLeft - walk;
};
const handleMouseUp = () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
const handleMouseUp = () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
}, []);
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
},
[]
);
// Memoize transform style to prevent unnecessary recalculations
const transformStyle = useMemo(
@@ -44,20 +54,20 @@ const RelatedArticles = memo(
: "none",
scrollBehavior: !isMobile ? "smooth" : "auto",
}),
[isMobile, currentIndex],
[isMobile, currentIndex]
);
// Memoize progress bar style calculation
const getProgressStyle = useCallback(
(index) => ({
(index: number): React.CSSProperties => ({
width:
index === currentIndex
? `${progress}%`
: index < currentIndex
? "100%"
: "0%",
? "100%"
: "0%",
}),
[currentIndex, progress],
[currentIndex, progress]
);
// Check if we're on mobile (below lg breakpoint)
@@ -121,7 +131,7 @@ const RelatedArticles = memo(
style={transformStyle}
onMouseDown={!isMobile ? handleMouseDown : undefined}
>
{filteredPosts.map((relatedPost, index) => (
{filteredPosts.map((relatedPost) => (
<div
key={relatedPost.slug}
className="flex flex-col items-center flex-shrink-0"
@@ -155,7 +165,7 @@ const RelatedArticles = memo(
</div>
</section>
);
},
}
);
RelatedArticles.displayName = "RelatedArticles";
@@ -2,7 +2,29 @@
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,
description,
@@ -31,7 +53,7 @@ const RuleCard = memo(
if (onClick) onClick();
};
const handleKeyDown = (event) => {
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
handleClick();
@@ -71,7 +93,7 @@ const RuleCard = memo(
)}
</div>
);
},
}
);
RuleCard.displayName = "RuleCard";
@@ -6,8 +6,25 @@ import RuleCard from "./RuleCard";
import Button from "./Button";
import { getAssetPath } from "../../lib/assetUtils";
const RuleStack = memo(({ className = "" }) => {
const handleTemplateClick = (templateName) => {
interface RuleStackProps {
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
if (typeof window !== "undefined") {
if (window.gtag) {
@@ -2,7 +2,14 @@
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" }) => {
return (
<div
@@ -52,7 +59,7 @@ const SectionHeader = memo(
</div>
</div>
);
},
}
);
SectionHeader.displayName = "SectionHeader";
@@ -2,8 +2,12 @@
import React, { memo } from "react";
const SectionNumber = memo(({ number }) => {
const getImageSrc = (num) => {
interface SectionNumberProps {
number: number;
}
const SectionNumber = memo<SectionNumberProps>(({ number }) => {
const getImageSrc = (num: number): string => {
switch (num) {
case 1:
return "/assets/SectionNumber_1.png";
@@ -12,7 +12,28 @@ import React, {
import SelectDropdown from "./SelectDropdown";
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,
@@ -27,26 +48,27 @@ const Select = forwardRef(
children,
value,
onChange,
options,
...props
},
ref,
ref
) => {
const generatedId = useId();
const selectId = id || `select-${generatedId}`;
const labelId = `${selectId}-label`;
const [isOpen, setIsOpen] = useState(false);
const [selectedValue, setSelectedValue] = useState(value || "");
const selectRef = useRef(null);
const menuRef = useRef(null);
const selectRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
// Handle click outside to close menu
useEffect(() => {
const handleClickOutside = (event) => {
const handleClickOutside = (event: MouseEvent) => {
if (
menuRef.current &&
!menuRef.current.contains(event.target) &&
!menuRef.current.contains(event.target as Node) &&
selectRef.current &&
!selectRef.current.contains(event.target)
!selectRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
@@ -61,7 +83,7 @@ const Select = forwardRef(
// Handle option selection
const handleOptionSelect = useCallback(
(optionValue, optionText) => {
(optionValue: string, optionText: string) => {
setSelectedValue(optionValue);
setIsOpen(false);
if (onChange) {
@@ -72,7 +94,7 @@ const Select = forwardRef(
selectRef.current.focus();
}
},
[onChange],
[onChange]
);
// Handle select button click
@@ -84,7 +106,7 @@ const Select = forwardRef(
// Handle keyboard navigation
const handleKeyDown = useCallback(
(e) => {
(e: React.KeyboardEvent<HTMLButtonElement>) => {
if (disabled) return;
if (e.key === "Enter" || e.key === " ") {
@@ -94,10 +116,10 @@ const Select = forwardRef(
setIsOpen(false);
}
},
[disabled, isOpen],
[disabled, isOpen]
);
const getSizeStyles = () => {
const getSizeStyles = (): string => {
const baseStyles = "w-full";
switch (size) {
@@ -114,7 +136,7 @@ const Select = forwardRef(
}
};
const getLabelSizeStyles = () => {
const getLabelSizeStyles = (): string => {
switch (size) {
case "small":
return "text-[12px] leading-[14px]";
@@ -127,7 +149,10 @@ const Select = forwardRef(
}
};
const getStateStyles = () => {
const getStateStyles = (): {
select: string;
label: string;
} => {
if (disabled) {
return {
select:
@@ -164,7 +189,7 @@ const Select = forwardRef(
}
};
const getBorderRadius = () => {
const getBorderRadius = (): string => {
switch (size) {
case "small":
return "rounded-[var(--measures-radius-small)]";
@@ -222,22 +247,27 @@ const Select = forwardRef(
: "flex flex-col";
// Get display text for selected value
const getDisplayText = () => {
const getDisplayText = (): string => {
if (!selectedValue) return placeholder;
// Handle options prop
if (props.options && Array.isArray(props.options)) {
const selectedOption = props.options.find(
(option) => option.value === selectedValue,
if (options && Array.isArray(options)) {
const selectedOption = options.find(
(option) => option.value === selectedValue
);
return selectedOption ? selectedOption.label : placeholder;
}
// Handle children (option elements)
const selectedOption = React.Children.toArray(children).find(
(child) => child.props.value === selectedValue,
);
return selectedOption ? selectedOption.props.children : placeholder;
(child) =>
React.isValidElement(child) && child.props.value === selectedValue
) as
| React.ReactElement<{ value: string; children: React.ReactNode }>
| undefined;
return selectedOption
? String(selectedOption.props.children)
: placeholder;
};
return (
@@ -293,8 +323,8 @@ const Select = forwardRef(
className="absolute top-full left-0 right-0 z-50 mt-1"
>
<SelectDropdown>
{props.options && Array.isArray(props.options)
? props.options.map((option) => (
{options && Array.isArray(options)
? options.map((option) => (
<SelectOption
key={option.value}
selected={option.value === selectedValue}
@@ -307,20 +337,27 @@ const Select = forwardRef(
</SelectOption>
))
: 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 (
<SelectOption
key={child.props.value}
selected={child.props.value === selectedValue}
key={optionProps.value}
selected={optionProps.value === selectedValue}
size={size}
onClick={() =>
handleOptionSelect(
child.props.value,
child.props.children,
optionProps.value,
String(optionProps.children)
)
}
>
{child.props.children}
{optionProps.children}
</SelectOption>
);
}
@@ -332,7 +369,7 @@ const Select = forwardRef(
</div>
</div>
);
},
}
);
Select.displayName = "Select";
@@ -2,7 +2,12 @@
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) => {
const menuClasses = `
bg-black
@@ -29,7 +34,7 @@ const SelectDropdown = forwardRef(
{children}
</div>
);
},
}
);
SelectDropdown.displayName = "SelectDropdown";
@@ -2,7 +2,18 @@
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,
@@ -13,9 +24,9 @@ const SelectOption = forwardRef(
size = "medium",
...props
},
ref,
ref
) => {
const getTextSize = () => {
const getTextSize = (): string => {
switch (size) {
case "small":
return "text-[10px] leading-[14px]";
@@ -51,16 +62,16 @@ const SelectOption = forwardRef(
.replace(/\s+/g, " ");
const handleClick = useCallback(
(e) => {
(e: React.MouseEvent<HTMLDivElement>) => {
if (!disabled && onClick) {
onClick(e);
}
},
[disabled, onClick],
[disabled, onClick]
);
const handleKeyDown = useCallback(
(e) => {
(e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (!disabled && onClick) {
@@ -68,7 +79,7 @@ const SelectOption = forwardRef(
}
}
},
[disabled, onClick],
[disabled, onClick]
);
return (
@@ -103,7 +114,7 @@ const SelectOption = forwardRef(
</div>
</div>
);
},
}
);
SelectOption.displayName = "SelectOption";
@@ -1,7 +1,22 @@
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(
forwardRef((props, ref) => {
forwardRef<HTMLButtonElement, SwitchProps>((props, ref) => {
const {
checked = false,
onChange,
@@ -16,16 +31,16 @@ const Switch = memo(
const switchId = useId();
const handleClick = useCallback(
(e) => {
(e: React.MouseEvent<HTMLButtonElement>) => {
if (onChange) {
onChange(e);
}
},
[onChange],
[onChange]
);
const handleKeyDown = useCallback(
(e) => {
(e: React.KeyboardEvent<HTMLButtonElement>) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (onChange) {
@@ -33,25 +48,25 @@ const Switch = memo(
}
}
},
[onChange],
[onChange]
);
const handleFocus = useCallback(
(e) => {
(e: React.FocusEvent<HTMLButtonElement>) => {
if (onFocus) {
onFocus(e);
}
},
[onFocus],
[onFocus]
);
const handleBlur = useCallback(
(e) => {
(e: React.FocusEvent<HTMLButtonElement>) => {
if (onBlur) {
onBlur(e);
}
},
[onBlur],
[onBlur]
);
// Switch track styles based on checked state
@@ -155,7 +170,7 @@ const Switch = memo(
{label && <span className={labelClasses}>{label}</span>}
</div>
);
}),
})
);
Switch.displayName = "Switch";
@@ -2,7 +2,27 @@
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",
@@ -22,14 +42,22 @@ const TextArea = forwardRef(
rows,
...props
},
ref,
ref
) => {
// Generate unique ID for accessibility if not provided
const generatedId = useId();
const textareaId = id || `textarea-${generatedId}`;
// Size variants with specific heights and radius for TextArea
const sizeStyles = {
const sizeStyles: Record<
string,
{
textarea: string;
label: string;
container: string;
radius: string;
}
> = {
small: {
textarea:
labelVariant === "horizontal"
@@ -57,7 +85,10 @@ const TextArea = forwardRef(
};
// State styles
const getStateStyles = () => {
const getStateStyles = (): {
textarea: string;
label: string;
} => {
if (disabled) {
return {
textarea:
@@ -125,30 +156,30 @@ const TextArea = forwardRef(
`.trim();
const handleChange = useCallback(
(e) => {
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
if (!disabled && onChange) {
onChange(e);
}
},
[disabled, onChange],
[disabled, onChange]
);
const handleFocus = useCallback(
(e) => {
(e: React.FocusEvent<HTMLTextAreaElement>) => {
if (!disabled && onFocus) {
onFocus(e);
}
},
[disabled, onFocus],
[disabled, onFocus]
);
const handleBlur = useCallback(
(e) => {
(e: React.FocusEvent<HTMLTextAreaElement>) => {
if (!disabled && onBlur) {
onBlur(e);
}
},
[disabled, onBlur],
[disabled, onBlur]
);
return (
@@ -182,7 +213,7 @@ const TextArea = forwardRef(
</div>
</div>
);
},
}
);
TextArea.displayName = "TextArea";
@@ -1,6 +1,26 @@
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,
@@ -17,7 +37,7 @@ const Toggle = forwardRef(
className = "",
...props
},
ref,
ref
) => {
const toggleId = useId();
const labelId = useId();
@@ -29,7 +49,10 @@ const Toggle = forwardRef(
};
// State styles
const getStateStyles = () => {
const getStateStyles = (): {
toggle: string;
label: string;
} => {
if (disabled) {
return {
toggle:
@@ -115,34 +138,38 @@ const Toggle = forwardRef(
.replace(/\s+/g, " ");
const handleChange = useCallback(
(e) => {
(
e:
| React.MouseEvent<HTMLButtonElement>
| React.KeyboardEvent<HTMLButtonElement>
) => {
if (!disabled && onChange) {
onChange(e);
}
},
[disabled, onChange],
[disabled, onChange]
);
const handleFocus = useCallback(
(e) => {
(e: React.FocusEvent<HTMLButtonElement>) => {
if (!disabled && onFocus) {
onFocus(e);
}
},
[disabled, onFocus],
[disabled, onFocus]
);
const handleBlur = useCallback(
(e) => {
(e: React.FocusEvent<HTMLButtonElement>) => {
if (!disabled && onBlur) {
onBlur(e);
}
},
[disabled, onBlur],
[disabled, onBlur]
);
const handleKeyDown = useCallback(
(e) => {
(e: React.KeyboardEvent<HTMLButtonElement>) => {
if (!disabled && (e.key === "Enter" || e.key === " ")) {
e.preventDefault();
if (onChange) {
@@ -150,7 +177,7 @@ const Toggle = forwardRef(
}
}
},
[disabled, onChange],
[disabled, onChange]
);
return (
@@ -186,7 +213,7 @@ const Toggle = forwardRef(
</div>
</div>
);
},
}
);
Toggle.displayName = "Toggle";
@@ -1,7 +1,24 @@
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(
forwardRef((props, ref) => {
forwardRef<HTMLButtonElement, ToggleGroupProps>((props, ref) => {
const {
children,
className = "",
@@ -18,7 +35,7 @@ const ToggleGroup = memo(
const groupId = useId();
// Position-based styling for border radius
const getPositionStyles = useCallback((pos) => {
const getPositionStyles = useCallback((pos: string): string => {
switch (pos) {
case "left":
return "rounded-l-[var(--measures-radius-medium)] rounded-r-none";
@@ -32,7 +49,7 @@ const ToggleGroup = memo(
}, []);
// State-based styling
const getStateStyles = useCallback((state) => {
const getStateStyles = useCallback((state: string): string => {
switch (state) {
case "hover":
return "bg-[var(--color-magenta-magenta100)] text-[var(--color-content-default-primary)]";
@@ -50,34 +67,34 @@ const ToggleGroup = memo(
const stateStyles = getStateStyles(state);
const handleClick = useCallback(
(e) => {
(e: React.MouseEvent<HTMLButtonElement>) => {
if (onChange) {
onChange(e);
}
},
[onChange],
[onChange]
);
const handleFocus = useCallback(
(e) => {
(e: React.FocusEvent<HTMLButtonElement>) => {
if (onFocus) {
onFocus(e);
}
},
[onFocus],
[onFocus]
);
const handleBlur = useCallback(
(e) => {
(e: React.FocusEvent<HTMLButtonElement>) => {
if (onBlur) {
onBlur(e);
}
},
[onBlur],
[onBlur]
);
const handleKeyDown = useCallback(
(e) => {
(e: React.KeyboardEvent<HTMLButtonElement>) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (onChange) {
@@ -85,7 +102,7 @@ const ToggleGroup = memo(
}
}
},
[onChange],
[onChange]
);
const toggleClasses = `
@@ -129,7 +146,7 @@ const ToggleGroup = memo(
{showText ? children : children || "☰"}
</button>
);
}),
})
);
ToggleGroup.displayName = "ToggleGroup";
@@ -2,8 +2,36 @@
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 [vitals, setVitals] = useState({
const [vitals, setVitals] = useState<Vitals>({
lcp: { value: 0, rating: "unknown" },
fid: { value: 0, rating: "unknown" },
cls: { value: 0, rating: "unknown" },
@@ -11,7 +39,7 @@ const WebVitalsDashboard = memo(() => {
ttfb: { value: 0, rating: "unknown" },
});
const [metrics, setMetrics] = useState({});
const [metrics, setMetrics] = useState<Metrics>({});
const [loading, setLoading] = useState(true);
useEffect(() => {
@@ -19,7 +47,7 @@ const WebVitalsDashboard = memo(() => {
const fetchVitals = async () => {
try {
const response = await fetch("/api/web-vitals");
const data = await response.json();
const data = (await response.json()) as { metrics?: Metrics };
setMetrics(data.metrics || {});
} catch (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) {
case "good":
return "text-green-600 bg-green-50";
@@ -106,7 +134,7 @@ const WebVitalsDashboard = memo(() => {
}
};
const getRatingIcon = (rating) => {
const getRatingIcon = (rating: string): string => {
switch (rating) {
case "good":
return "✅";
@@ -119,7 +147,7 @@ const WebVitalsDashboard = memo(() => {
}
};
const formatValue = (metric, value) => {
const formatValue = (metric: string, value: number): string => {
if (metric === "cls") {
return value.toFixed(3);
}
+4 -2
View File
@@ -1,4 +1,6 @@
import { Inter, Bricolage_Grotesque, Space_Grotesk } from "next/font/google";
import type { Metadata } from "next";
import type { ReactNode } from "react";
import "./globals.css";
import Header from "./components/Header";
import HomeHeader from "./components/HomeHeader";
@@ -32,7 +34,7 @@ const spaceGrotesk = Space_Grotesk({
fallback: ["system-ui", "arial"],
});
export const metadata = {
export const metadata: Metadata = {
title: "CommunityRule - Build operating manuals for successful communities",
description:
"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 (
<html lang="en" className="font-sans">
<head>
View File
+4 -4
View File
@@ -6,10 +6,10 @@
/**
* Get the correct asset path based on environment
* @param {string} assetPath - The asset path (e.g., "assets/Logo.svg")
* @returns {string} - The correct path for the current environment
* @param assetPath - The asset path (e.g., "assets/Logo.svg")
* @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
const isStorybook =
typeof window !== "undefined" &&
@@ -55,4 +55,4 @@ export const ASSETS = {
// Content page decorative shapes
CONTENT_SHAPE_1: "assets/Content_Shape_1.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
const blogPostCache = new Map();
const blogListCache = new Map();
const tagCache = new Map();
const authorCache = new Map();
const blogPostCache = new Map<string, CacheEntry<unknown>>();
const blogListCache = new Map<string, CacheEntry<unknown[]>>();
const tagCache = new Map<string, CacheEntry<string[]>>();
const authorCache = new Map<string, CacheEntry<string[]>>();
// Cache configuration
const isDevelopment =
@@ -17,13 +17,16 @@ const MAX_CACHE_SIZE = 100; // Maximum number of cached items
/**
* Cache entry with timestamp
*/
class CacheEntry {
constructor(data) {
class CacheEntry<T> {
data: T;
timestamp: number;
constructor(data: T) {
this.data = data;
this.timestamp = Date.now();
}
isExpired() {
isExpired(): boolean {
// In development, always consider cache expired (no caching)
if (isDevelopment) return true;
return Date.now() - this.timestamp > CACHE_TTL;
@@ -32,11 +35,11 @@ class CacheEntry {
/**
* Get cached blog post data
* @param {string} key - Cache key
* @returns {Object|null} Cached data or null if not found/expired
* @param key - Cache key
* @returns Cached data or null if not found/expired
*/
function getCached(key) {
const entry = blogPostCache.get(key);
function getCached<T>(key: string): T | null {
const entry = blogPostCache.get(key) as CacheEntry<T> | undefined;
if (!entry || entry.isExpired()) {
blogPostCache.delete(key);
return null;
@@ -46,10 +49,10 @@ function getCached(key) {
/**
* Set cached blog post data
* @param {string} key - Cache key
* @param {Object} data - Data to cache
* @param key - Cache key
* @param data - Data to cache
*/
function setCached(key, data) {
function setCached<T>(key: string, data: T): void {
// Implement LRU eviction if cache is full
if (blogPostCache.size >= MAX_CACHE_SIZE) {
const oldestKey = blogPostCache.keys().next().value;
@@ -62,7 +65,7 @@ function setCached(key, data) {
/**
* Clear expired cache entries
*/
function clearExpiredCache() {
function clearExpiredCache(): void {
for (const [key, entry] of blogPostCache.entries()) {
if (entry.isExpired()) {
blogPostCache.delete(key);
@@ -73,7 +76,7 @@ function clearExpiredCache() {
/**
* Clear all caches
*/
export function clearAllCaches() {
export function clearAllCaches(): void {
blogPostCache.clear();
blogListCache.clear();
tagCache.clear();
@@ -82,50 +85,50 @@ export function clearAllCaches() {
/**
* Get cached blog post by slug
* @param {string} slug - Blog post slug
* @returns {Object|null} Cached blog post or null
* @param slug - Blog post slug
* @returns Cached blog post or null
*/
export function getCachedBlogPost(slug) {
return getCached(`post:${slug}`);
export function getCachedBlogPost<T>(slug: string): T | null {
return getCached<T>(`post:${slug}`);
}
/**
* Cache blog post data
* @param {string} slug - Blog post slug
* @param {Object} postData - Blog post data
* @param slug - Blog post slug
* @param postData - Blog post data
*/
export function cacheBlogPost(slug, postData) {
export function cacheBlogPost<T>(slug: string, postData: T): void {
setCached(`post:${slug}`, postData);
}
/**
* Get cached blog post list
* @param {string} key - Cache key for list (e.g., 'all', 'recent', 'tag:governance')
* @returns {Array|null} Cached list or null
* @param key - Cache key for list (e.g., 'all', 'recent', 'tag:governance')
* @returns Cached list or null
*/
export function getCachedBlogList(key) {
export function getCachedBlogList<T>(key: string): T[] | null {
const entry = blogListCache.get(key);
if (!entry || entry.isExpired()) {
blogListCache.delete(key);
return null;
}
return entry.data;
return entry.data as T[];
}
/**
* Cache blog post list
* @param {string} key - Cache key
* @param {Array} listData - List data to cache
* @param key - Cache key
* @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));
}
/**
* 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");
if (!entry || entry.isExpired()) {
tagCache.delete("all");
@@ -136,17 +139,17 @@ export function getCachedTags() {
/**
* 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));
}
/**
* 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");
if (!entry || entry.isExpired()) {
authorCache.delete("all");
@@ -157,17 +160,17 @@ export function getCachedAuthors() {
/**
* 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));
}
/**
* 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}`);
// Also invalidate list caches since they might contain this post
blogListCache.clear();
@@ -176,15 +179,25 @@ export function invalidateBlogPostCache(slug) {
/**
* Invalidate all caches
*/
export function invalidateAllCaches() {
export function invalidateAllCaches(): void {
clearAllCaches();
}
export interface CacheStats {
blogPostCacheSize: number;
blogListCacheSize: number;
tagCacheSize: number;
authorCacheSize: number;
totalCacheSize: number;
maxCacheSize: number;
cacheTTL: number;
}
/**
* Get cache statistics
* @returns {Object} Cache statistics
* @returns Cache statistics
*/
export function getCacheStats() {
export function getCacheStats(): CacheStats {
clearExpiredCache();
return {
@@ -193,7 +206,7 @@ export function getCacheStats() {
tagCacheSize: tagCache.size,
authorCacheSize: authorCache.size,
totalCacheSize:
blogPostCache.size + blogListCache.size + tagCache.size + authorCacheSize,
blogPostCache.size + blogListCache.size + tagCache.size + authorCache.size,
maxCacheSize: MAX_CACHE_SIZE,
cacheTTL: CACHE_TTL,
};
@@ -201,10 +214,13 @@ export function getCacheStats() {
/**
* Warm up cache with frequently accessed data
* @param {Function} getAllPosts - Function to get all blog posts
* @param {Function} getAllTags - Function to get all tags
* @param getAllPosts - Function to get all blog posts
* @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 {
// Cache all blog posts
const allPosts = getAllPosts();
@@ -220,7 +236,8 @@ export async function warmCache(getAllPosts, getAllTags) {
// Cache individual posts (first 10)
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");
@@ -231,9 +248,9 @@ export async function warmCache(getAllPosts, getAllTags) {
/**
* 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 {
clearExpiredCache();
return blogPostCache.size < MAX_CACHE_SIZE;
+83 -55
View File
@@ -1,18 +1,31 @@
import fs from "fs";
import path from "path";
import matter from "gray-matter";
import { validateBlogPost, sanitizeBlogPost } from "./validation.js";
import {
validateBlogPost,
sanitizeBlogPost,
type BlogPostFrontmatter,
} from "./validation";
/**
* 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
* @param {string} text - Text to convert to slug
* @returns {string} URL-friendly slug
* @param text - Text to convert to slug
* @returns URL-friendly slug
*/
function generateSlug(text) {
function generateSlug(text: string): string {
return text
.toLowerCase()
.replace(/[^\w\s-]/g, "") // Remove special characters
@@ -23,9 +36,9 @@ function generateSlug(text) {
/**
* 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 "";
return (
@@ -54,13 +67,13 @@ export function markdownToHtml(markdown) {
);
}
export function getBlogPostFiles() {
export function getBlogPostFiles(): string[] {
const contentDirectory = path.join(process.cwd(), "content/blog");
try {
const files = fs.readdirSync(contentDirectory);
return files.filter(
(file) => file.endsWith(".md") || file.endsWith(".mdx"),
(file) => file.endsWith(".md") || file.endsWith(".mdx")
);
} catch (error) {
console.error("Error reading blog content directory:", error);
@@ -70,10 +83,10 @@ export function getBlogPostFiles() {
/**
* Parse a single blog post file
* @param {string} filePath - Path to the markdown file
* @returns {Object|null} Parsed blog post data or null if invalid
* @param filePath - Path to the markdown file
* @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);
try {
@@ -84,7 +97,7 @@ export function parseBlogPost(filePath) {
if (!validationResult.isValid) {
console.error(
`Validation errors for ${filePath}:`,
validationResult.errors,
validationResult.errors
);
return null;
}
@@ -108,51 +121,53 @@ export function parseBlogPost(filePath) {
/**
* 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 allPosts = fileNames
.map((fileName) => parseBlogPost(fileName))
.filter(Boolean) // Filter out nulls (invalid posts)
.filter((post): post is BlogPost => post !== null) // Filter out nulls (invalid posts)
.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
return allPosts;
}
/**
* Get a single blog post by its slug
* @param {string} slug - The slug of the blog post
* @returns {Object|null} The parsed blog post data or null if not found
* @param slug - The slug of the blog post
* @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();
return allPosts.find((post) => post.slug === slug) || null;
}
/**
* 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 {string[]} relatedSlugs - Array of slugs for explicitly related posts.
* @param {number} limit - Maximum number of related posts to return.
* @returns {Array} Array of related blog post objects.
* @param currentPostSlug - The slug of the current post to exclude.
* @param relatedSlugs - Array of slugs for explicitly related posts.
* @param limit - Maximum number of related posts to return.
* @returns Array of related blog post objects.
*/
export function getRelatedBlogPosts(
currentPostSlug,
relatedSlugs = [],
limit = 3,
) {
currentPostSlug: string,
relatedSlugs: string[] = [],
limit: number = 3
): BlogPost[] {
const allPosts = getAllBlogPosts();
const filteredPosts = allPosts.filter(
(post) => post.slug !== currentPostSlug,
(post) => post.slug !== currentPostSlug
);
let related = [];
let related: BlogPost[] = [];
if (relatedSlugs && relatedSlugs.length > 0) {
related = relatedSlugs
.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
@@ -170,11 +185,11 @@ export function getRelatedBlogPosts(
/**
* 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 tags = new Set();
const tags = new Set<string>();
allPosts.forEach((post) => {
if (post.frontmatter.tags) {
post.frontmatter.tags.forEach((tag) => tags.add(tag));
@@ -185,23 +200,23 @@ export function getAllTags() {
/**
* Get blog posts filtered by a specific tag.
* @param {string} tag - The tag to filter by.
* @returns {Object[]} Array of blog post objects matching the tag.
* @param tag - The tag to filter by.
* @returns Array of blog post objects matching the tag.
*/
export function getBlogPostsByTag(tag) {
export function getBlogPostsByTag(tag: string): BlogPost[] {
const allPosts = getAllBlogPosts();
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
* @param {string} query - Search query
* @param {number} limit - Maximum number of results
* @returns {Object[]} Array of matching blog post objects
* @param query - Search query
* @param limit - Maximum number of results
* @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 [];
const searchTerm = query.toLowerCase().trim();
@@ -216,7 +231,7 @@ export function searchBlogPosts(query, limit = 10) {
.includes(searchTerm);
const contentMatch = post.content.toLowerCase().includes(searchTerm);
const tagMatch = post.frontmatter.tags?.some((tag) =>
tag.toLowerCase().includes(searchTerm),
tag.toLowerCase().includes(searchTerm)
);
return titleMatch || descriptionMatch || contentMatch || tagMatch;
@@ -227,31 +242,42 @@ export function searchBlogPosts(query, limit = 10) {
/**
* Get blog posts by author
* @param {string} author - Author name to filter by
* @returns {Object[]} Array of blog post objects by the author
* @param author - Author name to filter by
* @returns Array of blog post objects by the author
*/
export function getBlogPostsByAuthor(author) {
export function getBlogPostsByAuthor(author: string): BlogPost[] {
const allPosts = getAllBlogPosts();
return allPosts.filter(
(post) => post.frontmatter.author.toLowerCase() === author.toLowerCase(),
(post) => post.frontmatter.author.toLowerCase() === author.toLowerCase()
);
}
/**
* Get recent blog posts
* @param {number} limit - Maximum number of posts to return
* @returns {Object[]} Array of recent blog post objects
* @param limit - Maximum number of posts to return
* @returns Array of recent blog post objects
*/
export function getRecentBlogPosts(limit = 5) {
export function getRecentBlogPosts(limit: number = 5): BlogPost[] {
const allPosts = getAllBlogPosts();
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
* @returns {Object} Statistics about blog posts
* @returns Statistics about blog posts
*/
export function getBlogStats() {
export function getBlogStats(): BlogStats {
const allPosts = getAllBlogPosts();
const tags = getAllTags();
@@ -272,11 +298,13 @@ export function getBlogStats() {
(allPosts.length /
Math.max(
1,
(new Date(allPosts[0].frontmatter.date) -
new Date(allPosts[allPosts.length - 1].frontmatter.date)) /
(1000 * 60 * 60 * 24 * 30),
(new Date(allPosts[0].frontmatter.date).getTime() -
new Date(
allPosts[allPosts.length - 1].frontmatter.date
).getTime()) /
(1000 * 60 * 60 * 24 * 30)
)) *
10,
10
) / 10
: 0,
};
+79 -40
View File
@@ -2,11 +2,47 @@
* 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
* Uses "Month Year" format (e.g., "April 2025")
*/
export function formatDate(dateString) {
export function formatDate(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleDateString("en-US", {
year: "numeric",
@@ -16,10 +52,10 @@ export function formatDate(dateString) {
/**
* Process markdown content and extract metadata
* @param {string} markdown - Raw markdown content
* @returns {object} Processed content with metadata
* @param markdown - Raw markdown content
* @returns Processed content with metadata
*/
export function processMarkdown(markdown) {
export function processMarkdown(markdown: string): ProcessedMarkdown {
if (!markdown) {
return {
content: "",
@@ -53,13 +89,13 @@ export function processMarkdown(markdown) {
/**
* Extract all headings from markdown content
* @param {string} markdown - Raw markdown content
* @returns {Array} Array of heading objects with level, text, and id
* @param markdown - Raw markdown content
* @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 headings = [];
let match;
const headings: Heading[] = [];
let match: RegExpExecArray | null;
while ((match = headingRegex.exec(markdown)) !== null) {
const level = match[1].length;
@@ -79,13 +115,13 @@ function extractHeadings(markdown) {
/**
* Extract all links from markdown content
* @param {string} markdown - Raw markdown content
* @returns {Array} Array of link objects
* @param markdown - Raw markdown content
* @returns Array of link objects
*/
function extractLinks(markdown) {
function extractLinks(markdown: string): Link[] {
const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
const links = [];
let match;
const links: Link[] = [];
let match: RegExpExecArray | null;
while ((match = linkRegex.exec(markdown)) !== null) {
links.push({
@@ -100,13 +136,13 @@ function extractLinks(markdown) {
/**
* Extract all images from markdown content
* @param {string} markdown - Raw markdown content
* @returns {Array} Array of image objects
* @param markdown - Raw markdown content
* @returns Array of image objects
*/
function extractImages(markdown) {
function extractImages(markdown: string): Image[] {
const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
const images = [];
let match;
const images: Image[] = [];
let match: RegExpExecArray | null;
while ((match = imageRegex.exec(markdown)) !== null) {
images.push({
@@ -121,10 +157,10 @@ function extractImages(markdown) {
/**
* Generate a unique ID for a heading
* @param {string} text - Heading text
* @returns {string} Unique ID
* @param text - Heading text
* @returns Unique ID
*/
function generateHeadingId(text) {
function generateHeadingId(text: string): string {
return text
.toLowerCase()
.replace(/[^\w\s-]/g, "")
@@ -137,10 +173,10 @@ function generateHeadingId(text) {
* Convert markdown to HTML with enhanced formatting
* - Preserves extra blank lines between paragraphs as visible gaps
* (each extra blank line becomes <p class="md-gap">&nbsp;</p>)
* @param {string} markdown - Raw markdown content
* @returns {string} HTML content
* @param markdown - Raw markdown content
* @returns HTML content
*/
function markdownToHtml(markdown) {
function markdownToHtml(markdown: string): string {
if (!markdown) return "";
// Normalize line endings
@@ -266,10 +302,10 @@ function markdownToHtml(markdown) {
/**
* Generate a table of contents from headings
* @param {Array} headings - Array of heading objects
* @returns {string} HTML table of contents
* @param headings - Array of heading objects
* @returns HTML table of contents
*/
export function generateTableOfContents(headings) {
export function generateTableOfContents(headings: Heading[]): string {
if (!headings || headings.length === 0) return "";
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
* @param {Object} frontmatter - Raw frontmatter data
* @returns {Object} Processed and validated frontmatter
* @param frontmatter - Raw frontmatter data
* @returns Processed and validated frontmatter
*/
export function processFrontmatter(frontmatter) {
export function processFrontmatter(
frontmatter: Record<string, unknown>,
): ProcessedFrontmatter {
// Add computed fields
const processed = {
const date = frontmatter.date as string;
const processed: ProcessedFrontmatter = {
...frontmatter,
publishedDate: new Date(frontmatter.date),
year: new Date(frontmatter.date).getFullYear(),
month: new Date(frontmatter.date).getMonth() + 1,
day: new Date(frontmatter.date).getDate(),
isRecent: isRecentPost(frontmatter.date),
publishedDate: new Date(date),
year: new Date(date).getFullYear(),
month: new Date(date).getMonth() + 1,
day: new Date(date).getDate(),
isRecent: isRecentPost(date),
};
return processed;
@@ -304,10 +343,10 @@ export function processFrontmatter(frontmatter) {
/**
* Check if a post is recent (within last 30 days)
* @param {string} date - Post date string
* @returns {boolean} True if post is recent
* @param date - Post date string
* @returns True if post is recent
*/
function isRecentPost(date) {
function isRecentPost(date: string): boolean {
const postDate = new Date(date);
const thirtyDaysAgo = new Date();
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
*/
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
*/
export const BLOG_POST_SCHEMA = {
export const BLOG_POST_SCHEMA: BlogPostSchema = {
title: {
type: "string",
required: true,
@@ -80,11 +124,13 @@ export const BLOG_POST_SCHEMA = {
/**
* Validate a blog post's frontmatter
* @param {Object} frontmatter - The frontmatter object to validate
* @returns {Object} Validation result with isValid boolean and errors array
* @param frontmatter - The frontmatter object to validate
* @returns Validation result with isValid boolean and errors array
*/
export function validateBlogPost(frontmatter) {
const errors = [];
export function validateBlogPost(
frontmatter: Record<string, unknown>,
): ValidationResult {
const errors: string[] = [];
// Check required fields first
for (const [field, config] of Object.entries(BLOG_POST_SCHEMA)) {
@@ -116,12 +162,13 @@ export function validateBlogPost(frontmatter) {
// Length validation for strings
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(
`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(
`Field ${field} must be no more than ${config.maxLength} characters`,
);
@@ -129,23 +176,38 @@ export function validateBlogPost(frontmatter) {
}
// 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`);
}
// Array item validation
if (config.type === "array" && Array.isArray(frontmatter[field])) {
for (let i = 0; i < frontmatter[field].length; i++) {
const item = frontmatter[field][i];
if (config.items.type === "string" && typeof item !== "string") {
const fieldArray = frontmatter[field] as unknown[];
for (let i = 0; i < fieldArray.length; i++) {
const item = fieldArray[i];
if (config.items?.type === "string" && typeof item !== "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(
`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(
`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
* @param {Object} frontmatter - Raw frontmatter data
* @returns {Object} Sanitized frontmatter
* @param frontmatter - Raw frontmatter data
* @returns Sanitized frontmatter
*/
export function sanitizeBlogPost(frontmatter) {
const sanitized = {};
export function sanitizeBlogPost(
frontmatter: Record<string, unknown>,
): BlogPostFrontmatter {
const sanitized: Record<string, unknown> = {};
for (const [field, config] of Object.entries(BLOG_POST_SCHEMA)) {
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,
"module": "esnext",
"esModuleInterop": true,
"moduleResolution": "node",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"forceConsistentCasingInFileNames": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"plugins": [
{
"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"]
}