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