Files
wiki/convex/posts.ts

1037 lines
30 KiB
TypeScript
Raw Normal View History

import { query, mutation, internalMutation, internalQuery } from "./_generated/server";
import { v } from "convex/values";
import { internal } from "./_generated/api";
// Get all posts (published and unpublished) for dashboard admin view
export const listAll = query({
args: {},
returns: v.array(
v.object({
_id: v.id("posts"),
_creationTime: v.number(),
slug: v.string(),
title: v.string(),
description: v.string(),
content: 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()),
source: v.optional(v.union(v.literal("dashboard"), v.literal("sync"))),
}),
),
handler: async (ctx) => {
const posts = await ctx.db.query("posts").collect();
// Sort by date descending
const sortedPosts = posts.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,
content: post.content,
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,
source: post.source,
}));
},
});
// Get all published posts, sorted by date descending
export const getAllPosts = query({
args: {},
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()),
layout: v.optional(v.string()),
rightSidebar: v.optional(v.boolean()),
showFooter: v.optional(v.boolean()),
footer: v.optional(v.string()),
blogFeatured: v.optional(v.boolean()),
}),
),
handler: async (ctx) => {
const posts = await ctx.db
.query("posts")
.withIndex("by_published", (q) => q.eq("published", true))
.collect();
// Filter out unlisted posts
const listedPosts = posts.filter((p) => !p.unlisted);
// Sort by date descending
const sortedPosts = listedPosts.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,
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 && !p.unlisted)
.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,
}));
},
});
// Get featured posts for the homepage featured section
export const getFeaturedPosts = query({
args: {},
returns: v.array(
v.object({
_id: v.id("posts"),
slug: v.string(),
title: v.string(),
excerpt: v.optional(v.string()),
description: v.string(),
image: v.optional(v.string()),
featuredOrder: v.optional(v.number()),
}),
),
handler: async (ctx) => {
const posts = await ctx.db
.query("posts")
.withIndex("by_featured", (q) => q.eq("featured", true))
.collect();
// Filter to only published posts and sort by featuredOrder
const featuredPosts = posts
.filter((p) => p.published && !p.unlisted)
.sort((a, b) => {
const orderA = a.featuredOrder ?? 999;
const orderB = b.featuredOrder ?? 999;
return orderA - orderB;
});
return featuredPosts.map((post) => ({
_id: post._id,
slug: post.slug,
title: post.title,
excerpt: post.excerpt,
description: post.description,
image: post.image,
featuredOrder: post.featuredOrder,
}));
},
});
// Get a single post by slug
export const getPostBySlug = query({
args: {
slug: v.string(),
},
returns: v.union(
v.object({
_id: v.id("posts"),
_creationTime: v.number(),
slug: v.string(),
title: v.string(),
description: v.string(),
content: v.string(),
date: v.string(),
published: v.boolean(),
tags: v.array(v.string()),
readTime: v.optional(v.string()),
image: v.optional(v.string()),
showImageAtTop: v.optional(v.boolean()),
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()),
layout: v.optional(v.string()),
rightSidebar: v.optional(v.boolean()),
showFooter: v.optional(v.boolean()),
footer: v.optional(v.string()),
showSocialFooter: v.optional(v.boolean()),
feat: add AI Agent chat integration with Anthropic Claude API Add AI writing assistant (Agent) powered by Anthropic Claude API. Agent can be enabled on Write page (replaces textarea) and optionally in RightSidebar on posts/pages via frontmatter. Features: - AIChatView component with per-page chat history - Page content context support for AI responses - Markdown rendering for AI responses - User-friendly error handling for missing API keys - System prompt configurable via Convex environment variables - Anonymous session authentication using localStorage Environment variables required: - ANTHROPIC_API_KEY (required) - CLAUDE_PROMPT_STYLE, CLAUDE_PROMPT_COMMUNITY, CLAUDE_PROMPT_RULES (optional split prompts) - CLAUDE_SYSTEM_PROMPT (optional single prompt fallback) Configuration: - siteConfig.aiChat.enabledOnWritePage: Enable Agent toggle on /write page - siteConfig.aiChat.enabledOnContent: Allow Agent on posts/pages via frontmatter - Frontmatter aiChat: true (requires rightSidebar: true) Updated files: - src/components/AIChatView.tsx: AI chat interface component - src/components/RightSidebar.tsx: Conditional Agent rendering - src/pages/Write.tsx: Agent mode toggle (title changes to Agent) - convex/aiChats.ts: Chat history queries and mutations - convex/aiChatActions.ts: Claude API integration with error handling - convex/schema.ts: aiChats table with indexes - src/config/siteConfig.ts: AIChatConfig interface - Documentation updated across all files Documentation: - files.md: Updated component descriptions - changelog.md: Added v1.33.0 entry - TASK.md: Marked AI chat tasks as completed - README.md: Added AI Agent Chat section - content/pages/docs.md: Added AI Agent chat documentation - content/blog/setup-guide.md: Added AI Agent chat setup instructions - public/raw/changelog.md: Added v1.33.0 entry
2025-12-26 12:31:33 -08:00
aiChat: v.optional(v.boolean()),
newsletter: v.optional(v.boolean()),
contactForm: v.optional(v.boolean()),
docsSection: v.optional(v.boolean()),
}),
v.null(),
),
handler: async (ctx, args) => {
const post = await ctx.db
.query("posts")
.withIndex("by_slug", (q) => q.eq("slug", args.slug))
.first();
if (!post || !post.published) {
return null;
}
return {
_id: post._id,
_creationTime: post._creationTime,
slug: post.slug,
title: post.title,
description: post.description,
content: post.content,
date: post.date,
published: post.published,
tags: post.tags,
readTime: post.readTime,
image: post.image,
showImageAtTop: post.showImageAtTop,
excerpt: post.excerpt,
featured: post.featured,
featuredOrder: post.featuredOrder,
authorName: post.authorName,
authorImage: post.authorImage,
layout: post.layout,
rightSidebar: post.rightSidebar,
showFooter: post.showFooter,
footer: post.footer,
showSocialFooter: post.showSocialFooter,
feat: add AI Agent chat integration with Anthropic Claude API Add AI writing assistant (Agent) powered by Anthropic Claude API. Agent can be enabled on Write page (replaces textarea) and optionally in RightSidebar on posts/pages via frontmatter. Features: - AIChatView component with per-page chat history - Page content context support for AI responses - Markdown rendering for AI responses - User-friendly error handling for missing API keys - System prompt configurable via Convex environment variables - Anonymous session authentication using localStorage Environment variables required: - ANTHROPIC_API_KEY (required) - CLAUDE_PROMPT_STYLE, CLAUDE_PROMPT_COMMUNITY, CLAUDE_PROMPT_RULES (optional split prompts) - CLAUDE_SYSTEM_PROMPT (optional single prompt fallback) Configuration: - siteConfig.aiChat.enabledOnWritePage: Enable Agent toggle on /write page - siteConfig.aiChat.enabledOnContent: Allow Agent on posts/pages via frontmatter - Frontmatter aiChat: true (requires rightSidebar: true) Updated files: - src/components/AIChatView.tsx: AI chat interface component - src/components/RightSidebar.tsx: Conditional Agent rendering - src/pages/Write.tsx: Agent mode toggle (title changes to Agent) - convex/aiChats.ts: Chat history queries and mutations - convex/aiChatActions.ts: Claude API integration with error handling - convex/schema.ts: aiChats table with indexes - src/config/siteConfig.ts: AIChatConfig interface - Documentation updated across all files Documentation: - files.md: Updated component descriptions - changelog.md: Added v1.33.0 entry - TASK.md: Marked AI chat tasks as completed - README.md: Added AI Agent Chat section - content/pages/docs.md: Added AI Agent chat documentation - content/blog/setup-guide.md: Added AI Agent chat setup instructions - public/raw/changelog.md: Added v1.33.0 entry
2025-12-26 12:31:33 -08:00
aiChat: post.aiChat,
newsletter: post.newsletter,
contactForm: post.contactForm,
docsSection: post.docsSection,
};
},
});
// Internal query to get post by slug (for newsletter sending)
// Returns post details needed for newsletter content
export const getPostBySlugInternal = internalQuery({
args: {
slug: v.string(),
},
returns: v.union(
v.object({
slug: v.string(),
title: v.string(),
description: v.string(),
content: v.string(),
excerpt: v.optional(v.string()),
}),
v.null(),
),
handler: async (ctx, args) => {
const post = await ctx.db
.query("posts")
.withIndex("by_slug", (q) => q.eq("slug", args.slug))
.first();
if (!post || !post.published) {
return null;
}
return {
slug: post.slug,
title: post.title,
description: post.description,
content: post.content,
excerpt: post.excerpt,
};
},
});
// Internal query to get recent posts (for weekly digest)
// Returns published posts with date >= since parameter
export const getRecentPostsInternal = internalQuery({
args: {
since: v.string(), // Date string in YYYY-MM-DD format
},
returns: v.array(
v.object({
slug: v.string(),
title: v.string(),
description: v.string(),
date: v.string(),
excerpt: 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 date and sort descending, excluding unlisted
const recentPosts = posts
.filter((post) => post.date >= args.since && !post.unlisted)
.sort((a, b) => b.date.localeCompare(a.date))
.map((post) => ({
slug: post.slug,
title: post.title,
description: post.description,
date: post.date,
excerpt: post.excerpt,
}));
return recentPosts;
},
});
// Internal mutation for syncing posts from markdown files
export const syncPosts = internalMutation({
args: {
posts: v.array(
v.object({
slug: v.string(),
title: v.string(),
description: v.string(),
content: v.string(),
date: v.string(),
published: v.boolean(),
tags: v.array(v.string()),
readTime: v.optional(v.string()),
image: v.optional(v.string()),
showImageAtTop: v.optional(v.boolean()),
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()),
layout: v.optional(v.string()),
rightSidebar: v.optional(v.boolean()),
showFooter: v.optional(v.boolean()),
footer: v.optional(v.string()),
showSocialFooter: v.optional(v.boolean()),
feat: add AI Agent chat integration with Anthropic Claude API Add AI writing assistant (Agent) powered by Anthropic Claude API. Agent can be enabled on Write page (replaces textarea) and optionally in RightSidebar on posts/pages via frontmatter. Features: - AIChatView component with per-page chat history - Page content context support for AI responses - Markdown rendering for AI responses - User-friendly error handling for missing API keys - System prompt configurable via Convex environment variables - Anonymous session authentication using localStorage Environment variables required: - ANTHROPIC_API_KEY (required) - CLAUDE_PROMPT_STYLE, CLAUDE_PROMPT_COMMUNITY, CLAUDE_PROMPT_RULES (optional split prompts) - CLAUDE_SYSTEM_PROMPT (optional single prompt fallback) Configuration: - siteConfig.aiChat.enabledOnWritePage: Enable Agent toggle on /write page - siteConfig.aiChat.enabledOnContent: Allow Agent on posts/pages via frontmatter - Frontmatter aiChat: true (requires rightSidebar: true) Updated files: - src/components/AIChatView.tsx: AI chat interface component - src/components/RightSidebar.tsx: Conditional Agent rendering - src/pages/Write.tsx: Agent mode toggle (title changes to Agent) - convex/aiChats.ts: Chat history queries and mutations - convex/aiChatActions.ts: Claude API integration with error handling - convex/schema.ts: aiChats table with indexes - src/config/siteConfig.ts: AIChatConfig interface - Documentation updated across all files Documentation: - files.md: Updated component descriptions - changelog.md: Added v1.33.0 entry - TASK.md: Marked AI chat tasks as completed - README.md: Added AI Agent Chat section - content/pages/docs.md: Added AI Agent chat documentation - content/blog/setup-guide.md: Added AI Agent chat setup instructions - public/raw/changelog.md: Added v1.33.0 entry
2025-12-26 12:31:33 -08:00
aiChat: v.optional(v.boolean()),
blogFeatured: v.optional(v.boolean()),
newsletter: v.optional(v.boolean()),
contactForm: v.optional(v.boolean()),
2025-12-30 15:26:59 -08:00
unlisted: v.optional(v.boolean()),
docsSection: v.optional(v.boolean()),
docsSectionGroup: v.optional(v.string()),
docsSectionOrder: v.optional(v.number()),
docsSectionGroupOrder: v.optional(v.number()),
docsSectionGroupIcon: v.optional(v.string()),
docsLanding: v.optional(v.boolean()),
}),
),
},
returns: v.object({
created: v.number(),
updated: v.number(),
deleted: v.number(),
}),
handler: async (ctx, args) => {
let created = 0;
let updated = 0;
let deleted = 0;
const now = Date.now();
const incomingSlugs = new Set(args.posts.map((p) => p.slug));
// Get all existing posts
const existingPosts = await ctx.db.query("posts").collect();
const existingBySlug = new Map(existingPosts.map((p) => [p.slug, p]));
// Upsert incoming posts
for (const post of args.posts) {
const existing = existingBySlug.get(post.slug);
if (existing) {
// Update existing post
await ctx.db.patch(existing._id, {
title: post.title,
description: post.description,
content: post.content,
date: post.date,
published: post.published,
tags: post.tags,
readTime: post.readTime,
image: post.image,
showImageAtTop: post.showImageAtTop,
excerpt: post.excerpt,
featured: post.featured,
featuredOrder: post.featuredOrder,
authorName: post.authorName,
authorImage: post.authorImage,
layout: post.layout,
rightSidebar: post.rightSidebar,
showFooter: post.showFooter,
footer: post.footer,
showSocialFooter: post.showSocialFooter,
feat: add AI Agent chat integration with Anthropic Claude API Add AI writing assistant (Agent) powered by Anthropic Claude API. Agent can be enabled on Write page (replaces textarea) and optionally in RightSidebar on posts/pages via frontmatter. Features: - AIChatView component with per-page chat history - Page content context support for AI responses - Markdown rendering for AI responses - User-friendly error handling for missing API keys - System prompt configurable via Convex environment variables - Anonymous session authentication using localStorage Environment variables required: - ANTHROPIC_API_KEY (required) - CLAUDE_PROMPT_STYLE, CLAUDE_PROMPT_COMMUNITY, CLAUDE_PROMPT_RULES (optional split prompts) - CLAUDE_SYSTEM_PROMPT (optional single prompt fallback) Configuration: - siteConfig.aiChat.enabledOnWritePage: Enable Agent toggle on /write page - siteConfig.aiChat.enabledOnContent: Allow Agent on posts/pages via frontmatter - Frontmatter aiChat: true (requires rightSidebar: true) Updated files: - src/components/AIChatView.tsx: AI chat interface component - src/components/RightSidebar.tsx: Conditional Agent rendering - src/pages/Write.tsx: Agent mode toggle (title changes to Agent) - convex/aiChats.ts: Chat history queries and mutations - convex/aiChatActions.ts: Claude API integration with error handling - convex/schema.ts: aiChats table with indexes - src/config/siteConfig.ts: AIChatConfig interface - Documentation updated across all files Documentation: - files.md: Updated component descriptions - changelog.md: Added v1.33.0 entry - TASK.md: Marked AI chat tasks as completed - README.md: Added AI Agent Chat section - content/pages/docs.md: Added AI Agent chat documentation - content/blog/setup-guide.md: Added AI Agent chat setup instructions - public/raw/changelog.md: Added v1.33.0 entry
2025-12-26 12:31:33 -08:00
aiChat: post.aiChat,
blogFeatured: post.blogFeatured,
newsletter: post.newsletter,
contactForm: post.contactForm,
2025-12-30 15:26:59 -08:00
unlisted: post.unlisted,
docsSection: post.docsSection,
docsSectionGroup: post.docsSectionGroup,
docsSectionOrder: post.docsSectionOrder,
docsSectionGroupOrder: post.docsSectionGroupOrder,
docsSectionGroupIcon: post.docsSectionGroupIcon,
docsLanding: post.docsLanding,
lastSyncedAt: now,
});
updated++;
} else {
// Create new post
await ctx.db.insert("posts", {
...post,
lastSyncedAt: now,
});
created++;
}
}
// Delete posts that no longer exist in the repo
for (const existing of existingPosts) {
if (!incomingSlugs.has(existing.slug)) {
await ctx.db.delete(existing._id);
deleted++;
}
}
return { created, updated, deleted };
},
});
// Public mutation wrapper for sync script (no auth required for build-time sync)
// Respects source field: only syncs posts where source !== "dashboard"
export const syncPostsPublic = mutation({
args: {
posts: v.array(
v.object({
slug: v.string(),
title: v.string(),
description: v.string(),
content: v.string(),
date: v.string(),
published: v.boolean(),
tags: v.array(v.string()),
readTime: v.optional(v.string()),
image: v.optional(v.string()),
showImageAtTop: v.optional(v.boolean()),
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()),
layout: v.optional(v.string()),
rightSidebar: v.optional(v.boolean()),
showFooter: v.optional(v.boolean()),
footer: v.optional(v.string()),
showSocialFooter: v.optional(v.boolean()),
feat: add AI Agent chat integration with Anthropic Claude API Add AI writing assistant (Agent) powered by Anthropic Claude API. Agent can be enabled on Write page (replaces textarea) and optionally in RightSidebar on posts/pages via frontmatter. Features: - AIChatView component with per-page chat history - Page content context support for AI responses - Markdown rendering for AI responses - User-friendly error handling for missing API keys - System prompt configurable via Convex environment variables - Anonymous session authentication using localStorage Environment variables required: - ANTHROPIC_API_KEY (required) - CLAUDE_PROMPT_STYLE, CLAUDE_PROMPT_COMMUNITY, CLAUDE_PROMPT_RULES (optional split prompts) - CLAUDE_SYSTEM_PROMPT (optional single prompt fallback) Configuration: - siteConfig.aiChat.enabledOnWritePage: Enable Agent toggle on /write page - siteConfig.aiChat.enabledOnContent: Allow Agent on posts/pages via frontmatter - Frontmatter aiChat: true (requires rightSidebar: true) Updated files: - src/components/AIChatView.tsx: AI chat interface component - src/components/RightSidebar.tsx: Conditional Agent rendering - src/pages/Write.tsx: Agent mode toggle (title changes to Agent) - convex/aiChats.ts: Chat history queries and mutations - convex/aiChatActions.ts: Claude API integration with error handling - convex/schema.ts: aiChats table with indexes - src/config/siteConfig.ts: AIChatConfig interface - Documentation updated across all files Documentation: - files.md: Updated component descriptions - changelog.md: Added v1.33.0 entry - TASK.md: Marked AI chat tasks as completed - README.md: Added AI Agent Chat section - content/pages/docs.md: Added AI Agent chat documentation - content/blog/setup-guide.md: Added AI Agent chat setup instructions - public/raw/changelog.md: Added v1.33.0 entry
2025-12-26 12:31:33 -08:00
aiChat: v.optional(v.boolean()),
blogFeatured: v.optional(v.boolean()),
newsletter: v.optional(v.boolean()),
contactForm: v.optional(v.boolean()),
2025-12-30 15:26:59 -08:00
unlisted: v.optional(v.boolean()),
docsSection: v.optional(v.boolean()),
docsSectionGroup: v.optional(v.string()),
docsSectionOrder: v.optional(v.number()),
docsSectionGroupOrder: v.optional(v.number()),
docsSectionGroupIcon: v.optional(v.string()),
docsLanding: v.optional(v.boolean()),
}),
),
},
returns: v.object({
created: v.number(),
updated: v.number(),
deleted: v.number(),
skipped: v.number(),
}),
handler: async (ctx, args) => {
let created = 0;
let updated = 0;
let deleted = 0;
let skipped = 0;
const now = Date.now();
const incomingSlugs = new Set(args.posts.map((p) => p.slug));
// Get all existing posts
const existingPosts = await ctx.db.query("posts").collect();
const existingBySlug = new Map(existingPosts.map((p) => [p.slug, p]));
// Upsert incoming posts (only if source !== "dashboard")
for (const post of args.posts) {
const existing = existingBySlug.get(post.slug);
if (existing) {
// Skip dashboard-created posts - don't overwrite them
if (existing.source === "dashboard") {
skipped++;
continue;
}
// Capture version before update (async, non-blocking)
await ctx.scheduler.runAfter(0, internal.versions.createVersion, {
contentType: "post",
contentId: existing._id,
slug: existing.slug,
title: existing.title,
content: existing.content,
description: existing.description,
source: "sync",
});
// Update existing sync post
await ctx.db.patch(existing._id, {
title: post.title,
description: post.description,
content: post.content,
date: post.date,
published: post.published,
tags: post.tags,
readTime: post.readTime,
image: post.image,
showImageAtTop: post.showImageAtTop,
excerpt: post.excerpt,
featured: post.featured,
featuredOrder: post.featuredOrder,
authorName: post.authorName,
authorImage: post.authorImage,
layout: post.layout,
rightSidebar: post.rightSidebar,
showFooter: post.showFooter,
footer: post.footer,
showSocialFooter: post.showSocialFooter,
feat: add AI Agent chat integration with Anthropic Claude API Add AI writing assistant (Agent) powered by Anthropic Claude API. Agent can be enabled on Write page (replaces textarea) and optionally in RightSidebar on posts/pages via frontmatter. Features: - AIChatView component with per-page chat history - Page content context support for AI responses - Markdown rendering for AI responses - User-friendly error handling for missing API keys - System prompt configurable via Convex environment variables - Anonymous session authentication using localStorage Environment variables required: - ANTHROPIC_API_KEY (required) - CLAUDE_PROMPT_STYLE, CLAUDE_PROMPT_COMMUNITY, CLAUDE_PROMPT_RULES (optional split prompts) - CLAUDE_SYSTEM_PROMPT (optional single prompt fallback) Configuration: - siteConfig.aiChat.enabledOnWritePage: Enable Agent toggle on /write page - siteConfig.aiChat.enabledOnContent: Allow Agent on posts/pages via frontmatter - Frontmatter aiChat: true (requires rightSidebar: true) Updated files: - src/components/AIChatView.tsx: AI chat interface component - src/components/RightSidebar.tsx: Conditional Agent rendering - src/pages/Write.tsx: Agent mode toggle (title changes to Agent) - convex/aiChats.ts: Chat history queries and mutations - convex/aiChatActions.ts: Claude API integration with error handling - convex/schema.ts: aiChats table with indexes - src/config/siteConfig.ts: AIChatConfig interface - Documentation updated across all files Documentation: - files.md: Updated component descriptions - changelog.md: Added v1.33.0 entry - TASK.md: Marked AI chat tasks as completed - README.md: Added AI Agent Chat section - content/pages/docs.md: Added AI Agent chat documentation - content/blog/setup-guide.md: Added AI Agent chat setup instructions - public/raw/changelog.md: Added v1.33.0 entry
2025-12-26 12:31:33 -08:00
aiChat: post.aiChat,
blogFeatured: post.blogFeatured,
newsletter: post.newsletter,
contactForm: post.contactForm,
2025-12-30 15:26:59 -08:00
unlisted: post.unlisted,
docsSection: post.docsSection,
docsSectionGroup: post.docsSectionGroup,
docsSectionOrder: post.docsSectionOrder,
docsSectionGroupOrder: post.docsSectionGroupOrder,
docsSectionGroupIcon: post.docsSectionGroupIcon,
docsLanding: post.docsLanding,
source: "sync",
lastSyncedAt: now,
});
updated++;
} else {
// Create new post with source: "sync"
await ctx.db.insert("posts", {
...post,
source: "sync",
lastSyncedAt: now,
});
created++;
}
}
// Delete posts that no longer exist in the repo (but not dashboard posts)
for (const existing of existingPosts) {
if (!incomingSlugs.has(existing.slug) && existing.source !== "dashboard") {
await ctx.db.delete(existing._id);
deleted++;
}
}
return { created, updated, deleted, skipped };
},
});
// Public mutation for incrementing view count
export const incrementViewCount = mutation({
args: {
slug: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
const existing = await ctx.db
.query("viewCounts")
.withIndex("by_slug", (q) => q.eq("slug", args.slug))
.first();
if (existing) {
await ctx.db.patch(existing._id, {
count: existing.count + 1,
});
} else {
await ctx.db.insert("viewCounts", {
slug: args.slug,
count: 1,
});
}
return null;
},
});
// Get view count for a post
export const getViewCount = query({
args: {
slug: v.string(),
},
returns: v.number(),
handler: async (ctx, args) => {
const viewCount = await ctx.db
.query("viewCounts")
.withIndex("by_slug", (q) => q.eq("slug", args.slug))
.first();
return viewCount?.count ?? 0;
},
});
// Get all unique tags from published posts
export const getAllTags = query({
args: {},
returns: v.array(
v.object({
tag: 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
const listedPosts = posts.filter((p) => !p.unlisted);
// Count occurrences of each tag
const tagCounts = new Map<string, number>();
for (const post of listedPosts) {
for (const tag of post.tags) {
tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1);
}
}
// Convert to array and sort by count (descending), then alphabetically
return Array.from(tagCounts.entries())
.map(([tag, count]) => ({ tag, count }))
.sort((a, b) => {
if (b.count !== a.count) return b.count - a.count;
return a.tag.localeCompare(b.tag);
});
},
});
// Get posts filtered by a specific tag
export const getPostsByTag = query({
args: {
tag: 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 that have the specified tag and are not unlisted
const filteredPosts = posts.filter(
(post) =>
!post.unlisted &&
post.tags.some((t) => t.toLowerCase() === args.tag.toLowerCase()),
);
// 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,
}));
},
});
// Get related posts that share tags with the current post
export const getRelatedPosts = query({
args: {
currentSlug: v.string(),
tags: v.array(v.string()),
limit: v.optional(v.number()),
},
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()),
sharedTags: v.number(),
}),
),
handler: async (ctx, args) => {
const maxResults = args.limit ?? 3;
// Skip if no tags provided
if (args.tags.length === 0) {
return [];
}
const posts = await ctx.db
.query("posts")
.withIndex("by_published", (q) => q.eq("published", true))
.collect();
// Find posts that share tags, excluding current post and unlisted posts
const relatedPosts = posts
.filter((post) => post.slug !== args.currentSlug && !post.unlisted)
.map((post) => {
const sharedTags = post.tags.filter((tag) =>
args.tags.some((t) => t.toLowerCase() === tag.toLowerCase()),
).length;
return {
_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,
sharedTags,
};
})
.filter((post) => post.sharedTags > 0)
.sort((a, b) => {
// Sort by shared tags count first, then by date
if (b.sharedTags !== a.sharedTags) return b.sharedTags - a.sharedTags;
return new Date(b.date).getTime() - new Date(a.date).getTime();
})
.slice(0, maxResults);
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,
}));
},
});
// Get all posts marked for docs section navigation
// Used by DocsSidebar to build the left navigation
export const getDocsPosts = query({
args: {},
returns: v.array(
v.object({
_id: v.id("posts"),
slug: v.string(),
title: v.string(),
docsSectionGroup: v.optional(v.string()),
docsSectionOrder: v.optional(v.number()),
docsSectionGroupOrder: v.optional(v.number()),
docsSectionGroupIcon: v.optional(v.string()),
}),
),
handler: async (ctx) => {
const posts = await ctx.db
.query("posts")
.withIndex("by_docsSection", (q) => q.eq("docsSection", true))
.collect();
// Filter to only published posts
const publishedDocs = posts.filter((p) => p.published);
// Sort by docsSectionOrder, then by title
const sortedDocs = publishedDocs.sort((a, b) => {
const orderA = a.docsSectionOrder ?? 999;
const orderB = b.docsSectionOrder ?? 999;
if (orderA !== orderB) return orderA - orderB;
return a.title.localeCompare(b.title);
});
return sortedDocs.map((post) => ({
_id: post._id,
slug: post.slug,
title: post.title,
docsSectionGroup: post.docsSectionGroup,
docsSectionOrder: post.docsSectionOrder,
docsSectionGroupOrder: post.docsSectionGroupOrder,
docsSectionGroupIcon: post.docsSectionGroupIcon,
}));
},
});
// Get the docs landing page (post with docsLanding: true)
// Returns null if no landing page is set
export const getDocsLandingPost = query({
args: {},
returns: v.union(
v.object({
_id: v.id("posts"),
slug: v.string(),
title: v.string(),
description: v.string(),
content: v.string(),
date: v.string(),
tags: v.array(v.string()),
readTime: v.optional(v.string()),
image: v.optional(v.string()),
showImageAtTop: v.optional(v.boolean()),
authorName: v.optional(v.string()),
authorImage: v.optional(v.string()),
docsSectionGroup: v.optional(v.string()),
docsSectionOrder: v.optional(v.number()),
2026-01-09 14:54:00 -08:00
showFooter: v.optional(v.boolean()),
footer: v.optional(v.string()),
aiChat: v.optional(v.boolean()),
}),
v.null(),
),
handler: async (ctx) => {
// Get all docs posts and find one with docsLanding: true
const posts = await ctx.db
.query("posts")
.withIndex("by_docsSection", (q) => q.eq("docsSection", true))
.collect();
const landing = posts.find((p) => p.published && p.docsLanding);
if (!landing) return null;
return {
_id: landing._id,
slug: landing.slug,
title: landing.title,
description: landing.description,
content: landing.content,
date: landing.date,
tags: landing.tags,
readTime: landing.readTime,
image: landing.image,
showImageAtTop: landing.showImageAtTop,
authorName: landing.authorName,
authorImage: landing.authorImage,
docsSectionGroup: landing.docsSectionGroup,
docsSectionOrder: landing.docsSectionOrder,
2026-01-09 14:54:00 -08:00
showFooter: landing.showFooter,
footer: landing.footer,
aiChat: landing.aiChat,
};
},
});