diff --git a/app/tailwind.css b/app/tailwind.css index c2ab555..f0fa8ee 100644 --- a/app/tailwind.css +++ b/app/tailwind.css @@ -19,15 +19,12 @@ --color-*: initial; /* Font families */ - --font-sans: - var(--font-inter), ui-sans-serif, system-ui, -apple-system, "Segoe UI", - Roboto, "Helvetica Neue", Arial, sans-serif; - --font-display: - var(--font-bricolage-grotesque), ui-sans-serif, system-ui, -apple-system, + --font-sans: var(--font-inter), ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; - --font-mono: - var(--font-space-grotesk), ui-monospace, SFMono-Regular, "SF Mono", - Consolas, "Liberation Mono", Menlo, monospace; + --font-display: var(--font-bricolage-grotesque), ui-sans-serif, system-ui, + -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + --font-mono: var(--font-space-grotesk), ui-monospace, SFMono-Regular, + "SF Mono", Consolas, "Liberation Mono", Menlo, monospace; /* Dimension */ --spacing-scale-000: 0px; @@ -370,6 +367,7 @@ --color-content-inverse-brand-accent: var(--color-yellow-yellow700); --color-content-inverse-brand-primary: var(--color-yellow-yellow900); --color-content-inverse-brand-secondary: var(--color-rust-rust800); + --color-content-inverse-brand-royal: var(--color-royal-blue-royal-blue1000); --color-content-inverse-primary: var(--color-gray-1000); --color-content-inverse-secondary: var(--color-gray-800); --color-content-inverse-tertiary: var(--color-gray-700); diff --git a/app/test-thumbnail/page.js b/app/test-thumbnail/page.js new file mode 100644 index 0000000..7ab9b8e --- /dev/null +++ b/app/test-thumbnail/page.js @@ -0,0 +1,153 @@ +import ContentThumbnailTemplate from "../../components/ContentThumbnailTemplate"; + +// Mock blog post data for testing +const mockPost1 = { + slug: "test-post-1", + frontmatter: { + title: "Resolving Active Conflicts", + description: + "Practical steps for resolving conflicts while maintaining trust, cooperation, and shared goals", + author: "Author name", + date: "2025-04-15", + tags: ["conflict-resolution", "governance", "community"], + }, + wordCount: 467, + readingTime: 3, +}; + +const mockPost2 = { + slug: "test-post-2", + frontmatter: { + title: "Operational Security for Mutual Aid", + description: + "Tactics to protect members, secure communication, and prevent Infiltration", + author: "Author name", + date: "2025-04-10", + tags: ["community-building", "sustainability", "structure"], + }, + wordCount: 523, + readingTime: 4, +}; + +const mockPost3 = { + slug: "test-post-3", + frontmatter: { + title: "Making decisions without hierarchy", + description: + "A brief guide to collaborative nonhierarchical decision making", + author: "Author name", + date: "2025-04-05", + tags: ["communication", "remote-work", "collaboration"], + }, + wordCount: 389, + readingTime: 2, +}; + +export default function TestThumbnailPage() { + return ( +
+
+

+ ContentThumbnailTemplate Test +

+ +
+ {/* Vertical Variant */} +
+

+ Vertical Variant +

+
+ + + +
+
+ + {/* Horizontal Variant */} +
+

+ Horizontal Variant +

+
+ + + +
+
+ + {/* Component Props */} +
+

+ Component Props +

+
+
+

+ Required Props: +

+
    +
  • + post - Blog post object with frontmatter +
  • +
+
+
+

+ Optional Props: +

+
    +
  • + variant - "vertical" (default) or "horizontal" +
  • +
  • + className - Additional CSS classes +
  • +
  • + showTags - Show/hide tags (default: true) +
  • +
  • + showReadingTime - Show/hide reading time + (default: true) +
  • +
+
+
+
+ + {/* Mock Data */} +
+

+ Mock Data Structure +

+
+              {JSON.stringify(mockPost1, null, 2)}
+            
+
+
+
+
+ ); +} diff --git a/components/ContentContainer.js b/components/ContentContainer.js new file mode 100644 index 0000000..40867f7 --- /dev/null +++ b/components/ContentContainer.js @@ -0,0 +1,115 @@ +"use client"; + +import React from "react"; + +const ContentContainer = ({ post, width = "200px", variant = "vertical" }) => { + // Get the corresponding icon based on the same logic as background images + const getIconImage = (slug, variant) => { + const verticalIcons = [ + "/assets/Content_Thumbnail/Icon_1.svg", + "/assets/Content_Thumbnail/Icon_2.svg", + "/assets/Content_Thumbnail/Icon_3.svg", + ]; + + const horizontalIcons = [ + "/assets/Content_Thumbnail/Icon_1.svg", + "/assets/Content_Thumbnail/Icon_2.svg", + "/assets/Content_Thumbnail/Icon_3.svg", + ]; + + const icons = variant === "vertical" ? verticalIcons : horizontalIcons; + + if (!slug) return icons[0]; + + // Use the same hash logic as background images to ensure matching + const hash = slug.split("").reduce((a, b) => { + a = (a << 5) - a + b.charCodeAt(0); + return a & a; + }, 0); + + return icons[Math.abs(hash) % icons.length]; + }; + + const iconImage = getIconImage(post.slug, variant); + + return ( +
+ {/* Content Container - 8px gap between icon and text */} +
+ {/* Icon */} +
+ {`Icon +
+ + {/* Text Container */} +
+ {/* Title */} +

+ {post.frontmatter.title} +

+ + {/* Description */} +

+ {post.frontmatter.description} +

+
+
+ + {/* Metadata Container - horizontal with 8px gap */} +
+ {/* Author Name */} + + {post.frontmatter.author} + + + {/* Date */} + + {new Date(post.frontmatter.date).toLocaleDateString("en-US", { + year: "numeric", + month: "long", + })} + +
+
+ ); +}; + +export default ContentContainer; diff --git a/components/ContentThumbnailTemplate.js b/components/ContentThumbnailTemplate.js new file mode 100644 index 0000000..4ad22dd --- /dev/null +++ b/components/ContentThumbnailTemplate.js @@ -0,0 +1,114 @@ +"use client"; + +import React from "react"; +import Link from "next/link"; +import ContentContainer from "./ContentContainer"; + +/** + * ContentThumbnailTemplate component for displaying blog post previews + * Simplified version to debug infinite loop + */ +const ContentThumbnailTemplate = ({ + post, + variant = "vertical", + className = "", + showReadingTime = true, +}) => { + // Post-specific background selection - different SVG for each post + const getBackgroundImage = (slug, variant) => { + const verticalImages = [ + "/assets/Content_Thumbnail/Vertical_1.svg", + "/assets/Content_Thumbnail/Vertical_2.svg", + "/assets/Content_Thumbnail/Vertical_3.svg", + ]; + + const horizontalImages = [ + "/assets/Content_Thumbnail/Horizontal_1.svg", + "/assets/Content_Thumbnail/Horizontal_2.svg", + "/assets/Content_Thumbnail/Horizontal_3.svg", + ]; + + const images = variant === "vertical" ? verticalImages : horizontalImages; + + if (!slug) return images[0]; + + // Use the slug to deterministically select an image + const hash = slug.split("").reduce((a, b) => { + a = (a << 5) - a + b.charCodeAt(0); + return a & a; + }, 0); + + return images[Math.abs(hash) % images.length]; + }; + + const backgroundImage = getBackgroundImage(post.slug, variant); + + if (variant === "vertical") { + return ( + +
+ {/* Background SVG - sized to fit the 260x390 container exactly */} +
+ {`Background + {/* Gradient overlay for better text readability */} +
+
+ + {/* Content Section - positioned within the padding constraints */} + +
+ + ); + } + + // Horizontal variant + return ( + +
+ {/* Background SVG - sized to fit the 320x225.5 container exactly */} +
+ {`Background + {/* Gradient overlay */} +
+
+ + {/* Content - positioned within the padding constraints */} + +
+ + ); +}; + +export default ContentThumbnailTemplate; diff --git a/components/ImagePlaceholder.js b/components/ImagePlaceholder.js new file mode 100644 index 0000000..c762aaa --- /dev/null +++ b/components/ImagePlaceholder.js @@ -0,0 +1,37 @@ +"use client"; + +import React from "react"; + +/** + * Simple image placeholder component for testing + * Generates colored backgrounds with text overlays + */ +const ImagePlaceholder = ({ + width = 260, + height = 390, + text = "Blog Image", + color = "blue", + className = "", +}) => { + const colors = { + blue: "bg-blue-500", + green: "bg-green-500", + purple: "bg-purple-500", + red: "bg-red-500", + orange: "bg-orange-500", + teal: "bg-teal-500", + }; + + const bgColor = colors[color] || colors.blue; + + return ( +
+ {text} +
+ ); +}; + +export default ImagePlaceholder; diff --git a/lib/contentProcessor.js b/lib/contentProcessor.js index 260345e..5801d6b 100644 --- a/lib/contentProcessor.js +++ b/lib/contentProcessor.js @@ -6,6 +6,7 @@ import { processMarkdown, generateTableOfContents, processFrontmatter, + formatDate, } from "./mdx.js"; import { validateBlogPost, sanitizeBlogPost } from "./validation.js"; import { diff --git a/lib/mdx.js b/lib/mdx.js index 0178b25..9049e26 100644 --- a/lib/mdx.js +++ b/lib/mdx.js @@ -3,9 +3,21 @@ */ /** - * Process markdown content with enhanced features + * Format date consistently across the markdown pipeline + * Uses "Month Year" format (e.g., "April 2025") + */ +export function formatDate(dateString) { + const date = new Date(dateString); + return date.toLocaleDateString("en-US", { + year: "numeric", + month: "long", + }); +} + +/** + * Process markdown content and extract metadata * @param {string} markdown - Raw markdown content - * @returns {Object} Processed content with metadata + * @returns {object} Processed content with metadata */ export function processMarkdown(markdown) { if (!markdown) { @@ -33,9 +45,12 @@ export function processMarkdown(markdown) { const wordCount = calculateWordCount(markdown); const readingTime = calculateReadingTime(wordCount); + // Convert markdown to HTML + const htmlContent = markdownToHtml(markdown); + return { content: markdown, - htmlContent: markdownToHtml(markdown), + htmlContent, wordCount, readingTime, headings, diff --git a/public/assets/Content_Thumbnail/Horizontal_1.svg b/public/assets/Content_Thumbnail/Horizontal_1.svg new file mode 100644 index 0000000..838dafd --- /dev/null +++ b/public/assets/Content_Thumbnail/Horizontal_1.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/assets/Content_Thumbnail/Horizontal_2.svg b/public/assets/Content_Thumbnail/Horizontal_2.svg new file mode 100644 index 0000000..ff6736d --- /dev/null +++ b/public/assets/Content_Thumbnail/Horizontal_2.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/assets/Content_Thumbnail/Horizontal_3.svg b/public/assets/Content_Thumbnail/Horizontal_3.svg new file mode 100644 index 0000000..d1a81fa --- /dev/null +++ b/public/assets/Content_Thumbnail/Horizontal_3.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/assets/Content_Thumbnail/Icon_1.svg b/public/assets/Content_Thumbnail/Icon_1.svg new file mode 100644 index 0000000..b8fd584 --- /dev/null +++ b/public/assets/Content_Thumbnail/Icon_1.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/public/assets/Content_Thumbnail/Icon_2.svg b/public/assets/Content_Thumbnail/Icon_2.svg new file mode 100644 index 0000000..873505c --- /dev/null +++ b/public/assets/Content_Thumbnail/Icon_2.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/public/assets/Content_Thumbnail/Icon_3.svg b/public/assets/Content_Thumbnail/Icon_3.svg new file mode 100644 index 0000000..0bf598d --- /dev/null +++ b/public/assets/Content_Thumbnail/Icon_3.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/public/assets/Content_Thumbnail/Vertical_1.svg b/public/assets/Content_Thumbnail/Vertical_1.svg new file mode 100644 index 0000000..ff5f25f --- /dev/null +++ b/public/assets/Content_Thumbnail/Vertical_1.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/assets/Content_Thumbnail/Vertical_2.svg b/public/assets/Content_Thumbnail/Vertical_2.svg new file mode 100644 index 0000000..0b9aa36 --- /dev/null +++ b/public/assets/Content_Thumbnail/Vertical_2.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/assets/Content_Thumbnail/Vertical_3.svg b/public/assets/Content_Thumbnail/Vertical_3.svg new file mode 100644 index 0000000..bc3df93 --- /dev/null +++ b/public/assets/Content_Thumbnail/Vertical_3.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/unit/ContentThumbnailTemplate.test.jsx b/tests/unit/ContentThumbnailTemplate.test.jsx new file mode 100644 index 0000000..c89a480 --- /dev/null +++ b/tests/unit/ContentThumbnailTemplate.test.jsx @@ -0,0 +1,218 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import ContentThumbnailTemplate from "../../components/ContentThumbnailTemplate"; + +// Mock Next.js components +vi.mock("next/link", () => { + return { + default: ({ children, href, ...props }) => ( + + {children} + + ), + }; +}); + +vi.mock("next/image", () => { + return { + default: ({ src, alt, ...props }) => {alt}, + }; +}); + +// Mock blog post data +const mockPost = { + slug: "test-post", + frontmatter: { + title: "Test Blog Post Title", + description: + "This is a test description for the blog post that should be long enough to test truncation.", + author: "Test Author", + date: "2025-04-15", + tags: ["test", "blog", "example"], + backgroundImages: ["/test-image-1.jpg", "/test-image-2.jpg"], + }, + wordCount: 500, + readingTime: 3, +}; + +describe("ContentThumbnailTemplate", () => { + describe("Vertical Variant", () => { + it("should render vertical variant with correct dimensions", () => { + render(); + + const container = screen.getByRole("link"); + expect(container).toBeInTheDocument(); + + // Check that the component has the correct classes for dimensions + const thumbnailDiv = container.querySelector("div"); + expect(thumbnailDiv).toHaveClass("w-[260px]", "h-[390px]"); + }); + + it("should display post title and description", () => { + render(); + + expect(screen.getByText("Test Blog Post Title")).toBeInTheDocument(); + expect( + screen.getByText(/This is a test description/) + ).toBeInTheDocument(); + }); + + it("should display tags when showTags is true", () => { + render( + + ); + + expect(screen.getByText("test")).toBeInTheDocument(); + expect(screen.getByText("blog")).toBeInTheDocument(); + }); + + it("should hide tags when showTags is false", () => { + render( + + ); + + expect(screen.queryByText("test")).not.toBeInTheDocument(); + expect(screen.queryByText("blog")).not.toBeInTheDocument(); + }); + + it("should display reading time when showReadingTime is true", () => { + render( + + ); + + expect(screen.getByText(/3 min read/)).toBeInTheDocument(); + }); + + it("should hide reading time when showReadingTime is false", () => { + render( + + ); + + expect(screen.queryByText(/min read/)).not.toBeInTheDocument(); + }); + + it("should display author and date", () => { + render(); + + expect(screen.getByText("Test Author")).toBeInTheDocument(); + // Check for "Month Year" format (e.g., "April 2025") + expect(screen.getByText("April 2025")).toBeInTheDocument(); + }); + }); + + describe("Horizontal Variant", () => { + it("should render horizontal variant", () => { + render(); + + const container = screen.getByRole("link"); + expect(container).toBeInTheDocument(); + + // Check that the component has the correct classes for horizontal layout + const thumbnailDiv = container.querySelector("div"); + expect(thumbnailDiv).toHaveClass("h-[226px]"); + }); + + it("should display post information in horizontal layout", () => { + render(); + + expect(screen.getByText("Test Blog Post Title")).toBeInTheDocument(); + expect( + screen.getByText(/This is a test description/) + ).toBeInTheDocument(); + expect(screen.getByText("Test Author")).toBeInTheDocument(); + }); + }); + + describe("Props and Customization", () => { + it("should apply custom className", () => { + render( + + ); + + const container = screen.getByRole("link"); + expect(container).toHaveClass("custom-class"); + }); + + it("should generate correct link href", () => { + render(); + + const link = screen.getByRole("link"); + expect(link).toHaveAttribute("href", "/blog/test-post"); + }); + + it("should handle posts without tags gracefully", () => { + const postWithoutTags = { + ...mockPost, + frontmatter: { + ...mockPost.frontmatter, + tags: [], + }, + }; + + render( + + ); + + // Should still render without errors + expect(screen.getByText("Test Blog Post Title")).toBeInTheDocument(); + }); + + it("should handle posts without background images", () => { + const postWithoutImages = { + ...mockPost, + frontmatter: { + ...mockPost.frontmatter, + backgroundImages: undefined, + }, + }; + + render( + + ); + + // Should still render without errors + expect(screen.getByText("Test Blog Post Title")).toBeInTheDocument(); + }); + }); + + describe("Default Behavior", () => { + it("should default to vertical variant when no variant specified", () => { + render(); + + const thumbnailDiv = screen.getByRole("link").querySelector("div"); + expect(thumbnailDiv).toHaveClass("w-[260px]", "h-[390px]"); + }); + + it("should show tags by default", () => { + render(); + + expect(screen.getByText("test")).toBeInTheDocument(); + }); + + it("should show reading time by default", () => { + render(); + + expect(screen.getByText(/min read/)).toBeInTheDocument(); + }); + }); +});