Update: Blog page featured layout ui, mobile menu padding and font change

This commit is contained in:
Wayne Sutton
2025-12-26 16:41:06 -08:00
parent bfe88d0217
commit b35dd17332
24 changed files with 748 additions and 73 deletions

19
TASK.md
View File

@@ -8,10 +8,27 @@
## Current Status
v1.33.0 ready. AI Chat Write Agent (Agent) integration complete with Anthropic Claude API support. Available on Write page and optionally in RightSidebar on posts/pages.
v1.34.0 ready. Blog page featured layout with hero post, featured row, and regular posts grid. Uses `blogFeatured` frontmatter field to control featured posts display.
## Completed
- [x] Blog page featured layout with hero post
- [x] `blogFeatured` frontmatter field for posts to mark as featured on blog page
- [x] `BlogHeroCard` component for the hero featured post (first blogFeatured post)
- [x] Featured row displays remaining blogFeatured posts in 2-column grid with excerpts
- [x] Regular posts display in 3-column grid without excerpts
- [x] `getBlogFeaturedPosts` query returns all published posts with `blogFeatured: true`
- [x] `PostList` component updated with `columns` prop (2 or 3) and `showExcerpts` prop
- [x] Schema updated with `blogFeatured` field and `by_blogFeatured` index
- [x] sync-posts.ts updated to parse `blogFeatured` frontmatter
- [x] Hero card displays landscape image, tags, date, title, excerpt, author info, and read more link
- [x] Featured row shows excerpts for blogFeatured posts
- [x] Regular posts hide excerpts for cleaner grid layout
- [x] Responsive design: hero stacks on mobile, grids adjust columns at breakpoints
- [x] CSS styles for `.blog-hero-section`, `.blog-hero-card`, `.blog-featured-row`, `.post-cards-2col`
- [x] Card images use 16:10 landscape aspect ratio matching Giga.ai style
- [x] Footer support on blog page via `siteConfig.footer.showOnBlogPage`
- [x] AI Chat Write Agent (Agent) integration
- [x] AIChatView component created with Anthropic Claude API integration
- [x] Write page AI Agent mode toggle (replaces textarea when active)

View File

@@ -4,6 +4,54 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [1.34.0] - 2025-12-26
### Added
- Blog page featured layout with hero post
- `blogFeatured` frontmatter field for posts to mark as featured on blog page
- First `blogFeatured` post displays as hero card with landscape image, tags, date, title, excerpt, author info, and read more link
- Remaining `blogFeatured` posts display in 2-column featured row with excerpts
- Regular (non-featured) posts display in 3-column grid without excerpts
- New `BlogHeroCard` component (`src/components/BlogHeroCard.tsx`) for hero display
- New `getBlogFeaturedPosts` query returns all published posts with `blogFeatured: true` sorted by date
- `PostList` component updated with `columns` prop (2 or 3) and `showExcerpts` prop
- Card images use 16:10 landscape aspect ratio
- Footer support on blog page via `siteConfig.footer.showOnBlogPage`
### Changed
- `convex/schema.ts`: Added `blogFeatured` field to posts table with `by_blogFeatured` index
- `convex/posts.ts`: Added `getBlogFeaturedPosts` query, updated sync mutations to handle `blogFeatured` field
- `scripts/sync-posts.ts`: Updated to parse `blogFeatured` from post frontmatter
- `src/pages/Blog.tsx`: Refactored to display hero, featured row, and regular posts sections
- `src/components/PostList.tsx`: Added `columns` and `showExcerpts` props for layout control
- `src/styles/global.css`: Added styles for `.blog-hero-section`, `.blog-hero-card`, `.blog-featured-row`, `.post-cards-2col`
### Technical
- Hero card responsive design: stacks content on mobile, side-by-side on desktop
- Featured row uses 2-column grid with excerpts visible
- Regular posts grid uses 3-column layout without excerpts for cleaner appearance
- Responsive breakpoints: 2 columns at 768px, 1 column at 480px
- Layout class names updated: `blog-page-cards` and `blog-page-list` for view modes
## [1.33.1] - 2025-12-26
### Changed
- Article centering in sidebar layouts
- Article content now centers in the middle column when sidebars are present
- Left sidebar stays flush left, right sidebar stays flush right
- Article uses `margin-left: auto; margin-right: auto` within its `1fr` grid column
- Works with both two-column (left sidebar only) and three-column (both sidebars) layouts
- Consistent `max-width: 800px` for article content across all sidebar configurations
### Technical
- Updated `.post-article-with-sidebar` in `src/styles/global.css` with auto margins for centering
- Added `padding-right: 48px` to match left padding for balanced spacing
## [1.33.0] - 2025-12-26
### Added

View File

@@ -8,6 +8,8 @@ tags: ["updates", "community", "ai"]
readTime: "2 min read"
featured: true
featuredOrder: 0
blogFeatured: true
aiChat: true
image: /images/1225-changelog.png
excerpt: "Thank you for the stars, forks, and feedback. More AI-first publishing features are coming."
authorName: "Wayne Sutton"

View File

@@ -9,8 +9,7 @@ readTime: "3 min read"
featured: false
featuredOrder: 3
authorName: "Markdown"
layout: "sidebar"
rightSidebar: true
blogFeatured: true
authorImage: "/images/authors/markdown.png"
image: "/images/matthew-smith-Rfflri94rs8-unsplash.jpg"
excerpt: "Quick guide to writing and publishing markdown posts with npm run sync."

View File

@@ -8,6 +8,7 @@ featured: false
featuredOrder: 4
tags: ["images", "tutorial", "markdown", "open-graph"]
readTime: "4 min read"
blogFeatured: true
authorName: "Markdown"
authorImage: "/images/authors/markdown.png"
image: "https://images.unsplash.com/photo-1499750310107-5fef28a66643?w=1200&h=630&fit=crop"
@@ -52,6 +53,10 @@ featuredOrder: 1
## Inline Images
You can add images using markdown syntax or HTML. The site uses `rehypeRaw` and `rehypeSanitize` to safely render HTML in markdown content.
### Markdown Syntax
Add images anywhere in your markdown content using standard syntax:
```markdown
@@ -64,6 +69,29 @@ Here's an example image from Unsplash:
The alt text appears as a caption below the image.
### HTML Syntax
You can also use HTML `<img>` tags directly in your markdown:
```html
<img src="/images/screenshot.png" alt="Alt text description" />
```
Or with additional attributes:
```html
<img
src="https://images.unsplash.com/photo-1499750310107-5fef28a66643?w=800&h=450&fit=crop"
alt="Laptop on a wooden desk with coffee and notebook"
width="800"
height="450"
/>
```
**HTML images:** HTML `<img>` tags are sanitized for security using `rehypeSanitize`. Allowed attributes include `src`, `alt`, `width`, `height`, `loading`, and `class`. The alt text still appears as a caption below HTML images, matching the markdown behavior.
**Combining markdown and HTML:** You can mix markdown and HTML in the same post. Both syntaxes render images with the same styling and caption behavior.
## Image Sources
You can use images from:
@@ -121,3 +149,4 @@ These sites offer free, high-quality images:
- [Pexels](https://pexels.com) - Photos and videos
- [unDraw](https://undraw.co) - Illustrations
- [Heroicons](https://heroicons.com) - Icons
- [Phosphor Icons](https://phosphoricons.com/) - Icons

View File

@@ -103,7 +103,7 @@ Content here...
```
| Field | Required | Description |
| --------------- | -------- | ------------------------------------------------- |
| --------------- | -------- | --------------------------------------------------------------------------------------------------------------------------- |
| `title` | Yes | Post title |
| `description` | Yes | SEO description |
| `date` | Yes | YYYY-MM-DD format |
@@ -111,7 +111,7 @@ Content here...
| `published` | Yes | `true` to show |
| `tags` | Yes | Array of strings |
| `readTime` | No | Display time estimate |
| `image` | No | OG image and featured card thumbnail |
| `image` | No | OG image and featured card thumbnail. See [Using Images in Blog Posts](/using-images-in-posts) for markdown and HTML syntax |
| `excerpt` | No | Short text for card view |
| `featured` | No | `true` to show in featured section |
| `featuredOrder` | No | Order in featured (lower = first) |
@@ -733,6 +733,8 @@ Mobile sizes defined in `@media (max-width: 768px)` block.
The `npm run sync` command only syncs markdown text content. Images are deployed when Netlify builds your site. Use `npm run sync:discovery` to update discovery files (AGENTS.md, llms.txt) when site configuration changes.
**Adding images to posts:** You can add images using markdown syntax `![alt](src)` or HTML `<img>` tags. The site uses `rehypeRaw` and `rehypeSanitize` to safely render HTML in markdown content. See [Using Images in Blog Posts](/using-images-in-posts) for complete examples and best practices.
**Logo options:**
- **Homepage logo:** Configured via `logo` in `siteConfig.ts`. Set to `null` to hide.

View File

@@ -105,10 +105,25 @@ export const generateResponse = action({
},
returns: v.string(),
handler: async (ctx, args) => {
// Get API key
// Get API key - return friendly message if not configured
const apiKey = process.env.ANTHROPIC_API_KEY;
if (!apiKey) {
throw new Error("API key is not set");
const notConfiguredMessage =
"**AI chat is not configured.**\n\n" +
"To enable AI responses, add your `ANTHROPIC_API_KEY` to the Convex environment variables.\n\n" +
"**Setup steps:**\n" +
"1. Get an API key from [Anthropic Console](https://console.anthropic.com/)\n" +
"2. Add it to Convex: `npx convex env set ANTHROPIC_API_KEY your-key-here`\n" +
"3. For production, set it in the [Convex Dashboard](https://dashboard.convex.dev/)\n\n" +
"See the [Convex environment variables docs](https://docs.convex.dev/production/environment-variables) for more details.";
// Save the message to chat history so it appears in the conversation
await ctx.runMutation(internal.aiChats.addAssistantMessage, {
chatId: args.chatId,
content: notConfiguredMessage,
});
return notConfiguredMessage;
}
// Get chat history

View File

@@ -25,6 +25,7 @@ export const getAllPosts = query({
rightSidebar: v.optional(v.boolean()),
showFooter: v.optional(v.boolean()),
footer: v.optional(v.string()),
blogFeatured: v.optional(v.boolean()),
}),
),
handler: async (ctx) => {
@@ -58,6 +59,53 @@ export const getAllPosts = query({
layout: post.layout,
rightSidebar: post.rightSidebar,
showFooter: post.showFooter,
blogFeatured: post.blogFeatured,
}));
},
});
// Get all blog featured posts for the /blog page (hero + featured row)
// Returns posts with blogFeatured: true, sorted by date descending
export const getBlogFeaturedPosts = query({
args: {},
returns: v.array(
v.object({
_id: v.id("posts"),
slug: v.string(),
title: v.string(),
description: v.string(),
date: v.string(),
tags: v.array(v.string()),
readTime: v.optional(v.string()),
image: v.optional(v.string()),
excerpt: v.optional(v.string()),
authorName: v.optional(v.string()),
authorImage: v.optional(v.string()),
}),
),
handler: async (ctx) => {
const posts = await ctx.db
.query("posts")
.withIndex("by_blogFeatured", (q) => q.eq("blogFeatured", true))
.collect();
// Filter to only published posts and sort by date descending
const publishedFeatured = posts
.filter((p) => p.published)
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
return publishedFeatured.map((post) => ({
_id: post._id,
slug: post.slug,
title: post.title,
description: post.description,
date: post.date,
tags: post.tags,
readTime: post.readTime,
image: post.image,
excerpt: post.excerpt,
authorName: post.authorName,
authorImage: post.authorImage,
}));
},
});
@@ -194,6 +242,7 @@ export const syncPosts = internalMutation({
showFooter: v.optional(v.boolean()),
footer: v.optional(v.string()),
aiChat: v.optional(v.boolean()),
blogFeatured: v.optional(v.boolean()),
}),
),
},
@@ -239,6 +288,7 @@ export const syncPosts = internalMutation({
showFooter: post.showFooter,
footer: post.footer,
aiChat: post.aiChat,
blogFeatured: post.blogFeatured,
lastSyncedAt: now,
});
updated++;
@@ -288,6 +338,7 @@ export const syncPostsPublic = mutation({
showFooter: v.optional(v.boolean()),
footer: v.optional(v.string()),
aiChat: v.optional(v.boolean()),
blogFeatured: v.optional(v.boolean()),
}),
),
},
@@ -333,6 +384,7 @@ export const syncPostsPublic = mutation({
showFooter: post.showFooter,
footer: post.footer,
aiChat: post.aiChat,
blogFeatured: post.blogFeatured,
lastSyncedAt: now,
});
updated++;

View File

@@ -23,12 +23,14 @@ export default defineSchema({
showFooter: v.optional(v.boolean()), // Show footer on this post (overrides siteConfig default)
footer: v.optional(v.string()), // Footer markdown content (overrides siteConfig defaultContent)
aiChat: v.optional(v.boolean()), // Enable AI chat in right sidebar
blogFeatured: v.optional(v.boolean()), // Show as hero featured post on /blog page
lastSyncedAt: v.number(),
})
.index("by_slug", ["slug"])
.index("by_date", ["date"])
.index("by_published", ["published"])
.index("by_featured", ["featured"])
.index("by_blogFeatured", ["blogFeatured"])
.searchIndex("search_content", {
searchField: "content",
filterFields: ["published"],

View File

@@ -40,7 +40,7 @@ A brief description of each file in the codebase.
| File | Description |
| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Home.tsx` | Landing page with featured content and optional post list. Supports configurable post limit (homePostsLimit) and optional "read more" link (homePostsReadMore) via siteConfig.postsDisplay |
| `Blog.tsx` | Dedicated blog page with post list or card grid view (configurable via siteConfig.blogPage, supports view toggle). Includes back button in navigation |
| `Blog.tsx` | Dedicated blog page with featured layout: hero post (first blogFeatured), featured row (remaining blogFeatured in 2 columns with excerpts), and regular posts (3 columns without excerpts). Supports list/card view toggle. Includes back button in navigation |
| `Post.tsx` | Individual blog post or page view with optional left sidebar (TOC) and right sidebar (CopyPageDropdown). Includes back button (hidden when used as homepage), tag links, and related posts section in footer for blog posts. Supports 3-column layout at 1135px+. Can be used as custom homepage via siteConfig.homepage (update SITE_URL/SITE_NAME when forking) |
| `Stats.tsx` | Real-time analytics dashboard with visitor stats and GitHub stars |
| `TagPage.tsx` | Tag archive page displaying posts filtered by a specific tag. Includes view mode toggle (list/cards) with localStorage persistence |
@@ -52,7 +52,8 @@ A brief description of each file in the codebase.
| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Layout.tsx` | Page wrapper with logo in header (top-left), search button, theme toggle, mobile menu (left-aligned on mobile), and scroll-to-top. Combines Blog link, hardcoded nav items, and markdown pages for navigation. Logo reads from siteConfig.innerPageLogo |
| `ThemeToggle.tsx` | Theme switcher (dark/light/tan/cloud) |
| `PostList.tsx` | Year-grouped blog post list or card grid (supports list/cards view modes) |
| `PostList.tsx` | Year-grouped blog post list or card grid (supports list/cards view modes, columns prop for 2/3 column grids, showExcerpts prop to control excerpt visibility) |
| `BlogHeroCard.tsx` | Hero card component for the first blogFeatured post on blog page. Displays landscape image, tags, date, title, excerpt, author info, and read more link |
| `BlogPost.tsx` | Markdown renderer with syntax highlighting, collapsible sections (details/summary), and text wrapping for plain text code blocks |
| `CopyPageDropdown.tsx` | Share dropdown with Copy page (markdown to clipboard), View as Markdown (opens raw .md file), Download as SKILL.md (Anthropic Agent Skills format), and Open in AI links (ChatGPT, Claude, Perplexity) using GitHub raw URLs with universal prompt |
| `Footer.tsx` | Footer component that renders markdown content from frontmatter footer field or siteConfig.defaultContent. Can be enabled/disabled globally and per-page via frontmatter showFooter field. Renders inside article at bottom for posts/pages, and in current position on homepage. Supports images with size control via HTML attributes (width, height, style, class) |
@@ -143,6 +144,7 @@ Markdown files with frontmatter for blog posts. Each file becomes a blog post.
| `excerpt` | Short excerpt for card view (optional) |
| `featured` | Show in featured section (optional) |
| `featuredOrder` | Order in featured section (optional) |
| `blogFeatured` | Show as featured on blog page (optional, first becomes hero, rest in 2-col row) |
| `authorName` | Author display name (optional) |
| `authorImage` | Round author avatar image URL (optional) |
| `rightSidebar` | Enable right sidebar with CopyPageDropdown (optional) |

View File

@@ -2,7 +2,7 @@
---
Type: page
Date: 2025-12-26
Date: 2025-12-27
---
An open-source publishing framework built for AI agents and developers to ship websites, docs, or blogs.. Write markdown, sync from the terminal. Your content is instantly available to browsers, LLMs, and AI agents. Built on Convex and Netlify.

View File

@@ -2,7 +2,7 @@
---
Type: page
Date: 2025-12-26
Date: 2025-12-27
---
All notable changes to this project.

View File

@@ -2,7 +2,7 @@
---
Type: page
Date: 2025-12-26
Date: 2025-12-27
---
You found the contact page. Nice

View File

@@ -2,7 +2,7 @@
---
Type: page
Date: 2025-12-26
Date: 2025-12-27
---
## Getting Started
@@ -99,7 +99,7 @@ Content here...
```
| Field | Required | Description |
| --------------- | -------- | ------------------------------------------------- |
| --------------- | -------- | --------------------------------------------------------------------------------------------------------------------------- |
| `title` | Yes | Post title |
| `description` | Yes | SEO description |
| `date` | Yes | YYYY-MM-DD format |
@@ -107,7 +107,7 @@ Content here...
| `published` | Yes | `true` to show |
| `tags` | Yes | Array of strings |
| `readTime` | No | Display time estimate |
| `image` | No | OG image and featured card thumbnail |
| `image` | No | OG image and featured card thumbnail. See [Using Images in Blog Posts](/using-images-in-posts) for markdown and HTML syntax |
| `excerpt` | No | Short text for card view |
| `featured` | No | `true` to show in featured section |
| `featuredOrder` | No | Order in featured (lower = first) |
@@ -729,6 +729,8 @@ Mobile sizes defined in `@media (max-width: 768px)` block.
The `npm run sync` command only syncs markdown text content. Images are deployed when Netlify builds your site. Use `npm run sync:discovery` to update discovery files (AGENTS.md, llms.txt) when site configuration changes.
**Adding images to posts:** You can add images using markdown syntax `![alt](src)` or HTML `<img>` tags. The site uses `rehypeRaw` and `rehypeSanitize` to safely render HTML in markdown content. See [Using Images in Blog Posts](/using-images-in-posts) for complete examples and best practices.
**Logo options:**
- **Homepage logo:** Configured via `logo` in `siteConfig.ts`. Set to `null` to hide.

View File

@@ -2,7 +2,7 @@
---
Type: page
Date: 2025-12-26
Date: 2025-12-27
---
This markdown framework is open source and built to be extended. Here is what ships out of the box.

View File

@@ -48,6 +48,10 @@ featuredOrder: 1
## Inline Images
You can add images using markdown syntax or HTML. The site uses `rehypeRaw` and `rehypeSanitize` to safely render HTML in markdown content.
### Markdown Syntax
Add images anywhere in your markdown content using standard syntax:
```markdown
@@ -60,6 +64,29 @@ Here's an example image from Unsplash:
The alt text appears as a caption below the image.
### HTML Syntax
You can also use HTML `<img>` tags directly in your markdown:
```html
<img src="/images/screenshot.png" alt="Alt text description" />
```
Or with additional attributes:
```html
<img
src="https://images.unsplash.com/photo-1499750310107-5fef28a66643?w=800&h=450&fit=crop"
alt="Laptop on a wooden desk with coffee and notebook"
width="800"
height="450"
/>
```
**HTML images:** HTML `<img>` tags are sanitized for security using `rehypeSanitize`. Allowed attributes include `src`, `alt`, `width`, `height`, `loading`, and `class`. The alt text still appears as a caption below HTML images, matching the markdown behavior.
**Combining markdown and HTML:** You can mix markdown and HTML in the same post. Both syntaxes render images with the same styling and caption behavior.
## Image Sources
You can use images from:
@@ -117,3 +144,4 @@ These sites offer free, high-quality images:
- [Pexels](https://pexels.com) - Photos and videos
- [unDraw](https://undraw.co) - Illustrations
- [Heroicons](https://heroicons.com) - Icons
- [Phosphor Icons](https://phosphoricons.com/) - Icons

View File

@@ -39,6 +39,7 @@ interface PostFrontmatter {
layout?: string; // Layout type: "sidebar" for docs-style layout
rightSidebar?: boolean; // Enable right sidebar with CopyPageDropdown (default: true when siteConfig.rightSidebar.enabled)
aiChat?: boolean; // Enable AI chat in right sidebar (requires rightSidebar: true)
blogFeatured?: boolean; // Show as hero featured post on /blog page
}
interface ParsedPost {
@@ -61,6 +62,7 @@ interface ParsedPost {
showFooter?: boolean; // Show footer on this post (overrides siteConfig default)
footer?: string; // Footer markdown content (overrides siteConfig defaultContent)
aiChat?: boolean; // Enable AI chat in right sidebar (requires rightSidebar: true)
blogFeatured?: boolean; // Show as hero featured post on /blog page
}
// Page frontmatter (for static pages like About, Projects, Contact)
@@ -143,6 +145,7 @@ function parseMarkdownFile(filePath: string): ParsedPost | null {
showFooter: frontmatter.showFooter, // Show footer on this post
footer: frontmatter.footer, // Footer markdown content
aiChat: frontmatter.aiChat, // Enable AI chat in right sidebar
blogFeatured: frontmatter.blogFeatured, // Show as hero featured post on /blog page
};
} catch (error) {
console.error(`Error parsing ${filePath}:`, error);

View File

@@ -0,0 +1,89 @@
import { Link } from "react-router-dom";
import { format, parseISO } from "date-fns";
interface BlogHeroCardProps {
slug: string;
title: string;
description: string;
date: string;
tags: string[];
readTime?: string;
image?: string;
excerpt?: string;
authorName?: string;
authorImage?: string;
}
// Hero card component for featured blog post on /blog page
// Displays as a large card with image on left, content on right (like Giga.ai/news)
export default function BlogHeroCard({
slug,
title,
description,
date,
tags,
readTime,
image,
excerpt,
authorName,
authorImage,
}: BlogHeroCardProps) {
return (
<Link to={`/${slug}`} className="blog-hero-card">
{/* Hero image on the left */}
{image && (
<div className="blog-hero-image-wrapper">
<img
src={image}
alt={title}
className="blog-hero-image"
loading="eager"
/>
</div>
)}
{/* Content on the right */}
<div className="blog-hero-content">
{/* Tags displayed as labels */}
{tags.length > 0 && (
<div className="blog-hero-tags">
{tags.slice(0, 2).map((tag) => (
<span key={tag} className="blog-hero-tag">
{tag}
</span>
))}
</div>
)}
{/* Date */}
<time className="blog-hero-date">
{format(parseISO(date), "MMM d, yyyy").toUpperCase()}
</time>
{/* Title */}
<h2 className="blog-hero-title">{title}</h2>
{/* Description or excerpt */}
<p className="blog-hero-excerpt">{excerpt || description}</p>
{/* Author info and read more */}
<div className="blog-hero-footer">
{authorName && (
<div className="blog-hero-author">
{authorImage && (
<img
src={authorImage}
alt={authorName}
className="blog-hero-author-image"
/>
)}
<span className="blog-hero-author-name">{authorName}</span>
</div>
)}
{readTime && <span className="blog-hero-read-time">{readTime}</span>}
<span className="blog-hero-read-more">Read more</span>
</div>
</div>
</Link>
);
}

View File

@@ -221,10 +221,12 @@ export default function Layout({ children }: LayoutProps) {
</nav>
</MobileMenu>
{/* Use wider layout for stats page, normal layout for other pages */}
{/* Use wider layout for stats and blog pages, normal layout for other pages */}
<main
className={
location.pathname === "/stats" ? "main-content-wide" : "main-content"
location.pathname === "/stats" || location.pathname === "/blog"
? "main-content-wide"
: "main-content"
}
>
{children}

View File

@@ -16,6 +16,8 @@ interface Post {
interface PostListProps {
posts: Post[];
viewMode?: "list" | "cards";
columns?: 2 | 3; // Number of columns for card view (default: 3)
showExcerpts?: boolean; // Show excerpts in card view (default: true)
}
// Group posts by year
@@ -33,7 +35,12 @@ function groupByYear(posts: Post[]): Record<string, Post[]> {
);
}
export default function PostList({ posts, viewMode = "list" }: PostListProps) {
export default function PostList({
posts,
viewMode = "list",
columns = 3,
showExcerpts = true,
}: PostListProps) {
// Sort posts by date descending
const sortedPosts = [...posts].sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
@@ -41,8 +48,11 @@ export default function PostList({ posts, viewMode = "list" }: PostListProps) {
// Card view: render all posts in a grid
if (viewMode === "cards") {
// Apply column class for 2 or 3 columns
const cardGridClass =
columns === 2 ? "post-cards post-cards-2col" : "post-cards";
return (
<div className="post-cards">
<div className={cardGridClass}>
{sortedPosts.map((post) => (
<Link key={post._id} to={`/${post.slug}`} className="post-card">
{/* Thumbnail image displayed as square using object-fit: cover */}
@@ -58,7 +68,8 @@ export default function PostList({ posts, viewMode = "list" }: PostListProps) {
)}
<div className="post-card-content">
<h3 className="post-card-title">{post.title}</h3>
{(post.excerpt || post.description) && (
{/* Only show excerpt if showExcerpts is true */}
{showExcerpts && (post.excerpt || post.description) && (
<p className="post-card-excerpt">
{post.excerpt || post.description}
</p>

View File

@@ -95,6 +95,7 @@ export interface FooterConfig {
showOnHomepage: boolean; // Show footer on homepage
showOnPosts: boolean; // Default: show footer on blog posts
showOnPages: boolean; // Default: show footer on static pages
showOnBlogPage: boolean; // Show footer on /blog page
defaultContent?: string; // Default markdown content if no frontmatter footer field provided
}
@@ -266,7 +267,7 @@ export const siteConfig: SiteConfig = {
title: "Blog", // Page title
description: "All posts from the blog, sorted by date.", // Optional description
order: 2, // Nav order (lower = first, e.g., 0 = first, 5 = after pages with order 0-4)
viewMode: "list", // Default view mode: "list" or "cards"
viewMode: "cards", // Default view mode: "list" or "cards"
showViewToggle: true, // Show toggle button to switch between list and card views
},
@@ -337,6 +338,7 @@ export const siteConfig: SiteConfig = {
showOnHomepage: true, // Show footer on homepage
showOnPosts: true, // Default: show footer on blog posts (override with frontmatter)
showOnPages: true, // Default: show footer on static pages (override with frontmatter)
showOnBlogPage: true, // Show footer on /blog page
// Default footer markdown (used when frontmatter footer field is not provided)
defaultContent: `Built with [Convex](https://convex.dev) for real-time sync and deployed on [Netlify](https://netlify.com). Read the [project on GitHub](https://github.com/waynesutton/markdown-site) to fork and deploy your own. View [real-time site stats](/stats).

View File

@@ -3,6 +3,8 @@ import { useNavigate } from "react-router-dom";
import { useQuery } from "convex/react";
import { api } from "../../convex/_generated/api";
import PostList from "../components/PostList";
import BlogHeroCard from "../components/BlogHeroCard";
import Footer from "../components/Footer";
import siteConfig from "../config/siteConfig";
import { ArrowLeft } from "lucide-react";
@@ -10,14 +12,20 @@ import { ArrowLeft } from "lucide-react";
const BLOG_VIEW_MODE_KEY = "blog-view-mode";
// Blog page component
// Displays all published posts in a year-grouped list or card grid
// Displays all published posts with featured blog posts layout:
// 1. Hero: first blogFeatured post (large card)
// 2. Featured row: remaining blogFeatured posts (2 columns)
// 3. Regular posts: non-featured posts (3 columns)
// Controlled by siteConfig.blogPage and siteConfig.postsDisplay settings
export default function Blog() {
const navigate = useNavigate();
// Fetch published posts from Convex
// Fetch all published posts from Convex
const posts = useQuery(api.posts.getAllPosts);
// Fetch all blog featured posts for hero + featured row
const blogFeaturedPosts = useQuery(api.posts.getBlogFeaturedPosts);
// State for view mode toggle (list or cards)
const [viewMode, setViewMode] = useState<"list" | "cards">(
siteConfig.blogPage.viewMode,
@@ -41,8 +49,33 @@ export default function Blog() {
// Check if posts should be shown on blog page
const showPosts = siteConfig.postsDisplay.showOnBlogPage;
// Check if footer should be shown on blog page
const showFooter =
siteConfig.footer.enabled && siteConfig.footer.showOnBlogPage;
// Split featured posts: first one is hero, rest go to featured row
const heroPost = blogFeaturedPosts && blogFeaturedPosts.length > 0 ? blogFeaturedPosts[0] : null;
const featuredRowPosts = blogFeaturedPosts && blogFeaturedPosts.length > 1 ? blogFeaturedPosts.slice(1) : [];
// Get slugs of all featured posts for filtering
const featuredSlugs = new Set(blogFeaturedPosts?.map((p) => p.slug) || []);
// Filter out all featured posts from regular posts list
const regularPosts = posts?.filter((post) => !featuredSlugs.has(post.slug));
// Determine if we have featured content to show
const hasFeaturedContent = heroPost !== null;
// Build CSS class for the blog page
const blogPageClass = [
"blog-page",
viewMode === "cards" ? "blog-page-cards" : "blog-page-list",
]
.filter(Boolean)
.join(" ");
return (
<div className="blog-page">
<div className={blogPageClass}>
{/* Navigation with back button */}
<nav className="post-nav">
<button onClick={() => navigate("/")} className="back-button">
@@ -112,13 +145,50 @@ export default function Blog() {
</div>
</header>
{/* Blog posts section */}
{/* Hero featured post section (only in cards view) */}
{showPosts && hasFeaturedContent && viewMode === "cards" && heroPost && (
<section className="blog-hero-section">
<BlogHeroCard
slug={heroPost.slug}
title={heroPost.title}
description={heroPost.description}
date={heroPost.date}
tags={heroPost.tags}
readTime={heroPost.readTime}
image={heroPost.image}
excerpt={heroPost.excerpt}
authorName={heroPost.authorName}
authorImage={heroPost.authorImage}
/>
</section>
)}
{/* Featured row: remaining featured posts in 2 columns (only in cards view) */}
{showPosts && featuredRowPosts.length > 0 && viewMode === "cards" && (
<section className="blog-featured-row">
<PostList
posts={featuredRowPosts}
viewMode="cards"
columns={2}
showExcerpts={true}
/>
</section>
)}
{/* Regular posts section: non-featured posts in 3 columns */}
{showPosts && (
<section className="blog-posts">
{posts === undefined ? null : posts.length === 0 ? (
{regularPosts === undefined ? null : regularPosts.length === 0 ? (
!hasFeaturedContent && (
<p className="no-posts">No posts yet. Check back soon!</p>
)
) : (
<PostList posts={posts} viewMode={viewMode} />
<PostList
posts={regularPosts}
viewMode={viewMode}
columns={3}
showExcerpts={false}
/>
)}
</section>
)}
@@ -130,6 +200,9 @@ export default function Blog() {
<code>postsDisplay.showOnBlogPage</code> in siteConfig to enable.
</p>
)}
{/* Footer section */}
{showFooter && <Footer />}
</div>
);
}

View File

@@ -206,6 +206,8 @@ export default function Post({
// Check if right sidebar is enabled (only when explicitly set in frontmatter)
const hasRightSidebar = siteConfig.rightSidebar.enabled && page.rightSidebar === true;
const hasAnySidebar = hasLeftSidebar || hasRightSidebar;
// Track if only right sidebar is enabled (for centering article)
const hasOnlyRightSidebar = hasRightSidebar && !hasLeftSidebar;
return (
<div className={`post-page ${hasAnySidebar ? "post-page-with-sidebar" : ""}`}>
@@ -229,7 +231,7 @@ export default function Post({
)}
</nav>
<div className={hasAnySidebar ? "post-content-with-sidebar" : ""}>
<div className={`${hasAnySidebar ? "post-content-with-sidebar" : ""} ${hasOnlyRightSidebar ? "post-content-right-sidebar-only" : ""}`}>
{/* Left sidebar - TOC */}
{hasLeftSidebar && (
<aside className="post-sidebar-wrapper post-sidebar-left">
@@ -238,7 +240,7 @@ export default function Post({
)}
{/* Main content */}
<article className={`post-article ${hasAnySidebar ? "post-article-with-sidebar" : ""}`}>
<article className={`post-article ${hasAnySidebar ? "post-article-with-sidebar" : ""} ${hasOnlyRightSidebar ? "post-article-centered" : ""}`}>
<header className="post-header">
<div className="post-title-row">
<h1 className="post-title">{page.title}</h1>
@@ -334,6 +336,8 @@ export default function Post({
// Check if right sidebar is enabled (only when explicitly set in frontmatter)
const hasRightSidebar = siteConfig.rightSidebar.enabled && post.rightSidebar === true;
const hasAnySidebar = hasLeftSidebar || hasRightSidebar;
// Track if only right sidebar is enabled (for centering article)
const hasOnlyRightSidebar = hasRightSidebar && !hasLeftSidebar;
// Render blog post with full metadata
return (
@@ -361,7 +365,7 @@ export default function Post({
)}
</nav>
<div className={hasAnySidebar ? "post-content-with-sidebar" : ""}>
<div className={`${hasAnySidebar ? "post-content-with-sidebar" : ""} ${hasOnlyRightSidebar ? "post-content-right-sidebar-only" : ""}`}>
{/* Left sidebar - TOC */}
{hasLeftSidebar && (
<aside className="post-sidebar-wrapper post-sidebar-left">
@@ -369,7 +373,7 @@ export default function Post({
</aside>
)}
<article className={`post-article ${hasAnySidebar ? "post-article-with-sidebar" : ""}`}>
<article className={`post-article ${hasAnySidebar ? "post-article-with-sidebar" : ""} ${hasOnlyRightSidebar ? "post-article-centered" : ""}`}>
<header className="post-header">
<div className="post-title-row">
<h1 className="post-title">{post.title}</h1>

View File

@@ -309,6 +309,13 @@ body {
flex-direction: column;
}
/* Mobile and tablet layout padding */
@media (max-width: 1024px) {
.layout {
padding: 15px;
}
}
.main-content {
flex: 1;
max-width: 800px;
@@ -901,13 +908,51 @@ body {
max-width: 100%;
}
/* Adjust main content padding when right sidebar exists */
/* Center article in middle column when right sidebar exists */
.post-content-with-sidebar:has(.post-sidebar-right)
.post-article-with-sidebar {
max-width: 800px;
margin-left: auto;
margin-right: auto;
padding-left: 48px;
padding-right: 48px;
}
}
/* Center article layout when ONLY right sidebar is enabled (no left sidebar) */
/* Right sidebar flush right, article centered in remaining space */
.post-content-with-sidebar.post-content-right-sidebar-only {
display: grid;
grid-template-columns: 1fr 280px;
width: 100%;
max-width: 100%;
}
/* Centered article styling when only right sidebar exists */
.post-article-with-sidebar.post-article-centered {
max-width: 800px;
width: 100%;
margin-left: auto;
margin-right: auto;
padding-left: 48px;
padding-right: 48px;
}
@media (max-width: 1134px) {
/* Hide right sidebar on smaller screens, center article fully */
.post-content-with-sidebar.post-content-right-sidebar-only {
grid-template-columns: 1fr;
}
.post-article-with-sidebar.post-article-centered {
max-width: 800px;
margin-left: auto;
margin-right: auto;
padding-left: 24px;
padding-right: 24px;
}
}
/* Left sidebar wrapper - docs-style with alt background and borders */
.post-sidebar-wrapper {
position: sticky;
@@ -992,7 +1037,10 @@ body {
min-width: 0; /* Prevent overflow */
max-width: 800px;
padding-left: 48px;
padding-right: 48px;
padding-top: 0;
margin-left: auto;
margin-right: auto;
}
/* Page sidebar TOC navigation */
@@ -1847,6 +1895,20 @@ body {
============================================ */
.blog-page {
padding-top: 20px;
max-width: 1200px;
margin: 0 auto;
padding-left: 24px;
padding-right: 24px;
}
/* List view: constrain to narrower width for readability */
.blog-page-list {
max-width: 800px;
}
/* Card view: use full width up to 1200px */
.blog-page-cards {
max-width: 1200px;
}
.blog-header {
@@ -1878,20 +1940,166 @@ body {
margin-top: 20px;
}
/* Blog hero section for featured post */
.blog-hero-section {
margin-bottom: 32px;
}
/* Blog featured row section (2-column grid for additional featured posts) */
.blog-featured-row {
margin-bottom: 40px;
}
.blog-featured-row .post-cards {
grid-template-columns: repeat(2, 1fr);
}
/* Blog hero card (large featured card like Giga.ai/news) */
.blog-hero-card {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 32px;
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
text-decoration: none;
overflow: hidden;
transition: all 0.15s ease;
}
.blog-hero-card:hover {
background-color: var(--bg-hover);
border-color: var(--text-muted);
}
/* Hero image wrapper */
.blog-hero-image-wrapper {
aspect-ratio: 16 / 10;
overflow: hidden;
}
.blog-hero-image {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
transition: transform 0.3s ease;
}
.blog-hero-card:hover .blog-hero-image {
transform: scale(1.03);
}
/* Hero content section */
.blog-hero-content {
padding: 32px 32px 32px 0;
display: flex;
flex-direction: column;
justify-content: center;
}
/* Hero tags */
.blog-hero-tags {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.blog-hero-tag {
font-size: var(--font-size-xs);
font-weight: 500;
color: var(--accent-color, var(--text-secondary));
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Hero date */
.blog-hero-date {
font-size: var(--font-size-xs);
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 12px;
}
/* Hero title */
.blog-hero-title {
font-size: var(--font-size-4xl);
font-weight: 600;
color: var(--text-primary);
margin: 0 0 16px 0;
line-height: 1.2;
}
/* Hero excerpt */
.blog-hero-excerpt {
font-size: var(--font-size-base);
color: var(--text-secondary);
line-height: 1.6;
margin: 0 0 20px 0;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Hero footer with author and read more */
.blog-hero-footer {
display: flex;
align-items: center;
gap: 16px;
margin-top: auto;
}
.blog-hero-author {
display: flex;
align-items: center;
gap: 8px;
}
.blog-hero-author-image {
width: 28px;
height: 28px;
border-radius: 50%;
object-fit: cover;
}
.blog-hero-author-name {
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.blog-hero-read-time {
font-size: var(--font-size-sm);
color: var(--text-muted);
}
.blog-hero-read-more {
font-size: var(--font-size-sm);
color: var(--text-primary);
font-weight: 500;
margin-left: auto;
}
/* Blog post cards grid (thumbnail view) */
.post-cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
gap: 24px;
margin-top: 8px;
}
/* 2-column layout when there's a hero post */
.post-cards-2col {
grid-template-columns: repeat(2, 1fr);
}
.post-card {
display: flex;
flex-direction: column;
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
border-radius: 12px;
text-decoration: none;
transition: all 0.15s ease;
overflow: hidden;
@@ -1902,15 +2110,15 @@ body {
border-color: var(--text-muted);
}
/* Thumbnail image wrapper with square aspect ratio */
/* Thumbnail image wrapper with landscape aspect ratio (like Giga.ai) */
.post-card-image-wrapper {
width: 100%;
aspect-ratio: 1 / 1;
aspect-ratio: 16 / 10;
overflow: hidden;
flex-shrink: 0;
}
/* Image displays as square regardless of original aspect ratio */
/* Image displays as landscape regardless of original aspect ratio */
.post-card-image {
width: 100%;
height: 100%;
@@ -2080,6 +2288,11 @@ body {
}
/* Blog page responsive */
.blog-page {
padding-left: 20px;
padding-right: 20px;
}
.blog-title {
font-size: var(--font-size-blog-page-title);
}
@@ -3942,12 +4155,39 @@ body {
max-width: 100px;
}
/* Blog hero card responsive (tablet) */
.blog-hero-card {
grid-template-columns: 1fr;
gap: 0;
}
.blog-hero-image-wrapper {
aspect-ratio: 16 / 9;
}
.blog-hero-content {
padding: 24px;
}
.blog-hero-title {
font-size: var(--font-size-3xl);
}
/* Blog featured row responsive (tablet) */
.blog-featured-row .post-cards {
grid-template-columns: repeat(2, 1fr);
}
/* Blog post cards responsive */
.post-cards {
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.post-cards-2col {
grid-template-columns: repeat(2, 1fr);
}
.post-card:not(:has(.post-card-image-wrapper)) {
padding: 16px;
}
@@ -4003,6 +4243,40 @@ body {
}
@media (max-width: 480px) {
/* Blog page mobile padding */
.blog-page {
padding-left: 16px;
padding-right: 16px;
}
/* Blog hero card mobile */
.blog-hero-content {
padding: 16px;
}
.blog-hero-title {
font-size: var(--font-size-2xl);
}
.blog-hero-excerpt {
font-size: var(--font-size-sm);
-webkit-line-clamp: 2;
}
.blog-hero-footer {
flex-wrap: wrap;
gap: 8px;
}
.blog-hero-read-more {
margin-left: 0;
}
/* Blog featured row mobile (single column) */
.blog-featured-row .post-cards {
grid-template-columns: 1fr;
}
.featured-cards {
grid-template-columns: 1fr;
}
@@ -4014,6 +4288,11 @@ body {
.post-cards {
grid-template-columns: 1fr;
gap: 16px;
}
.post-cards-2col {
grid-template-columns: 1fr;
}
.post-card-image-wrapper {
@@ -4228,12 +4507,20 @@ body {
text-align: left;
border-radius: 4px;
cursor: pointer;
/* Mobile touch improvements */
appearance: none;
-webkit-appearance: none;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
user-select: none;
-webkit-user-select: none;
transition:
background-color 0.15s ease,
color 0.15s ease;
}
.mobile-menu-toc-link:hover {
.mobile-menu-toc-link:hover,
.mobile-menu-toc-link:active {
background-color: var(--bg-hover);
color: var(--text-primary);
}
@@ -4243,6 +4530,12 @@ body {
font-weight: 500;
}
/* Pressed state for mobile touch feedback */
.mobile-menu-toc-link:active {
background-color: var(--bg-tertiary, var(--bg-hover));
transform: scale(0.98);
}
.mobile-menu-toc-icon {
flex-shrink: 0;
opacity: 0.5;
@@ -4360,7 +4653,7 @@ body {
.mobile-menu-toc-link {
padding: 5px 10px;
font-size: var(--font-size-xs);
font-size: var(--font-size-md);
}
}