From 1eaec05fecf3c259bb40aaf3c7db4196db23c43f Mon Sep 17 00:00:00 2001 From: Wayne Sutton Date: Thu, 1 Jan 2026 00:05:10 -0800 Subject: [PATCH] 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 --- .claude/skills/help.md | 121 ++++++++++++++ .gitignore | 3 +- TASK.md | 17 +- changelog.md | 26 +++ content/blog/happy-holidays-2025.md | 1 + content/pages/changelog-page.md | 31 ++++ convex/http.ts | 9 + convex/posts.ts | 105 ++++++++++++ convex/schema.ts | 1 + files.md | 1 + prds/authorname-blogs.md | 250 ++++++++++++++++++++++++++++ public/raw/changelog.md | 31 ++++ src/App.tsx | 3 + src/components/FontToggle.tsx | 28 ++++ src/components/Layout.tsx | 5 + src/pages/AuthorPage.tsx | 169 +++++++++++++++++++ src/pages/Post.tsx | 14 +- src/styles/global.css | 90 ++++++++++ 18 files changed, 899 insertions(+), 6 deletions(-) create mode 100644 .claude/skills/help.md create mode 100644 prds/authorname-blogs.md create mode 100644 src/components/FontToggle.tsx create mode 100644 src/pages/AuthorPage.tsx diff --git a/.claude/skills/help.md b/.claude/skills/help.md new file mode 100644 index 0000000..9deb929 --- /dev/null +++ b/.claude/skills/help.md @@ -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 diff --git a/.gitignore b/.gitignore index 51b819b..ee43fad 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/TASK.md b/TASK.md index 2529abf..8b6a1f7 100644 --- a/TASK.md +++ b/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) diff --git a/changelog.md b/changelog.md index 91e923a..0944ef4 100644 --- a/changelog.md +++ b/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 diff --git a/content/blog/happy-holidays-2025.md b/content/blog/happy-holidays-2025.md index c7e00d3..4e035d0 100644 --- a/content/blog/happy-holidays-2025.md +++ b/content/blog/happy-holidays-2025.md @@ -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." diff --git a/content/pages/changelog-page.md b/content/pages/changelog-page.md index 0ab5d28..ce6d72f 100644 --- a/content/pages/changelog-page.md +++ b/content/pages/changelog-page.md @@ -10,6 +10,37 @@ layout: "sidebar" All notable changes to this project. ![](https://img.shields.io/badge/License-MIT-yellow.svg) +## 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 diff --git a/convex/http.ts b/convex/http.ts index 46c9609..67a3a0b 100644 --- a/convex/http.ts +++ b/convex/http.ts @@ -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({ ${SITE_URL}/tags/${encodeURIComponent(tagInfo.tag.toLowerCase())} weekly 0.6 + `, + ), + // All author pages + ...authors.map( + (author: { slug: string }) => ` + ${SITE_URL}/author/${encodeURIComponent(author.slug)} + weekly + 0.6 `, ), ]; diff --git a/convex/posts.ts b/convex/posts.ts index af47204..f01f964 100644 --- a/convex/posts.ts +++ b/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(); + 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, + })); + }, +}); diff --git a/convex/schema.ts b/convex/schema.ts index fc7939a..6a7073f 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -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"], diff --git a/files.md b/files.md index 4080d56..f469ba0 100644 --- a/files.md +++ b/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. | diff --git a/prds/authorname-blogs.md b/prds/authorname-blogs.md new file mode 100644 index 0000000..0261555 --- /dev/null +++ b/prds/authorname-blogs.md @@ -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 `` 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(); + 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 +} /> +``` + +### Step 5: Make Author Name Clickable + +**File:** `src/pages/Post.tsx` + +Change author name from `` to ``: + +```typescript +// Before: +{post.authorName && ( + {post.authorName} +)} + +// After: +{post.authorName && ( + + {post.authorName} + +)} +``` + +### 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 }) => ` + ${SITE_URL}/author/${encodeURIComponent(author.slug)} + weekly + 0.6 + `, +), +``` + +### 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` diff --git a/public/raw/changelog.md b/public/raw/changelog.md index e9adf8f..de28615 100644 --- a/public/raw/changelog.md +++ b/public/raw/changelog.md @@ -8,6 +8,37 @@ Date: 2026-01-01 All notable changes to this project. ![](https://img.shields.io/badge/License-MIT-yellow.svg) +## 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 diff --git a/src/App.tsx b/src/App.tsx index 1bbc2ce..ad51d8a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 */} } /> + {/* Author page route - displays posts by a specific author */} + } /> {/* Catch-all for post/page slugs - must be last */} } /> diff --git a/src/components/FontToggle.tsx b/src/components/FontToggle.tsx new file mode 100644 index 0000000..5622075 --- /dev/null +++ b/src/components/FontToggle.tsx @@ -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 ( + + ); +} diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 6407cea..6939966 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -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) { > + {/* Font toggle */} + {/* Theme toggle */}
@@ -196,6 +199,8 @@ export default function Layout({ children }: LayoutProps) { > + {/* Font toggle */} + {/* Theme toggle */}
diff --git a/src/pages/AuthorPage.tsx b/src/pages/AuthorPage.tsx new file mode 100644 index 0000000..19ad15f --- /dev/null +++ b/src/pages/AuthorPage.tsx @@ -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 ( +
+ +
+

No posts found

+

+ No posts by this author were found. +

+ + + Back to home + +
+
+ ); + } + + return ( +
+ {/* Navigation with back button */} + + + {/* Author page header */} +
+
+
+
+ +

+ {authorInfo?.name || decodedSlug.replace(/-/g, " ")} +

+
+

+ {authorInfo + ? `${authorInfo.count} post${authorInfo.count !== 1 ? "s" : ""}` + : "Loading..."} +

+
+ {/* View toggle button */} + {posts !== undefined && posts.length > 0 && ( + + )} +
+
+ + {/* Author posts section */} +
+ {posts === undefined ? null : ( + + )} +
+
+ ); +} diff --git a/src/pages/Post.tsx b/src/pages/Post.tsx index 1a69f23..995fe4b 100644 --- a/src/pages/Post.tsx +++ b/src/pages/Post.tsx @@ -282,7 +282,12 @@ export default function Post({ /> )} {page.authorName && ( - {page.authorName} + + {page.authorName} + )}
@@ -449,7 +454,12 @@ export default function Post({ /> )} {post.authorName && ( - {post.authorName} + + {post.authorName} + )} ยท diff --git a/src/styles/global.css b/src/styles/global.css index f5fda5f..2fa2d7f 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -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 {