mirror of
https://github.com/waynesutton/markdown-site.git
synced 2026-01-11 20:08:57 +00:00
feat: add author pages at /author/:authorSlug with clickable author names in posts
Add author archive pages displaying all posts by a specific author, following the existing tag pages pattern. Author names in post headers are now clickable links that navigate to the author's page. Changes: - Add by_authorName index to posts table (convex/schema.ts) - Add getAllAuthors and getPostsByAuthor queries (convex/posts.ts) - Create AuthorPage.tsx component with list/cards view toggle - Add /author/:authorSlug route (src/App.tsx) - Make authorName clickable in Post.tsx for posts and pages - Add author link and page styles (src/styles/global.css) - Add author pages to sitemap (convex/http.ts) - Update documentation: files.md, TASK.md, changelog.md, changelog-page.md - Save implementation plan to prds/authorname-blogs.md
This commit is contained in:
121
.claude/skills/help.md
Normal file
121
.claude/skills/help.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# Core Development Guidelines Skill
|
||||
|
||||
Deep reflection and problem-solving methodology for full-stack Convex development.
|
||||
|
||||
## 1. Reflect deeply before acting
|
||||
|
||||
Before implementing any solution, follow this process:
|
||||
|
||||
1. **Reflect** - Carefully consider why the current implementation may not be working
|
||||
2. **Identify** - What's missing, incomplete, or incorrect based on the request
|
||||
3. **Theorize** - Different possible sources of the problem or areas requiring updates
|
||||
4. **Distill** - Narrow down to 1-2 most probable root causes or solutions
|
||||
5. **Proceed** - Only move forward after clear understanding
|
||||
|
||||
**Never assume.** If anything is unclear, ask questions and clarify.
|
||||
|
||||
## 2. Convex implementation guidelines
|
||||
|
||||
### Core principles
|
||||
|
||||
**Direct mutation pattern:**
|
||||
- Use direct mutation calls with plain objects
|
||||
- Create dedicated mutation functions that map form fields to database fields
|
||||
- Form field names should exactly match database field names when applicable
|
||||
|
||||
**Best practices:**
|
||||
- Patch directly without reading first
|
||||
- Use indexed queries for ownership checks (not `ctx.db.get()`)
|
||||
- Make mutations idempotent with early returns
|
||||
- Use timestamp-based ordering for new items
|
||||
- Use `Promise.all()` for parallel independent operations to avoid write conflicts
|
||||
|
||||
### Essential documentation
|
||||
|
||||
**Functions:**
|
||||
- Mutations: https://docs.convex.dev/functions/mutation-functions
|
||||
- Queries: https://docs.convex.dev/functions/query-functions
|
||||
- Validation: https://docs.convex.dev/functions/validation
|
||||
- General: https://docs.convex.dev/functions
|
||||
|
||||
**Core concepts:**
|
||||
- Zen of Convex: https://docs.convex.dev/understanding/zen
|
||||
- TypeScript best practices: https://docs.convex.dev/understanding/best-practices/typescript
|
||||
- Best practices: https://docs.convex.dev/understanding/best-practices/
|
||||
- Schema validation: https://docs.convex.dev/database/schemas
|
||||
|
||||
**Authentication:**
|
||||
- WorkOS AuthKit: https://workos.com/docs/authkit/vanilla/nodejs
|
||||
- WorkOS docs: https://workos.com/docs
|
||||
- Convex + WorkOS setup: https://docs.convex.dev/auth/authkit/
|
||||
|
||||
## 3. Change scope and restrictions
|
||||
|
||||
### What to update
|
||||
|
||||
- Update Convex schema if needed
|
||||
- Only update files directly necessary to fix the original request
|
||||
- When tasks touch changelog.md, changelog page, or files.md:
|
||||
- Run `git log --date=short` to check commit history
|
||||
- Set release dates to match real commit timeline
|
||||
- No placeholders or future months
|
||||
|
||||
### What NOT to change
|
||||
|
||||
- Do not change UI, layout, design, or color styles unless specifically instructed
|
||||
- Preserve all admin dashboard sections and frontend components unless explicitly told to update
|
||||
- Never remove sections, features, or components unless directly requested
|
||||
|
||||
## 4. UI/UX guidelines
|
||||
|
||||
### Design system compliance
|
||||
|
||||
**For pop-ups, alerts, modals, warnings, notifications, and confirmations:**
|
||||
- Always follow the site's existing design system
|
||||
- Never use browser default pop-ups
|
||||
- Use site design system components only
|
||||
|
||||
### Follow Vercel guidelines
|
||||
|
||||
https://raw.githubusercontent.com/vercel-labs/web-interface-guidelines/refs/heads/main/AGENTS.md
|
||||
|
||||
## 5. Documentation policy
|
||||
|
||||
**IMPORTANT:** Do NOT create documentation files unless explicitly instructed.
|
||||
|
||||
**Banned unless requested:**
|
||||
- README.md
|
||||
- CONTRIBUTING.md
|
||||
- SUMMARY.md
|
||||
- USAGE_GUIDELINES.md
|
||||
|
||||
You may include a brief summary in responses, but don't create separate documentation files.
|
||||
|
||||
**Formatting rules:**
|
||||
- Never use emojis in readme or app unless instructed
|
||||
|
||||
## 6. Code confidence requirement
|
||||
|
||||
**98% confidence rule:**
|
||||
|
||||
Don't write any code until you're very confident (98% or more) in what needs to be done.
|
||||
|
||||
If unclear, ask for more information.
|
||||
|
||||
## Quick reference checklist
|
||||
|
||||
Before writing code:
|
||||
- [ ] Have I reflected on the root cause?
|
||||
- [ ] Do I understand what's actually broken?
|
||||
- [ ] Am I 98% confident in the solution?
|
||||
- [ ] Am I only changing files that need to change?
|
||||
- [ ] Am I preserving existing UI/features not mentioned?
|
||||
- [ ] Am I using the site's design system (not browser defaults)?
|
||||
- [ ] Am I following Convex mutation best practices?
|
||||
- [ ] Have I checked the relevant docs?
|
||||
|
||||
When uncertain:
|
||||
- [ ] Ask clarifying questions
|
||||
- [ ] Don't assume
|
||||
- [ ] Reference documentation
|
||||
- [ ] Narrow down to 1-2 most likely solutions
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -35,7 +35,8 @@ fork-config.json
|
||||
.cursor/rules/write.mdc
|
||||
|
||||
# Claude skills
|
||||
.claude/skills/write.mdc
|
||||
.claude/skills/write.md
|
||||
|
||||
|
||||
# PRD files
|
||||
prds/metadataforsubs.md
|
||||
|
||||
17
TASK.md
17
TASK.md
@@ -2,15 +2,26 @@
|
||||
|
||||
## To Do
|
||||
|
||||
- [ ] Newsletter signup
|
||||
- [ ] Draft preview mode
|
||||
- [x] Link author name to author page with post list
|
||||
- [ ] site confg add header icons
|
||||
|
||||
## Current Status
|
||||
|
||||
v2.2.2 ready. Homepage intro loading flash fix. Removed "Loading..." text from Suspense fallback in main.tsx and fixed Home.tsx conditional to render nothing while homeIntro query loads. Home intro content now appears without any visible loading state.
|
||||
v2.3.0 ready. Author pages feature. Links authorName to `/author/:authorSlug` archive pages displaying all posts by that author. Follows existing tag pages pattern.
|
||||
|
||||
## Completed
|
||||
|
||||
- [x] Author pages at `/author/:authorSlug` with post list
|
||||
- [x] Added `by_authorName` index to posts table in convex/schema.ts
|
||||
- [x] Added `getAllAuthors` and `getPostsByAuthor` queries in convex/posts.ts
|
||||
- [x] Created AuthorPage.tsx component with view mode toggle (list/cards)
|
||||
- [x] Added `/author/:authorSlug` route in App.tsx
|
||||
- [x] Made authorName clickable in Post.tsx (links to author page)
|
||||
- [x] Added author link styles and author page styles to global.css
|
||||
- [x] Added author pages to sitemap in convex/http.ts
|
||||
- [x] Updated files.md with AuthorPage.tsx documentation
|
||||
- [x] Saved implementation plan to prds/authorname-blogs.md
|
||||
|
||||
- [x] Homepage intro loading flash fix
|
||||
- [x] Removed "Loading..." text from Suspense fallback in main.tsx
|
||||
- [x] Fixed Home.tsx conditional to render nothing while homeIntro query loads (undefined vs null)
|
||||
|
||||
26
changelog.md
26
changelog.md
@@ -4,6 +4,32 @@ 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/).
|
||||
|
||||
## [2.3.0] - 2025-12-31
|
||||
|
||||
### Added
|
||||
|
||||
- Author pages at `/author/:authorSlug` with post list
|
||||
- Click on any author name in a post to view all their posts
|
||||
- View mode toggle (list/cards) with localStorage persistence
|
||||
- Mobile responsive layout matching tag pages design
|
||||
- Sitemap updated to include all author pages dynamically
|
||||
- New Convex queries for author data
|
||||
- `getAllAuthors`: Returns all unique authors with post counts
|
||||
- `getPostsByAuthor`: Returns posts by a specific author slug
|
||||
- Author name links in post headers
|
||||
- Author names now clickable with hover underline effect
|
||||
- Works on both blog posts and pages with authorName field
|
||||
|
||||
### Technical
|
||||
|
||||
- Added `by_authorName` index to posts table in `convex/schema.ts`
|
||||
- New queries in `convex/posts.ts`: `getAllAuthors`, `getPostsByAuthor`
|
||||
- New component: `src/pages/AuthorPage.tsx` (based on TagPage.tsx pattern)
|
||||
- Added route `/author/:authorSlug` in `src/App.tsx`
|
||||
- Updated `src/pages/Post.tsx` to make authorName a clickable Link
|
||||
- Added author link and page styles to `src/styles/global.css`
|
||||
- Added author pages to sitemap in `convex/http.ts`
|
||||
|
||||
## [2.2.2] - 2025-12-31
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -9,6 +9,7 @@ readTime: "2 min read"
|
||||
featured: false
|
||||
featuredOrder: 0
|
||||
blogFeatured: false
|
||||
authorName: "Wayne Sutton"
|
||||
aiChat: false
|
||||
image: /images/1225-changelog.png
|
||||
excerpt: "Thank you for the stars, forks, and feedback. More AI-first publishing features are coming."
|
||||
|
||||
@@ -10,6 +10,37 @@ layout: "sidebar"
|
||||
All notable changes to this project.
|
||||

|
||||
|
||||
## v2.3.0
|
||||
|
||||
Released December 31, 2025
|
||||
|
||||
**Author pages feature**
|
||||
|
||||
- Author archive pages at `/author/:authorSlug` displaying all posts by that author
|
||||
- Click on any author name in a post to view all their posts
|
||||
- View mode toggle (list/cards) with localStorage persistence
|
||||
- Mobile responsive layout matching tag pages design
|
||||
- Sitemap updated to include all author pages dynamically
|
||||
- New Convex queries for author data
|
||||
- `getAllAuthors`: Returns all unique authors with post counts
|
||||
- `getPostsByAuthor`: Returns posts by a specific author slug
|
||||
- Author name links in post headers
|
||||
- Author names now clickable with hover underline effect
|
||||
- Works on both blog posts and pages with authorName field
|
||||
- Follows existing tag pages pattern for consistent UX
|
||||
|
||||
**Technical details:**
|
||||
|
||||
- Added `by_authorName` index to posts table in `convex/schema.ts`
|
||||
- New queries in `convex/posts.ts`: `getAllAuthors`, `getPostsByAuthor`
|
||||
- New component: `src/pages/AuthorPage.tsx` (based on TagPage.tsx pattern)
|
||||
- Added route `/author/:authorSlug` in `src/App.tsx`
|
||||
- Updated `src/pages/Post.tsx` to make authorName a clickable Link
|
||||
- Added author link and page styles to `src/styles/global.css`
|
||||
- Added author pages to sitemap in `convex/http.ts`
|
||||
|
||||
Updated files: `convex/schema.ts`, `convex/posts.ts`, `convex/http.ts`, `src/pages/AuthorPage.tsx`, `src/App.tsx`, `src/pages/Post.tsx`, `src/styles/global.css`, `files.md`, `prds/authorname-blogs.md`
|
||||
|
||||
## v2.2.2
|
||||
|
||||
Released December 31, 2025
|
||||
|
||||
@@ -31,6 +31,7 @@ http.route({
|
||||
const posts = await ctx.runQuery(api.posts.getAllPosts);
|
||||
const pages = await ctx.runQuery(api.pages.getAllPages);
|
||||
const tags = await ctx.runQuery(api.posts.getAllTags);
|
||||
const authors = await ctx.runQuery(api.posts.getAllAuthors);
|
||||
|
||||
const urls = [
|
||||
// Homepage
|
||||
@@ -62,6 +63,14 @@ http.route({
|
||||
<loc>${SITE_URL}/tags/${encodeURIComponent(tagInfo.tag.toLowerCase())}</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
</url>`,
|
||||
),
|
||||
// All author pages
|
||||
...authors.map(
|
||||
(author: { slug: string }) => ` <url>
|
||||
<loc>${SITE_URL}/author/${encodeURIComponent(author.slug)}</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
</url>`,
|
||||
),
|
||||
];
|
||||
|
||||
105
convex/posts.ts
105
convex/posts.ts
@@ -769,3 +769,108 @@ export const getRelatedPosts = query({
|
||||
return relatedPosts;
|
||||
},
|
||||
});
|
||||
|
||||
// Get all unique authors with post counts (for author pages)
|
||||
export const getAllAuthors = query({
|
||||
args: {},
|
||||
returns: v.array(
|
||||
v.object({
|
||||
name: v.string(),
|
||||
slug: v.string(),
|
||||
count: v.number(),
|
||||
}),
|
||||
),
|
||||
handler: async (ctx) => {
|
||||
const posts = await ctx.db
|
||||
.query("posts")
|
||||
.withIndex("by_published", (q) => q.eq("published", true))
|
||||
.collect();
|
||||
|
||||
// Filter out unlisted posts and posts without author
|
||||
const publishedPosts = posts.filter((p) => !p.unlisted && p.authorName);
|
||||
|
||||
// Count posts per author
|
||||
const authorCounts = new Map<string, number>();
|
||||
for (const post of publishedPosts) {
|
||||
if (post.authorName) {
|
||||
const count = authorCounts.get(post.authorName) || 0;
|
||||
authorCounts.set(post.authorName, count + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to array with slugs, sorted by count then name
|
||||
return Array.from(authorCounts.entries())
|
||||
.map(([name, count]) => ({
|
||||
name,
|
||||
slug: name.toLowerCase().replace(/\s+/g, "-"),
|
||||
count,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (b.count !== a.count) return b.count - a.count;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Get posts filtered by author slug
|
||||
export const getPostsByAuthor = query({
|
||||
args: {
|
||||
authorSlug: v.string(),
|
||||
},
|
||||
returns: v.array(
|
||||
v.object({
|
||||
_id: v.id("posts"),
|
||||
_creationTime: v.number(),
|
||||
slug: v.string(),
|
||||
title: v.string(),
|
||||
description: v.string(),
|
||||
date: v.string(),
|
||||
published: v.boolean(),
|
||||
tags: v.array(v.string()),
|
||||
readTime: v.optional(v.string()),
|
||||
image: v.optional(v.string()),
|
||||
excerpt: v.optional(v.string()),
|
||||
featured: v.optional(v.boolean()),
|
||||
featuredOrder: v.optional(v.number()),
|
||||
authorName: v.optional(v.string()),
|
||||
authorImage: v.optional(v.string()),
|
||||
}),
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
const posts = await ctx.db
|
||||
.query("posts")
|
||||
.withIndex("by_published", (q) => q.eq("published", true))
|
||||
.collect();
|
||||
|
||||
// Filter posts by author slug match and not unlisted
|
||||
const filteredPosts = posts.filter((post) => {
|
||||
if (!post.authorName || post.unlisted) return false;
|
||||
const slug = post.authorName.toLowerCase().replace(/\s+/g, "-");
|
||||
return slug === args.authorSlug;
|
||||
});
|
||||
|
||||
// Sort by date descending
|
||||
const sortedPosts = filteredPosts.sort(
|
||||
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(),
|
||||
);
|
||||
|
||||
// Return without content for list view
|
||||
return sortedPosts.map((post) => ({
|
||||
_id: post._id,
|
||||
_creationTime: post._creationTime,
|
||||
slug: post.slug,
|
||||
title: post.title,
|
||||
description: post.description,
|
||||
date: post.date,
|
||||
published: post.published,
|
||||
tags: post.tags,
|
||||
readTime: post.readTime,
|
||||
image: post.image,
|
||||
excerpt: post.excerpt,
|
||||
featured: post.featured,
|
||||
featuredOrder: post.featuredOrder,
|
||||
authorName: post.authorName,
|
||||
authorImage: post.authorImage,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -36,6 +36,7 @@ export default defineSchema({
|
||||
.index("by_published", ["published"])
|
||||
.index("by_featured", ["featured"])
|
||||
.index("by_blogFeatured", ["blogFeatured"])
|
||||
.index("by_authorName", ["authorName"])
|
||||
.searchIndex("search_content", {
|
||||
searchField: "content",
|
||||
filterFields: ["published"],
|
||||
|
||||
1
files.md
1
files.md
@@ -46,6 +46,7 @@ A brief description of each file in the codebase.
|
||||
| `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, related posts section in footer for blog posts, footer component with markdown support, and social footer. Supports 3-column layout at 1135px+. Can display image at top when showImageAtTop: true. 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. Configurable via `siteConfig.statsPage` to enable/disable public access and navigation visibility. Shows disabled message when `enabled: false` (similar to NewsletterAdmin pattern). |
|
||||
| `TagPage.tsx` | Tag archive page displaying posts filtered by a specific tag. Includes view mode toggle (list/cards) with localStorage persistence |
|
||||
| `AuthorPage.tsx` | Author archive page displaying posts by a specific author. Includes view mode toggle (list/cards) with localStorage persistence. Author name clickable in posts links to this page. |
|
||||
| `Write.tsx` | Three-column markdown writing page with Cursor docs-style UI, frontmatter reference with copy buttons, theme toggle, font switcher (serif/sans/monospace), localStorage persistence, and optional AI Agent mode (toggleable via siteConfig.aiChat.enabledOnWritePage). When enabled, Agent replaces the textarea with AIChatView component. Includes scroll prevention when switching to Agent mode to prevent page jump. Title changes to "Agent" when in AI chat mode. |
|
||||
| `Dashboard.tsx` | Centralized dashboard at `/dashboard` for content management and site configuration. Features include: Posts and Pages list views with filtering, search, pagination, items per page selector; Post/Page editor with markdown editor, live preview, draggable/resizable frontmatter sidebar (200px-600px), independent scrolling, download markdown; Write Post/Page sections with full-screen writing interface; AI Agent section (dedicated chat separate from Write page); Newsletter management (all Newsletter Admin features integrated); Content import (Firecrawl UI); Site configuration (Config Generator UI for all siteConfig.ts settings); Index HTML editor; Analytics (real-time stats dashboard); Sync commands UI with buttons for all sync operations; Header sync buttons for quick sync; Dashboard search; Toast notifications; Command modal; Mobile responsive design. Uses Convex queries for real-time data, localStorage for preferences, ReactMarkdown for preview. Optional WorkOS authentication via siteConfig.dashboard.requireAuth. When requireAuth is false, dashboard is open access. When requireAuth is true and WorkOS is configured, dashboard requires login. Shows setup instructions if requireAuth is true but WorkOS is not configured. |
|
||||
| `Callback.tsx` | OAuth callback handler for WorkOS authentication. Handles redirect from WorkOS after user login, exchanges authorization code for user information, then redirects to dashboard. Only used when WorkOS is configured. |
|
||||
|
||||
250
prds/authorname-blogs.md
Normal file
250
prds/authorname-blogs.md
Normal file
@@ -0,0 +1,250 @@
|
||||
# Author Pages Implementation Plan
|
||||
|
||||
## Overview
|
||||
|
||||
Add author pages at `/author/:authorSlug` that display all posts by a specific author. Follows the existing tag pages pattern. Works with existing `npm run sync` workflow - no sync changes needed.
|
||||
|
||||
## Files to Modify
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `convex/schema.ts` | Add `by_authorName` index to posts table |
|
||||
| `convex/posts.ts` | Add `getAllAuthors` and `getPostsByAuthor` queries |
|
||||
| `src/pages/AuthorPage.tsx` | New component (based on TagPage.tsx pattern) |
|
||||
| `src/App.tsx` | Add `/author/:authorSlug` route |
|
||||
| `src/pages/Post.tsx` | Make authorName a clickable `<Link>` to author page |
|
||||
| `convex/http.ts` | Add author pages to sitemap |
|
||||
| `files.md` | Document new AuthorPage.tsx |
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Add Index to Schema
|
||||
|
||||
**File:** `convex/schema.ts`
|
||||
|
||||
Add index to posts table for efficient author queries:
|
||||
|
||||
```typescript
|
||||
// In posts table definition, add to indexes:
|
||||
.index("by_authorName", ["authorName"])
|
||||
```
|
||||
|
||||
### Step 2: Add Convex Queries
|
||||
|
||||
**File:** `convex/posts.ts`
|
||||
|
||||
Add two new queries following existing patterns:
|
||||
|
||||
```typescript
|
||||
// Get all unique authors (similar to getAllTags)
|
||||
export const getAllAuthors = query({
|
||||
args: {},
|
||||
returns: v.array(v.object({
|
||||
name: v.string(),
|
||||
slug: v.string(),
|
||||
count: v.number(),
|
||||
})),
|
||||
handler: async (ctx) => {
|
||||
const posts = await ctx.db
|
||||
.query("posts")
|
||||
.withIndex("by_published", (q) => q.eq("published", true))
|
||||
.collect();
|
||||
|
||||
// Filter out unlisted posts and posts without author
|
||||
const publishedPosts = posts.filter(p => !p.unlisted && p.authorName);
|
||||
|
||||
// Count posts per author
|
||||
const authorCounts = new Map<string, number>();
|
||||
for (const post of publishedPosts) {
|
||||
if (post.authorName) {
|
||||
const count = authorCounts.get(post.authorName) || 0;
|
||||
authorCounts.set(post.authorName, count + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to array with slugs
|
||||
return Array.from(authorCounts.entries())
|
||||
.map(([name, count]) => ({
|
||||
name,
|
||||
slug: name.toLowerCase().replace(/\s+/g, "-"),
|
||||
count,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (b.count !== a.count) return b.count - a.count;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Get posts by author slug (similar to getPostsByTag)
|
||||
export const getPostsByAuthor = query({
|
||||
args: { authorSlug: v.string() },
|
||||
returns: v.array(v.object({
|
||||
_id: v.id("posts"),
|
||||
_creationTime: v.number(),
|
||||
slug: v.string(),
|
||||
title: v.string(),
|
||||
description: v.string(),
|
||||
date: v.string(),
|
||||
published: v.boolean(),
|
||||
tags: v.array(v.string()),
|
||||
readTime: v.optional(v.string()),
|
||||
image: v.optional(v.string()),
|
||||
excerpt: v.optional(v.string()),
|
||||
featured: v.optional(v.boolean()),
|
||||
featuredOrder: v.optional(v.number()),
|
||||
authorName: v.optional(v.string()),
|
||||
authorImage: v.optional(v.string()),
|
||||
})),
|
||||
handler: async (ctx, args) => {
|
||||
const posts = await ctx.db
|
||||
.query("posts")
|
||||
.withIndex("by_published", (q) => q.eq("published", true))
|
||||
.collect();
|
||||
|
||||
// Filter by author slug match and not unlisted
|
||||
const filtered = posts.filter(post => {
|
||||
if (!post.authorName || post.unlisted) return false;
|
||||
const slug = post.authorName.toLowerCase().replace(/\s+/g, "-");
|
||||
return slug === args.authorSlug;
|
||||
});
|
||||
|
||||
// Sort by date descending
|
||||
const sortedPosts = filtered.sort(
|
||||
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||
);
|
||||
|
||||
return sortedPosts.map((post) => ({
|
||||
_id: post._id,
|
||||
_creationTime: post._creationTime,
|
||||
slug: post.slug,
|
||||
title: post.title,
|
||||
description: post.description,
|
||||
date: post.date,
|
||||
published: post.published,
|
||||
tags: post.tags,
|
||||
readTime: post.readTime,
|
||||
image: post.image,
|
||||
excerpt: post.excerpt,
|
||||
featured: post.featured,
|
||||
featuredOrder: post.featuredOrder,
|
||||
authorName: post.authorName,
|
||||
authorImage: post.authorImage,
|
||||
}));
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Step 3: Create AuthorPage Component
|
||||
|
||||
**File:** `src/pages/AuthorPage.tsx` (new file)
|
||||
|
||||
Based on `src/pages/TagPage.tsx` pattern:
|
||||
|
||||
- Accept `authorSlug` from URL params
|
||||
- Query `getPostsByAuthor(authorSlug)`
|
||||
- Display author name as heading
|
||||
- Show post count
|
||||
- List/card view toggle with localStorage persistence
|
||||
- Reuse PostList component
|
||||
- Back button to blog page
|
||||
- Handle loading and empty states
|
||||
|
||||
### Step 4: Add Route
|
||||
|
||||
**File:** `src/App.tsx`
|
||||
|
||||
Add route alongside tag route:
|
||||
|
||||
```typescript
|
||||
<Route path="/author/:authorSlug" element={<AuthorPage />} />
|
||||
```
|
||||
|
||||
### Step 5: Make Author Name Clickable
|
||||
|
||||
**File:** `src/pages/Post.tsx`
|
||||
|
||||
Change author name from `<span>` to `<Link>`:
|
||||
|
||||
```typescript
|
||||
// Before:
|
||||
{post.authorName && (
|
||||
<span className="post-author-name">{post.authorName}</span>
|
||||
)}
|
||||
|
||||
// After:
|
||||
{post.authorName && (
|
||||
<Link
|
||||
to={`/author/${post.authorName.toLowerCase().replace(/\s+/g, "-")}`}
|
||||
className="post-author-name post-author-link"
|
||||
>
|
||||
{post.authorName}
|
||||
</Link>
|
||||
)}
|
||||
```
|
||||
|
||||
### Step 6: Add Author Link Styles
|
||||
|
||||
**File:** `src/styles/global.css`
|
||||
|
||||
```css
|
||||
.post-author-link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.post-author-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 7: Add to Sitemap
|
||||
|
||||
**File:** `convex/http.ts`
|
||||
|
||||
In sitemap generation, add author pages (similar to tag pages):
|
||||
|
||||
```typescript
|
||||
const authors = await ctx.runQuery(api.posts.getAllAuthors);
|
||||
|
||||
// Add author page URLs
|
||||
...authors.map(
|
||||
(author: { slug: string }) => ` <url>
|
||||
<loc>${SITE_URL}/author/${encodeURIComponent(author.slug)}</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
</url>`,
|
||||
),
|
||||
```
|
||||
|
||||
### Step 8: Update Documentation
|
||||
|
||||
**File:** `files.md`
|
||||
|
||||
Add AuthorPage.tsx entry:
|
||||
|
||||
```markdown
|
||||
| `AuthorPage.tsx` | Author archive page displaying posts by a specific author. Includes view mode toggle (list/cards) with localStorage persistence |
|
||||
```
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Posts with `authorName` show clickable link
|
||||
- [ ] `/author/wayne-sutton` displays correct posts
|
||||
- [ ] Authors with multiple posts show all posts
|
||||
- [ ] View toggle (list/cards) works and persists
|
||||
- [ ] Empty author slug shows 404 or empty state
|
||||
- [ ] Sitemap includes author pages
|
||||
- [ ] Mobile responsive layout works
|
||||
- [ ] All four themes display correctly
|
||||
|
||||
## No Changes Needed
|
||||
|
||||
- `scripts/sync-posts.ts` - authorName already syncs
|
||||
- `convex/schema.ts` fields - authorName field exists
|
||||
- Frontmatter format - works as-is
|
||||
|
||||
## References
|
||||
|
||||
- Tag pages pattern: `src/pages/TagPage.tsx`, `convex/posts.ts` (getPostsByTag, getAllTags)
|
||||
- Convex best practices: `.claude/skills/convex.md`
|
||||
- Schema patterns: `.claude/skills/dev.md`
|
||||
@@ -8,6 +8,37 @@ Date: 2026-01-01
|
||||
All notable changes to this project.
|
||||

|
||||
|
||||
## v2.3.0
|
||||
|
||||
Released December 31, 2025
|
||||
|
||||
**Author pages feature**
|
||||
|
||||
- Author archive pages at `/author/:authorSlug` displaying all posts by that author
|
||||
- Click on any author name in a post to view all their posts
|
||||
- View mode toggle (list/cards) with localStorage persistence
|
||||
- Mobile responsive layout matching tag pages design
|
||||
- Sitemap updated to include all author pages dynamically
|
||||
- New Convex queries for author data
|
||||
- `getAllAuthors`: Returns all unique authors with post counts
|
||||
- `getPostsByAuthor`: Returns posts by a specific author slug
|
||||
- Author name links in post headers
|
||||
- Author names now clickable with hover underline effect
|
||||
- Works on both blog posts and pages with authorName field
|
||||
- Follows existing tag pages pattern for consistent UX
|
||||
|
||||
**Technical details:**
|
||||
|
||||
- Added `by_authorName` index to posts table in `convex/schema.ts`
|
||||
- New queries in `convex/posts.ts`: `getAllAuthors`, `getPostsByAuthor`
|
||||
- New component: `src/pages/AuthorPage.tsx` (based on TagPage.tsx pattern)
|
||||
- Added route `/author/:authorSlug` in `src/App.tsx`
|
||||
- Updated `src/pages/Post.tsx` to make authorName a clickable Link
|
||||
- Added author link and page styles to `src/styles/global.css`
|
||||
- Added author pages to sitemap in `convex/http.ts`
|
||||
|
||||
Updated files: `convex/schema.ts`, `convex/posts.ts`, `convex/http.ts`, `src/pages/AuthorPage.tsx`, `src/App.tsx`, `src/pages/Post.tsx`, `src/styles/global.css`, `files.md`, `prds/authorname-blogs.md`
|
||||
|
||||
## v2.2.2
|
||||
|
||||
Released December 31, 2025
|
||||
|
||||
@@ -5,6 +5,7 @@ import Stats from "./pages/Stats";
|
||||
import Blog from "./pages/Blog";
|
||||
import Write from "./pages/Write";
|
||||
import TagPage from "./pages/TagPage";
|
||||
import AuthorPage from "./pages/AuthorPage";
|
||||
import Unsubscribe from "./pages/Unsubscribe";
|
||||
import NewsletterAdmin from "./pages/NewsletterAdmin";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
@@ -87,6 +88,8 @@ function App() {
|
||||
)}
|
||||
{/* Tag page route - displays posts filtered by tag */}
|
||||
<Route path="/tags/:tag" element={<TagPage />} />
|
||||
{/* Author page route - displays posts by a specific author */}
|
||||
<Route path="/author/:authorSlug" element={<AuthorPage />} />
|
||||
{/* Catch-all for post/page slugs - must be last */}
|
||||
<Route path="/:slug" element={<Post />} />
|
||||
</Routes>
|
||||
|
||||
28
src/components/FontToggle.tsx
Normal file
28
src/components/FontToggle.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { TextAa } from "@phosphor-icons/react";
|
||||
import { useFont } from "../context/FontContext";
|
||||
|
||||
export default function FontToggle() {
|
||||
const { fontFamily, toggleFontFamily } = useFont();
|
||||
|
||||
const getLabel = () => {
|
||||
switch (fontFamily) {
|
||||
case "serif":
|
||||
return "Serif";
|
||||
case "sans":
|
||||
return "Sans";
|
||||
case "monospace":
|
||||
return "Mono";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className="font-toggle"
|
||||
onClick={toggleFontFamily}
|
||||
aria-label={`Font: ${getLabel()}. Click to change.`}
|
||||
title={`Font: ${getLabel()}`}
|
||||
>
|
||||
<TextAa size={18} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useQuery } from "convex/react";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import { MagnifyingGlass } from "@phosphor-icons/react";
|
||||
import ThemeToggle from "./ThemeToggle";
|
||||
import FontToggle from "./FontToggle";
|
||||
import SearchModal from "./SearchModal";
|
||||
import MobileMenu, { HamburgerButton } from "./MobileMenu";
|
||||
import ScrollToTop, { ScrollToTopConfig } from "./ScrollToTop";
|
||||
@@ -165,6 +166,8 @@ export default function Layout({ children }: LayoutProps) {
|
||||
>
|
||||
<MagnifyingGlass size={18} weight="bold" />
|
||||
</button>
|
||||
{/* Font toggle */}
|
||||
<FontToggle />
|
||||
{/* Theme toggle */}
|
||||
<div className="theme-toggle-container">
|
||||
<ThemeToggle />
|
||||
@@ -196,6 +199,8 @@ export default function Layout({ children }: LayoutProps) {
|
||||
>
|
||||
<MagnifyingGlass size={18} weight="bold" />
|
||||
</button>
|
||||
{/* Font toggle */}
|
||||
<FontToggle />
|
||||
{/* Theme toggle */}
|
||||
<div className="theme-toggle-container">
|
||||
<ThemeToggle />
|
||||
|
||||
169
src/pages/AuthorPage.tsx
Normal file
169
src/pages/AuthorPage.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useParams, useNavigate, Link } from "react-router-dom";
|
||||
import { useQuery } from "convex/react";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import PostList from "../components/PostList";
|
||||
import { ArrowLeft, User } from "lucide-react";
|
||||
|
||||
// Local storage key for author page view mode preference
|
||||
const AUTHOR_VIEW_MODE_KEY = "author-view-mode";
|
||||
|
||||
// Author page component
|
||||
// Displays all posts written by a specific author
|
||||
export default function AuthorPage() {
|
||||
const { authorSlug } = useParams<{ authorSlug: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Decode the URL-encoded author slug
|
||||
const decodedSlug = authorSlug ? decodeURIComponent(authorSlug) : "";
|
||||
|
||||
// Fetch posts by this author from Convex
|
||||
const posts = useQuery(
|
||||
api.posts.getPostsByAuthor,
|
||||
decodedSlug ? { authorSlug: decodedSlug } : "skip",
|
||||
);
|
||||
|
||||
// Fetch all authors for showing count and display name
|
||||
const allAuthors = useQuery(api.posts.getAllAuthors);
|
||||
|
||||
// Find the author info for this slug
|
||||
const authorInfo = allAuthors?.find(
|
||||
(a) => a.slug.toLowerCase() === decodedSlug.toLowerCase(),
|
||||
);
|
||||
|
||||
// State for view mode toggle (list or cards)
|
||||
const [viewMode, setViewMode] = useState<"list" | "cards">("list");
|
||||
|
||||
// Load saved view mode preference from localStorage
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem(AUTHOR_VIEW_MODE_KEY);
|
||||
if (saved === "list" || saved === "cards") {
|
||||
setViewMode(saved);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Toggle view mode and save preference
|
||||
const toggleViewMode = () => {
|
||||
const newMode = viewMode === "list" ? "cards" : "list";
|
||||
setViewMode(newMode);
|
||||
localStorage.setItem(AUTHOR_VIEW_MODE_KEY, newMode);
|
||||
};
|
||||
|
||||
// Update page title
|
||||
useEffect(() => {
|
||||
if (authorInfo) {
|
||||
document.title = `Posts by ${authorInfo.name} | markdown sync framework`;
|
||||
} else if (decodedSlug) {
|
||||
document.title = `Author | markdown sync framework`;
|
||||
}
|
||||
return () => {
|
||||
document.title = "markdown sync framework";
|
||||
};
|
||||
}, [authorInfo, decodedSlug]);
|
||||
|
||||
// Handle not found author
|
||||
if (posts !== undefined && posts.length === 0) {
|
||||
return (
|
||||
<div className="author-page">
|
||||
<nav className="post-nav">
|
||||
<button onClick={() => navigate(-1)} className="back-button">
|
||||
<ArrowLeft size={16} />
|
||||
<span>Back</span>
|
||||
</button>
|
||||
</nav>
|
||||
<div className="author-not-found">
|
||||
<h1>No posts found</h1>
|
||||
<p>
|
||||
No posts by this author were found.
|
||||
</p>
|
||||
<Link to="/" className="back-link">
|
||||
<ArrowLeft size={16} />
|
||||
Back to home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="author-page">
|
||||
{/* Navigation with back button */}
|
||||
<nav className="post-nav">
|
||||
<button onClick={() => navigate(-1)} className="back-button">
|
||||
<ArrowLeft size={16} />
|
||||
<span>Back</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{/* Author page header */}
|
||||
<header className="author-header">
|
||||
<div className="author-header-top">
|
||||
<div>
|
||||
<div className="author-title-row">
|
||||
<User size={24} className="author-icon" />
|
||||
<h1 className="author-title">
|
||||
{authorInfo?.name || decodedSlug.replace(/-/g, " ")}
|
||||
</h1>
|
||||
</div>
|
||||
<p className="author-description">
|
||||
{authorInfo
|
||||
? `${authorInfo.count} post${authorInfo.count !== 1 ? "s" : ""}`
|
||||
: "Loading..."}
|
||||
</p>
|
||||
</div>
|
||||
{/* View toggle button */}
|
||||
{posts !== undefined && posts.length > 0 && (
|
||||
<button
|
||||
className="view-toggle-button"
|
||||
onClick={toggleViewMode}
|
||||
aria-label={`Switch to ${viewMode === "list" ? "card" : "list"} view`}
|
||||
>
|
||||
{viewMode === "list" ? (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="3" y="3" width="7" height="7" />
|
||||
<rect x="14" y="3" width="7" height="7" />
|
||||
<rect x="3" y="14" width="7" height="7" />
|
||||
<rect x="14" y="14" width="7" height="7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="8" y1="6" x2="21" y2="6" />
|
||||
<line x1="8" y1="12" x2="21" y2="12" />
|
||||
<line x1="8" y1="18" x2="21" y2="18" />
|
||||
<line x1="3" y1="6" x2="3.01" y2="6" />
|
||||
<line x1="3" y1="12" x2="3.01" y2="12" />
|
||||
<line x1="3" y1="18" x2="3.01" y2="18" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Author posts section */}
|
||||
<section className="author-posts">
|
||||
{posts === undefined ? null : (
|
||||
<PostList posts={posts} viewMode={viewMode} />
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -282,7 +282,12 @@ export default function Post({
|
||||
/>
|
||||
)}
|
||||
{page.authorName && (
|
||||
<span className="post-author-name">{page.authorName}</span>
|
||||
<Link
|
||||
to={`/author/${page.authorName.toLowerCase().replace(/\s+/g, "-")}`}
|
||||
className="post-author-name post-author-link"
|
||||
>
|
||||
{page.authorName}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -449,7 +454,12 @@ export default function Post({
|
||||
/>
|
||||
)}
|
||||
{post.authorName && (
|
||||
<span className="post-author-name">{post.authorName}</span>
|
||||
<Link
|
||||
to={`/author/${post.authorName.toLowerCase().replace(/\s+/g, "-")}`}
|
||||
className="post-author-name post-author-link"
|
||||
>
|
||||
{post.authorName}
|
||||
</Link>
|
||||
)}
|
||||
<span className="post-meta-separator">·</span>
|
||||
</div>
|
||||
|
||||
@@ -435,6 +435,26 @@ body {
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
|
||||
.font-toggle {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition:
|
||||
color 0.2s ease,
|
||||
background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.font-toggle:hover {
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* Home page styles */
|
||||
.home {
|
||||
padding-top: 10px;
|
||||
@@ -1333,6 +1353,15 @@ body {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.post-author-link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.post-author-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.post-description {
|
||||
font-size: var(--font-size-post-description);
|
||||
color: var(--text-secondary);
|
||||
@@ -2484,6 +2513,67 @@ body {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* Author page styles */
|
||||
.author-page {
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.author-header {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.author-header-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.author-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.author-icon {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.author-title {
|
||||
font-size: var(--font-size-blog-page-title);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.author-description {
|
||||
font-size: var(--font-size-blog-page-description);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.author-posts {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.author-not-found {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.author-not-found h1 {
|
||||
font-size: var(--font-size-3xl);
|
||||
margin-bottom: 16px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.author-not-found p {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* Responsive styles */
|
||||
@media (max-width: 768px) {
|
||||
.main-content {
|
||||
|
||||
Reference in New Issue
Block a user